From b0cb4ef07f052f22ce29a27cf63445a6c9cc6ae8 Mon Sep 17 00:00:00 2001 From: DKL Date: Mon, 17 Aug 2026 10:32:04 +0200 Subject: [PATCH 1/7] Port JS to TS and use original viewer.js as compile target. Signed-off-by: DKL --- .gitignore | 3 + core/.prettierignore | 2 + core/.prettierrc | 11 + core/eslint.config.mjs | 20 + core/package-lock.json | 1999 ++++++++ core/package.json | 28 + core/src/constants.ts | 140 + core/src/document.ts | 705 +++ core/src/main.ts | 2071 ++++++++ core/src/markup.ts | 610 +++ core/src/overlay.ts | 808 +++ core/src/rendered.ts | 1008 ++++ core/src/types.ts | 181 + core/src/xml.ts | 371 ++ core/src/xml_overlay.ts | 76 + core/src/zip.ts | 248 + core/tsconfig.json | 14 + core/vite.config.ts | 23 + viewer.js | 10125 ++++++++++++++++++-------------------- 19 files changed, 12997 insertions(+), 5446 deletions(-) create mode 100644 core/.prettierignore create mode 100644 core/.prettierrc create mode 100644 core/eslint.config.mjs create mode 100644 core/package-lock.json create mode 100644 core/package.json create mode 100644 core/src/constants.ts create mode 100644 core/src/document.ts create mode 100644 core/src/main.ts create mode 100644 core/src/markup.ts create mode 100644 core/src/overlay.ts create mode 100644 core/src/rendered.ts create mode 100644 core/src/types.ts create mode 100644 core/src/xml.ts create mode 100644 core/src/xml_overlay.ts create mode 100644 core/src/zip.ts create mode 100644 core/tsconfig.json create mode 100644 core/vite.config.ts diff --git a/.gitignore b/.gitignore index 2a70e71..6e429e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ *~ +# Core +core/node_modules + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] diff --git a/core/.prettierignore b/core/.prettierignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/core/.prettierignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/core/.prettierrc b/core/.prettierrc new file mode 100644 index 0000000..b6c1a17 --- /dev/null +++ b/core/.prettierrc @@ -0,0 +1,11 @@ +{ + "arrowParens": "avoid", + "bracketSpacing": true, + "printWidth": 88, + "quoteProps": "as-needed", + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false +} diff --git a/core/eslint.config.mjs b/core/eslint.config.mjs new file mode 100644 index 0000000..0fdde16 --- /dev/null +++ b/core/eslint.config.mjs @@ -0,0 +1,20 @@ +// @ts-check + +import eslint from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['dist', 'node_modules'], + }, + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + languageOptions: { + globals: { + ...globals.browser, + }, + }, + } +); diff --git a/core/package-lock.json b/core/package-lock.json new file mode 100644 index 0000000..3b077bd --- /dev/null +++ b/core/package-lock.json @@ -0,0 +1,1999 @@ +{ + "name": "@doclang/viewer-core", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@doclang/viewer-core", + "version": "0.1.0", + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.8.1", + "globals": "^17.11.0", + "prettier": "^3.9.6", + "typescript": "^6.0.3", + "typescript-eslint": "^8.67.0", + "vite": "^8.2.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", + "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", + "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/core/package.json b/core/package.json new file mode 100644 index 0000000..6e6e71b --- /dev/null +++ b/core/package.json @@ -0,0 +1,28 @@ +{ + "name": "@doclang/viewer-core", + "version": "0.1.0", + "description": "TypeScript source for the DocLang viewer", + "type": "module", + "private": true, + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "check": "npm run format:check && npm run lint && npm run typecheck && npm run build" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.8.1", + "globals": "^17.11.0", + "prettier": "^3.9.6", + "typescript": "^6.0.3", + "typescript-eslint": "^8.67.0", + "vite": "^8.2.1" + }, + "engines": { + "node": ">=18" + } +} diff --git a/core/src/constants.ts b/core/src/constants.ts new file mode 100644 index 0000000..aa1fda2 --- /dev/null +++ b/core/src/constants.ts @@ -0,0 +1,140 @@ +/* DocLang Archive Viewer — archive format: github.com/doclang-project/doclang spec.md#doclang-archive-format */ + +export const SUPPORTED_FILE_EXTENSIONS = ['.dclx', '.dclg']; +export const OPEN_FILE_HINT = `Open a DocLang file (${SUPPORTED_FILE_EXTENSIONS.join(', ')})`; +export const VIRTUAL_TEXT_TAG_HINT = + 'DocLang virtual ; wrapping tags not included in source'; +export const FRAGMENT_LINK_LABEL_CROSS_PAGE = 'cross-page content'; +export const FRAGMENT_LINK_LABEL_SAME_PAGE = 'fragmented content'; +export const FRAGMENT_NAV_HINT_PREV = 'Previous fragment'; +export const FRAGMENT_NAV_HINT_NEXT = 'Next fragment'; +export const DOCLANG_NS = 'https://www.doclang.ai/ns/v0'; +export const PAGE_IMAGE_RE = /^(\d+)\.(png|jpe?g|webp)$/i; +export const NO_MARKUP = '(No markup to be shown.)'; +export const NO_IMAGE = '(No page image available.)'; +export const FILE_THUMB_PLACEHOLDER_SVG = ``; +export const PICTURE_UNAVAILABLE_ALT = 'Picture asset not available'; +export const INVALID_PICTURE_SRC = 'data:image/png;base64,NOT_A_VALID_IMAGE'; +/** Allow small embedded raster data URIs only; remote/blob/other schemes are rejected. */ +export const SAFE_DATA_IMAGE_RE = /^data:image\/(png|jpe?g|webp|gif);base64,/i; +export const MAX_DATA_IMAGE_URI_LENGTH = 2 * 1024 * 1024; // 2 MiB +export const ZIP_MAX_ENTRIES = 5000; +export const ZIP_MAX_ENTRY_COMPRESSED_BYTES = 128 * 1024 * 1024; // 128 MiB +export const ZIP_MAX_ENTRY_UNCOMPRESSED_BYTES = 128 * 1024 * 1024; // 128 MiB +export const ZIP_MAX_TOTAL_UNCOMPRESSED_BYTES = 512 * 1024 * 1024; // 512 MiB +export const ZIP_MAX_COMPRESSION_RATIO = 100; // uncompressed / compressed +export const LONG_EMBEDDED_URI_PREVIEW_LENGTH = 30; +export const HEAD_TAGS = new Set([ + 'label', + 'thread', + 'xref', + 'href', + 'layer', + 'location', + 'caption', + 'description', + 'summary', + 'custom', +]); +export const SEMANTIC_TAGS = new Set([ + 'text', + 'heading', + 'footnote', + 'page_header', + 'page_footer', + 'field_region', + 'list', + 'table', + 'index', + 'formula', + 'code', + 'picture', + 'marker', + 'group', + 'field_heading', + 'field_item', + 'key', + 'value', + 'hint', + 'caption', + 'page_break', +]); +export const CELL_TOKENS = new Set([ + 'fcel', + 'ecel', + 'ched', + 'rhed', + 'corn', + 'srow', + 'lcel', + 'ucel', + 'xcel', + 'nl', +]); +export const CELL_CONTENT_TAGS = new Set(['fcel', 'ecel', 'ched', 'rhed', 'corn', 'srow']); +export const CELL_SPAN_TAGS = new Set(['lcel', 'ucel', 'xcel']); +export const OTSL_CONTAINER_TAGS = new Set(['table', 'index', 'tabular']); +export const RENDER_BLOCK_TAGS = new Set([ + 'text', + 'heading', + 'field_heading', + 'footnote', + 'page_header', + 'page_footer', + 'list', + 'code', + 'formula', + 'picture', + 'group', + 'field_region', + 'field_item', + 'table', + 'index', + 'tabular', +]); +export const RENDER_FORMAT_TAGS = new Set([ + 'bold', + 'italic', + 'underline', + 'strikethrough', + 'superscript', + 'subscript', + 'handwriting', + 'rtl', + 'content', +]); +export const FORMAT_HTML_TAG: Record = { + bold: 'strong', + italic: 'em', + underline: 'u', + strikethrough: 's', + superscript: 'sup', + subscript: 'sub', +}; + +export const PAGE_WHEEL_COOLDOWN_MS = 200; +export const PAGE_WHEEL_PIXEL_THRESHOLD = 4; +export const PAGE_WHEEL_GESTURE_MS = 100; +export const OVERLAY_BADGE_FONT_SIZE = 11 * 1.5 * 0.8; +export const OVERLAY_BADGE_PAD_X = 3; +export const OVERLAY_BADGE_PAD_Y = 2; +export const OVERLAY_BADGE_RADIUS_SCREEN_PX = 3; +/** Demo page size; overlay lengths are calibrated to match pre-fix sizing on these images. */ +export const OVERLAY_REF_IMAGE_WIDTH = 1224; +export const OVERLAY_REF_IMAGE_HEIGHT = 1584; +export const PAGE_ZOOM_DEFAULT = 100; +export const PAGE_PAN_DRAG_THRESHOLD = 5; +export const PAGE_VIEW_BORDER_PX = 2; +export const LAYOUT_STORAGE_KEY = 'doclang-viewer-pane-layout'; +export const PANE_MIN_RATIO = 0.12; +export const PANE_KEYS = ['file', 'page', 'markup', 'reading'] as const; +export const DEFAULT_PANE_RATIOS = [1, 1, 1, 1]; +export const DEFAULT_USER_PANE_VISIBLE = { + file: false, + page: true, + markup: true, + reading: true, +}; +export const LAYOUT_STACK_BREAKPOINT_PX = 1200; + +export type PaneKey = (typeof PANE_KEYS)[number]; diff --git a/core/src/document.ts b/core/src/document.ts new file mode 100644 index 0000000..1555112 --- /dev/null +++ b/core/src/document.ts @@ -0,0 +1,705 @@ +import { + HEAD_TAGS, + OTSL_CONTAINER_TAGS, + CELL_SPAN_TAGS, + RENDER_FORMAT_TAGS, + PAGE_IMAGE_RE, + DOCLANG_NS, +} from './constants'; +import { + childElements, + localName, + headLocations, + firstHeadChild, + walkElements, + locationResolution, + parseElementHeadAt, + skipContainerLevelHead, + skipUntilListItemBoundary, + skipUntilCellBoundary, + isCellToken, + isTextLikeNode, + isWhitespaceOnlyText, + serializeMarkupTextNodes, + formatMarkupTextNode, + archiveRelativeAssetPath, + elementLayer, + elementLabel, + isVirtualTextOverlayUnit, +} from './xml'; +import type { + DocumentState, + Resolution, + BoundingBox, + ReadingOrderStep, + CaptionLink, + XrefLink, + FragmentLink, + FragmentNavItem, + ThreadNav, +} from './types'; +import { extractArchiveFromZipBuffer } from './zip'; +import { isPictureContentElement, isTableContentElement } from './xml_overlay'; + +// --------------------------------------------------------------------------- +// Document XML parsing helpers +// --------------------------------------------------------------------------- + +export function splitIntoSegments(root: Element): Element[][] { + const body = childElements(root).filter(el => localName(el) !== 'head'); + const segments: Element[][] = [[]]; + for (const el of body) { + if (localName(el) === 'page_break') { + segments.push([]); + } else { + segments[segments.length - 1]!.push(el); + } + } + return segments.length ? segments : [[]]; +} + +export function segmentHasMarkup(segment: Element[]): boolean { + return segment.some(el => el.nodeType === Node.ELEMENT_NODE); +} + +export function readDefaultResolution(head: Element | null): Resolution { + const fallback = { width: 512, height: 512 }; + if (!head) return fallback; + const dr = childElements(head).find(el => localName(el) === 'default_resolution'); + if (!dr) return fallback; + const w = parseInt(dr.getAttribute('width') ?? '512', 10); + const h = parseInt(dr.getAttribute('height') ?? '512', 10); + return { + width: Number.isFinite(w) && w > 0 ? w : 512, + height: Number.isFinite(h) && h > 0 ? h : 512, + }; +} + +export function assignElementIds(segment: Element[]): Map { + const ids = new Map(); + let counter = 0; + walkElements(segment, el => { + ids.set(el, `el-${counter++}`); + }); + return ids; +} + +export function invertElementIds(elementIds: Map): Map { + const idToElement = new Map(); + for (const [el, id] of elementIds) idToElement.set(id, el); + return idToElement; +} + +// --------------------------------------------------------------------------- +// Thread / reading order helpers +// --------------------------------------------------------------------------- + +function buildThreadsById(docRoot: Element): Map { + const map = new Map(); + const roots = childElements(docRoot).filter(el => localName(el) !== 'head'); + walkElements(roots, el => { + for (const child of childElements(el)) { + if (localName(child) !== 'thread') continue; + const threadId = child.getAttribute('thread_id'); + if (!threadId) continue; + if (!map.has(threadId)) map.set(threadId, []); + map.get(threadId)!.push(el); + } + }); + return map; +} + +export function buildElementPageMap(segments: Element[][]): Map { + const elementPage = new Map(); + segments.forEach((segment, idx) => { + const pageNum = idx + 1; + walkElements(segment, el => elementPage.set(el, pageNum)); + }); + return elementPage; +} + +export function buildThreadPagesById( + docRoot: Element, + elementPageByEl: Map +): Map> { + const threadPagesById = new Map>(); + for (const [threadId, elements] of buildThreadsById(docRoot)) { + const pages = new Set(); + for (const el of elements) { + const page = elementPageByEl.get(el); + if (page) pages.add(page); + } + if (pages.size) threadPagesById.set(threadId, pages); + } + return threadPagesById; +} + +export function buildThreadNavByElement(docRoot: Element): Map { + const nav = new Map(); + for (const [, elements] of buildThreadsById(docRoot)) { + for (let i = 0; i < elements.length; i += 1) { + nav.set(elements[i]!, { + prev: i > 0 ? elements[i - 1]! : null, + next: i < elements.length - 1 ? elements[i + 1]! : null, + }); + } + } + return nav; +} + +function isReadingOrderUnit(el: Element): boolean { + const tag = localName(el); + if (tag === 'caption') return true; + if ( + HEAD_TAGS.has(tag) || + tag === 'location' || + tag === 'h_thread' || + tag === 'page_break' + ) + return false; + if (tag === 'nl' || CELL_SPAN_TAGS.has(tag)) return false; + if (RENDER_FORMAT_TAGS.has(tag) || tag === 'src' || tag === 'checkbox') return false; + return true; +} + +export function computeReadingOrder(docRoot: Element): Element[] { + const bodyChildren = childElements(docRoot).filter(el => localName(el) !== 'head'); + const threadsById = buildThreadsById(docRoot); + const consumedViaXref = new Set(); + const order: Element[] = []; + + function record(el: Element): void { + if (!isReadingOrderUnit(el)) return; + order.push(el); + } + + function consumeThread(threadId: string): void { + for (const target of threadsById.get(threadId) ?? []) { + if (consumedViaXref.has(target)) continue; + consumedViaXref.add(target); + visitElement(target); + } + } + + function walkChildren(parent: Element): void { + for (const child of parent.childNodes) { + if (child.nodeType !== Node.ELEMENT_NODE) continue; + const el = child as Element; + const tag = localName(el); + if (tag === 'xref') { + const threadId = el.getAttribute('thread_id'); + if (threadId) consumeThread(threadId); + continue; + } + if (tag === 'page_break') continue; + visitElement(el); + } + } + + function visitElement(el: Element): void { + record(el); + walkChildren(el); + } + + for (const el of bodyChildren) { + if (localName(el) === 'page_break') continue; + if (consumedViaXref.has(el)) continue; + visitElement(el); + } + + return order; +} + +function isReadingOrderOverlayUnit(el: Element): boolean { + if (!isReadingOrderUnit(el)) return false; + if (isPictureContentElement(el) || isTableContentElement(el)) return false; + if (headLocations(el).length === 4) return true; + return isVirtualTextOverlayUnit(el); +} + +export function computeReadingOrderDisplayNumbers( + readingOrder: Element[] +): Map { + const numbers = new Map(); + let n = 0; + for (const el of readingOrder) { + if (!isReadingOrderOverlayUnit(el)) continue; + n += 1; + numbers.set(el, n); + } + return numbers; +} + +// --------------------------------------------------------------------------- +// Bounding box collection +// --------------------------------------------------------------------------- + +function pushBoundingBox( + boxes: BoundingBox[], + locs: Element[], + defaultResolution: Resolution, + kind: string, + tag: string, + elementId: string, + layer = 'body' +): void { + const [x0el, y0el, x1el, y1el] = locs as [Element, Element, Element, Element]; + const resW = locationResolution(x0el, defaultResolution.width); + const resH = locationResolution(y0el, defaultResolution.height); + boxes.push({ + kind, + tag, + elementId, + layer, + x0: parseInt(x0el.getAttribute('value') ?? '0', 10), + y0: parseInt(y0el.getAttribute('value') ?? '0', 10), + x1: parseInt(x1el.getAttribute('value') ?? '0', 10), + y1: parseInt(y1el.getAttribute('value') ?? '0', 10), + resW, + resH, + }); +} + +function collectListVirtualTextBoxes( + list: Element, + defaultResolution: Resolution, + boxes: BoundingBox[], + elementIds: Map +): void { + const nodes = [...list.childNodes]; + let i = skipContainerLevelHead(nodes, 0); + while (i < nodes.length) { + const node = nodes[i]!; + if (node.nodeType !== Node.ELEMENT_NODE || localName(node as Element) !== 'ldiv') { + i += 1; + continue; + } + i += 1; + const head = parseElementHeadAt(nodes, i); + if (head) { + const elementId = elementIds.get(node as Element); + if (elementId) { + pushBoundingBox( + boxes, + head.locs, + defaultResolution, + 'text', + 'text', + elementId, + elementLayer(node as Element) + ); + } + i = head.nextIndex; + } + i = skipUntilListItemBoundary(nodes, i); + } +} + +function collectTableVirtualTextBoxes( + container: Element, + defaultResolution: Resolution, + boxes: BoundingBox[], + elementIds: Map +): void { + const nodes = [...container.childNodes]; + let i = skipContainerLevelHead(nodes, 0); + while (i < nodes.length) { + const node = nodes[i]!; + if (node.nodeType !== Node.ELEMENT_NODE) { + i += 1; + continue; + } + const tag = localName(node as Element); + if (!isCellToken(tag)) { + i += 1; + continue; + } + if (tag === 'nl') { + i += 1; + continue; + } + i += 1; + const head = parseElementHeadAt(nodes, i); + if (head) { + const elementId = elementIds.get(node as Element); + if (elementId) { + pushBoundingBox( + boxes, + head.locs, + defaultResolution, + 'text', + 'text', + elementId, + elementLayer(node as Element) + ); + } + i = head.nextIndex; + } + i = skipUntilCellBoundary(nodes, i); + } +} + +export function collectBoundingBoxes( + segment: Element[], + defaultResolution: Resolution, + elementIds: Map +): BoundingBox[] { + const boxes: BoundingBox[] = []; + walkElements(segment, el => { + const locs = headLocations(el); + if (locs.length !== 4) return; + const elementId = elementIds.get(el); + if (!elementId) return; + pushBoundingBox( + boxes, + locs, + defaultResolution, + localName(el), + elementLabel(el), + elementId, + elementLayer(el) + ); + }); + walkElements(segment, el => { + const tag = localName(el); + if (tag === 'list') + collectListVirtualTextBoxes(el, defaultResolution, boxes, elementIds); + else if (OTSL_CONTAINER_TAGS.has(tag)) { + collectTableVirtualTextBoxes(el, defaultResolution, boxes, elementIds); + } + }); + return boxes; +} + +export function collectCaptionLinks( + segment: Element[], + elementIds: Map, + boxes: BoundingBox[] +): CaptionLink[] { + const boxById = new Map(boxes.map(b => [b.elementId, b])); + const links: CaptionLink[] = []; + walkElements(segment, el => { + if (localName(el) !== 'caption') return; + const captionId = elementIds.get(el); + const captionBox = captionId ? boxById.get(captionId) : null; + if (!captionBox) return; + const host = el.parentElement; + if (!host || headLocations(host).length !== 4) return; + const hostId = elementIds.get(host); + const hostBox = hostId ? boxById.get(hostId) : null; + if (!hostBox) return; + links.push({ + captionBox, + hostBox, + captionElementId: captionId!, + hostElementId: hostId!, + }); + }); + return links; +} + +export function collectXrefLinks( + segment: Element[], + elementIds: Map, + boxes: BoundingBox[] +): XrefLink[] { + const boxById = new Map(boxes.map(b => [b.elementId, b])); + const threadsById = new Map(); + + walkElements(segment, el => { + const elementId = elementIds.get(el); + const box = elementId ? boxById.get(elementId) : null; + if (!box) return; + for (const thread of childElements(el)) { + if (localName(thread) !== 'thread') continue; + const threadId = thread.getAttribute('thread_id'); + if (!threadId) continue; + if (!threadsById.has(threadId)) threadsById.set(threadId, []); + threadsById.get(threadId)!.push({ elementId: elementId!, box }); + } + }); + + const links: XrefLink[] = []; + walkElements(segment, el => { + const xrefs = childElements(el).filter(c => localName(c) === 'xref'); + if (!xrefs.length) return; + const from = findNearestLocatedBox(el, elementIds, boxById); + if (!from) return; + for (const xref of xrefs) { + const threadId = xref.getAttribute('thread_id'); + if (!threadId) continue; + for (const { elementId: toId, box: toBox } of threadsById.get(threadId) ?? []) { + if (toId === from.elementId) continue; + links.push({ + fromBox: from.box, + toBox, + fromElementId: from.elementId, + toElementId: toId, + }); + } + } + }); + return links; +} + +function findNearestLocatedBox( + el: Element, + elementIds: Map, + boxById: Map +): { elementId: string; box: BoundingBox } | null { + let node: Element | null = el; + while (node) { + const elementId = elementIds.get(node); + const box = elementId ? boxById.get(elementId) : null; + if (box) return { elementId: elementId!, box }; + if (localName(node) === 'doclang') break; + node = node.parentElement; + } + return null; +} + +export function collectFragmentLinks( + segment: Element[], + elementIds: Map, + boxes: BoundingBox[], + pageNum: number, + threadPagesById: Map> +): FragmentLink[] { + const boxById = new Map(boxes.map(b => [b.elementId, b])); + const onPage = new Map(); + + walkElements(segment, el => { + const thread = firstHeadChild(el, 'thread'); + const threadId = thread?.getAttribute('thread_id'); + if (!threadId) return; + const elementId = elementIds.get(el); + const box = elementId ? boxById.get(elementId) : null; + if (!box) return; + if (!onPage.has(threadId)) onPage.set(threadId, []); + onPage.get(threadId)!.push({ elementId: elementId!, box }); + }); + + const links: FragmentLink[] = []; + + for (const [threadId, members] of onPage) { + if (members.length >= 2) { + for (let i = 0; i < members.length - 1; i += 1) { + links.push({ + fromBox: members[i]!.box, + toBox: members[i + 1]!.box, + fromElementId: members[i]!.elementId, + toElementId: members[i + 1]!.elementId, + threadId, + }); + } + continue; + } + + if (members.length !== 1) continue; + const threadPages = threadPagesById.get(threadId); + if (!threadPages) continue; + const hasPrevious = [...threadPages].some(p => p < pageNum); + const hasFollowing = [...threadPages].some(p => p > pageNum); + if (!hasPrevious && !hasFollowing) continue; + + const base = { + fromBox: members[0]!.box, + toBox: null, + fromElementId: members[0]!.elementId, + toElementId: null, + threadId, + }; + if (hasPrevious) links.push({ ...base, targetCorner: 'tl' as const }); + if (hasFollowing) links.push({ ...base, targetCorner: 'br' as const }); + } + + return links; +} + +export function collectFragmentNavItems( + segment: Element[], + elementIds: Map, + boxes: BoundingBox[], + threadNavByElement: Map +): FragmentNavItem[] { + const boxById = new Map(boxes.map(b => [b.elementId, b])); + const items: FragmentNavItem[] = []; + walkElements(segment, el => { + const nav = threadNavByElement.get(el); + if (!nav || (!nav.prev && !nav.next)) return; + const elementId = elementIds.get(el); + const box = elementId ? boxById.get(elementId) : null; + if (!box) return; + items.push({ + elementId: elementId!, + box, + hasPrev: nav.prev !== null, + hasNext: nav.next !== null, + }); + }); + return items; +} + +export function collectReadingOrderSteps( + segment: Element[], + elementIds: Map, + boxes: BoundingBox[], + readingOrder: Element[], + globalNumbering = true, + displayNumbers: Map | null = null +): ReadingOrderStep[] { + const boxById = new Map(boxes.map(b => [b.elementId, b])); + const steps: ReadingOrderStep[] = []; + let pageOrder = 0; + + readingOrder.forEach(el => { + if (isPictureContentElement(el) || isTableContentElement(el)) return; + const elementId = elementIds.get(el); + if (!elementId) return; + const box = boxById.get(elementId); + if (!box) return; + pageOrder += 1; + steps.push({ + order: globalNumbering ? (displayNumbers?.get(el) ?? pageOrder) : pageOrder, + box, + elementId, + }); + }); + + return steps; +} + +// --------------------------------------------------------------------------- +// Build document state +// --------------------------------------------------------------------------- + +export function buildDocumentState( + markupXml: string, + pageImages: Map, + label: string, + assetUrls: Map, + { markupOnly }: { markupOnly: boolean } +): DocumentState | null { + const doc = new DOMParser().parseFromString(markupXml, 'application/xml'); + const parseError = doc.querySelector('parsererror'); + if (parseError) { + alert(`Invalid XML in ${label}`); + return null; + } + const root = doc.documentElement; + if (localName(root) !== 'doclang') { + alert(`${label}: root element must be `); + return null; + } + + const head = childElements(root).find(el => localName(el) === 'head') ?? null; + const defaultResolution = readDefaultResolution(head); + const segments = markupOnly + ? [childElements(root).filter(el => localName(el) !== 'head')] + : splitIntoSegments(root); + const hasPageView = !markupOnly && pageImages.size > 0; + const maxImagePage = hasPageView ? Math.max(...pageImages.keys()) : 0; + const pageCount = markupOnly ? 1 : Math.max(segments.length, maxImagePage, 1); + const readingOrder = computeReadingOrder(root); + const elementPageByEl = buildElementPageMap(segments); + + return { + pageImages, + assetUrls, + currentPage: 1, + pageCount, + segments, + defaultResolution, + elementIds: new Map(), + idToElement: new Map(), + hasPageView, + markupOnly, + docRoot: root, + threadPagesById: buildThreadPagesById(root, elementPageByEl), + elementPageByEl, + threadNavByElement: buildThreadNavByElement(root), + pendingSelectElement: null, + readingOrder, + readingOrderDisplayNumbers: computeReadingOrderDisplayNumbers(readingOrder), + pageViewOverlay: null, + }; +} + +// --------------------------------------------------------------------------- +// Archive extraction helpers +// --------------------------------------------------------------------------- + +export async function extractArchiveFromFiles(files: File[]): Promise<{ + markupXml: string; + pageImages: Map; + assetUrls: Map; +}> { + const markupFile = files.find(f => f.name === 'document.xml'); + if (!markupFile) throw new Error('Archive must contain document.xml at its root.'); + const markupXml = await markupFile.text(); + const pageImages = new Map(); + const assetUrls = new Map(); + for (const f of files) { + const relPath = f.webkitRelativePath || f.name; + const parts = relPath.split('/'); + if (parts.length >= 2 && parts[parts.length - 2] === 'pages') { + const m = PAGE_IMAGE_RE.exec(f.name); + if (m) pageImages.set(Number(m[1]), URL.createObjectURL(f)); + } + const assetPath = archiveRelativeAssetPath(relPath); + if (assetPath) assetUrls.set(assetPath, URL.createObjectURL(f)); + } + return { markupXml, pageImages, assetUrls }; +} + +export { extractArchiveFromZipBuffer }; + +export function revokeDocumentState(docState: DocumentState): void { + if (!docState) return; + for (const url of docState.pageImages.values()) { + if (url.startsWith('blob:')) URL.revokeObjectURL(url); + } + for (const url of docState.assetUrls.values()) { + if (url.startsWith('blob:')) URL.revokeObjectURL(url); + } +} + +export function serializeSegment(segment: Element[]): string { + return segment.map(el => serializeElement(el, 0)).join('\n'); +} + +function serializeElement(el: Element, depth: number): string { + const pad = ' '.repeat(depth); + const tag = localName(el); + const attrs = [...el.attributes] + .filter(a => a.name !== 'xmlns' || a.value !== DOCLANG_NS) + .map(a => `${a.name}="${a.value}"`) + .join(' '); + const attrStr = attrs ? ` ${attrs}` : ''; + + if (!el.childNodes.length) return `${pad}<${tag}${attrStr}/>`; + + const meaningfulText = [...el.childNodes].filter( + n => isTextLikeNode(n) && !isWhitespaceOnlyText(n) + ); + const textOnly = + meaningfulText.length > 0 && + meaningfulText.every(isTextLikeNode) && + !childElements(el).length; + if (textOnly) { + const text = serializeMarkupTextNodes(el.childNodes); + if (text) return `${pad}<${tag}${attrStr}>${text}`; + } + + const parts = [`${pad}<${tag}${attrStr}>`]; + for (const child of el.childNodes) { + if (isTextLikeNode(child)) { + const text = formatMarkupTextNode(child); + if (text) parts.push(' '.repeat(depth + 1) + text); + } else if (child.nodeType === Node.ELEMENT_NODE) { + parts.push(serializeElement(child as Element, depth + 1)); + } + } + parts.push(`${pad}`); + return parts.join('\n'); +} diff --git a/core/src/main.ts b/core/src/main.ts new file mode 100644 index 0000000..d23b307 --- /dev/null +++ b/core/src/main.ts @@ -0,0 +1,2071 @@ +/* DocLang Archive Viewer — archive format: github.com/doclang-project/doclang spec.md#doclang-archive-format */ + +import { + SUPPORTED_FILE_EXTENSIONS, + OPEN_FILE_HINT, + VIRTUAL_TEXT_TAG_HINT, + FRAGMENT_NAV_HINT_PREV, + FRAGMENT_NAV_HINT_NEXT, + NO_MARKUP, + NO_IMAGE, + FILE_THUMB_PLACEHOLDER_SVG, + PAGE_IMAGE_RE, + PAGE_ZOOM_DEFAULT, + PAGE_PAN_DRAG_THRESHOLD, + PAGE_WHEEL_COOLDOWN_MS, + PAGE_WHEEL_PIXEL_THRESHOLD, + PAGE_WHEEL_GESTURE_MS, + LAYOUT_STORAGE_KEY, + PANE_MIN_RATIO, + PANE_KEYS, + DEFAULT_PANE_RATIOS, + DEFAULT_USER_PANE_VISIBLE, + LAYOUT_STACK_BREAKPOINT_PX, + CELL_SPAN_TAGS, + OTSL_CONTAINER_TAGS, +} from './constants'; +import type { PaneKey } from './constants'; +import type { + DocumentState, + FileCatalogEntry, + PageLayoutCache, + PagePanDrag, + PaneDragState, + UserPaneVisible, + HeadInfo, +} from './types'; +import { + localName, + escapeHtml, + elementLabel, + elementHeadLocations, + elementThreadId, + firstHeadChild, + locationResolution, + isSemanticElement, + isVirtualTextHost, + isListOrOtslContainer, + xmlContains, + skipContainerLevelHead, + skipUntilListItemBoundary, + skipUntilCellBoundary, + isCellToken, +} from './xml'; +import { + buildDocumentState, + assignElementIds, + invertElementIds, + segmentHasMarkup, + collectBoundingBoxes, + collectCaptionLinks, + collectXrefLinks, + collectFragmentLinks, + collectFragmentNavItems, + collectReadingOrderSteps, + extractArchiveFromFiles, + extractArchiveFromZipBuffer, + revokeDocumentState, +} from './document'; +import { buildMarkupView } from './markup'; +import { + buildRenderedView, + applyReadingLayerClasses, + setStateAccessor, +} from './rendered'; +import { + buildOverlay, + syncOverlayBadges, + applyPageImageSize, + setOverlayAccessors, +} from './overlay'; +import { isPictureContentElement, isTableContentElement } from './xml_overlay'; +import { unzip } from './zip'; + +// --------------------------------------------------------------------------- +// Global mutable state +// --------------------------------------------------------------------------- + +let state: DocumentState | null = null; +let fileCatalog: FileCatalogEntry[] = []; +let activeFileIndex = -1; +let filePaneUserToggled = false; +let pagePaneResizeObserver: ResizeObserver | null = null; +let selectedElementId: string | null = null; +let showAllBboxes = true; +let showLayoutBadges = true; +let showCaptionLinks = false; +let showPictureContents = false; +let showTableContents = false; +let showFragmentLinks = false; +let showXrefLinks = false; +let showReadingOrder = false; +let showReadingOrderArrows = true; +let readingOrderGlobalNumbering = false; +let showReadingFurniture = true; +let showReadingBackground = true; +let pageSettingsOpen = false; +let readingSettingsOpen = false; +let pageZoomPercent = PAGE_ZOOM_DEFAULT; +let pagePanDrag: PagePanDrag | null = null; +let pagePanSuppressClick = false; +let pageLayoutCache: PageLayoutCache | null = null; +let userPaneVisible: UserPaneVisible = { ...DEFAULT_USER_PANE_VISIBLE }; +let paneRatios: number[] = [...DEFAULT_PANE_RATIOS]; +let filePaneWidthPx: number | null = null; +let toolbarOptionsOpen = false; +let paneDrag: PaneDragState | null = null; +let layoutStackQuery: MediaQueryList | null = null; +let pageLayoutFrame = 0; +let demoLoadInProgress = false; + +// Wire state accessors into sub-modules +setStateAccessor(() => state); +setOverlayAccessors({ + getPageZoomPercent: () => pageZoomPercent, + getPagePane: () => els.pagePane, + getPageLayoutCache: () => pageLayoutCache, + setPageLayoutCache: c => { pageLayoutCache = c; }, + getSelectedElementId: () => selectedElementId, +}); + +// --------------------------------------------------------------------------- +// DOM element references +// --------------------------------------------------------------------------- + +const els = { + openFileBtn: document.getElementById('open-file-btn'), + emptyStateFileTypes: document.getElementById('empty-state-file-types'), + docLabel: document.getElementById('doc-label'), + filePane: document.getElementById('file-pane'), + filePaneCloseAll: document.getElementById('btn-file-pane-close-all'), + pageNav: document.getElementById('page-nav'), + pageIndicator: document.getElementById('page-indicator'), + pageNumberInput: document.getElementById('page-number-input') as HTMLInputElement | null, + pageCountIndicator: document.getElementById('page-count-indicator'), + btnPrev: document.getElementById('btn-prev') as HTMLButtonElement | null, + btnNext: document.getElementById('btn-next') as HTMLButtonElement | null, + showAllBboxes: document.getElementById('show-all-bboxes') as HTMLInputElement | null, + showLayoutBadges: document.getElementById('show-layout-badges') as HTMLInputElement | null, + showLayoutBadgesLabel: document.getElementById('show-layout-badges-label'), + settingsToggle: document.getElementById('btn-settings-toggle'), + readingSettingsToggle: document.getElementById('btn-reading-settings-toggle'), + pageSettingsLayer: document.getElementById('page-settings-layer'), + pageSettingsScrim: document.getElementById('page-settings-scrim'), + pageSettingsClose: document.getElementById('btn-page-settings-close'), + readingSettingsLayer: document.getElementById('reading-settings-layer'), + readingSettingsScrim: document.getElementById('reading-settings-scrim'), + readingSettingsClose: document.getElementById('btn-reading-settings-close'), + showReadingFurniture: document.getElementById('show-reading-furniture') as HTMLInputElement | null, + showReadingFurnitureLabel: document.getElementById('show-reading-furniture-label'), + showReadingBackground: document.getElementById('show-reading-background') as HTMLInputElement | null, + showReadingBackgroundLabel: document.getElementById('show-reading-background-label'), + showCaptionLinks: document.getElementById('show-caption-links') as HTMLInputElement | null, + showCaptionLinksLabel: document.getElementById('show-caption-links-label'), + showPictureContents: document.getElementById('show-picture-contents') as HTMLInputElement | null, + showPictureContentsLabel: document.getElementById('show-picture-contents-label'), + showTableContents: document.getElementById('show-table-contents') as HTMLInputElement | null, + showTableContentsLabel: document.getElementById('show-table-contents-label'), + showFragmentLinks: document.getElementById('show-fragment-links') as HTMLInputElement | null, + showFragmentLinksLabel: document.getElementById('show-fragment-links-label'), + showXrefLinks: document.getElementById('show-xref-links') as HTMLInputElement | null, + showXrefLinksLabel: document.getElementById('show-xref-links-label'), + showReadingOrder: document.getElementById('show-reading-order') as HTMLInputElement | null, + showReadingOrderLabel: document.getElementById('show-reading-order-label'), + readingOrderArrows: document.getElementById('reading-order-arrows') as HTMLInputElement | null, + readingOrderArrowsLabel: document.getElementById('reading-order-arrows-label'), + readingOrderGlobal: document.getElementById('reading-order-global') as HTMLInputElement | null, + readingOrderGlobalLabel: document.getElementById('reading-order-global-label'), + pageZoom: document.getElementById('page-zoom') as HTMLInputElement | null, + pageZoomLabel: document.getElementById('page-zoom-label'), + pageZoomReset: document.getElementById('page-zoom-reset') as HTMLButtonElement | null, + main: document.getElementById('main'), + emptyState: document.getElementById('empty-state'), + emptyStateLoading: document.getElementById('empty-state-loading'), + emptyStatePrompt: document.getElementById('empty-state-prompt'), + markupPane: document.getElementById('markup-pane'), + renderedPane: document.getElementById('rendered-pane'), + pagePane: document.getElementById('page-pane'), + paneFile: document.querySelector('.pane-file') as HTMLElement | null, + panePageView: document.querySelector('.pane-page-view') as HTMLElement | null, + paneMarkup: document.querySelector('.pane-markup') as HTMLElement | null, + paneReading: document.querySelector('.pane-reading') as HTMLElement | null, + splitters: [ + document.getElementById('splitter-0'), + document.getElementById('splitter-1'), + document.getElementById('splitter-2'), + ] as (HTMLElement | null)[], + toolbarOptionsBtn: document.getElementById('btn-toolbar-options'), + toolbarOptionsPanel: document.getElementById('toolbar-options-panel'), + toggleFilePane: document.getElementById('toggle-file-pane') as HTMLInputElement | null, + toggleFilePaneLabel: document.getElementById('toggle-file-pane-label'), + togglePagePane: document.getElementById('toggle-page-pane') as HTMLInputElement | null, + toggleMarkupPane: document.getElementById('toggle-markup-pane') as HTMLInputElement | null, + toggleReadingPane: document.getElementById('toggle-reading-pane') as HTMLInputElement | null, + togglePagePaneLabel: document.getElementById('toggle-page-pane-label'), + resetPaneLayoutBtn: document.getElementById('btn-reset-pane-layout') as HTMLButtonElement | null, +}; + +// --------------------------------------------------------------------------- +// Cursor hint +// --------------------------------------------------------------------------- + +const cursorHintEl = document.getElementById('cursor-hint') as HTMLElement | null; +const CURSOR_HINT_OFFSET = 10; +const CURSOR_HINT_MARGIN = 8; + +function hideCursorHint(): void { + if (!cursorHintEl) return; + cursorHintEl.hidden = true; + cursorHintEl.classList.remove('cursor-hint-detail'); + cursorHintEl.replaceChildren(); +} + +function showCursorHint( + content: string | Node, + clientX: number, + clientY: number, + { detail = false } = {} +): void { + if (!cursorHintEl) return; + cursorHintEl.replaceChildren(); + if (typeof content === 'string') { + cursorHintEl.textContent = content; + } else { + cursorHintEl.appendChild(content); + } + cursorHintEl.classList.toggle('cursor-hint-detail', detail); + cursorHintEl.hidden = false; + + let left = clientX + CURSOR_HINT_OFFSET; + let top = clientY + CURSOR_HINT_OFFSET; + const rect = cursorHintEl.getBoundingClientRect(); + if (left + rect.width > window.innerWidth - CURSOR_HINT_MARGIN) { + left = clientX - rect.width - CURSOR_HINT_OFFSET; + } + if (top + rect.height > window.innerHeight - CURSOR_HINT_MARGIN) { + top = clientY - rect.height - CURSOR_HINT_OFFSET; + } + cursorHintEl.style.left = `${Math.max(CURSOR_HINT_MARGIN, left)}px`; + cursorHintEl.style.top = `${Math.max(CURSOR_HINT_MARGIN, top)}px`; +} + +function showCursorHintHtml(html: string, clientX: number, clientY: number): void { + if (!cursorHintEl) return; + cursorHintEl.innerHTML = html; + cursorHintEl.classList.add('cursor-hint-detail'); + cursorHintEl.hidden = false; + + let left = clientX + CURSOR_HINT_OFFSET; + let top = clientY + CURSOR_HINT_OFFSET; + const rect = cursorHintEl.getBoundingClientRect(); + if (left + rect.width > window.innerWidth - CURSOR_HINT_MARGIN) { + left = clientX - rect.width - CURSOR_HINT_OFFSET; + } + if (top + rect.height > window.innerHeight - CURSOR_HINT_MARGIN) { + top = clientY - rect.height - CURSOR_HINT_OFFSET; + } + cursorHintEl.style.left = `${Math.max(CURSOR_HINT_MARGIN, left)}px`; + cursorHintEl.style.top = `${Math.max(CURSOR_HINT_MARGIN, top)}px`; +} + +// --------------------------------------------------------------------------- +// Element head tooltip +// --------------------------------------------------------------------------- + +function headTextPreview(el: Element, maxLen = 72): string { + const text = el.textContent?.replace(/\s+/g, ' ').trim() ?? ''; + if (!text) return '—'; + return text.length > maxLen ? `${text.slice(0, maxLen)}…` : text; +} + +function collectElementHeadInfo(el: Element, defaultResolution: { width: number; height: number }): HeadInfo[] { + const labelEl = firstHeadChild(el, 'label'); + const threadEl = firstHeadChild(el, 'thread'); + const xrefEl = firstHeadChild(el, 'xref'); + const hrefEl = firstHeadChild(el, 'href'); + const layerEl = firstHeadChild(el, 'layer'); + const captionEl = firstHeadChild(el, 'caption'); + const descriptionEl = firstHeadChild(el, 'description'); + const summaryEl = firstHeadChild(el, 'summary'); + const customEl = firstHeadChild(el, 'custom'); + const locs = elementHeadLocations(el); + const rows: HeadInfo[] = [{ key: 'element', value: elementLabel(el), isDefault: false }]; + + rows.push({ + key: 'label', + value: labelEl?.getAttribute('value') ?? 'undefined', + isDefault: !labelEl?.hasAttribute('value'), + }); + + if (threadEl) { + rows.push({ key: 'thread_id', value: threadEl.getAttribute('thread_id') ?? '—', isDefault: false }); + } else { + rows.push({ key: 'thread', value: '—', isDefault: true }); + } + + if (xrefEl) { + rows.push({ key: 'xref', value: `thread_id ${xrefEl.getAttribute('thread_id') ?? '—'}`, isDefault: false }); + } else { + rows.push({ key: 'xref', value: '—', isDefault: true }); + } + + if (hrefEl) { + rows.push({ key: 'href', value: hrefEl.getAttribute('uri') ?? '—', isDefault: false }); + } else { + rows.push({ key: 'href', value: '—', isDefault: true }); + } + + rows.push({ + key: 'layer', + value: layerEl?.getAttribute('value') ?? 'body', + isDefault: !layerEl?.hasAttribute('value'), + }); + + const cornerLabels = ['x_min', 'y_min', 'x_max', 'y_max']; + if (locs.length === 4) { + for (let idx = 0; idx < 4; idx += 1) { + const loc = locs[idx]!; + const axisDefault = idx % 2 === 0 ? defaultResolution.width : defaultResolution.height; + const resolution = locationResolution(loc, axisDefault); + const value = loc.getAttribute('value') ?? '0'; + rows.push({ key: cornerLabels[idx]!, value: `${value} @ ${resolution}`, isDefault: false }); + } + } else { + for (const key of cornerLabels) { + rows.push({ key, value: '—', isDefault: false }); + } + } + + rows.push({ key: 'caption', value: captionEl ? headTextPreview(captionEl) : '—', isDefault: !captionEl }); + rows.push({ key: 'description', value: descriptionEl ? headTextPreview(descriptionEl) : '—', isDefault: !descriptionEl }); + rows.push({ key: 'summary', value: summaryEl ? headTextPreview(summaryEl) : '—', isDefault: !summaryEl }); + rows.push({ key: 'custom', value: customEl ? headTextPreview(customEl) : '—', isDefault: !customEl }); + + return rows; +} + +function elementHeadTooltipHtml(el: Element, defaultResolution: { width: number; height: number }): string { + const rows = collectElementHeadInfo(el, defaultResolution); + const body = rows + .map(({ key, value, isDefault }) => { + const rendered = escapeHtml(value); + const suffix = isDefault ? ' (default)' : ''; + return `${escapeHtml(key)}${rendered}${suffix}`; + }) + .join(''); + return `${body}
`; +} + +// --------------------------------------------------------------------------- +// Reading layer +// --------------------------------------------------------------------------- + +function syncReadingLayerCheckboxes(): void { + if (els.showReadingFurniture) els.showReadingFurniture.checked = showReadingFurniture; + if (els.showReadingBackground) els.showReadingBackground.checked = showReadingBackground; +} + +function syncReadingLayerVisibility(): void { + const root = els.renderedPane?.querySelector('.rendered-doc') as HTMLElement | null; + if (root) applyReadingLayerClasses(root, showReadingFurniture, showReadingBackground); +} + +// --------------------------------------------------------------------------- +// Page zoom +// --------------------------------------------------------------------------- + +function updatePageZoomResetButton(): void { + if (!els.pageZoomReset) return; + els.pageZoomReset.textContent = `${pageZoomPercent}%`; + els.pageZoomReset.disabled = pageZoomPercent === PAGE_ZOOM_DEFAULT; +} + +function pageViewScrollPane(): HTMLElement | null { + if (!els.pagePane) return null; + return (els.pagePane.querySelector('.page-view-port') as HTMLElement | null) ?? els.pagePane; +} + +function isPagePaneScrollable(): boolean { + const pane = pageViewScrollPane(); + if (!pane) return false; + return pane.scrollWidth > pane.clientWidth || pane.scrollHeight > pane.clientHeight; +} + +function resetPageZoom(): void { + pageZoomPercent = PAGE_ZOOM_DEFAULT; + if (els.pageZoom) { + els.pageZoom.value = String(PAGE_ZOOM_DEFAULT); + els.pageZoom.setAttribute('aria-valuenow', String(PAGE_ZOOM_DEFAULT)); + } + updatePageZoomResetButton(); + const port = pageViewScrollPane(); + if (port) { port.scrollLeft = 0; port.scrollTop = 0; } + refreshPageViewLayout(); +} + +function refreshPageViewLayout(): void { + if (!els.pagePane) return; + cancelAnimationFrame(pageLayoutFrame); + pageLayoutFrame = requestAnimationFrame(() => { + pageLayoutFrame = 0; + const img = els.pagePane?.querySelector('.page-view img') as HTMLImageElement | null; + if (img?.naturalWidth) applyPageLayout(img); + }); +} + +function applyPageLayout(img: HTMLImageElement, pane = els.pagePane!): void { + applyPageImageSize(img, pane); + syncPageViewChrome(img); +} + +function syncPageViewChrome(img: HTMLImageElement): void { + const svg = img.parentElement?.querySelector('svg.overlay') as SVGSVGElement | null; + if (svg && state?.pageViewOverlay) { + syncOverlayBadges( + img, svg, + state.pageViewOverlay.boxes, + state.pageViewOverlay.readingOrderSteps, + showAllBboxes, showLayoutBadges, showReadingOrder + ); + } + updatePagePanePanCursor(); + applyBboxVisibility(); +} + +function updatePagePanePanCursor(): void { + if (!els.pagePane) return; + els.pagePane.classList.toggle('can-pan', isPagePaneScrollable() && !pagePanDrag); +} + +// --------------------------------------------------------------------------- +// Settings open/close +// --------------------------------------------------------------------------- + +function setPageSettingsOpen(open: boolean): void { + pageSettingsOpen = open; + if (els.pageSettingsLayer) els.pageSettingsLayer.hidden = !open; + if (els.settingsToggle) els.settingsToggle.setAttribute('aria-expanded', String(open)); +} + +function setReadingSettingsOpen(open: boolean): void { + readingSettingsOpen = open; + if (els.readingSettingsLayer) els.readingSettingsLayer.hidden = !open; + if (els.readingSettingsToggle) els.readingSettingsToggle.setAttribute('aria-expanded', String(open)); +} + +function closeAllSettings(): void { + setPageSettingsOpen(false); + setReadingSettingsOpen(false); +} + +// --------------------------------------------------------------------------- +// Layout subtoggle sync +// --------------------------------------------------------------------------- + +function syncLayoutSubtoggles(): void { + const layoutEnabled = Boolean(state?.hasPageView && showAllBboxes); + for (const label of [ + els.showLayoutBadgesLabel, els.showPictureContentsLabel, els.showTableContentsLabel, + els.showFragmentLinksLabel, els.showCaptionLinksLabel, els.showXrefLinksLabel, els.showReadingOrderLabel, + ]) { + if (!label) continue; + label.classList.toggle('settings-option-disabled', !layoutEnabled); + const input = label.querySelector('input') as HTMLInputElement | null; + if (input) input.disabled = !layoutEnabled; + } + const readingOrderEnabled = layoutEnabled && showReadingOrder; + for (const label of [els.readingOrderArrowsLabel, els.readingOrderGlobalLabel]) { + if (!label) continue; + label.classList.toggle('settings-option-disabled', !readingOrderEnabled); + const input = label.querySelector('input') as HTMLInputElement | null; + if (input) input.disabled = !readingOrderEnabled; + } +} + +// --------------------------------------------------------------------------- +// Pane layout +// --------------------------------------------------------------------------- + +function paneDef(key: PaneKey): { key: PaneKey; el: HTMLElement | null; canShow: () => boolean } { + const defs = { + file: { key: 'file' as PaneKey, el: els.paneFile, canShow: () => fileCatalog.length > 0 }, + page: { key: 'page' as PaneKey, el: els.panePageView, canShow: () => Boolean(state?.hasPageView) }, + markup: { key: 'markup' as PaneKey, el: els.paneMarkup, canShow: () => Boolean(state) }, + reading: { key: 'reading' as PaneKey, el: els.paneReading, canShow: () => Boolean(state) }, + }; + return defs[key]; +} + +function isPaneAvailable(key: PaneKey): boolean { + return paneDef(key)?.canShow() ?? false; +} + +function isPaneVisible(key: PaneKey): boolean { + if (!isPaneAvailable(key)) return false; + return Boolean(userPaneVisible[key]); +} + +function visiblePaneKeys(): PaneKey[] { + return [...PANE_KEYS].filter(key => isPaneVisible(key)); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function paneMinRatio(_unused: PaneKey): number { + return PANE_MIN_RATIO; +} + +function filePaneFitWidthPx(): number { + const probe = document.createElement('div'); + probe.style.cssText = 'position:absolute;visibility:hidden;width:var(--file-pane-fit-width);'; + document.documentElement.appendChild(probe); + const px = probe.getBoundingClientRect().width; + probe.remove(); + return Math.ceil(px) || 108; +} + +function resolvedFilePaneWidthPx(): number { + const fit = filePaneFitWidthPx(); + return Math.max(fit, filePaneWidthPx ?? fit); +} + +function contentPaneFrWeights(keys: PaneKey[]): number[] { + const contentKeys = keys.filter(key => key !== 'file'); + const weights = contentKeys.map(key => paneRatios[paneRatioIndex(key)]!); + const sum = weights.reduce((a, b) => a + b, 0) || contentKeys.length; + return weights.map(w => w / sum); +} + +function paneRatioIndex(key: PaneKey): number { + return PANE_KEYS.indexOf(key); +} + +function normalizePaneRatios(): void { + const sum = paneRatios.reduce((a, b) => a + b, 0); + if (sum <= 0) { paneRatios = [...DEFAULT_PANE_RATIOS]; return; } + paneRatios = paneRatios.map(r => r / sum); +} + +function loadLayoutPrefs(): void { + try { + const raw = localStorage.getItem(LAYOUT_STORAGE_KEY); + if (!raw) return; + const data = JSON.parse(raw) as { visible?: Record; ratios?: unknown[]; filePaneWidthPx?: unknown }; + if (data?.visible && typeof data.visible === 'object') { + for (const key of PANE_KEYS) { + if (typeof data.visible[key] === 'boolean') userPaneVisible[key] = data.visible[key] as boolean; + } + if (typeof data.visible['file'] === 'boolean') filePaneUserToggled = true; + } + if (Array.isArray(data?.ratios)) { + const valid = data.ratios.every(n => typeof n === 'number' && n > 0); + if (valid && data.ratios.length === 4) { + paneRatios = [...(data.ratios as number[])]; + normalizePaneRatios(); + } else if (valid && data.ratios.length === 3) { + paneRatios = [1, ...(data.ratios as number[])]; + normalizePaneRatios(); + } + } + if (typeof data?.filePaneWidthPx === 'number' && data.filePaneWidthPx > 0) { + filePaneWidthPx = data.filePaneWidthPx; + } + } catch { /* ignore invalid stored layout */ } +} + +function saveLayoutPrefs(): void { + try { + localStorage.setItem( + LAYOUT_STORAGE_KEY, + JSON.stringify({ visible: userPaneVisible, ratios: paneRatios, filePaneWidthPx }) + ); + } catch { /* ignore quota / private mode */ } +} + +function resetPaneLayout(): void { + filePaneUserToggled = false; + userPaneVisible = { file: defaultFilePaneVisible(), page: true, markup: true, reading: true }; + paneRatios = [...DEFAULT_PANE_RATIOS]; + normalizePaneRatios(); + filePaneWidthPx = null; + setReadingSettingsOpen(false); + syncPagePaneControls(); + syncToolbarPaneCheckboxes(); + saveLayoutPrefs(); + applyPaneLayout(); +} + +function isLayoutStacked(): boolean { + return Boolean(layoutStackQuery?.matches); +} + +function initLayoutStackListener(): void { + if (!els.main) return; + layoutStackQuery = window.matchMedia(`(max-width: ${LAYOUT_STACK_BREAKPOINT_PX}px)`); + const onChange = (): void => applyPaneLayout(); + layoutStackQuery.addEventListener('change', onChange); + onChange(); +} + +function paneKeysAdjacent(leftKey: PaneKey, rightKey: PaneKey): boolean { + const leftIdx = PANE_KEYS.indexOf(leftKey); + const rightIdx = PANE_KEYS.indexOf(rightKey); + return leftIdx >= 0 && rightIdx === leftIdx + 1; +} + +function onlyHiddenPanesBetween(leftKey: PaneKey, rightKey: PaneKey): boolean { + const leftIdx = PANE_KEYS.indexOf(leftKey); + const rightIdx = PANE_KEYS.indexOf(rightKey); + if (leftIdx < 0 || rightIdx <= leftIdx) return false; + for (let i = leftIdx + 1; i < rightIdx; i++) { + if (isPaneVisible(PANE_KEYS[i]!)) return false; + } + return true; +} + +function shouldShowSplitterBetween(leftKey: PaneKey, rightKey: PaneKey): boolean { + if (!isPaneVisible(leftKey) || !isPaneVisible(rightKey)) return false; + if (paneKeysAdjacent(leftKey, rightKey)) return true; + return onlyHiddenPanesBetween(leftKey, rightKey); +} + +function splitterForLayoutGap(leftKey: PaneKey, rightKey: PaneKey): HTMLElement | null { + if (!shouldShowSplitterBetween(leftKey, rightKey)) return null; + const leftIdx = PANE_KEYS.indexOf(leftKey); + if (leftIdx < 0) return null; + return els.splitters[leftIdx] ?? null; +} + +function visiblePaneNeighborAfter(key: PaneKey): PaneKey | null { + const idx = PANE_KEYS.indexOf(key); + if (idx < 0) return null; + for (let i = idx + 1; i < PANE_KEYS.length; i++) { + const neighbor = PANE_KEYS[i]!; + if (isPaneVisible(neighbor)) return neighbor; + } + return null; +} + +function visiblePaneNeighborBefore(key: PaneKey): PaneKey | null { + const idx = PANE_KEYS.indexOf(key); + if (idx < 0) return null; + for (let i = idx - 1; i >= 0; i--) { + const neighbor = PANE_KEYS[i]!; + if (isPaneVisible(neighbor)) return neighbor; + } + return null; +} + +function resolvedPhysicalSplitterKeys(physicalSplitterIndex: number): { leftKey: PaneKey; rightKey: PaneKey } | null { + const leftPhysical = PANE_KEYS[physicalSplitterIndex]; + const rightPhysical = PANE_KEYS[physicalSplitterIndex + 1]; + if (!leftPhysical || !rightPhysical) return null; + const leftKey = isPaneVisible(leftPhysical) ? leftPhysical : visiblePaneNeighborBefore(rightPhysical); + const rightKey = isPaneVisible(rightPhysical) ? rightPhysical : visiblePaneNeighborAfter(leftPhysical); + if (!leftKey || !rightKey || leftKey === rightKey) return null; + if (!shouldShowSplitterBetween(leftKey, rightKey)) return null; + const canonical = splitterForLayoutGap(leftKey, rightKey); + if (!canonical || canonical !== els.splitters[physicalSplitterIndex]) return null; + return { leftKey, rightKey }; +} + +function resetPaneGridStyles(): void { + for (const key of PANE_KEYS) { + const el = paneDef(key)?.el; + if (el) { el.style.gridColumn = ''; el.style.gridRow = ''; } + } + for (const splitter of els.splitters) { + if (!splitter) continue; + splitter.style.gridColumn = ''; + splitter.style.gridRow = ''; + splitter.hidden = true; + } +} + +function setUserPaneVisible(key: PaneKey, visible: boolean): void { + userPaneVisible[key] = visible; + if (key === 'file') filePaneUserToggled = true; + if (key === 'page') syncPagePaneControls(); + if (key === 'reading' && !visible) setReadingSettingsOpen(false); + syncToolbarPaneCheckboxes(); + saveLayoutPrefs(); + applyPaneLayout(); +} + +function applyPaneLayout(): void { + if (!els.main) return; + const stacked = document.body.classList.contains('viewer-loaded') && isLayoutStacked(); + els.main.classList.toggle('layout-stacked', stacked); + + for (const key of PANE_KEYS) { + const def = paneDef(key); + if (!def?.el) continue; + def.el.hidden = !isPaneVisible(key); + def.el.classList.remove('pane-layout-last'); + } + + if (!document.body.classList.contains('viewer-loaded')) { + resetPaneGridStyles(); + els.main.style.gridTemplateColumns = ''; + els.main.style.gridTemplateRows = ''; + return; + } + + let keys = visiblePaneKeys(); + if (!keys.length) { userPaneVisible.markup = true; keys = visiblePaneKeys(); } + + const lastKey = keys[keys.length - 1]!; + paneDef(lastKey)?.el?.classList.add('pane-layout-last'); + resetPaneGridStyles(); + + if (stacked) { + els.main.style.gridTemplateRows = ''; + els.main.style.gridTemplateColumns = '1fr'; + let row = 1; + for (const key of keys) { + const def = paneDef(key); + if (!def?.el) continue; + def.el.style.gridRow = String(row++); + } + refreshPageViewLayout(); + if (els.readingSettingsToggle) els.readingSettingsToggle.hidden = !state || !isPaneVisible('reading'); + return; + } + + const contentFr = contentPaneFrWeights(keys); + const cols: string[] = []; + let contentFrIndex = 0; + keys.forEach((key, index) => { + if (key === 'file') { + cols.push(`${resolvedFilePaneWidthPx()}px`); + } else { + cols.push(`minmax(0, ${contentFr[contentFrIndex++]!.toFixed(6)}fr)`); + } + if (index < keys.length - 1 && shouldShowSplitterBetween(keys[index]!, keys[index + 1]!)) { + cols.push('1px'); + } + }); + els.main.style.gridTemplateColumns = cols.join(' '); + els.main.style.gridTemplateRows = 'minmax(0, 1fr)'; + + let col = 1; + keys.forEach((key, index) => { + const def = paneDef(key); + if (!def?.el) return; + def.el.style.gridColumn = String(col); + col += 1; + if (index < keys.length - 1) { + const leftKey = keys[index]!; + const rightKey = keys[index + 1]!; + if (!shouldShowSplitterBetween(leftKey, rightKey)) return; + const splitter = splitterForLayoutGap(leftKey, rightKey); + if (splitter) { splitter.hidden = false; splitter.style.gridColumn = String(col); col += 1; } + } + }); + + refreshPageViewLayout(); + if (els.readingSettingsToggle) els.readingSettingsToggle.hidden = !state || !isPaneVisible('reading'); +} + +// --------------------------------------------------------------------------- +// Toolbar options +// --------------------------------------------------------------------------- + +function setToolbarOptionsOpen(open: boolean): void { + toolbarOptionsOpen = open; + if (els.toolbarOptionsPanel) els.toolbarOptionsPanel.hidden = !open; + if (els.toolbarOptionsBtn) els.toolbarOptionsBtn.setAttribute('aria-expanded', String(open)); +} + +function syncToolbarPaneCheckboxes(): void { + if (els.toggleFilePane) { + const available = isPaneAvailable('file'); + els.toggleFilePane.checked = available && userPaneVisible.file; + els.toggleFilePane.disabled = !available; + } + if (els.toggleFilePaneLabel) els.toggleFilePaneLabel.classList.toggle('toolbar-options-item-disabled', !isPaneAvailable('file')); + if (els.togglePagePane) { + const available = isPaneAvailable('page'); + els.togglePagePane.checked = available && userPaneVisible.page; + els.togglePagePane.disabled = !available; + } + if (els.togglePagePaneLabel) els.togglePagePaneLabel.classList.toggle('toolbar-options-item-disabled', !isPaneAvailable('page')); + if (els.toggleMarkupPane) { els.toggleMarkupPane.checked = userPaneVisible.markup; els.toggleMarkupPane.disabled = !state; } + if (els.toggleReadingPane) { els.toggleReadingPane.checked = userPaneVisible.reading; els.toggleReadingPane.disabled = !state; } + for (const label of [ + els.toggleFilePaneLabel, els.togglePagePaneLabel, + document.getElementById('toggle-markup-pane-label'), + document.getElementById('toggle-reading-pane-label'), + ]) { + if (!label) continue; + const input = label.querySelector('input') as HTMLInputElement | null; + label.classList.toggle('toolbar-options-item-disabled', Boolean(input?.disabled)); + } + if (els.resetPaneLayoutBtn) els.resetPaneLayoutBtn.disabled = !state; +} + +function initToolbarOptions(): void { + if (!els.toolbarOptionsBtn || !els.toolbarOptionsPanel) return; + els.toolbarOptionsBtn.addEventListener('click', e => { + e.stopPropagation(); + setToolbarOptionsOpen(!toolbarOptionsOpen); + }); + document.addEventListener('click', e => { + if (!toolbarOptionsOpen) return; + if (e.target instanceof Node && els.toolbarOptionsPanel!.contains(e.target)) return; + if (e.target instanceof Node && els.toolbarOptionsBtn!.contains(e.target)) return; + setToolbarOptionsOpen(false); + }); + const onToggle = (key: PaneKey, input: HTMLInputElement | null): void => { + input?.addEventListener('change', () => { + if (!state) return; + const nextKeys = [...PANE_KEYS].filter(k => (k === key ? input.checked : userPaneVisible[k] && isPaneAvailable(k))); + if (!nextKeys.length) { input.checked = true; return; } + setUserPaneVisible(key, input.checked); + }); + }; + onToggle('file', els.toggleFilePane); + onToggle('page', els.togglePagePane); + onToggle('markup', els.toggleMarkupPane); + onToggle('reading', els.toggleReadingPane); + els.resetPaneLayoutBtn?.addEventListener('click', () => { if (state) resetPaneLayout(); }); + syncToolbarPaneCheckboxes(); +} + +// --------------------------------------------------------------------------- +// Pane splitter drag +// --------------------------------------------------------------------------- + +function contentPaneAvailableWidthPx(): number { + if (!els.main) return 1; + const rect = els.main.getBoundingClientRect(); + const keys = visiblePaneKeys(); + let reserved = 0; + if (keys.includes('file')) reserved += resolvedFilePaneWidthPx(); + for (let i = 0; i < keys.length - 1; i++) { + if (shouldShowSplitterBetween(keys[i]!, keys[i + 1]!)) reserved += 1; + } + return Math.max(rect.width - reserved, 1); +} + +function startPaneDrag(e: PointerEvent, physicalSplitterIndex: number): void { + if (e.button !== 0 || isLayoutStacked() || !document.body.classList.contains('viewer-loaded')) return; + const resolved = resolvedPhysicalSplitterKeys(physicalSplitterIndex); + if (!resolved) return; + const { leftKey, rightKey } = resolved; + normalizePaneRatios(); + const leftIndex = paneRatioIndex(leftKey); + const rightIndex = paneRatioIndex(rightKey); + const dragState: PaneDragState = { + physicalSplitterIndex, leftKey, rightKey, + startX: e.clientX, + leftStart: paneRatios[leftIndex]!, + rightStart: paneRatios[rightIndex]!, + pointerId: e.pointerId, + }; + if (leftKey === 'file') dragState.leftStartPx = resolvedFilePaneWidthPx(); + else if (rightKey === 'file') dragState.rightStartPx = resolvedFilePaneWidthPx(); + paneDrag = dragState; + e.preventDefault(); + (e.currentTarget as Element).setPointerCapture(e.pointerId); + (e.currentTarget as Element).classList.add('is-dragging'); + document.body.classList.add('pane-drag-active'); +} + +function onPaneDragMove(e: PointerEvent): void { + if (!paneDrag || e.pointerId !== paneDrag.pointerId || !els.main) return; + if (paneDrag.leftKey === 'file' && typeof paneDrag.leftStartPx === 'number') { + filePaneWidthPx = Math.max(filePaneFitWidthPx(), paneDrag.leftStartPx + (e.clientX - paneDrag.startX)); + applyPaneLayout(); return; + } + if (paneDrag.rightKey === 'file' && typeof paneDrag.rightStartPx === 'number') { + filePaneWidthPx = Math.max(filePaneFitWidthPx(), paneDrag.rightStartPx - (e.clientX - paneDrag.startX)); + applyPaneLayout(); return; + } + const keys = visiblePaneKeys(); + const contentFr = contentPaneFrWeights(keys); + const contentKeys = keys.filter((key): key is Exclude => key !== 'file'); + const leftContentIndex = contentKeys.indexOf(paneDrag.leftKey as Exclude); + if (leftContentIndex < 0 || leftContentIndex + 1 >= contentFr.length) return; + const pairFrTotal = contentFr[leftContentIndex]! + contentFr[leftContentIndex + 1]!; + if (!(pairFrTotal > 0)) return; + const pairPixels = Math.max(contentPaneAvailableWidthPx() * pairFrTotal, 1); + const deltaRatio = (e.clientX - paneDrag.startX) / pairPixels; + const leftIndex = paneRatioIndex(paneDrag.leftKey); + const rightIndex = paneRatioIndex(paneDrag.rightKey); + const pairStoredTotal = paneDrag.leftStart + paneDrag.rightStart; + if (!(pairStoredTotal > 0) || leftIndex < 0 || rightIndex < 0) return; + let nextLeft = paneDrag.leftStart + deltaRatio * pairStoredTotal; + const leftMin = Math.min(paneMinRatio(paneDrag.leftKey), pairStoredTotal / 2); + const rightMin = Math.min(paneMinRatio(paneDrag.rightKey), pairStoredTotal / 2); + nextLeft = Math.min(Math.max(nextLeft, leftMin), pairStoredTotal - rightMin); + paneRatios[leftIndex] = nextLeft; + paneRatios[rightIndex] = pairStoredTotal - nextLeft; + applyPaneLayout(); +} + +function endPaneDrag(e: PointerEvent): void { + if (!paneDrag || e.pointerId !== paneDrag.pointerId) return; + const splitter = els.splitters[paneDrag.physicalSplitterIndex]; + splitter?.classList.remove('is-dragging'); + if (splitter?.hasPointerCapture(e.pointerId)) splitter.releasePointerCapture(e.pointerId); + paneDrag = null; + document.body.classList.remove('pane-drag-active'); + normalizePaneRatios(); + saveLayoutPrefs(); +} + +function initPaneSplitters(): void { + for (const [index, splitter] of els.splitters.entries()) { + if (!splitter) continue; + splitter.addEventListener('pointerdown', e => startPaneDrag(e as PointerEvent, index)); + } + window.addEventListener('pointermove', e => onPaneDragMove(e as PointerEvent)); + window.addEventListener('pointerup', e => endPaneDrag(e as PointerEvent)); + window.addEventListener('pointercancel', e => endPaneDrag(e as PointerEvent)); +} + +// --------------------------------------------------------------------------- +// Page navigation +// --------------------------------------------------------------------------- + +function goToPage(n: number): void { + if (!state) return; + setPageSettingsOpen(false); + const page = Math.min(Math.max(1, n), state.pageCount); + state.currentPage = page; + renderPage(page); +} + +function resetPageNumberInput(): void { + if (!els.pageNumberInput) return; + els.pageNumberInput.value = state ? String(state.currentPage) : '1'; +} + +function commitPageNumberInput(): void { + if (!state || !els.pageNumberInput) return; + const n = Number.parseInt(els.pageNumberInput.value.trim(), 10); + if (!Number.isFinite(n)) { resetPageNumberInput(); return; } + goToPage(n); +} + +function setPageIndicator(pageNum: number, pageCount: number): void { + if (!els.pageIndicator) return; + if (els.pageCountIndicator) els.pageCountIndicator.textContent = `\u00A0of ${pageCount}`; + if (els.pageNumberInput) { + const digits = Math.max(1, String(pageCount).length); + els.pageNumberInput.style.setProperty('--page-num-digits', String(digits)); + els.pageNumberInput.disabled = !state; + if (document.activeElement !== els.pageNumberInput) els.pageNumberInput.value = String(pageNum); + } +} + +function syncPagePaneControls(): void { + const pageVisible = isPaneVisible('page'); + if (els.settingsToggle) els.settingsToggle.hidden = !pageVisible; + if (els.pageZoomLabel) els.pageZoomLabel.hidden = !pageVisible; + if (pageVisible) updatePageZoomResetButton(); + if (els.pagePane) els.pagePane.tabIndex = pageVisible ? 0 : -1; +} + +// --------------------------------------------------------------------------- +// Fragment thread navigation +// --------------------------------------------------------------------------- + +function findElementIdOnPage(el: Element): string | null { + if (!state?.elementIds) return null; + for (const [node, id] of state.elementIds) { + if (node === el) return id; + } + return null; +} + +function navigateThreadFragment(elementId: string, direction: string): void { + const el = state?.idToElement?.get(elementId); + if (!el) return; + const nav = state?.threadNavByElement?.get(el); + if (!nav) return; + const target = direction === 'prev' ? nav.prev : nav.next; + if (!target) return; + const page = state!.elementPageByEl.get(target); + if (!page) return; + if (page === state!.currentPage) { + const id = findElementIdOnPage(target); + if (id) selectElement(id); + return; + } + state!.pendingSelectElement = target; + goToPage(page); +} + +// --------------------------------------------------------------------------- +// Selection +// --------------------------------------------------------------------------- + +function findListVirtualTextHost(list: Element, target: Element): Element | null { + const nodes = [...list.childNodes] as ChildNode[]; + let i = skipContainerLevelHead(nodes, 0); + while (i < nodes.length) { + const node = nodes[i]!; + if (node.nodeType !== Node.ELEMENT_NODE || localName(node as Element) !== 'ldiv') { i += 1; continue; } + const ldiv = node as Element; i += 1; + const end = skipUntilListItemBoundary(nodes, i); + if (target === ldiv || nodes.slice(i, end).some(n => xmlContains(target, n))) return ldiv; + i = end; + } + return null; +} + +function findTableVirtualTextHost(container: Element, target: Element): Element | null { + const nodes = [...container.childNodes] as ChildNode[]; + let i = skipContainerLevelHead(nodes, 0); + while (i < nodes.length) { + const node = nodes[i]!; + if (node.nodeType !== Node.ELEMENT_NODE) { i += 1; continue; } + const tag = localName(node as Element); + if (tag === 'nl' || isVirtualTextHost(node as Element) || CELL_SPAN_TAGS.has(tag) || !isCellToken(tag)) { i += 1; continue; } + const cell = node as Element; i += 1; + const end = skipUntilCellBoundary(nodes, i); + if (target === cell || nodes.slice(i, end).some(n => xmlContains(target, n))) return cell; + i = end; + } + return null; +} + +function findVirtualTextHost(xmlEl: Element): Element | null { + let node: Element | null = xmlEl; + while (node) { + const parent: Element | null = node.parentElement; + if (!parent) return null; + const tag = localName(parent); + if (tag === 'list') { const host = findListVirtualTextHost(parent, xmlEl); if (host) return host; } + if (OTSL_CONTAINER_TAGS.has(tag)) { const host = findTableVirtualTextHost(parent, xmlEl); if (host) return host; } + node = parent; + } + return null; +} + +function resolveSelectionElement(xmlEl: Element | null): Element | null { + if (!xmlEl) return null; + if (isSemanticElement(xmlEl) || isVirtualTextHost(xmlEl)) return xmlEl; + let node: Element | null = xmlEl.parentElement; + while (node) { + if (localName(node) === 'doclang') break; + if (isSemanticElement(node) && !isListOrOtslContainer(node)) return node; + node = node.parentElement; + } + const virtualHost = findVirtualTextHost(xmlEl); + if (virtualHost) return virtualHost; + node = xmlEl.parentElement; + while (node) { + if (localName(node) === 'doclang') break; + if (isSemanticElement(node) || isVirtualTextHost(node)) return node; + node = node.parentElement; + } + return null; +} + +function resolveSelectionElementId(rawElementId: string | null): string | null { + if (!rawElementId || !state?.idToElement || !state.elementIds) return null; + const xmlEl = state.idToElement.get(rawElementId); + if (!xmlEl) return null; + const resolved = resolveSelectionElement(xmlEl); + return resolved ? (state.elementIds.get(resolved) ?? null) : null; +} + +function selectElement(elementId: string): void { + if (!elementId) return; + selectedElementId = elementId; + applySelection(); +} + +function clearSelection(): void { + selectedElementId = null; + applySelection(); +} + +function fragmentPeerElementIds(elementId: string): Set { + const peers = new Set(); + if (!elementId || !state?.elementIds || !state.idToElement) return peers; + const el = state.idToElement.get(elementId); + const threadId = el ? elementThreadId(el) : null; + if (!threadId) return peers; + for (const [node, id] of state.elementIds) { + if (elementThreadId(node) === threadId) peers.add(id); + } + return peers; +} + +function isPictureContentOverlayElement(elementId: string): boolean { + return isPictureContentElement(state?.idToElement?.get(elementId) ?? null); +} +function isTableContentOverlayElement(elementId: string): boolean { + return isTableContentElement(state?.idToElement?.get(elementId) ?? null); +} +function isContentsOptionHidden(elementId: string, clickVisible: boolean): boolean { + if (clickVisible) return false; + if (!showPictureContents && isPictureContentOverlayElement(elementId)) return true; + if (!showTableContents && isTableContentOverlayElement(elementId)) return true; + return false; +} + +function isFragmentLinkRelevant(linkEl: Element, peerIds: Set): boolean { + const fromId = linkEl.getAttribute('data-fragment-from-id'); + const toId = linkEl.getAttribute('data-fragment-to-id'); + if (fromId && peerIds.has(fromId)) return true; + if (toId && peerIds.has(toId)) return true; + return false; +} + +function applyBboxVisibility(): void { + if (!state?.hasPageView || !els.pagePane) return; + const peerIds = selectedElementId ? fragmentPeerElementIds(selectedElementId) : new Set(); + + for (const el of els.pagePane.querySelectorAll('.bbox')) { + el.classList.remove('related'); + const elementId = el.getAttribute('data-element-id') ?? ''; + const clickVisible = elementId === selectedElementId || peerIds.has(elementId); + if (showAllBboxes) { + if (isContentsOptionHidden(elementId, clickVisible)) { el.classList.add('bbox-hidden'); } + else { el.classList.remove('bbox-hidden'); if (peerIds.has(elementId)) el.classList.add('related'); } + continue; + } + if (elementId === selectedElementId) { el.classList.remove('bbox-hidden'); } + else if (peerIds.has(elementId)) { el.classList.remove('bbox-hidden'); el.classList.add('related'); } + else { el.classList.add('bbox-hidden'); } + } + + for (const el of els.pagePane.querySelectorAll('.element-badge')) { + const elementId = el.getAttribute('data-element-id') ?? ''; + const clickVisible = elementId === selectedElementId || peerIds.has(elementId); + if (!showAllBboxes || !showLayoutBadges) { el.classList.add('bbox-hidden'); continue; } + if (isContentsOptionHidden(elementId, clickVisible)) el.classList.add('bbox-hidden'); + else el.classList.remove('bbox-hidden'); + } + + for (const el of els.pagePane.querySelectorAll('.caption-link')) { + if (!showAllBboxes || !showCaptionLinks) { el.classList.add('bbox-hidden'); continue; } + el.classList.remove('bbox-hidden'); + } + for (const el of els.pagePane.querySelectorAll('.xref-link')) { + if (!showAllBboxes || !showXrefLinks) { el.classList.add('bbox-hidden'); continue; } + el.classList.remove('bbox-hidden'); + } + for (const el of els.pagePane.querySelectorAll('.fragment-link')) { + const clickVisible = Boolean(selectedElementId && isFragmentLinkRelevant(el, peerIds)); + el.classList.toggle('bbox-hidden', !(clickVisible || (showAllBboxes && showFragmentLinks))); + } + for (const el of els.pagePane.querySelectorAll('.fragment-nav')) { + const elementId = el.getAttribute('data-element-id') ?? ''; + const clickVisible = elementId === selectedElementId || peerIds.has(elementId); + el.classList.toggle('bbox-hidden', !(clickVisible || (showAllBboxes && showFragmentLinks))); + } + for (const el of els.pagePane.querySelectorAll('.reading-order-badge')) { + const elementId = el.getAttribute('data-element-id') ?? ''; + const clickVisible = elementId === selectedElementId || peerIds.has(elementId); + if (!showAllBboxes || !showReadingOrder) { el.classList.add('bbox-hidden'); continue; } + if (isPictureContentOverlayElement(elementId) || isTableContentOverlayElement(elementId) || isContentsOptionHidden(elementId, clickVisible)) { + el.classList.add('bbox-hidden'); continue; + } + el.classList.remove('bbox-hidden'); + } + for (const el of els.pagePane.querySelectorAll('.reading-order-step')) { + if (!showAllBboxes || !showReadingOrder || !showReadingOrderArrows) { el.classList.add('bbox-hidden'); continue; } + el.classList.remove('bbox-hidden'); + } +} + +function findMarkupElementForSelection(elementId: string): Element | null { + if (!els.markupPane) return null; + return ( + els.markupPane.querySelector(`.markup-el-virtual-text[data-element-id="${elementId}"]`) || + els.markupPane.querySelector(`[data-element-id="${elementId}"]`) + ); +} + +function findRenderedElementForSelection(elementId: string): Element | null { + if (!els.renderedPane) return null; + const direct = + els.renderedPane.querySelector(`.rendered-el-virtual-text[data-element-id="${elementId}"]`) || + els.renderedPane.querySelector(`.rendered-el[data-element-id="${elementId}"]`); + if (direct) return direct; + const xmlEl = state?.idToElement?.get(elementId); + const threadId = xmlEl ? elementThreadId(xmlEl) : null; + if (!threadId) return null; + const merged = els.renderedPane.querySelector(`.rendered-fragment-merged[data-thread-id="${threadId}"]`); + if (!merged) return null; + const primaryId = merged.getAttribute('data-element-id'); + if (!primaryId || primaryId === elementId) return merged; + return fragmentPeerElementIds(primaryId).has(elementId) ? merged : null; +} + +function revealReadingLayerForSelection(renderedEl: Element): void { + const layer = renderedEl.getAttribute('data-doclang-layer'); + if (!layer || layer === 'body') return; + let changed = false; + if (layer === 'furniture' && !showReadingFurniture) { showReadingFurniture = true; changed = true; } + else if (layer === 'background' && !showReadingBackground) { showReadingBackground = true; changed = true; } + if (!changed) return; + syncReadingLayerCheckboxes(); + syncReadingLayerVisibility(); +} + +function revealRenderedSelectionContext(renderedEl: Element): void { + const pictureContents = renderedEl.closest('.rendered-picture-contents') as HTMLDetailsElement | null; + if (pictureContents && !pictureContents.open) pictureContents.open = true; + revealReadingLayerForSelection(renderedEl); +} + +function applySelection(): void { + els.markupPane?.querySelectorAll('.markup-el.selected').forEach(el => el.classList.remove('selected')); + els.renderedPane?.querySelectorAll('.rendered-el.selected').forEach(el => el.classList.remove('selected')); + if (!els.pagePane) return; + els.pagePane.querySelectorAll('.bbox.selected, .overlay-badge.selected').forEach(el => el.classList.remove('selected')); + + if (!selectedElementId) { + const img = els.pagePane.querySelector('.page-view img') as HTMLImageElement | null; + if (img) { + const svg = img.parentElement?.querySelector('svg.overlay') as SVGSVGElement | null; + if (svg && state?.pageViewOverlay) { + syncOverlayBadges(img, svg, state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); + } + } + applyBboxVisibility(); return; + } + + const markupEl = findMarkupElementForSelection(selectedElementId); + if (markupEl) { markupEl.classList.add('selected'); markupEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } + + const renderedEl = findRenderedElementForSelection(selectedElementId); + if (renderedEl) { + revealRenderedSelectionContext(renderedEl); + renderedEl.classList.add('selected'); + renderedEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + + if (state?.hasPageView) { + for (const el of els.pagePane.querySelectorAll(`[data-element-id="${selectedElementId}"]`)) { + el.classList.add('selected'); + } + } + + const img = els.pagePane.querySelector('.page-view img') as HTMLImageElement | null; + if (img) { + const svg = img.parentElement?.querySelector('svg.overlay') as SVGSVGElement | null; + if (svg && state?.pageViewOverlay) { + syncOverlayBadges(img, svg, state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); + } + } + applyBboxVisibility(); +} + +// --------------------------------------------------------------------------- +// Page rendering +// --------------------------------------------------------------------------- + +function renderPage(pageNum: number): void { + if (!state) return; + const { segments, pageImages, pageCount, defaultResolution } = state; + const idx = pageNum - 1; + const segment = segments[idx] ?? []; + selectedElementId = null; + + setPageIndicator(pageNum, pageCount); + if (els.btnPrev) els.btnPrev.disabled = pageNum <= 1; + if (els.btnNext) els.btnNext.disabled = pageNum >= pageCount; + + if (!els.markupPane || !els.renderedPane || !els.pagePane) return; + + els.markupPane.innerHTML = ''; + const elementIds = assignElementIds(segment); + state.elementIds = elementIds; + state.idToElement = invertElementIds(elementIds); + + if (segmentHasMarkup(segment)) { + els.markupPane.appendChild(buildMarkupView(segment, elementIds, id => selectElement(resolveSelectionElementId(id) ?? id))); + els.renderedPane.innerHTML = ''; + els.renderedPane.appendChild(buildRenderedView(segment, elementIds, showReadingFurniture, showReadingBackground, id => selectElement(resolveSelectionElementId(id) ?? id))); + } else { + els.markupPane.innerHTML = `
${NO_MARKUP}
`; + els.renderedPane.innerHTML = `
${NO_MARKUP}
`; + } + + els.pagePane.innerHTML = ''; + if (state.hasPageView) { + const imageUrl = pageImages.get(pageNum); + if (imageUrl) { + const port = document.createElement('div'); + port.className = 'page-view-port'; + const wrap = document.createElement('div'); + wrap.className = 'page-view'; + const img = document.createElement('img'); + img.alt = `Page ${pageNum}`; + + const onImageReady = (): void => { + if (img.dataset.layoutGeneration === String(pageNum)) return; + img.dataset.layoutGeneration = String(pageNum); + applyPageImageSize(img, els.pagePane!); + const boxes = collectBoundingBoxes(segment, defaultResolution, elementIds); + const existing = wrap.querySelector('svg.overlay'); + if (existing) existing.remove(); + const readingOrderSteps = collectReadingOrderSteps( + segment, elementIds, boxes, + state!.readingOrder, readingOrderGlobalNumbering, state!.readingOrderDisplayNumbers + ); + state!.pageViewOverlay = { boxes, readingOrderSteps }; + if (boxes.length) { + wrap.appendChild( + buildOverlay( + img, boxes, + collectCaptionLinks(segment, elementIds, boxes), + collectXrefLinks(segment, elementIds, boxes), + readingOrderSteps, + collectFragmentLinks(segment, elementIds, boxes, pageNum, state!.threadPagesById), + collectFragmentNavItems(segment, elementIds, boxes, state!.threadNavByElement), + defaultResolution, + id => selectElement(resolveSelectionElementId(id) ?? id), + navigateThreadFragment, + clearSelection, + () => pagePanSuppressClick, + v => { pagePanSuppressClick = v; } + ) + ); + } + syncPageViewChrome(img); + const pending = state!.pendingSelectElement; + if (pending) { + state!.pendingSelectElement = null; + const id = findElementIdOnPage(pending); + if (id) selectElement(id); + } + }; + + img.addEventListener('load', onImageReady, { once: true }); + wrap.appendChild(img); + port.appendChild(wrap); + els.pagePane.appendChild(port); + img.src = imageUrl; + if (img.complete) onImageReady(); + + if (!pagePaneResizeObserver) { + pagePaneResizeObserver = new ResizeObserver(() => refreshPageViewLayout()); + pagePaneResizeObserver.observe(els.pagePane); + } + } else { + els.pagePane.innerHTML = `
${NO_IMAGE}
`; + } + } +} + +// --------------------------------------------------------------------------- +// Document open/close/label +// --------------------------------------------------------------------------- + +function setDocLabel(label: string | null): void { + if (!els.docLabel) return; + if (label) { els.docLabel.textContent = label; els.docLabel.hidden = false; } + else { els.docLabel.textContent = ''; els.docLabel.hidden = true; } +} + +function setDocumentOpen(open: boolean, { markupOnly = false } = {}): void { + document.body.classList.toggle('viewer-loaded', open); + document.body.classList.toggle('markup-only', open && markupOnly); + if (els.pageNav) els.pageNav.hidden = !open || markupOnly; + syncToolbarPaneCheckboxes(); + applyPaneLayout(); +} + +function setPageViewVisible(visible: boolean): void { + document.body.classList.toggle('has-page-view', visible); + syncPagePaneControls(); + syncToolbarPaneCheckboxes(); + if (!visible) setPageSettingsOpen(false); + applyPaneLayout(); + syncLayoutSubtoggles(); +} + +function resetViewer(): void { + setDemoLoading(false); + clearFileCatalog(); + filePaneUserToggled = false; + selectedElementId = null; + pagePanDrag = null; + pagePanSuppressClick = false; + showReadingFurniture = true; + showReadingBackground = true; + syncReadingLayerCheckboxes(); + resetPageZoom(); + setDocLabel(null); + setDocumentOpen(false); + document.body.classList.remove('has-page-view'); + closeAllSettings(); + setToolbarOptionsOpen(false); + if (els.markupPane) els.markupPane.innerHTML = ''; + if (els.renderedPane) els.renderedPane.innerHTML = ''; + if (els.pagePane) els.pagePane.innerHTML = ''; + setPageIndicator(1, 1); + if (els.btnPrev) els.btnPrev.disabled = true; + if (els.btnNext) els.btnNext.disabled = true; + if (els.filePane) els.filePane.replaceChildren(); + updateFileView(); + applyPaneLayout(); +} + +// --------------------------------------------------------------------------- +// File catalog helpers +// --------------------------------------------------------------------------- + +function pageImageMimeFromExt(ext: string): string { + const normalized = ext.toLowerCase().replace('jpeg', 'jpg'); + if (normalized === 'png') return 'image/png'; + if (normalized === 'webp') return 'image/webp'; + return 'image/jpeg'; +} + +function createPageImageObjectUrl(data: Uint8Array, ext: string): string { + return URL.createObjectURL(new Blob([data as BlobPart], { type: pageImageMimeFromExt(ext) })); +} + +function createFirstPageImageUrlFromFiles(files: File[]): string | null { + let bestPage = Infinity; + let bestFile: File | null = null; + for (const f of files) { + const relPath = f.webkitRelativePath || f.name; + const parts = relPath.split('/'); + if (parts.length < 2 || parts[parts.length - 2] !== 'pages') continue; + const m = PAGE_IMAGE_RE.exec(f.name); + if (!m) continue; + const pageNum = Number(m[1]); + if (pageNum < bestPage) { bestPage = pageNum; bestFile = f; } + } + return bestFile ? URL.createObjectURL(bestFile) : null; +} + +async function createFirstPageImageUrlFromZip(source: File | ArrayBuffer): Promise { + const buffer = source instanceof File ? await source.arrayBuffer() : source; + const entries = await unzip(buffer, { + shouldExtract: name => /^pages\/\d+\.(png|jpe?g|webp)$/i.test(name), + }); + let bestPage = Infinity; + let bestEntry: { name: string; data: Uint8Array } | null = null; + for (const e of entries) { + const m = e.name.match(/^pages\/(\d+)\.(png|jpe?g|webp)$/i); + if (!m) continue; + const pageNum = Number(m[1]); + if (pageNum < bestPage) { bestPage = pageNum; bestEntry = e; } + } + if (!bestEntry) return null; + const ext = bestEntry.name.split('.').pop() ?? 'png'; + return createPageImageObjectUrl(bestEntry.data, ext); +} + +async function resolveCatalogEntryThumbnail(entry: FileCatalogEntry): Promise { + if (entry.thumbnailUrl) return entry.thumbnailUrl; + if (entry.kind === 'markup') return null; + try { + if (entry.kind === 'folder') { + entry.thumbnailUrl = createFirstPageImageUrlFromFiles(entry.source as File[]); + } else if (entry.kind === 'archive') { + entry.thumbnailUrl = await createFirstPageImageUrlFromZip(entry.source as File | ArrayBuffer); + } + } catch { entry.thumbnailUrl = null; } + return entry.thumbnailUrl; +} + +function enrichCatalogEntryThumbnail(entry: FileCatalogEntry): void { + resolveCatalogEntryThumbnail(entry).then(url => { + if (!fileCatalog.includes(entry)) { + if (url?.startsWith('blob:')) URL.revokeObjectURL(url); + return; + } + if (url) renderFileView(); + }); +} + +function revokeCatalogEntry(entry: FileCatalogEntry | null): void { + if (entry?.thumbnailUrl?.startsWith('blob:')) URL.revokeObjectURL(entry.thumbnailUrl); + if (entry) entry.thumbnailUrl = null; +} + +function createFileViewThumbnail(entry: FileCatalogEntry): HTMLElement { + const thumb = document.createElement('span'); + thumb.className = 'file-view-thumb'; + thumb.setAttribute('aria-hidden', 'true'); + if (entry.thumbnailUrl) { + const img = document.createElement('img'); + img.src = entry.thumbnailUrl; + img.alt = ''; + thumb.appendChild(img); + } else { + const placeholder = document.createElement('span'); + placeholder.className = 'file-view-thumb-placeholder'; + placeholder.innerHTML = FILE_THUMB_PLACEHOLDER_SVG; + thumb.appendChild(placeholder); + } + return thumb; +} + +function createFileCatalogEntry(file: File): FileCatalogEntry { + return { + id: crypto.randomUUID(), + label: file.name, + kind: isMarkupFile(file) ? 'markup' : 'archive', + source: file, + currentPage: 1, + pageZoom: PAGE_ZOOM_DEFAULT, + snapshot: null, + thumbnailUrl: null, + }; +} + +function isArchiveFile(file: File): boolean { + return /\.dclx$/i.test(file.name) || /\.zip$/i.test(file.name); +} + +function isMarkupFile(file: File): boolean { + return /\.(?:dclg(?:\.xml)?|xml)$/i.test(file.name); +} + +function hasArchiveTransfer(dataTransfer: DataTransfer | null): boolean { + return Boolean(dataTransfer && [...dataTransfer.types].includes('Files')); +} + +async function parseCatalogEntry(entry: FileCatalogEntry): Promise { + try { + if (entry.kind === 'markup') { + const text = entry.source instanceof File + ? await (entry.source as File).text() + : new TextDecoder().decode(entry.source as ArrayBuffer); + return buildDocumentState(text, new Map(), entry.label, new Map(), { markupOnly: true }); + } + if (entry.kind === 'archive') { + const buffer = entry.source instanceof File + ? await (entry.source as File).arrayBuffer() + : entry.source as ArrayBuffer; + const { markupXml, pageImages, assetUrls } = await extractArchiveFromZipBuffer(buffer); + return buildDocumentState(markupXml, pageImages, entry.label, assetUrls, { markupOnly: false }); + } + if (entry.kind === 'folder') { + const { markupXml, pageImages, assetUrls } = await extractArchiveFromFiles(entry.source as File[]); + return buildDocumentState(markupXml, pageImages, entry.label, assetUrls, { markupOnly: false }); + } + } catch (err) { + alert(`Failed to read ${entry.label}: ${(err as Error).message}`); + } + return null; +} + +function persistActiveFileViewState(): void { + if (activeFileIndex < 0 || !state) return; + const entry = fileCatalog[activeFileIndex]; + if (!entry) return; + entry.currentPage = state.currentPage; + entry.pageZoom = pageZoomPercent; +} + +function releaseActiveDocument(): void { + if (activeFileIndex >= 0) { + const entry = fileCatalog[activeFileIndex]; + if (entry?.snapshot) { revokeDocumentState(entry.snapshot); entry.snapshot = null; } + } + if (state) revokeDocumentState(state); + state = null; + selectedElementId = null; + pagePanDrag = null; + pagePanSuppressClick = false; +} + +function clearFileCatalog(): void { + releaseActiveDocument(); + for (const entry of fileCatalog) revokeCatalogEntry(entry); + fileCatalog = []; + activeFileIndex = -1; +} + +async function switchToFile(index: number): Promise { + if (index < 0 || index >= fileCatalog.length) return; + persistActiveFileViewState(); + releaseActiveDocument(); + activeFileIndex = index; + const entry = fileCatalog[index]!; + const docState = await parseCatalogEntry(entry); + if (!docState) { + revokeCatalogEntry(entry); + fileCatalog.splice(index, 1); + activeFileIndex = -1; + if (fileCatalog.length) { await switchToFile(Math.min(index, fileCatalog.length - 1)); } + else resetViewer(); + return; + } + entry.snapshot = docState; + docState.currentPage = entry.currentPage ?? 1; + activateDocument(docState, entry); +} + +function defaultFilePaneVisible(): boolean { + return fileCatalog.length > 1; +} + +function syncFilePaneDefault(): void { + if (!filePaneUserToggled) { + const wasVisible = userPaneVisible.file; + const shouldBeVisible = defaultFilePaneVisible(); + userPaneVisible.file = shouldBeVisible; + if (!wasVisible && shouldBeVisible) { + paneRatios = [...DEFAULT_PANE_RATIOS]; + normalizePaneRatios(); + filePaneWidthPx = null; + } + } +} + +async function closeCatalogFile(index: number): Promise { + if (index < 0 || index >= fileCatalog.length) return; + const wasActive = index === activeFileIndex; + const entry = fileCatalog[index]!; + if (wasActive) { releaseActiveDocument(); activeFileIndex = -1; } + revokeCatalogEntry(entry); + fileCatalog.splice(index, 1); + if (!fileCatalog.length) { resetViewer(); return; } + if (wasActive) { await switchToFile(Math.min(index, fileCatalog.length - 1)); return; } + if (index < activeFileIndex) activeFileIndex -= 1; + updateFileView(); +} + +function renderFileView(): void { + if (!els.filePane) return; + els.filePane.replaceChildren(); + if (!fileCatalog.length) return; + const list = document.createElement('ul'); + list.className = 'file-view-list'; + list.setAttribute('role', 'listbox'); + list.setAttribute('aria-label', 'Open files'); + fileCatalog.forEach((entry, index) => { + const item = document.createElement('li'); + const card = document.createElement('div'); + card.className = 'file-view-item'; + card.title = entry.label; + card.tabIndex = 0; + card.setAttribute('role', 'option'); + if (index === activeFileIndex) { card.classList.add('is-active'); card.setAttribute('aria-selected', 'true'); } + else card.setAttribute('aria-selected', 'false'); + const thumbWrap = document.createElement('div'); + thumbWrap.className = 'file-view-thumb-wrap'; + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'file-view-close'; + closeBtn.setAttribute('aria-label', `Close ${entry.label}`); + closeBtn.textContent = '×'; + closeBtn.addEventListener('click', e => { e.stopPropagation(); closeCatalogFile(index); }); + thumbWrap.append(createFileViewThumbnail(entry), closeBtn); + const label = document.createElement('span'); + label.className = 'file-view-label'; + label.textContent = entry.label; + card.append(thumbWrap, label); + card.addEventListener('click', e => { if ((e.target as Element).closest('.file-view-close')) return; switchToFile(index); }); + card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchToFile(index); } }); + item.appendChild(card); + list.appendChild(item); + }); + els.filePane.appendChild(list); +} + +function updateFileView(): void { + syncFilePaneDefault(); + syncFilePaneCloseAllButton(); + renderFileView(); + syncToolbarPaneCheckboxes(); + applyPaneLayout(); +} + +function syncFilePaneCloseAllButton(): void { + if (!els.filePaneCloseAll) return; + els.filePaneCloseAll.hidden = fileCatalog.length === 0; +} + +function initFilePaneCloseAll(): void { + els.filePaneCloseAll?.addEventListener('click', () => { + if (!fileCatalog.length) return; + const count = fileCatalog.length; + const message = count === 1 + ? `Remove "${fileCatalog[0]!.label}" from the viewer?` + : `Remove all ${count} open files from the viewer?`; + if (confirm(message)) resetViewer(); + }); +} + +function activateDocument(docState: DocumentState, entry: FileCatalogEntry): void { + state = docState; + pageLayoutCache = null; + closeAllSettings(); + pageZoomPercent = entry.pageZoom ?? PAGE_ZOOM_DEFAULT; + if (els.pageZoom) { + els.pageZoom.value = String(pageZoomPercent); + els.pageZoom.setAttribute('aria-valuenow', String(pageZoomPercent)); + } + updatePageZoomResetButton(); + const port = pageViewScrollPane(); + if (port) { port.scrollLeft = 0; port.scrollTop = 0; } + setDocLabel(entry.label); + setDocumentOpen(true, { markupOnly: state.markupOnly }); + setPageViewVisible(state.hasPageView); + renderPage(state.currentPage); + updateFileView(); +} + +// --------------------------------------------------------------------------- +// Demo loading +// --------------------------------------------------------------------------- + +declare const DEMO_ARCHIVE_URL: string; + +function setDemoLoading(loading: boolean): void { + document.body.classList.toggle('demo-loading', loading); + const btnDemo = document.getElementById('btn-demo') as HTMLButtonElement | null; + if (btnDemo) btnDemo.disabled = loading; +} + +async function loadDemo(): Promise { + if (demoLoadInProgress) return; + demoLoadInProgress = true; + setDemoLoading(true); + try { + const res = await fetch(DEMO_ARCHIVE_URL); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const label = DEMO_ARCHIVE_URL.split('/').pop() || 'demo.dclx'; + await addArchiveBufferToCatalog(await res.arrayBuffer(), label, { replace: true }); + } catch (err) { + alert(`Failed to load demo: ${(err as Error).message}\n\nServe this directory over HTTP (e.g. python3 -m http.server) and open the viewer from localhost.`); + } finally { + demoLoadInProgress = false; + if (!document.body.classList.contains('viewer-loaded')) setDemoLoading(false); + } +} + +async function addFilesToCatalog(files: File[], { replace = false } = {}): Promise { + if (replace) { clearFileCatalog(); filePaneUserToggled = false; } + const startIndex = fileCatalog.length; + for (const file of files) { + const entry = createFileCatalogEntry(file); + fileCatalog.push(entry); + enrichCatalogEntryThumbnail(entry); + } + if (!fileCatalog.length) return; + await switchToFile(replace ? 0 : startIndex); +} + +async function appendFolderArchive(files: File[]): Promise { + if (!files.some(f => f.name === 'document.xml')) { alert('Archive must contain document.xml at its root.'); return; } + const rootName = (files[0]!.webkitRelativePath || files[0]!.name).split('/')[0] || 'archive'; + const entry: FileCatalogEntry = { + id: crypto.randomUUID(), label: rootName, kind: 'folder', source: files, + currentPage: 1, pageZoom: PAGE_ZOOM_DEFAULT, snapshot: null, thumbnailUrl: null, + }; + fileCatalog.push(entry); + enrichCatalogEntryThumbnail(entry); + await switchToFile(fileCatalog.length - 1); +} + +async function addArchiveBufferToCatalog(buffer: ArrayBuffer, label: string, { replace = false } = {}): Promise { + if (replace) { clearFileCatalog(); filePaneUserToggled = false; } + const entry: FileCatalogEntry = { + id: crypto.randomUUID(), label, kind: 'archive', source: buffer, + currentPage: 1, pageZoom: PAGE_ZOOM_DEFAULT, snapshot: null, thumbnailUrl: null, + }; + fileCatalog.push(entry); + enrichCatalogEntryThumbnail(entry); + await switchToFile(replace ? 0 : fileCatalog.length - 1); +} + +async function loadFromDrop(dataTransfer: DataTransfer): Promise { + const files = [...dataTransfer.files]; + if (files.some(f => f.name === 'document.xml')) { await appendFolderArchive(files); return; } + const supported = files.filter(f => isArchiveFile(f) || isMarkupFile(f)); + if (supported.length) await addFilesToCatalog(supported, { replace: false }); +} + +// --------------------------------------------------------------------------- +// Wheel navigation +// --------------------------------------------------------------------------- + +function initPageWheelNav(): void { + if (!els.pagePane) return; + let pixelAccum = 0; + let pixelGestureUntil = 0; + let lastFlipAt = 0; + + function wheelDir(e: WheelEvent): number { + if (e.deltaMode === 1) return e.deltaY > 0 ? 1 : e.deltaY < 0 ? -1 : 0; + if (e.deltaMode === 2) return Math.sign(e.deltaY); + const now = performance.now(); + if (now > pixelGestureUntil) pixelAccum = 0; + pixelGestureUntil = now + PAGE_WHEEL_GESTURE_MS; + pixelAccum += e.deltaY; + if (Math.abs(pixelAccum) >= PAGE_WHEEL_PIXEL_THRESHOLD) { + const dir = pixelAccum > 0 ? 1 : -1; pixelAccum = 0; return dir; + } + return 0; + } + + function tryFlipPage(dir: number): boolean { + if (!dir || !state) return false; + const now = performance.now(); + if (now - lastFlipAt < PAGE_WHEEL_COOLDOWN_MS) return false; + const before = state.currentPage; + goToPage(state.currentPage + dir); + if (state.currentPage !== before) { lastFlipAt = now; return true; } + return false; + } + + function isScrollAtTop(pane: HTMLElement): boolean { return pane.scrollTop <= 0; } + function isScrollAtBottom(pane: HTMLElement): boolean { return pane.scrollTop + pane.clientHeight >= pane.scrollHeight - 1; } + + function onScrollPaneWheel(e: WheelEvent, pane: HTMLElement): void { + if (!state || state.markupOnly || state.pageCount <= 1) return; + const dir = wheelDir(e); + if (!dir) return; + const atTop = isScrollAtTop(pane); + const atBottom = isScrollAtBottom(pane); + const wantPrev = dir < 0 && atTop; + const wantNext = dir > 0 && atBottom; + if (!wantPrev && !wantNext) return; + e.preventDefault(); + if (!tryFlipPage(dir)) return; + requestAnimationFrame(() => { pane.scrollTop = dir > 0 ? 0 : pane.scrollHeight; }); + } + + els.pagePane.addEventListener('wheel', e => { + if (!state?.hasPageView) return; + const pane = pageViewScrollPane(); + if (!pane) return; + const scrollable = pane.scrollHeight > pane.clientHeight || pane.scrollWidth > pane.clientWidth; + if (scrollable) { onScrollPaneWheel(e, pane); return; } + e.preventDefault(); + const dir = wheelDir(e); + if (dir) tryFlipPage(dir); + }, { passive: false }); + + for (const pane of [els.markupPane, els.renderedPane]) { + if (!pane) continue; + pane.addEventListener('wheel', e => onScrollPaneWheel(e as WheelEvent, pane), { passive: false }); + } + + els.pagePane.tabIndex = 0; + els.pagePane.setAttribute('role', 'region'); + els.pagePane.setAttribute('aria-label', 'Original page'); + els.pagePane.addEventListener('pointerdown', () => { if (state?.hasPageView) els.pagePane!.focus({ preventScroll: true }); }); + els.pagePane.addEventListener('keydown', e => { + if (!state?.hasPageView) return; + switch (e.key) { + case 'ArrowDown': case 'PageDown': case 'ArrowRight': e.preventDefault(); tryFlipPage(1); break; + case 'ArrowUp': case 'PageUp': case 'ArrowLeft': e.preventDefault(); tryFlipPage(-1); break; + } + }); +} + +// --------------------------------------------------------------------------- +// Page pan controls +// --------------------------------------------------------------------------- + +function canStartPagePan(event: PointerEvent): boolean { + if (!(event.target instanceof Element)) return false; + if (!event.target.closest('.page-view')) return false; + return isPagePaneScrollable(); +} + +function initPageViewControls(): void { + if (!els.pagePane) return; + els.pageZoom?.addEventListener('input', () => { + pageZoomPercent = Math.max(PAGE_ZOOM_DEFAULT, Number(els.pageZoom!.value)); + if (Number(els.pageZoom!.value) < PAGE_ZOOM_DEFAULT) els.pageZoom!.value = String(pageZoomPercent); + els.pageZoom!.setAttribute('aria-valuenow', String(pageZoomPercent)); + updatePageZoomResetButton(); + refreshPageViewLayout(); + }); + els.pageZoomReset?.addEventListener('click', () => { if (pageZoomPercent !== PAGE_ZOOM_DEFAULT) resetPageZoom(); }); + + els.pagePane.addEventListener('pointerdown', e => { + if (e.button !== 0 || !canStartPagePan(e)) return; + const scrollPane = pageViewScrollPane(); + if (!scrollPane) return; + pagePanDrag = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, scrollLeft: scrollPane.scrollLeft, scrollTop: scrollPane.scrollTop, moved: false }; + }); + + els.pagePane.addEventListener('pointermove', e => { + if (!pagePanDrag || e.pointerId !== pagePanDrag.pointerId) return; + const dx = e.clientX - pagePanDrag.startX; + const dy = e.clientY - pagePanDrag.startY; + if (!pagePanDrag.moved && Math.hypot(dx, dy) >= PAGE_PAN_DRAG_THRESHOLD) { + pagePanDrag.moved = true; + els.pagePane!.classList.add('is-panning'); + els.pagePane!.classList.remove('can-pan'); + els.pagePane!.setPointerCapture(e.pointerId); + } + if (!pagePanDrag.moved) return; + const scrollPane = pageViewScrollPane(); + if (!scrollPane) return; + scrollPane.scrollLeft = pagePanDrag.scrollLeft + pagePanDrag.startX - e.clientX; + scrollPane.scrollTop = pagePanDrag.scrollTop + pagePanDrag.startY - e.clientY; + e.preventDefault(); + }); + + function endPagePan(e: PointerEvent): void { + if (!pagePanDrag || e.pointerId !== pagePanDrag.pointerId) return; + if (pagePanDrag.moved) pagePanSuppressClick = true; + pagePanDrag = null; + els.pagePane!.classList.remove('is-panning'); + if (els.pagePane!.hasPointerCapture(e.pointerId)) els.pagePane!.releasePointerCapture(e.pointerId); + updatePagePanePanCursor(); + } + + els.pagePane.addEventListener('pointerup', e => endPagePan(e as PointerEvent)); + els.pagePane.addEventListener('pointercancel', e => endPagePan(e as PointerEvent)); +} + +// --------------------------------------------------------------------------- +// Hints / drag-drop init +// --------------------------------------------------------------------------- + +function initFileTypeHints(): void { + if (!els.emptyStateFileTypes) return; + const markup = SUPPORTED_FILE_EXTENSIONS.map(ext => `${ext}`).join(', '); + els.emptyStateFileTypes.innerHTML = markup; +} + +function initCursorHints(): void { + els.markupPane?.addEventListener('mousemove', e => { + if (!(e.target as Element).closest('.markup-ghost-tag-part')) { hideCursorHint(); return; } + showCursorHint(VIRTUAL_TEXT_TAG_HINT, e.clientX, e.clientY); + }); + els.markupPane?.addEventListener('mouseleave', hideCursorHint); + els.openFileBtn?.addEventListener('mousemove', e => showCursorHint(OPEN_FILE_HINT, e.clientX, e.clientY)); + els.openFileBtn?.addEventListener('mouseleave', hideCursorHint); +} + +function initBboxHints(): void { + if (!els.pagePane) return; + els.pagePane.addEventListener('mousemove', e => { + if (pagePanDrag?.moved || els.pagePane!.classList.contains('is-panning')) { hideCursorHint(); return; } + const navBtn = (e.target as Element).closest('.fragment-nav-btn:not(.fragment-nav-btn-disabled)'); + if (navBtn) { + const hint = navBtn.getAttribute('data-nav') === 'prev' ? FRAGMENT_NAV_HINT_PREV : FRAGMENT_NAV_HINT_NEXT; + showCursorHint(hint, e.clientX, e.clientY); return; + } + const badge = (e.target as Element).closest('.element-badge[data-element-id]'); + if (!badge || !state?.idToElement) { hideCursorHint(); return; } + const elementId = badge.getAttribute('data-element-id'); + const xmlEl = elementId ? state.idToElement.get(elementId) : null; + if (!xmlEl) { hideCursorHint(); return; } + showCursorHintHtml(elementHeadTooltipHtml(xmlEl, state.defaultResolution), e.clientX, e.clientY); + }); + els.pagePane.addEventListener('mouseleave', hideCursorHint); +} + +function initDragDrop(): void { + document.body.addEventListener('dragenter', e => { + if (!hasArchiveTransfer(e.dataTransfer)) return; + e.preventDefault(); + document.body.classList.add('drag-over'); + }); + document.body.addEventListener('dragover', e => { + if (!hasArchiveTransfer(e.dataTransfer)) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + }); + document.body.addEventListener('dragleave', e => { + if (!hasArchiveTransfer(e.dataTransfer)) return; + if (e.relatedTarget && document.body.contains(e.relatedTarget as Node)) return; + document.body.classList.remove('drag-over'); + }); + document.body.addEventListener('drop', async e => { + if (!hasArchiveTransfer(e.dataTransfer)) return; + e.preventDefault(); + document.body.classList.remove('drag-over'); + if (e.dataTransfer) await loadFromDrop(e.dataTransfer); + }); +} + +// --------------------------------------------------------------------------- +// Event wiring +// --------------------------------------------------------------------------- + +document.getElementById('btn-demo')?.addEventListener('click', loadDemo); +document.getElementById('demo-empty-link')?.addEventListener('click', e => { e.preventDefault(); loadDemo(); }); +document.getElementById('home-link')?.addEventListener('click', e => { e.preventDefault(); resetViewer(); }); +document.getElementById('input-archive')?.addEventListener('change', async e => { + const input = e.target as HTMLInputElement; + const files = [...(input.files ?? [])].filter(f => isArchiveFile(f) || isMarkupFile(f)); + if (!files.length) return; + await addFilesToCatalog(files, { replace: true }); + input.value = ''; +}); + +els.btnPrev?.addEventListener('click', () => state && goToPage(state.currentPage - 1)); +els.btnNext?.addEventListener('click', () => state && goToPage(state.currentPage + 1)); + +els.pageNumberInput?.addEventListener('keydown', e => { + if (e.key === 'Enter') { e.preventDefault(); commitPageNumberInput(); els.pageNumberInput?.blur(); } + else if (e.key === 'Escape') { e.preventDefault(); resetPageNumberInput(); els.pageNumberInput?.blur(); } +}); +els.pageNumberInput?.addEventListener('blur', resetPageNumberInput); +els.pageNumberInput?.addEventListener('focus', e => (e.target as HTMLInputElement).select()); + +els.showAllBboxes?.addEventListener('change', () => { + showAllBboxes = els.showAllBboxes!.checked; + syncLayoutSubtoggles(); + const img = els.pagePane?.querySelector('.page-view img') as HTMLImageElement | null; + if (img && state?.pageViewOverlay) syncOverlayBadges(img, img.parentElement!.querySelector('svg.overlay') as SVGSVGElement, state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); + applyBboxVisibility(); +}); +els.showLayoutBadges?.addEventListener('change', () => { + showLayoutBadges = els.showLayoutBadges!.checked; + const img = els.pagePane?.querySelector('.page-view img') as HTMLImageElement | null; + if (img && state?.pageViewOverlay) syncOverlayBadges(img, img.parentElement!.querySelector('svg.overlay') as SVGSVGElement, state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); + applyBboxVisibility(); +}); +els.showCaptionLinks?.addEventListener('change', () => { showCaptionLinks = els.showCaptionLinks!.checked; applyBboxVisibility(); }); +els.showPictureContents?.addEventListener('change', () => { showPictureContents = els.showPictureContents!.checked; applyBboxVisibility(); }); +els.showTableContents?.addEventListener('change', () => { showTableContents = els.showTableContents!.checked; applyBboxVisibility(); }); +els.showFragmentLinks?.addEventListener('change', () => { showFragmentLinks = els.showFragmentLinks!.checked; applyBboxVisibility(); }); +els.showXrefLinks?.addEventListener('change', () => { showXrefLinks = els.showXrefLinks!.checked; applyBboxVisibility(); }); +els.showReadingOrder?.addEventListener('change', () => { + showReadingOrder = els.showReadingOrder!.checked; + syncLayoutSubtoggles(); + const img = els.pagePane?.querySelector('.page-view img') as HTMLImageElement | null; + if (img && state?.pageViewOverlay) syncOverlayBadges(img, img.parentElement!.querySelector('svg.overlay') as SVGSVGElement, state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); + applyBboxVisibility(); +}); +els.readingOrderArrows?.addEventListener('change', () => { showReadingOrderArrows = els.readingOrderArrows!.checked; applyBboxVisibility(); }); +els.readingOrderGlobal?.addEventListener('change', () => { readingOrderGlobalNumbering = els.readingOrderGlobal!.checked; if (state) renderPage(state.currentPage); }); +els.settingsToggle?.addEventListener('click', () => setPageSettingsOpen(!pageSettingsOpen)); +els.readingSettingsToggle?.addEventListener('click', () => setReadingSettingsOpen(!readingSettingsOpen)); +els.pageSettingsClose?.addEventListener('click', () => setPageSettingsOpen(false)); +els.pageSettingsScrim?.addEventListener('click', () => setPageSettingsOpen(false)); +els.readingSettingsClose?.addEventListener('click', () => setReadingSettingsOpen(false)); +els.readingSettingsScrim?.addEventListener('click', () => setReadingSettingsOpen(false)); +document.addEventListener('keydown', e => { + if (e.key !== 'Escape') return; + if (toolbarOptionsOpen) setToolbarOptionsOpen(false); + else if (pageSettingsOpen) setPageSettingsOpen(false); + else if (readingSettingsOpen) setReadingSettingsOpen(false); +}); +els.showReadingFurniture?.addEventListener('change', () => { showReadingFurniture = els.showReadingFurniture!.checked; syncReadingLayerVisibility(); }); +els.showReadingBackground?.addEventListener('change', () => { showReadingBackground = els.showReadingBackground!.checked; syncReadingLayerVisibility(); }); + +// --------------------------------------------------------------------------- +// Bootstrap +// --------------------------------------------------------------------------- + +loadLayoutPrefs(); +normalizePaneRatios(); +initToolbarOptions(); +initPaneSplitters(); +initLayoutStackListener(); +initFileTypeHints(); +initCursorHints(); +initBboxHints(); +initDragDrop(); +initFilePaneCloseAll(); +initPageWheelNav(); +initPageViewControls(); + +if (document.getElementById('btn-demo')) loadDemo(); diff --git a/core/src/markup.ts b/core/src/markup.ts new file mode 100644 index 0000000..d7116d6 --- /dev/null +++ b/core/src/markup.ts @@ -0,0 +1,610 @@ +import { + LONG_EMBEDDED_URI_PREVIEW_LENGTH, + VIRTUAL_TEXT_TAG_HINT, + HEAD_TAGS, + CELL_SPAN_TAGS, + OTSL_CONTAINER_TAGS, +} from './constants'; +import { + isTextLikeNode, + isWhitespaceOnlyText, + markupAttributes, + localName, + childElements, + formatMarkupTextNode, + serializeMarkupTextNodes, + skipUntilListItemBoundary, + skipUntilCellBoundary, + isCellToken, + isSemanticElement, +} from './xml'; + +// --------------------------------------------------------------------------- +// Truncatable embedded URI helpers +// --------------------------------------------------------------------------- + +function isTruncatableEmbeddedImageUri(value: string): boolean { + if (!value || value.length <= LONG_EMBEDDED_URI_PREVIEW_LENGTH) return false; + return /^(data:image\/|blob:)/i.test(value); +} + +function formatCompactByteSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) { + const kb = bytes / 1024; + return kb < 10 ? `${kb.toFixed(1)} KB` : `${Math.round(kb)} KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatEmbeddedUriSizeLabel(value: string): string { + if (/^blob:/i.test(value)) { + return value.length >= 1024 + ? `${Math.round(value.length / 1024)} KB URL` + : `${value.length} char URL`; + } + const comma = value.indexOf(','); + if (comma === -1) return 'embedded data'; + const header = value.slice(0, comma); + const payload = value.slice(comma + 1).replace(/\s/g, ''); + const mime = /^data:([^;,]+)/i.exec(header)?.[1] ?? ''; + const shortMime = mime.startsWith('image/') ? mime.slice(6) : mime || 'data'; + const bytes = Math.floor((payload.replace(/=+$/, '').length * 3) / 4); + return `${shortMime} · ${formatCompactByteSize(bytes)}`; +} + +function createEmbeddedUriContinuationPanel(value: string, depth: number): HTMLElement { + const { line, content } = createMarkupLineRow(depth); + line.className = 'markup-line markup-embedded-uri-panel'; + const body = document.createElement('div'); + body.className = 'markup-embedded-uri-panel-body'; + body.textContent = value; + body.addEventListener('click', e => e.stopPropagation()); + content.appendChild(body); + return line; +} + +function createTruncatableMarkupAttrValue(value: string): HTMLElement { + const wrapper = document.createElement('span'); + wrapper.className = 'xml-attr-value xml-attr-value-truncatable'; + (wrapper as HTMLElement & { dataset: DOMStringMap }).dataset.fullValue = value; + + const text = document.createElement('span'); + text.className = 'xml-attr-value-text'; + text.textContent = value.slice(0, LONG_EMBEDDED_URI_PREVIEW_LENGTH); + + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'xml-attr-value-chip'; + const sizeLabel = formatEmbeddedUriSizeLabel(value); + toggle.dataset.collapsedLabel = sizeLabel; + toggle.setAttribute('aria-expanded', 'false'); + toggle.setAttribute('aria-label', `Show full value (${sizeLabel})`); + + const label = document.createElement('span'); + label.className = 'xml-attr-value-chip-label'; + label.textContent = sizeLabel; + toggle.appendChild(label); + + wrapper.append(text, toggle); + return wrapper; +} + +export function toggleTruncatableMarkupAttrValue(toggle: Element): void { + const wrapper = toggle.closest('.xml-attr-value-truncatable') as HTMLElement | null; + if (!wrapper) return; + + const markupLine = wrapper.closest('.markup-line') as HTMLElement | null; + const fullValue = (wrapper as HTMLElement & { dataset: DOMStringMap }).dataset.fullValue ?? ''; + const label = toggle.querySelector('.xml-attr-value-chip-label'); + const collapsedLabel = + (toggle as HTMLElement & { dataset: DOMStringMap }).dataset.collapsedLabel ?? + label?.textContent ?? + ''; + const expanded = toggle.getAttribute('aria-expanded') === 'true'; + + if (expanded) { + toggle.setAttribute('aria-expanded', 'false'); + toggle.setAttribute('aria-label', `Show full value (${collapsedLabel})`); + if (label) label.textContent = collapsedLabel; + if ( + markupLine?.nextElementSibling?.classList.contains('markup-embedded-uri-panel') + ) { + markupLine.nextElementSibling.remove(); + } + return; + } + + toggle.setAttribute('aria-expanded', 'true'); + toggle.setAttribute('aria-label', 'Hide full value'); + if (label) label.textContent = 'hide'; + if ( + !markupLine || + markupLine.nextElementSibling?.classList.contains('markup-embedded-uri-panel') + ) { + return; + } + const depth = Number(markupLine.style.getPropertyValue('--markup-depth') || 0); + markupLine.insertAdjacentElement( + 'afterend', + createEmbeddedUriContinuationPanel(fullValue, depth) + ); +} + +// --------------------------------------------------------------------------- +// Low-level markup DOM builders +// --------------------------------------------------------------------------- + +function xmlSpan(className: string, text: string, { ghost = false } = {}): HTMLElement { + const span = document.createElement('span'); + span.className = ghost ? `${className} markup-ghost-tag-part` : className; + span.textContent = text; + return span; +} + +function createMarkupLine(): HTMLDivElement { + const line = document.createElement('div'); + line.className = 'markup-line'; + return line; +} + +function createMarkupFoldToggle(): HTMLButtonElement { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'markup-fold-toggle'; + btn.setAttribute('aria-expanded', 'true'); + btn.setAttribute('aria-label', 'Collapse'); + return btn; +} + +function createMarkupLineRow( + depth: number, + { foldToggle = false } = {} +): { line: HTMLDivElement; content: HTMLElement } { + const line = createMarkupLine(); + line.style.setProperty('--markup-depth', String(depth)); + + const row = document.createElement('span'); + row.className = 'markup-line-content'; + + const gutter = document.createElement('span'); + gutter.className = 'markup-gutter'; + gutter.setAttribute('aria-hidden', 'true'); + if (foldToggle) gutter.appendChild(createMarkupFoldToggle()); + row.appendChild(gutter); + + const body = document.createElement('span'); + body.className = 'markup-line-body'; + row.appendChild(body); + + line.appendChild(row); + return { line, content: body }; +} + +function appendMarkupAttrValue(line: HTMLElement, value: string): void { + if (isTruncatableEmbeddedImageUri(value)) { + line.appendChild(createTruncatableMarkupAttrValue(value)); + } else { + line.appendChild(xmlSpan('xml-attr-value', value)); + } +} + +function appendMarkupAttributes( + line: HTMLElement, + attributes: { name: string; value: string }[] +): void { + for (const { name, value } of attributes) { + line.appendChild(document.createTextNode(' ')); + line.appendChild(xmlSpan('xml-attr-name', name)); + line.appendChild(xmlSpan('xml-bracket', '="')); + appendMarkupAttrValue(line, value); + line.appendChild(xmlSpan('xml-bracket', '"')); + } +} + +function appendMarkupTextContent(line: HTMLElement, text: string): void { + const cdataMatch = /^$/.exec(text); + if (cdataMatch) { + line.appendChild(xmlSpan('xml-cdata-delimiter', '')); + return; + } + line.appendChild(xmlSpan('xml-text', text)); +} + +function appendOpenTagContent( + line: HTMLElement, + tag: string, + attributes: { name: string; value: string }[], + ghost = false +): void { + line.appendChild(xmlSpan('xml-bracket', '<', { ghost })); + line.appendChild(xmlSpan('xml-tag', tag, { ghost })); + appendMarkupAttributes(line, attributes); + line.appendChild(xmlSpan('xml-bracket', '>', { ghost })); +} + +function appendMarkupFoldableOpen( + parent: HTMLElement, + depth: number, + tag: string, + attributes: { name: string; value: string }[], + tagHint?: string +): void { + const ghost = Boolean(tagHint); + const { line, content } = createMarkupLineRow(depth, { foldToggle: true }); + line.classList.add('markup-line-open'); + appendOpenTagContent(content, tag, attributes, ghost); + + const suffix = document.createElement('span'); + suffix.className = 'markup-fold-suffix'; + suffix.appendChild(xmlSpan('xml-bracket', '...', { ghost })); + suffix.appendChild(xmlSpan('xml-bracket', '', { ghost })); + content.appendChild(suffix); + parent.appendChild(line); +} + +function appendMarkupCloseTag( + parent: HTMLElement, + depth: number, + tag: string, + tagHint?: string +): void { + const ghost = Boolean(tagHint); + const { line, content } = createMarkupLineRow(depth); + content.appendChild(xmlSpan('xml-bracket', '', { ghost })); + parent.appendChild(line); +} + +function appendMarkupSelfClosingTag( + parent: HTMLElement, + depth: number, + tag: string, + attributes: { name: string; value: string }[] +): void { + const { line, content } = createMarkupLineRow(depth); + content.appendChild(xmlSpan('xml-bracket', '<')); + content.appendChild(xmlSpan('xml-tag', tag)); + appendMarkupAttributes(content, attributes); + content.appendChild(xmlSpan('xml-bracket', '/>')); + parent.appendChild(line); +} + +function appendMarkupInlineElement( + parent: HTMLElement, + depth: number, + tag: string, + attributes: { name: string; value: string }[], + text: string +): void { + const { line, content } = createMarkupLineRow(depth); + appendOpenTagContent(content, tag, attributes); + appendMarkupTextContent(content, text); + content.appendChild(xmlSpan('xml-bracket', '')); + parent.appendChild(line); +} + +function appendMarkupTextLine(parent: HTMLElement, depth: number, text: string): void { + const { line, content } = createMarkupLineRow(depth); + appendMarkupTextContent(content, text); + parent.appendChild(line); +} + +// --------------------------------------------------------------------------- +// Element-level markup builders +// --------------------------------------------------------------------------- + +function appendMarkupNodesFromSlice( + parent: HTMLElement, + depth: number, + nodes: ArrayLike, + elementIds: Map +): void { + for (let i = 0; i < nodes.length; i += 1) { + const child = nodes[i]; + if (!child) continue; + if (isTextLikeNode(child)) { + const text = formatMarkupTextNode(child); + if (text) appendMarkupTextLine(parent, depth, text); + } else if (child.nodeType === Node.ELEMENT_NODE) { + parent.appendChild(buildMarkupElement(child as Element, depth, elementIds)); + } + } +} + +function appendMarkupVirtualText( + parent: HTMLElement, + depth: number, + hostEl: Element, + contentNodes: ChildNode[], + elementIds: Map +): void { + if (!shouldWrapVirtualText(contentNodes)) { + appendMarkupNodesFromSlice(parent, depth, contentNodes, elementIds); + return; + } + + const block = document.createElement('div'); + block.className = 'markup-el markup-el-virtual-text'; + const elementId = elementIds.get(hostEl); + if (elementId) block.setAttribute('data-element-id', elementId); + + appendMarkupFoldableOpen(block, depth, 'text', [], VIRTUAL_TEXT_TAG_HINT); + block.classList.add('markup-el-foldable'); + + const foldBody = document.createElement('div'); + foldBody.className = 'markup-fold-body'; + const children = document.createElement('div'); + children.className = 'markup-children'; + appendMarkupNodesFromSlice(children, depth + 1, contentNodes, elementIds); + foldBody.appendChild(children); + appendMarkupCloseTag(foldBody, depth, 'text', VIRTUAL_TEXT_TAG_HINT); + block.appendChild(foldBody); + parent.appendChild(block); +} + +function buildMarkupFoldableElement( + el: Element, + depth: number, + elementIds: Map, + buildBody: (children: HTMLElement, childDepth: number) => void +): HTMLElement { + const tag = localName(el); + const block = document.createElement('div'); + block.className = 'markup-el'; + const elementId = elementIds.get(el); + if (elementId) block.setAttribute('data-element-id', elementId); + + const attributes = markupAttributes(el); + appendMarkupFoldableOpen(block, depth, tag, attributes); + block.classList.add('markup-el-foldable'); + + const foldBody = document.createElement('div'); + foldBody.className = 'markup-fold-body'; + const children = document.createElement('div'); + children.className = 'markup-children'; + buildBody(children, depth + 1); + foldBody.appendChild(children); + appendMarkupCloseTag(foldBody, depth, tag); + block.appendChild(foldBody); + return block; +} + +function buildMarkupList( + el: Element, + depth: number, + elementIds: Map +): HTMLElement { + return buildMarkupFoldableElement(el, depth, elementIds, (children, childDepth) => { + const nodes = [...el.childNodes]; + let i = 0; + while (i < nodes.length) { + const node = nodes[i]; + if (!node) { i += 1; continue; } + if (node.nodeType === Node.ELEMENT_NODE && localName(node as Element) === 'ldiv') break; + if (isTextLikeNode(node) && isWhitespaceOnlyText(node)) { + i += 1; + continue; + } + if (isTextLikeNode(node)) break; + if (node.nodeType === Node.ELEMENT_NODE) { + const tag = localName(node as Element); + if (HEAD_TAGS.has(tag) || tag === 'location') { + children.appendChild(buildMarkupElement(node as Element, childDepth, elementIds)); + i += 1; + continue; + } + } + break; + } + while (i < nodes.length) { + const node = nodes[i]; + if (!node) { i += 1; continue; } + if (node.nodeType !== Node.ELEMENT_NODE || localName(node as Element) !== 'ldiv') { + appendMarkupNodesFromSlice(children, childDepth, [node], elementIds); + i += 1; + continue; + } + const ldiv = node as Element; + children.appendChild(buildMarkupElement(ldiv, childDepth, elementIds)); + i += 1; + const end = skipUntilListItemBoundary(nodes, i); + appendMarkupVirtualText(children, childDepth, ldiv, nodes.slice(i, end), elementIds); + i = end; + } + }); +} + +function buildMarkupOtslContainer( + el: Element, + depth: number, + elementIds: Map +): HTMLElement { + return buildMarkupFoldableElement(el, depth, elementIds, (children, childDepth) => { + const nodes = [...el.childNodes]; + let i = 0; + while (i < nodes.length) { + const node = nodes[i]; + if (!node) { i += 1; continue; } + if (node.nodeType === Node.ELEMENT_NODE && isCellToken(localName(node as Element))) break; + if (isTextLikeNode(node) && isWhitespaceOnlyText(node)) { + i += 1; + continue; + } + if (isTextLikeNode(node)) break; + if (node.nodeType === Node.ELEMENT_NODE) { + const tag = localName(node as Element); + if (HEAD_TAGS.has(tag) || tag === 'location' || tag === 'h_thread') { + children.appendChild(buildMarkupElement(node as Element, childDepth, elementIds)); + i += 1; + continue; + } + } + break; + } + while (i < nodes.length) { + const node = nodes[i]; + if (!node) { i += 1; continue; } + if (node.nodeType !== Node.ELEMENT_NODE) { + appendMarkupNodesFromSlice(children, childDepth, [node], elementIds); + i += 1; + continue; + } + const tag = localName(node as Element); + if (tag === 'nl') { + appendMarkupSelfClosingTag(children, childDepth, 'nl', []); + i += 1; + continue; + } + if (!isCellToken(tag)) { + appendMarkupNodesFromSlice(children, childDepth, [node], elementIds); + i += 1; + continue; + } + const cell = node as Element; + children.appendChild(buildMarkupElement(cell, childDepth, elementIds)); + i += 1; + if (CELL_SPAN_TAGS.has(tag)) continue; + const end = skipUntilCellBoundary(nodes, i); + appendMarkupVirtualText(children, childDepth, cell, nodes.slice(i, end), elementIds); + i = end; + } + }); +} + +export function buildMarkupElement( + el: Element, + depth: number, + elementIds: Map +): HTMLElement { + const tag = localName(el); + if (tag === 'list') return buildMarkupList(el, depth, elementIds); + if (OTSL_CONTAINER_TAGS.has(tag)) return buildMarkupOtslContainer(el, depth, elementIds); + + const block = document.createElement('div'); + block.className = 'markup-el'; + const elementId = elementIds.get(el); + if (elementId) block.setAttribute('data-element-id', elementId); + + const attributes = markupAttributes(el); + + if (!el.childNodes.length) { + appendMarkupSelfClosingTag(block, depth, tag, attributes); + return block; + } + + const meaningfulText = [...el.childNodes].filter( + n => isTextLikeNode(n) && !isWhitespaceOnlyText(n) + ); + const textOnly = + meaningfulText.length > 0 && + meaningfulText.every(isTextLikeNode) && + !childElements(el).length; + if (textOnly) { + const text = serializeMarkupTextNodes(el.childNodes); + if (text) { + appendMarkupInlineElement(block, depth, tag, attributes, text); + return block; + } + } + + appendMarkupFoldableOpen(block, depth, tag, attributes); + block.classList.add('markup-el-foldable'); + + const foldBody = document.createElement('div'); + foldBody.className = 'markup-fold-body'; + const children = document.createElement('div'); + children.className = 'markup-children'; + for (const child of el.childNodes) { + if (isTextLikeNode(child)) { + const text = formatMarkupTextNode(child); + if (text) appendMarkupTextLine(children, depth + 1, text); + } else if (child.nodeType === Node.ELEMENT_NODE) { + children.appendChild(buildMarkupElement(child as Element, depth + 1, elementIds)); + } + } + foldBody.appendChild(children); + appendMarkupCloseTag(foldBody, depth, tag); + block.appendChild(foldBody); + return block; +} + +// --------------------------------------------------------------------------- +// Helpers used in buildMarkupView / appendMarkupVirtualText +// --------------------------------------------------------------------------- + +function sliceHasMarkupContent(nodes: ArrayLike): boolean { + for (let i = 0; i < nodes.length; i += 1) { + const node = nodes[i]; + if (!node) continue; + if (isTextLikeNode(node) && !isWhitespaceOnlyText(node)) return true; + if (node.nodeType === Node.ELEMENT_NODE) return true; + } + return false; +} + +function isVirtualTextSkippableNode(node: ChildNode): boolean { + if (isWhitespaceOnlyText(node)) return true; + if (node.nodeType !== Node.ELEMENT_NODE) return false; + const tag = localName(node as Element); + return tag === 'location' || HEAD_TAGS.has(tag); +} + +function shouldWrapVirtualText(contentNodes: ChildNode[]): boolean { + if (!sliceHasMarkupContent(contentNodes)) return false; + for (const node of contentNodes) { + if (isVirtualTextSkippableNode(node)) continue; + if (isTextLikeNode(node)) return true; + if (node.nodeType === Node.ELEMENT_NODE && !isSemanticElement(node as Element)) + return true; + } + return false; +} + +export function buildMarkupView( + segment: Element[], + elementIds: Map, + onSelectElement: (id: string) => void +): HTMLElement { + const root = document.createElement('div'); + root.className = 'markup'; + for (const el of segment) { + if (el.nodeType === Node.ELEMENT_NODE) { + root.appendChild(buildMarkupElement(el, 0, elementIds)); + } + } + root.addEventListener('click', e => { + const target = e.target as Element; + const attrToggle = target.closest('.xml-attr-value-chip'); + if (attrToggle) { + e.stopPropagation(); + toggleTruncatableMarkupAttrValue(attrToggle); + return; + } + const toggle = target.closest('.markup-fold-toggle'); + if (toggle) { + e.stopPropagation(); + const block = toggle.closest('.markup-el-foldable'); + if (block) { + const collapsed = block.classList.toggle('markup-collapsed'); + toggle.setAttribute('aria-expanded', String(!collapsed)); + toggle.setAttribute('aria-label', collapsed ? 'Expand' : 'Collapse'); + } + return; + } + const ghostText = target.closest('.markup-el-virtual-text'); + const elementId = ghostText?.hasAttribute('data-element-id') + ? ghostText.getAttribute('data-element-id')! + : target.closest('.markup-el[data-element-id]')?.getAttribute('data-element-id') ?? + null; + if (elementId) onSelectElement(elementId); + }); + return root; +} diff --git a/core/src/overlay.ts b/core/src/overlay.ts new file mode 100644 index 0000000..70f15ab --- /dev/null +++ b/core/src/overlay.ts @@ -0,0 +1,808 @@ +import { + OVERLAY_BADGE_FONT_SIZE, + OVERLAY_BADGE_PAD_X, + OVERLAY_BADGE_PAD_Y, + OVERLAY_BADGE_RADIUS_SCREEN_PX, + OVERLAY_REF_IMAGE_WIDTH, + OVERLAY_REF_IMAGE_HEIGHT, + PAGE_VIEW_BORDER_PX, + PAGE_ZOOM_DEFAULT, + FRAGMENT_LINK_LABEL_CROSS_PAGE, + FRAGMENT_LINK_LABEL_SAME_PAGE, + FRAGMENT_NAV_HINT_PREV, + FRAGMENT_NAV_HINT_NEXT, +} from './constants'; +import { + layerClassForValue, + kindClassForTag, + bboxClassForKind, +} from './xml_overlay'; +import type { + BoundingBox, + Resolution, + PixelRect, + CaptionLink, + XrefLink, + FragmentLink, + FragmentNavItem, + OverlayLinkOptions, + PageLayoutCache, +} from './types'; + +// We re-export the overlay box paint utilities needed by main.ts +export { elementKindKey, kindClassForTag, bboxClassForKind } from './xml_overlay'; + +// --------------------------------------------------------------------------- +// State accessors — injected by main.ts +// --------------------------------------------------------------------------- +let getPageZoomPercent: () => number = () => PAGE_ZOOM_DEFAULT; +let getPagePane: () => HTMLElement | null = () => null; +let getPageLayoutCache: () => PageLayoutCache | null = () => null; +let setPageLayoutCache: (c: PageLayoutCache) => void = () => { /* noop */ }; +let getSelectedElementId: () => string | null = () => null; + +export function setOverlayAccessors(opts: { + getPageZoomPercent: () => number; + getPagePane: () => HTMLElement | null; + getPageLayoutCache: () => PageLayoutCache | null; + setPageLayoutCache: (c: PageLayoutCache) => void; + getSelectedElementId: () => string | null; +}): void { + getPageZoomPercent = opts.getPageZoomPercent; + getPagePane = opts.getPagePane; + getPageLayoutCache = opts.getPageLayoutCache; + setPageLayoutCache = opts.setPageLayoutCache; + getSelectedElementId = opts.getSelectedElementId; +} + +// --------------------------------------------------------------------------- +// Page layout helpers +// --------------------------------------------------------------------------- + +function pageZoomFactor(): number { + return getPageZoomPercent() / PAGE_ZOOM_DEFAULT; +} + +function paneContentSize(pane: HTMLElement): { w: number; h: number } { + const style = getComputedStyle(pane); + const padX = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); + const padY = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom); + return { w: pane.clientWidth - padX, h: pane.clientHeight - padY }; +} + +export function getCachedFitScale(img: HTMLImageElement, pane: HTMLElement): number { + const { w: paneW, h: paneH } = paneContentSize(pane); + const imgW = img.naturalWidth; + const imgH = img.naturalHeight; + const cache = getPageLayoutCache(); + if ( + cache && + cache.paneW === paneW && + cache.paneH === paneH && + cache.imgW === imgW && + cache.imgH === imgH + ) { + return cache.fitScale; + } + const fitScale = + paneW > 0 && paneH > 0 && imgW > 0 && imgH > 0 + ? Math.min( + (paneW - PAGE_VIEW_BORDER_PX) / imgW, + (paneH - PAGE_VIEW_BORDER_PX) / imgH + ) + : 1; + setPageLayoutCache({ paneW, paneH, imgW, imgH, fitScale }); + return fitScale; +} + +export function overlayUserLength( + baseUserPx: number, + img?: HTMLImageElement | null +): number { + const resolvedImg = + img ?? (getPagePane()?.querySelector('.page-view img') as HTMLImageElement | null); + const zoom = pageZoomFactor(); + if (!(zoom > 0)) return baseUserPx; + const pane = getPagePane(); + if (!resolvedImg?.naturalWidth || !resolvedImg.naturalHeight || !pane) { + return baseUserPx / zoom; + } + + const { w: paneW, h: paneH } = paneContentSize(pane); + if (!(paneW > 0 && paneH > 0)) return baseUserPx / zoom; + + const refFit = Math.min( + (paneW - PAGE_VIEW_BORDER_PX) / OVERLAY_REF_IMAGE_WIDTH, + (paneH - PAGE_VIEW_BORDER_PX) / OVERLAY_REF_IMAGE_HEIGHT + ); + const fitScale = getCachedFitScale(resolvedImg, pane); + if (!(refFit > 0) || !(fitScale > 0)) return baseUserPx / zoom; + + return (baseUserPx * refFit) / (fitScale * zoom); +} + +export function applyPageImageSize( + img: HTMLImageElement, + pane: HTMLElement +): boolean { + if (!img?.naturalWidth || !img.naturalHeight) return false; + const zoomPct = Math.max(PAGE_ZOOM_DEFAULT, getPageZoomPercent()); + const fitScale = getCachedFitScale(img, pane); + const scale = fitScale * (zoomPct / PAGE_ZOOM_DEFAULT); + const w = Math.floor(img.naturalWidth * scale); + const h = Math.floor(img.naturalHeight * scale); + const nextW = `${w}px`; + const nextH = `${h}px`; + const unchanged = img.style.width === nextW && img.style.height === nextH; + if (!unchanged) { + img.style.width = nextW; + img.style.height = nextH; + img.style.maxWidth = 'none'; + img.style.maxHeight = 'none'; + } + img.dataset.layoutReady = '1'; + return !unchanged; +} + +// --------------------------------------------------------------------------- +// Bounding box pixel rect +// --------------------------------------------------------------------------- + +export function boxPixelRect(b: BoundingBox, img: HTMLImageElement): PixelRect { + const x = (b.x0 / b.resW) * img.naturalWidth; + const y = (b.y0 / b.resH) * img.naturalHeight; + const w = ((b.x1 - b.x0) / b.resW) * img.naturalWidth; + const h = ((b.y1 - b.y0) / b.resH) * img.naturalHeight; + return { x, y, w, h, area: w * h }; +} + +function boxCenter(rect: PixelRect): { x: number; y: number } { + return { x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 }; +} + +// --------------------------------------------------------------------------- +// Overlay sort helpers +// --------------------------------------------------------------------------- + +function overlayBoxPaintPriority(box: BoundingBox): number { + if (box.kind === 'text') return 0; + if ( + box.kind === 'list' || + box.kind === 'table' || + box.kind === 'index' || + box.kind === 'tabular' + ) { + return 2; + } + return 1; +} + +function overlayLayerPriority(layer: string): number { + if (layer === 'background') return 0; + if (layer === 'furniture') return 1; + return 2; +} + +function compareOverlayBoxPaintOrder(a: BoundingBox, b: BoundingBox): number { + const byLayer = + overlayLayerPriority(a.layer ?? 'body') - overlayLayerPriority(b.layer ?? 'body'); + if (byLayer !== 0) return byLayer; + const byPriority = overlayBoxPaintPriority(a) - overlayBoxPaintPriority(b); + if (byPriority !== 0) return byPriority; + const selectedId = getSelectedElementId(); + if (selectedId) { + const aSelected = a.elementId === selectedId; + const bSelected = b.elementId === selectedId; + if (aSelected !== bSelected) return aSelected ? 1 : -1; + } + return 0; +} + +export function sortedOverlayBoxes(boxes: BoundingBox[]): BoundingBox[] { + return [...boxes].sort(compareOverlayBoxPaintOrder); +} + +// --------------------------------------------------------------------------- +// SVG helper functions +// --------------------------------------------------------------------------- + +function ensureArrowMarker(defs: SVGDefsElement, markerId: string): void { + if (defs.querySelector(`#${markerId}`)) return; + const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); + marker.setAttribute('id', markerId); + marker.setAttribute('viewBox', '0 0 6 6'); + marker.setAttribute('refX', '6'); + marker.setAttribute('refY', '3'); + marker.setAttribute('markerWidth', '5'); + marker.setAttribute('markerHeight', '5'); + marker.setAttribute('orient', 'auto'); + const arrowPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + arrowPath.setAttribute('d', 'M0,0 L6,3 L0,6 Z'); + arrowPath.setAttribute('fill', 'currentColor'); + marker.appendChild(arrowPath); + defs.appendChild(marker); +} + +function alignDashedLineToEnd( + line: SVGLineElement, + start: { x: number; y: number }, + end: { x: number; y: number } +): void { + const dash = 6; + const gap = 4; + const period = dash + gap; + const len = Math.hypot(end.x - start.x, end.y - start.y); + if (!len) return; + line.setAttribute('stroke-dasharray', `${dash} ${gap}`); + const offset = len % period; + if (offset > 0.01) line.setAttribute('stroke-dashoffset', String(offset)); +} + +function ensureLayerHatchPatterns(defs: SVGDefsElement): void { + if (defs.querySelector('#layer-hatch')) return; + const pattern = document.createElementNS('http://www.w3.org/2000/svg', 'pattern'); + pattern.setAttribute('id', 'layer-hatch'); + pattern.setAttribute('width', '16'); + pattern.setAttribute('height', '16'); + pattern.setAttribute('patternUnits', 'userSpaceOnUse'); + pattern.setAttribute('patternTransform', 'rotate(45 8 8)'); + const stripe = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + stripe.setAttribute('class', 'layer-hatch-stripe'); + stripe.setAttribute('x', '10'); + stripe.setAttribute('y', '-4'); + stripe.setAttribute('width', '6'); + stripe.setAttribute('height', '24'); + pattern.appendChild(stripe); + defs.appendChild(pattern); +} + +function ensureOverlayDefs(svg: SVGSVGElement): SVGDefsElement { + let defs = svg.querySelector('defs'); + if (!defs) { + defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + svg.insertBefore(defs, svg.firstChild); + } + return defs; +} + +// --------------------------------------------------------------------------- +// Overlay badge rendering +// --------------------------------------------------------------------------- + +function overlayBadgeLayout( + svg: SVGSVGElement, + text: string | number, + fontSizeUser: number +): { width: number; height: number } { + const padXUser = overlayUserLength(OVERLAY_BADGE_PAD_X); + const padYUser = overlayUserLength(OVERLAY_BADGE_PAD_Y); + const probe = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + probe.setAttribute('class', 'overlay-badge-label'); + probe.setAttribute('font-size', String(fontSizeUser)); + probe.setAttribute('font-weight', '700'); + probe.setAttribute('text-anchor', 'start'); + probe.setAttribute('dominant-baseline', 'text-before-edge'); + probe.setAttribute('visibility', 'hidden'); + probe.textContent = String(text); + svg.appendChild(probe); + + let width: number; + let height: number; + try { + const bbox = probe.getBBox(); + width = bbox.width + padXUser * 2; + height = bbox.height + padYUser * 2; + } catch { + const label = String(text); + width = overlayUserLength( + label.length * OVERLAY_BADGE_FONT_SIZE * 0.55 + OVERLAY_BADGE_PAD_X * 2 + ); + height = overlayUserLength( + OVERLAY_BADGE_FONT_SIZE * 1.1 + OVERLAY_BADGE_PAD_Y * 2 + ); + } + probe.remove(); + return { width, height }; +} + +export function appendOverlayBadge( + svg: SVGSVGElement, + anchorX: number, + anchorY: number, + text: string | number, + { extraClass, elementId }: { extraClass: string; elementId: string } +): SVGGElement { + const fontSize = overlayUserLength(OVERLAY_BADGE_FONT_SIZE); + const { width, height } = overlayBadgeLayout(svg, text, fontSize); + const radius = overlayUserLength(OVERLAY_BADGE_RADIUS_SCREEN_PX); + + const badge = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + badge.setAttribute('class', `overlay-badge ${extraClass}`); + badge.setAttribute('data-element-id', elementId); + + const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bg.setAttribute('class', 'overlay-badge-bg'); + bg.setAttribute('x', String(anchorX - width / 2)); + bg.setAttribute('y', String(anchorY - height / 2)); + bg.setAttribute('width', String(width)); + bg.setAttribute('height', String(height)); + bg.setAttribute('rx', String(radius)); + bg.setAttribute('ry', String(radius)); + badge.appendChild(bg); + + const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + label.setAttribute('class', 'overlay-badge-label'); + label.setAttribute('x', String(anchorX)); + label.setAttribute('y', String(anchorY)); + label.setAttribute('font-size', String(fontSize)); + label.textContent = String(text); + badge.appendChild(label); + + svg.appendChild(badge); + return badge; +} + +export function syncOverlayBadges( + img: HTMLImageElement, + svg: SVGSVGElement, + boxes: BoundingBox[], + readingOrderSteps: { order: number; box: BoundingBox; elementId: string }[], + showAllBboxes: boolean, + showLayoutBadges: boolean, + showReadingOrder: boolean +): void { + if (!img.naturalWidth) return; + + svg.querySelectorAll('.element-badge, .reading-order-badge').forEach(b => b.remove()); + + const fontSize = overlayUserLength(OVERLAY_BADGE_FONT_SIZE); + const badgeGap = overlayUserLength(2); + const readingOrderByElementId = new Map< + string, + { order: number; box: BoundingBox; elementId: string } + >(); + if (showAllBboxes && showReadingOrder) { + for (const step of readingOrderSteps) { + readingOrderByElementId.set(step.elementId, step); + } + } + + const sorted = sortedOverlayBoxes(boxes); + + for (const b of sorted) { + const { x, y } = boxPixelRect(b, img); + let tagLayout = { width: 0 }; + if (showAllBboxes && showLayoutBadges) { + tagLayout = overlayBadgeLayout(svg, b.tag, fontSize); + appendOverlayBadge(svg, x, y, b.tag, { + extraClass: `element-badge ${kindClassForTag(b.kind)}`, + elementId: b.elementId, + }); + } + + const step = readingOrderByElementId.get(b.elementId); + if (step) { + const orderText = String(step.order); + const orderLayout = overlayBadgeLayout(svg, orderText, fontSize); + const orderAnchorX = + showAllBboxes && showLayoutBadges + ? x + tagLayout.width / 2 + badgeGap + orderLayout.width / 2 + : x; + appendOverlayBadge(svg, orderAnchorX, y, orderText, { + extraClass: 'reading-order-badge', + elementId: b.elementId, + }); + } + } +} + +// --------------------------------------------------------------------------- +// Link overlays +// --------------------------------------------------------------------------- + +function appendOverlayLinks( + svg: SVGSVGElement, + img: HTMLImageElement, + links: (CaptionLink | XrefLink)[], + { markerId, linkClass, fromIdAttr, toIdAttr }: OverlayLinkOptions +): void { + if (!links.length) return; + const defs = ensureOverlayDefs(svg); + ensureArrowMarker(defs, markerId); + + for (const link of links) { + const fromBox = (link as CaptionLink).captionBox ?? (link as XrefLink).fromBox; + const toBox = (link as CaptionLink).hostBox ?? (link as XrefLink).toBox; + const from = boxPixelRect(fromBox, img); + const to = boxPixelRect(toBox, img); + const start = boxCenter(from); + const end = boxCenter(to); + + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('class', linkClass); + line.setAttribute('x1', String(start.x)); + line.setAttribute('y1', String(start.y)); + line.setAttribute('x2', String(end.x)); + line.setAttribute('y2', String(end.y)); + line.setAttribute('marker-end', `url(#${markerId})`); + const fromId = + (link as CaptionLink).captionElementId ?? (link as XrefLink).fromElementId; + const toId = + (link as CaptionLink).hostElementId ?? (link as XrefLink).toElementId; + line.setAttribute(fromIdAttr, fromId); + line.setAttribute(toIdAttr, toId); + svg.appendChild(line); + } +} + +export function appendCaptionLinks( + svg: SVGSVGElement, + img: HTMLImageElement, + captionLinks: CaptionLink[] +): void { + appendOverlayLinks(svg, img, captionLinks, { + markerId: 'caption-arrowhead', + linkClass: 'caption-link', + fromIdAttr: 'data-caption-id', + toIdAttr: 'data-host-id', + }); +} + +export function appendXrefLinks( + svg: SVGSVGElement, + img: HTMLImageElement, + xrefLinks: XrefLink[] +): void { + appendOverlayLinks(svg, img, xrefLinks, { + markerId: 'xref-arrowhead', + linkClass: 'xref-link', + fromIdAttr: 'data-xref-from-id', + toIdAttr: 'data-xref-to-id', + }); +} + +function docPointToPixel( + xDoc: number, + yDoc: number, + resW: number, + resH: number, + img: HTMLImageElement +): { x: number; y: number } { + return { + x: (xDoc / resW) * img.naturalWidth, + y: (yDoc / resH) * img.naturalHeight, + }; +} + +function pageCornerTarget( + img: HTMLImageElement, + defaultResolution: Resolution, + corner: 'tl' | 'br' +): { x: number; y: number } { + const { width: resW, height: resH } = defaultResolution; + const inset = overlayUserLength(5); + const tl = docPointToPixel(0, 0, resW, resH, img); + const br = docPointToPixel(resW, resH, resW, resH, img); + if (corner === 'tl') { + return { + x: Math.min(tl.x + inset, br.x - inset), + y: Math.min(tl.y + inset, br.y - inset), + }; + } + return { + x: Math.max(br.x - inset, tl.x + inset), + y: Math.max(br.y - inset, tl.y + inset), + }; +} + +export function appendFragmentLinks( + svg: SVGSVGElement, + img: HTMLImageElement, + links: FragmentLink[], + defaultResolution: Resolution +): void { + if (!links.length) return; + const defs = ensureOverlayDefs(svg); + ensureArrowMarker(defs, 'fragment-arrowhead'); + const fontSize = overlayUserLength(OVERLAY_BADGE_FONT_SIZE); + + for (const link of links) { + const fromRect = boxPixelRect(link.fromBox, img); + + const group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + group.setAttribute('class', 'fragment-link'); + group.setAttribute('data-thread-id', link.threadId); + group.setAttribute('data-fragment-from-id', link.fromElementId); + if (link.toElementId) group.setAttribute('data-fragment-to-id', link.toElementId); + + let labelAt: { x: number; y: number }; + if (link.toBox) { + const toRect = boxPixelRect(link.toBox, img); + const start = boxCenter(fromRect); + const end = boxCenter(toRect); + + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('class', 'fragment-link-path fragment-link-path-dashed'); + line.setAttribute('x1', String(start.x)); + line.setAttribute('y1', String(start.y)); + line.setAttribute('x2', String(end.x)); + line.setAttribute('y2', String(end.y)); + line.setAttribute('marker-end', 'url(#fragment-arrowhead)'); + alignDashedLineToEnd(line, start, end); + group.appendChild(line); + labelAt = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; + } else { + const corner = link.targetCorner ?? 'br'; + const cornerPoint = pageCornerTarget(img, defaultResolution, corner); + const elementAnchor = boxCenter(fromRect); + const incoming = corner === 'tl'; + const start = incoming ? cornerPoint : elementAnchor; + const end = incoming ? elementAnchor : cornerPoint; + + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('class', 'fragment-link-path fragment-link-path-dashed'); + line.setAttribute('x1', String(start.x)); + line.setAttribute('y1', String(start.y)); + line.setAttribute('x2', String(end.x)); + line.setAttribute('y2', String(end.y)); + line.setAttribute('marker-end', 'url(#fragment-arrowhead)'); + alignDashedLineToEnd(line, start, end); + group.appendChild(line); + labelAt = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; + } + + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + text.setAttribute('class', 'fragment-link-label'); + text.setAttribute('x', String(labelAt.x)); + text.setAttribute('y', String(labelAt.y)); + text.setAttribute('font-size', String(fontSize)); + text.textContent = link.toBox + ? FRAGMENT_LINK_LABEL_SAME_PAGE + : FRAGMENT_LINK_LABEL_CROSS_PAGE; + group.appendChild(text); + + svg.appendChild(group); + } +} + +export function appendFragmentNavButtons( + svg: SVGSVGElement, + img: HTMLImageElement, + items: FragmentNavItem[] +): void { + if (!items.length) return; + + const btnSize = overlayUserLength(13 * 1.5); + const gap = overlayUserLength(2); + const inset = overlayUserLength(3); + const fontSize = overlayUserLength(10 * 1.5); + const radius = overlayUserLength(2 * 1.5); + + for (const item of items) { + const { x, y, w, h } = boxPixelRect(item.box, img); + const group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + group.setAttribute('class', 'fragment-nav'); + group.setAttribute('data-element-id', item.elementId); + + const rowY = y + h - inset - btnSize; + const nextX = x + w - inset - btnSize; + const prevX = nextX - gap - btnSize; + + appendFragmentNavButton( + group, + prevX, + rowY, + btnSize, + radius, + fontSize, + 'prev', + '‹', + item.hasPrev + ); + appendFragmentNavButton( + group, + nextX, + rowY, + btnSize, + radius, + fontSize, + 'next', + '›', + item.hasNext + ); + + svg.appendChild(group); + } +} + +function appendFragmentNavButton( + group: SVGGElement, + x: number, + y: number, + size: number, + radius: number, + fontSize: number, + direction: string, + label: string, + enabled: boolean +): void { + const btn = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + btn.setAttribute( + 'class', + `fragment-nav-btn fragment-nav-btn-${direction}${enabled ? '' : ' fragment-nav-btn-disabled'}` + ); + btn.setAttribute('data-nav', direction); + if (enabled) { + btn.setAttribute('role', 'button'); + btn.setAttribute( + 'aria-label', + direction === 'prev' ? FRAGMENT_NAV_HINT_PREV : FRAGMENT_NAV_HINT_NEXT + ); + } + + const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bg.setAttribute('class', 'fragment-nav-btn-bg'); + bg.setAttribute('x', String(x)); + bg.setAttribute('y', String(y)); + bg.setAttribute('width', String(size)); + bg.setAttribute('height', String(size)); + bg.setAttribute('rx', String(radius)); + bg.setAttribute('ry', String(radius)); + btn.appendChild(bg); + + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + text.setAttribute('class', 'fragment-nav-btn-label'); + text.setAttribute('x', String(x + size / 2)); + text.setAttribute('y', String(y + size / 2)); + text.setAttribute('font-size', String(fontSize)); + text.textContent = label; + btn.appendChild(text); + + group.appendChild(btn); +} + +export function appendReadingOrderOverlay( + svg: SVGSVGElement, + img: HTMLImageElement, + steps: { box: BoundingBox }[] +): void { + if (!steps.length) return; + if (steps.length >= 2) { + const defs = ensureOverlayDefs(svg); + ensureArrowMarker(defs, 'reading-order-arrowhead'); + for (let i = 0; i < steps.length - 1; i += 1) { + const from = boxPixelRect(steps[i]!.box, img); + const to = boxPixelRect(steps[i + 1]!.box, img); + const start = boxCenter(from); + const end = boxCenter(to); + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('class', 'reading-order-step'); + line.setAttribute('x1', String(start.x)); + line.setAttribute('y1', String(start.y)); + line.setAttribute('x2', String(end.x)); + line.setAttribute('y2', String(end.y)); + line.setAttribute('marker-end', 'url(#reading-order-arrowhead)'); + svg.appendChild(line); + } + } +} + +export function buildOverlay( + img: HTMLImageElement, + boxes: BoundingBox[], + captionLinks: CaptionLink[] = [], + xrefLinks: XrefLink[] = [], + readingOrderSteps: { order: number; box: BoundingBox; elementId: string }[] = [], + fragmentLinks: FragmentLink[] = [], + fragmentNavItems: FragmentNavItem[] = [], + defaultResolution: Resolution = { width: 512, height: 512 }, + onSelectElement: (id: string) => void, + onNavigateFragment: (elementId: string, direction: string) => void, + onClearSelection: () => void, + getPagPanSuppressClick: () => boolean, + setPagPanSuppressClick: (v: boolean) => void +): SVGSVGElement { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.classList.add('overlay'); + svg.setAttribute('viewBox', `0 0 ${img.naturalWidth} ${img.naturalHeight}`); + svg.setAttribute('overflow', 'hidden'); + + appendCaptionLinks(svg, img, captionLinks); + appendXrefLinks(svg, img, xrefLinks); + appendFragmentLinks(svg, img, fragmentLinks, defaultResolution); + appendReadingOrderOverlay(svg, img, readingOrderSteps); + + const defs = ensureOverlayDefs(svg); + ensureLayerHatchPatterns(defs); + + for (const b of sortedOverlayBoxes(boxes)) { + const { x, y, w, h } = boxPixelRect(b, img); + const cls = bboxClassForKind(b.kind); + const kindClass = kindClassForTag(b.kind); + const layerClass = layerClassForValue(b.layer ?? 'body'); + + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rect.setAttribute( + 'class', + `bbox bbox-${cls} ${kindClass}${layerClass ? ` ${layerClass}` : ''}` + ); + rect.setAttribute('x', String(x)); + rect.setAttribute('y', String(y)); + rect.setAttribute('width', String(Math.max(w, 1))); + rect.setAttribute('height', String(Math.max(h, 1))); + rect.setAttribute('data-element-id', b.elementId); + svg.appendChild(rect); + } + + appendFragmentNavButtons(svg, img, fragmentNavItems); + + svg.addEventListener('click', e => { + if (getPagPanSuppressClick()) { + setPagPanSuppressClick(false); + return; + } + const target = e.target as Element; + const navBtn = target.closest('.fragment-nav-btn:not(.fragment-nav-btn-disabled)'); + if (navBtn) { + e.stopPropagation(); + const navGroup = navBtn.closest('.fragment-nav'); + const elementId = navGroup?.getAttribute('data-element-id'); + const direction = navBtn.getAttribute('data-nav'); + if (elementId && direction) onNavigateFragment(elementId, direction); + return; + } + const badge = target.closest('.overlay-badge[data-element-id]'); + if (badge) { + const id = badge.getAttribute('data-element-id'); + if (id) onSelectElement(id); + return; + } + const coords = imageCoordsFromEvent(svg, e as MouseEvent); + if (!coords) return; + const hit = hitTestBoxes(boxes, img, coords.x, coords.y); + if (hit) onSelectElement(hit.elementId); + else onClearSelection(); + }); + + svg.addEventListener('mousemove', e => { + const coords = imageCoordsFromEvent(svg, e as MouseEvent); + svg.style.cursor = + coords && hitTestBoxes(boxes, img, coords.x, coords.y) ? 'pointer' : ''; + }); + svg.addEventListener('mouseleave', () => { + svg.style.cursor = ''; + }); + + return svg; +} + +function imageCoordsFromEvent( + svg: SVGSVGElement, + evt: MouseEvent +): { x: number; y: number } | null { + const pt = svg.createSVGPoint(); + pt.x = evt.clientX; + pt.y = evt.clientY; + const ctm = svg.getScreenCTM()?.inverse(); + if (!ctm) return null; + const p = pt.matrixTransform(ctm); + return { x: p.x, y: p.y }; +} + +export function hitTestBoxes( + boxes: BoundingBox[], + img: HTMLImageElement, + x: number, + y: number +): BoundingBox | null { + let best: BoundingBox | null = null; + let bestArea = Infinity; + for (const b of boxes) { + const { x: bx, y: by, w, h, area } = boxPixelRect(b, img); + if (x >= bx && x <= bx + w && y >= by && y <= by + h && area < bestArea) { + best = b; + bestArea = area; + } + } + return best; +} diff --git a/core/src/rendered.ts b/core/src/rendered.ts new file mode 100644 index 0000000..14f86ad --- /dev/null +++ b/core/src/rendered.ts @@ -0,0 +1,1008 @@ +import { + RENDER_BLOCK_TAGS, + RENDER_FORMAT_TAGS, + FORMAT_HTML_TAG, + HEAD_TAGS, + CELL_SPAN_TAGS, + SAFE_DATA_IMAGE_RE, + MAX_DATA_IMAGE_URI_LENGTH, + INVALID_PICTURE_SRC, + PICTURE_UNAVAILABLE_ALT, +} from './constants'; +import { + isTextLikeNode, + isWhitespaceOnlyText, + localName, + childElements, + isCellToken, + headingLevel, + parseElementHeadAt, + skipElementHeadNodes, + skipContainerLevelHead, + skipUntilCellBoundary, + skipOtslContainerHead, + elementLayer, + elementThreadId, + normalizeArchivePath, + isSemanticElement, +} from './xml'; +import type { DocumentState, RenderCtx, OtslCell, ParsedOtslCell } from './types'; + +// --------------------------------------------------------------------------- +// State accessor — injected at runtime by main.ts +// --------------------------------------------------------------------------- +let getState: () => DocumentState | null = () => null; + +export function setStateAccessor(fn: () => DocumentState | null): void { + getState = fn; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function applyElementLayerAttr(sourceEl: Element, domEl: HTMLElement): void { + domEl.setAttribute('data-doclang-layer', elementLayer(sourceEl)); +} + +export function resolveArchiveUri(uri: string | null | undefined): string | null { + if (!uri) return null; + const trimmed = uri.trim(); + if (!trimmed) return null; + + if (SAFE_DATA_IMAGE_RE.test(trimmed)) { + return trimmed.length <= MAX_DATA_IMAGE_URI_LENGTH ? trimmed : null; + } + // Reject any other scheme (http, https, blob, javascript, …) and protocol-relative URLs. + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed) || trimmed.startsWith('//')) return null; + + return getState()?.assetUrls?.get(normalizeArchivePath(trimmed)) ?? null; +} + +function markPictureUnavailable(img: HTMLImageElement): void { + img.classList.add('rendered-picture-unavailable'); + img.alt = '\u00A0'; + img.setAttribute('aria-label', PICTURE_UNAVAILABLE_ALT); +} + +function appendPictureFigureImage( + figure: HTMLElement, + uri: string | null, + captionEl: Element | null, + elementIds: Map +): void { + const img = document.createElement('img'); + figure.appendChild(img); + + const resolved = uri ? resolveArchiveUri(uri) : null; + if (resolved) { + img.alt = ''; + img.src = resolved; + img.addEventListener('error', () => markPictureUnavailable(img), { once: true }); + } else { + markPictureUnavailable(img); + img.src = INVALID_PICTURE_SRC; + } + + if (captionEl) { + figure.appendChild(renderEmbeddedCaption(captionEl, elementIds, 'figcaption')); + } +} + +function readCaptionElement(el: Element): Element | null { + return childElements(el).find(c => localName(c) === 'caption') ?? null; +} + +function renderEmbeddedCaption( + captionEl: Element, + elementIds: Map, + tagName: string +): HTMLElement { + const node = document.createElement(tagName); + node.classList.add('rendered-el', 'rendered-caption'); + const elementId = elementIds.get(captionEl); + if (elementId) node.setAttribute('data-element-id', elementId); + appendRenderedBody(node, captionEl, elementIds, { inline: true }); + return node; +} + +function wrapRendered( + el: Element, + node: HTMLElement, + elementId: string | undefined, + extraClass?: string +): HTMLElement { + const tag = localName(el); + const wrap = document.createElement('div'); + wrap.className = `rendered-el rendered-${tag}${extraClass ? ` ${extraClass}` : ''}`; + if (elementId) wrap.setAttribute('data-element-id', elementId); + applyElementLayerAttr(el, wrap); + wrap.appendChild(node); + return wrap; +} + +export function appendRenderedBody( + parent: HTMLElement, + el: Element, + elementIds: Map, + ctx: RenderCtx +): void { + const nodes = [...el.childNodes]; + let i = skipElementHeadNodes(nodes, 0); + while (i < nodes.length) { + appendRenderedNode(parent, nodes[i]!, elementIds, ctx); + i += 1; + } +} + +function appendRenderedBodyBlocks( + parent: HTMLElement, + el: Element, + elementIds: Map +): void { + const nodes = [...el.childNodes]; + let i = skipElementHeadNodes(nodes, 0); + while (i < nodes.length) { + const node = nodes[i]!; + if (node.nodeType === Node.ELEMENT_NODE && RENDER_BLOCK_TAGS.has(localName(node as Element))) { + const rendered = renderBlockElement(node as Element, elementIds, { inline: false }); + if (rendered) parent.appendChild(rendered); + } else { + appendRenderedNode(parent, node, elementIds, { inline: false }); + } + i += 1; + } +} + +function renderMarkerElement( + el: Element, + elementIds: Map, + ctx: RenderCtx +): HTMLElement { + const marker = document.createElement('span'); + marker.className = 'rendered-marker rendered-el'; + const elementId = elementIds.get(el); + if (elementId) marker.setAttribute('data-element-id', elementId); + appendRenderedBody(marker, el, elementIds, ctx); + return marker; +} + +function renderCheckboxElement( + el: Element, + elementIds: Map +): HTMLElement { + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.disabled = true; + cb.checked = (el.getAttribute('class') ?? 'unselected') === 'selected'; + cb.className = 'rendered-checkbox'; + + const wrap = document.createElement('span'); + wrap.className = 'rendered-checkbox-wrap rendered-el'; + const elementId = elementIds.get(el); + if (elementId) wrap.setAttribute('data-element-id', elementId); + applyElementLayerAttr(el, wrap); + wrap.appendChild(cb); + return wrap; +} + +function renderFieldKeyElement( + el: Element, + elementIds: Map, + ctx: RenderCtx +): HTMLElement { + const node = document.createElement('span'); + node.className = 'rendered-field-key rendered-el'; + const elementId = elementIds.get(el); + if (elementId) node.setAttribute('data-element-id', elementId); + applyElementLayerAttr(el, node); + appendRenderedBody(node, el, elementIds, { ...ctx, inline: true }); + return node; +} + +function renderFieldValueElement( + el: Element, + elementIds: Map, + ctx: RenderCtx +): HTMLElement { + const valueClass = el.getAttribute('class') ?? 'read_only'; + const node = document.createElement('span'); + node.className = `rendered-field-value rendered-field-value-${valueClass} rendered-el`; + const elementId = elementIds.get(el); + if (elementId) node.setAttribute('data-element-id', elementId); + applyElementLayerAttr(el, node); + appendRenderedBody(node, el, elementIds, { ...ctx, inline: true }); + if ( + valueClass === 'fillable' && + !node.textContent?.trim() && + !node.querySelector('.rendered-checkbox-wrap, img, .rendered-marker') + ) { + const slot = document.createElement('span'); + slot.className = 'rendered-field-fillable-slot'; + slot.setAttribute('aria-hidden', 'true'); + node.appendChild(slot); + } + return node; +} + +function renderFieldHintElement( + el: Element, + elementIds: Map, + ctx: RenderCtx +): HTMLElement { + const node = document.createElement('span'); + node.className = 'rendered-field-hint rendered-el'; + const elementId = elementIds.get(el); + if (elementId) node.setAttribute('data-element-id', elementId); + applyElementLayerAttr(el, node); + appendRenderedBody(node, el, elementIds, { ...ctx, inline: true }); + return node; +} + +function renderFormatElement( + el: Element, + elementIds: Map, + ctx: RenderCtx +): HTMLElement { + const tag = localName(el); + if (tag === 'content') { + const span = document.createElement('span'); + span.textContent = el.textContent ?? ''; + return span; + } + + let node: HTMLElement; + if (tag === 'handwriting') { + node = document.createElement('span'); + node.className = 'rendered-handwriting'; + } else if (tag === 'rtl') { + node = document.createElement('bdi'); + node.setAttribute('dir', 'rtl'); + } else { + node = document.createElement(FORMAT_HTML_TAG[tag] ?? 'span'); + } + + appendRenderedBody(node, el, elementIds, ctx); + return node; +} + +function renderCode( + el: Element, + elementIds: Map, + ctx: RenderCtx +): HTMLElement { + const labelEl = childElements(el).find(c => localName(c) === 'label'); + const labelValue = labelEl?.getAttribute('value'); + + const code = document.createElement('code'); + appendRenderedBody(code, el, elementIds, { inline: ctx.inline }); + + if (ctx.inline) { + code.classList.add('rendered-el'); + const id = elementIds.get(el); + if (id) code.setAttribute('data-element-id', id); + return code; + } + + const pre = document.createElement('pre'); + if (labelValue && labelValue !== 'undefined') { + const label = document.createElement('span'); + label.className = 'rendered-code-label'; + label.textContent = labelValue; + pre.appendChild(label); + } + pre.appendChild(code); + return wrapRendered(el, pre, elementIds.get(el)); +} + +function renderFormula( + el: Element, + elementIds: Map, + ctx: RenderCtx +): HTMLElement { + const span = document.createElement('span'); + span.className = ctx.inline ? 'rendered-formula-inline' : 'rendered-formula'; + appendRenderedBody(span, el, elementIds, { inline: true }); + if (ctx.inline) { + span.classList.add('rendered-el'); + const id = elementIds.get(el); + if (id) span.setAttribute('data-element-id', id); + return span; + } + return wrapRendered(el, span, elementIds.get(el)); +} + +function renderPicture( + el: Element, + elementIds: Map +): HTMLElement { + const figure = document.createElement('figure'); + const captionEl = readCaptionElement(el); + const srcEl = childElements(el).find(c => localName(c) === 'src') ?? null; + const uri = srcEl?.getAttribute('uri')?.trim() || null; + + appendPictureFigureImage(figure, uri, captionEl, elementIds); + + const nodes = [...el.childNodes]; + let i = skipElementHeadNodes(nodes, 0); + while (i < nodes.length) { + const node = nodes[i]!; + if (node.nodeType !== Node.ELEMENT_NODE) { + i += 1; + continue; + } + const tag = localName(node as Element); + if (tag === 'src') { + i += 1; + continue; + } + if (tag === 'tabular') { + const rendered = renderOtslContainer(node as Element, elementIds); + if (rendered) figure.appendChild(rendered); + i += 1; + continue; + } + break; + } + + const bodyInner = document.createElement('div'); + bodyInner.className = 'rendered-picture-contents-body'; + appendPictureBodyContent(bodyInner, nodes, i, elementIds); + if (bodyInner.textContent?.trim()) { + const details = document.createElement('details'); + details.className = 'rendered-picture-contents'; + const summary = document.createElement('summary'); + summary.textContent = 'Picture contents'; + details.appendChild(summary); + details.appendChild(bodyInner); + figure.appendChild(details); + } + + return wrapRendered(el, figure, elementIds.get(el)); +} + +function appendPictureBodyContent( + container: HTMLElement, + nodes: ChildNode[], + startIdx: number, + elementIds: Map +): void { + let i = startIdx; + while (i < nodes.length) { + const node = nodes[i]!; + if (node.nodeType === Node.ELEMENT_NODE && RENDER_BLOCK_TAGS.has(localName(node as Element))) { + const rendered = renderBlockElement(node as Element, elementIds, { inline: false }); + if (rendered) container.appendChild(rendered); + } else { + appendRenderedNode(container, node, elementIds, { inline: false }); + } + i += 1; + } +} + +function renderVirtualTextBlock( + hostEl: Element, + contentNodes: ChildNode[], + elementIds: Map +): HTMLElement { + const hasBlock = contentNodes.some( + n => + n.nodeType === Node.ELEMENT_NODE && + RENDER_BLOCK_TAGS.has(localName(n as Element)) + ); + const inner = document.createElement(hasBlock ? 'div' : 'p'); + for (const node of contentNodes) { + if ( + node.nodeType === Node.ELEMENT_NODE && + RENDER_BLOCK_TAGS.has(localName(node as Element)) + ) { + const rendered = renderBlockElement(node as Element, elementIds, { inline: false }); + if (rendered) inner.appendChild(rendered); + } else { + appendRenderedNode(inner, node, elementIds, { inline: true }); + } + } + const wrap = document.createElement('div'); + wrap.className = 'rendered-el rendered-text rendered-el-virtual-text'; + const elementId = elementIds.get(hostEl); + if (elementId) wrap.setAttribute('data-element-id', elementId); + applyElementLayerAttr(hostEl, wrap); + wrap.appendChild(inner); + return wrap; +} + +function sliceHasMarkupContent(nodes: ArrayLike): boolean { + for (let i = 0; i < nodes.length; i += 1) { + const node = nodes[i]; + if (!node) continue; + if (isTextLikeNode(node) && !isWhitespaceOnlyText(node)) return true; + if (node.nodeType === Node.ELEMENT_NODE) return true; + } + return false; +} + +function isVirtualTextSkippableNode(node: ChildNode): boolean { + if (isWhitespaceOnlyText(node)) return true; + if (node.nodeType !== Node.ELEMENT_NODE) return false; + const tag = localName(node as Element); + return tag === 'location' || HEAD_TAGS.has(tag); +} + +function shouldWrapVirtualText(contentNodes: ChildNode[]): boolean { + if (!sliceHasMarkupContent(contentNodes)) return false; + for (const node of contentNodes) { + if (isVirtualTextSkippableNode(node)) continue; + if (isTextLikeNode(node)) return true; + if (node.nodeType === Node.ELEMENT_NODE && !isSemanticElement(node as Element)) return true; + } + return false; +} + +function appendRenderedSliceContent( + container: HTMLElement, + hostEl: Element, + contentNodes: ChildNode[], + elementIds: Map +): void { + if (!sliceHasMarkupContent(contentNodes)) return; + if (shouldWrapVirtualText(contentNodes)) { + container.appendChild(renderVirtualTextBlock(hostEl, contentNodes, elementIds)); + return; + } + for (const node of contentNodes) { + if (isVirtualTextSkippableNode(node)) continue; + if ( + node.nodeType === Node.ELEMENT_NODE && + RENDER_BLOCK_TAGS.has(localName(node as Element)) + ) { + const rendered = renderBlockElement(node as Element, elementIds, { inline: false }); + if (rendered) container.appendChild(rendered); + } else { + appendRenderedNode(container, node, elementIds, { inline: true }); + } + } +} + +function appendListItemsFromElement( + list: HTMLElement, + el: Element, + elementIds: Map +): void { + const nodes = [...el.childNodes]; + let i = skipContainerLevelHead(nodes, 0); + + while (i < nodes.length) { + const node = nodes[i]!; + if (node.nodeType !== Node.ELEMENT_NODE || localName(node as Element) !== 'ldiv') { + i += 1; + continue; + } + + const ldiv = node as Element; + i += 1; + const li = document.createElement('li'); + + for (const child of childElements(ldiv)) { + const childTag = localName(child); + if (childTag === 'marker') { + li.appendChild(renderMarkerElement(child, elementIds, { inline: true })); + } else if (childTag === 'checkbox') { + li.appendChild(renderCheckboxElement(child, elementIds)); + } + } + + const contentStart = i; + const head = parseElementHeadAt(nodes, i); + if (head) i = head.nextIndex; + + while (i < nodes.length) { + const contentNode = nodes[i]!; + if ( + contentNode.nodeType === Node.ELEMENT_NODE && + localName(contentNode as Element) === 'ldiv' + ) + break; + i += 1; + } + + const contentNodes = nodes.slice(contentStart, i); + appendRenderedSliceContent(li, ldiv, contentNodes, elementIds); + + list.appendChild(li); + } +} + +function renderList(el: Element, elementIds: Map): HTMLElement { + const listClass = el.getAttribute('class') ?? 'unordered'; + const list = document.createElement(listClass === 'ordered' ? 'ol' : 'ul'); + appendListItemsFromElement(list, el, elementIds); + return wrapRendered(el, list, elementIds.get(el)); +} + +function isHeaderCellKind(kind: string): boolean { + return kind === 'ched' || kind === 'rhed' || kind === 'corn' || kind === 'srow'; +} + +function parseOtslRows(container: Element): ParsedOtslCell[][] { + const nodes = [...container.childNodes]; + let i = skipOtslContainerHead(nodes, 0); + const rows: ParsedOtslCell[][] = []; + let currentRow: ParsedOtslCell[] = []; + + while (i < nodes.length) { + const node = nodes[i]!; + if (node.nodeType !== Node.ELEMENT_NODE) { + i += 1; + continue; + } + const tag = localName(node as Element); + if (tag === 'nl') { + rows.push(currentRow); + currentRow = []; + i += 1; + continue; + } + if (!isCellToken(tag)) { + i += 1; + continue; + } + + if (CELL_SPAN_TAGS.has(tag)) { + currentRow.push({ kind: tag, token: node as Element, contentNodes: [] }); + i += 1; + continue; + } + + i += 1; + const head = parseElementHeadAt(nodes, i); + if (head) i = head.nextIndex; + const contentStart = i; + i = skipUntilCellBoundary(nodes, i); + currentRow.push({ + kind: tag, + token: node as Element, + contentNodes: nodes.slice(contentStart, i), + }); + } + + if (currentRow.length) rows.push(currentRow); + return rows; +} + +function findVerticalCellOrigin( + grid: (OtslCell | undefined)[][], + row: number, + col: number +): { cell: OtslCell; row: number; col: number } | null { + for (let r = row - 1; r >= 0; r -= 1) { + const cell = grid[r]?.[col]; + if (!cell || cell.covered) continue; + return { cell, row: r, col }; + } + return null; +} + +function findHorizontalCellOrigin( + grid: (OtslCell | undefined)[][], + row: number, + col: number +): { cell: OtslCell; row: number; col: number } | null { + for (let c = col - 1; c >= 0; c -= 1) { + const cell = grid[row]?.[c]; + if (!cell || cell.covered) continue; + return { cell, row, col: c }; + } + return null; +} + +function nextFreeColumn(grid: (OtslCell | undefined)[][], row: number, col: number): number { + let c = col; + while (grid[row]?.[c]?.covered) c += 1; + return c; +} + +function buildOtslGrid(rows: ParsedOtslCell[][]): (OtslCell | undefined)[][] { + const grid: (OtslCell | undefined)[][] = []; + + for (let rowIdx = 0; rowIdx < rows.length; rowIdx += 1) { + if (!grid[rowIdx]) grid[rowIdx] = []; + let col = 0; + + for (const parsed of rows[rowIdx]!) { + col = nextFreeColumn(grid, rowIdx, col); + + if (parsed.kind === 'lcel') { + const origin = findHorizontalCellOrigin(grid, rowIdx, col); + if (origin) origin.cell.colspan += 1; + grid[rowIdx]![col] = { + kind: 'lcel', + token: parsed.token, + contentNodes: [], + colspan: 0, + rowspan: 0, + covered: true, + }; + col += 1; + continue; + } + + if (parsed.kind === 'ucel') { + const origin = findVerticalCellOrigin(grid, rowIdx, col); + if (origin) origin.cell.rowspan += 1; + grid[rowIdx]![col] = { + kind: 'ucel', + token: parsed.token, + contentNodes: [], + colspan: 0, + rowspan: 0, + covered: true, + }; + col += 1; + continue; + } + + if (parsed.kind === 'xcel') { + const vOrigin = findVerticalCellOrigin(grid, rowIdx, col); + const hOrigin = findHorizontalCellOrigin(grid, rowIdx, col); + if (vOrigin && hOrigin && vOrigin.cell === hOrigin.cell) { + vOrigin.cell.rowspan += 1; + vOrigin.cell.colspan += 1; + } else { + if (vOrigin) vOrigin.cell.rowspan += 1; + if (hOrigin) hOrigin.cell.colspan += 1; + } + grid[rowIdx]![col] = { + kind: 'xcel', + token: parsed.token, + contentNodes: [], + colspan: 0, + rowspan: 0, + covered: true, + }; + col += 1; + continue; + } + + grid[rowIdx]![col] = { + kind: parsed.kind, + token: parsed.token, + contentNodes: parsed.contentNodes, + colspan: 1, + rowspan: 1, + covered: false, + }; + col += 1; + } + } + + return grid; +} + +function renderOtslContainer( + el: Element, + elementIds: Map +): HTMLElement { + const table = document.createElement('table'); + table.className = 'rendered-table'; + + const captionEl = readCaptionElement(el); + if (captionEl) { + table.appendChild(renderEmbeddedCaption(captionEl, elementIds, 'caption')); + } + + const grid = buildOtslGrid(parseOtslRows(el)); + const tbody = document.createElement('tbody'); + + for (const row of grid) { + const tr = document.createElement('tr'); + for (const cell of row ?? []) { + if (!cell || cell.covered) continue; + const cellTag = isHeaderCellKind(cell.kind) ? 'th' : 'td'; + const td = document.createElement(cellTag); + if (cell.colspan > 1) td.colSpan = cell.colspan; + if (cell.rowspan > 1) td.rowSpan = cell.rowspan; + appendRenderedSliceContent(td, cell.token, cell.contentNodes as ChildNode[], elementIds); + tr.appendChild(td); + } + if (tr.childNodes.length) tbody.appendChild(tr); + } + + if (tbody.childNodes.length) table.appendChild(tbody); + return wrapRendered(el, table, elementIds.get(el)); +} + +function renderUnsupported(el: Element, elementIds: Map): HTMLElement { + const stub = document.createElement('div'); + stub.className = 'rendered-unsupported'; + stub.textContent = `<${localName(el)}> — not yet rendered`; + return wrapRendered(el, stub, elementIds.get(el)); +} + +export function renderBlockElement( + el: Element, + elementIds: Map, + ctx: RenderCtx +): HTMLElement | null { + const tag = localName(el); + const elementId = elementIds.get(el); + + switch (tag) { + case 'text': { + const p = document.createElement('p'); + appendRenderedBody(p, el, elementIds, { inline: true }); + return wrapRendered(el, p, elementId); + } + case 'heading': { + const h = document.createElement(`h${headingLevel(el)}`); + appendRenderedBody(h, el, elementIds, { inline: true }); + return wrapRendered(el, h, elementId); + } + case 'field_heading': { + const h = document.createElement(`h${headingLevel(el)}`); + h.className = 'rendered-field-heading'; + appendRenderedBody(h, el, elementIds, { inline: true }); + return wrapRendered(el, h, elementId); + } + case 'footnote': { + const aside = document.createElement('aside'); + appendRenderedBody(aside, el, elementIds, { inline: false }); + return wrapRendered(el, aside, elementId); + } + case 'page_header': { + const header = document.createElement('header'); + header.className = 'rendered-page-header'; + appendRenderedBody(header, el, elementIds, { inline: true }); + return wrapRendered(el, header, elementId); + } + case 'page_footer': { + const footer = document.createElement('footer'); + footer.className = 'rendered-page-footer'; + appendRenderedBody(footer, el, elementIds, { inline: true }); + return wrapRendered(el, footer, elementId); + } + case 'list': + return renderList(el, elementIds); + case 'table': + case 'index': + case 'tabular': + return renderOtslContainer(el, elementIds); + case 'code': + return renderCode(el, elementIds, ctx); + case 'formula': + return renderFormula(el, elementIds, ctx); + case 'picture': + return renderPicture(el, elementIds); + case 'group': { + const figure = document.createElement('figure'); + figure.className = 'rendered-group'; + appendRenderedBodyBlocks(figure, el, elementIds); + const cap = readCaptionElement(el); + if (cap) figure.appendChild(renderEmbeddedCaption(cap, elementIds, 'figcaption')); + return wrapRendered(el, figure, elementId); + } + case 'field_region': { + const div = document.createElement('div'); + div.className = 'rendered-field-region'; + appendRenderedBodyBlocks(div, el, elementIds); + return wrapRendered(el, div, elementId); + } + case 'field_item': { + const div = document.createElement('div'); + div.className = 'rendered-field-item'; + appendRenderedBodyBlocks(div, el, elementIds); + return wrapRendered(el, div, elementId); + } + default: + return renderUnsupported(el, elementIds); + } +} + +export function appendRenderedNode( + parent: HTMLElement, + node: ChildNode, + elementIds: Map, + ctx: RenderCtx +): void { + if (isTextLikeNode(node)) { + let text = node.textContent; + if (!text || !text.trim()) return; + if (ctx.trimLeading) { + text = text.replace(/^\s+/u, ''); + ctx.trimLeading = false; + if (!text) return; + } + parent.appendChild(document.createTextNode(text)); + return; + } + if (node.nodeType !== Node.ELEMENT_NODE) return; + + const el = node as Element; + const tag = localName(el); + if (HEAD_TAGS.has(tag)) return; + + if (RENDER_FORMAT_TAGS.has(tag)) { + parent.appendChild(renderFormatElement(el, elementIds, ctx)); + return; + } + + if (tag === 'code' || tag === 'formula') { + const rendered = renderBlockElement(el, elementIds, { inline: true }); + if (rendered) parent.appendChild(rendered); + return; + } + + if (RENDER_BLOCK_TAGS.has(tag)) { + const rendered = renderBlockElement(el, elementIds, ctx); + if (rendered) parent.appendChild(rendered); + return; + } + + if (tag === 'marker') { + parent.appendChild(renderMarkerElement(el, elementIds, ctx)); + return; + } + + if (tag === 'checkbox') { + parent.appendChild(renderCheckboxElement(el, elementIds)); + return; + } + + if (tag === 'key') { + parent.appendChild(renderFieldKeyElement(el, elementIds, ctx)); + return; + } + + if (tag === 'value') { + parent.appendChild(renderFieldValueElement(el, elementIds, ctx)); + return; + } + + if (tag === 'hint') { + parent.appendChild(renderFieldHintElement(el, elementIds, ctx)); + return; + } + + if (tag === 'ldiv') return; + if (isCellToken(tag) || tag === 'src' || tag === 'tabular') return; + + appendRenderedBody(parent, el, elementIds, ctx); +} + +// --------------------------------------------------------------------------- +// Fragment merge helpers +// --------------------------------------------------------------------------- + +function findLastTextNode(node: Node): Node | null { + if (isTextLikeNode(node)) return node; + if (node.nodeType !== Node.ELEMENT_NODE) return null; + for (let i = node.childNodes.length - 1; i >= 0; i -= 1) { + const found = findLastTextNode(node.childNodes[i]!); + if (found) return found; + } + return null; +} + +function trimParentTrailingForFragmentJoin(parent: HTMLElement): void { + const lastText = findLastTextNode(parent); + if (!lastText) return; + let value = lastText.textContent ?? ''; + value = value.replace(/\s+$/u, ''); + if (value.endsWith('-')) value = value.slice(0, -1); + if (!value) { + lastText.parentNode?.removeChild(lastText); + trimParentTrailingForFragmentJoin(parent); + return; + } + lastText.textContent = value; +} + +function appendMergedTextFragments( + parent: HTMLElement, + fragments: Element[], + elementIds: Map +): void { + for (let i = 0; i < fragments.length; i += 1) { + if (i > 0) trimParentTrailingForFragmentJoin(parent); + appendRenderedBody(parent, fragments[i]!, elementIds, { + inline: true, + trimLeading: i > 0, + }); + } +} + +function renderMergedIntraPageFragments( + fragments: Element[], + elementIds: Map +): HTMLElement { + const first = fragments[0]!; + const tag = localName(first); + const firstId = elementIds.get(first); + const threadId = elementThreadId(first); + + let node: HTMLElement; + if (tag === 'text') { + node = document.createElement('p'); + appendMergedTextFragments(node, fragments, elementIds); + } else if (tag === 'list') { + const listClass = first.getAttribute('class') ?? 'unordered'; + node = document.createElement(listClass === 'ordered' ? 'ol' : 'ul'); + for (const el of fragments) { + appendListItemsFromElement(node, el, elementIds); + } + } else { + node = document.createElement('div'); + node.className = 'rendered-fragment-merged-body'; + for (const el of fragments) { + appendRenderedBodyBlocks(node, el, elementIds); + } + } + + const wrap = wrapRendered(first, node, firstId, 'rendered-fragment-merged'); + if (threadId) wrap.setAttribute('data-thread-id', threadId); + return wrap; +} + +function collectIntraPageThreads(segment: Element[]): Map { + const byThread = new Map(); + for (const el of segment) { + if (el.nodeType !== Node.ELEMENT_NODE) continue; + if (localName(el) === 'page_break') continue; + const tid = elementThreadId(el); + if (!tid) continue; + if (!byThread.has(tid)) byThread.set(tid, []); + byThread.get(tid)!.push(el); + } + for (const [tid, members] of byThread) { + if (members.length < 2) byThread.delete(tid); + } + return byThread; +} + +export function applyReadingLayerClasses(root: HTMLElement, showFurniture: boolean, showBackground: boolean): void { + root.classList.toggle('show-reading-furniture', showFurniture); + root.classList.toggle('show-reading-background', showBackground); +} + +export function buildRenderedView( + segment: Element[], + elementIds: Map, + showFurniture: boolean, + showBackground: boolean, + onSelectElement: (id: string) => void +): HTMLElement { + const intraPageThreads = collectIntraPageThreads(segment); + const skipElements = new Set(); + const mergeGroups = new Map(); + + for (const [, members] of intraPageThreads) { + mergeGroups.set(members[0]!, members); + for (let i = 1; i < members.length; i += 1) { + skipElements.add(members[i]!); + } + } + + const root = document.createElement('article'); + root.className = 'rendered-doc'; + for (const el of segment) { + if (el.nodeType !== Node.ELEMENT_NODE) continue; + if (localName(el) === 'page_break') continue; + if (skipElements.has(el)) continue; + + const mergeGroup = mergeGroups.get(el); + const rendered = mergeGroup + ? renderMergedIntraPageFragments(mergeGroup, elementIds) + : renderBlockElement(el, elementIds, { inline: false }); + if (rendered) root.appendChild(rendered); + } + root.addEventListener('click', e => { + const target = e.target as Element; + const ghostText = target.closest('.rendered-el-virtual-text'); + const elementId = ghostText?.hasAttribute('data-element-id') + ? ghostText.getAttribute('data-element-id')! + : target.closest('.rendered-el[data-element-id]')?.getAttribute('data-element-id') ?? + null; + if (elementId) onSelectElement(elementId); + }); + applyReadingLayerClasses(root, showFurniture, showBackground); + return root; +} diff --git a/core/src/types.ts b/core/src/types.ts new file mode 100644 index 0000000..0b9b912 --- /dev/null +++ b/core/src/types.ts @@ -0,0 +1,181 @@ +import type { PaneKey } from './constants'; + +export interface BoundingBox { + kind: string; + tag: string; + elementId: string; + layer: string; + x0: number; + y0: number; + x1: number; + y1: number; + resW: number; + resH: number; +} + +export interface Resolution { + width: number; + height: number; +} + +export interface PageViewOverlay { + boxes: BoundingBox[]; + readingOrderSteps: ReadingOrderStep[]; +} + +export interface ReadingOrderStep { + order: number; + box: BoundingBox; + elementId: string; +} + +export interface CaptionLink { + captionBox: BoundingBox; + hostBox: BoundingBox; + captionElementId: string; + hostElementId: string; +} + +export interface XrefLink { + fromBox: BoundingBox; + toBox: BoundingBox; + fromElementId: string; + toElementId: string; +} + +export interface FragmentLink { + fromBox: BoundingBox; + toBox: BoundingBox | null; + fromElementId: string; + toElementId: string | null; + threadId: string; + targetCorner?: 'tl' | 'br'; +} + +export interface FragmentNavItem { + elementId: string; + box: BoundingBox; + hasPrev: boolean; + hasNext: boolean; +} + +export interface ThreadNav { + prev: Element | null; + next: Element | null; +} + +export interface DocumentState { + pageImages: Map; + assetUrls: Map; + currentPage: number; + pageCount: number; + segments: Element[][]; + defaultResolution: Resolution; + elementIds: Map; + idToElement: Map; + hasPageView: boolean; + markupOnly: boolean; + docRoot: Element; + threadPagesById: Map>; + elementPageByEl: Map; + threadNavByElement: Map; + pendingSelectElement: Element | null; + readingOrder: Element[]; + readingOrderDisplayNumbers: Map; + pageViewOverlay: PageViewOverlay | null; +} + +export type FileCatalogEntryKind = 'archive' | 'markup' | 'folder'; + +export interface FileCatalogEntry { + id: string; + label: string; + kind: FileCatalogEntryKind; + source: File | ArrayBuffer | File[]; + currentPage: number; + pageZoom: number; + snapshot: DocumentState | null; + thumbnailUrl: string | null; +} + +export interface PageLayoutCache { + paneW: number; + paneH: number; + imgW: number; + imgH: number; + fitScale: number; +} + +export interface PagePanDrag { + pointerId: number; + startX: number; + startY: number; + scrollLeft: number; + scrollTop: number; + moved: boolean; +} + +export interface PaneDragState { + physicalSplitterIndex: number; + leftKey: PaneKey; + rightKey: PaneKey; + startX: number; + leftStart: number; + rightStart: number; + pointerId: number; + leftStartPx?: number; + rightStartPx?: number; +} + +export interface UserPaneVisible { + file: boolean; + page: boolean; + markup: boolean; + reading: boolean; +} + +export interface OtslCell { + kind: string; + token: Element; + contentNodes: Node[]; + colspan: number; + rowspan: number; + covered?: boolean; +} + +export interface ParsedOtslCell { + kind: string; + token: Element; + contentNodes: Node[]; +} + +export interface RenderCtx { + inline: boolean; + trimLeading?: boolean; +} + +export interface HeadInfo { + key: string; + value: string; + isDefault: boolean; +} + +export interface OverlayLinkOptions { + markerId: string; + linkClass: string; + fromIdAttr: string; + toIdAttr: string; +} + +export interface ParsedElementHead { + locs: Element[]; + nextIndex: number; +} + +export interface PixelRect { + x: number; + y: number; + w: number; + h: number; + area: number; +} diff --git a/core/src/xml.ts b/core/src/xml.ts new file mode 100644 index 0000000..2466012 --- /dev/null +++ b/core/src/xml.ts @@ -0,0 +1,371 @@ +import { + DOCLANG_NS, + HEAD_TAGS, + CELL_TOKENS, + CELL_CONTENT_TAGS, + CELL_SPAN_TAGS, + OTSL_CONTAINER_TAGS, + SEMANTIC_TAGS, +} from './constants'; +import type { ParsedElementHead } from './types'; + +export function isTextLikeNode(node: Node): boolean { + return ( + node.nodeType === Node.TEXT_NODE || node.nodeType === Node.CDATA_SECTION_NODE + ); +} + +export function isWhitespaceOnlyText(node: Node): boolean { + return isTextLikeNode(node) && !node.textContent?.trim(); +} + +export function markupAttributes(el: Element): { name: string; value: string }[] { + return [...el.attributes] + .filter(a => a.name !== 'xmlns' || a.value !== DOCLANG_NS) + .map(a => ({ name: a.name, value: a.value })); +} + +export function childElements(el: Element): Element[] { + return [...el.children]; +} + +export function localName(el: Element): string { + return el.localName || el.tagName.replace(/^.*:/, ''); +} + +export function headLocations(el: Element): Element[] { + return parseElementHeadAt([...el.childNodes], 0)?.locs ?? []; +} + +export function parseElementHeadAt( + nodes: ArrayLike, + startIdx: number +): ParsedElementHead | null { + const locs: Element[] = []; + let i = startIdx; + while (i < nodes.length) { + const node = nodes[i]; + if (!node) { + i += 1; + continue; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + i += 1; + continue; + } + const tag = localName(node as Element); + if (tag === 'location') { + locs.push(node as Element); + i += 1; + if (locs.length === 4) return { locs, nextIndex: i }; + continue; + } + if (locs.length) break; + if (HEAD_TAGS.has(tag)) { + i += 1; + continue; + } + break; + } + return locs.length === 4 ? { locs, nextIndex: i } : null; +} + +export function walkElements( + nodes: ArrayLike | Element[], + fn: (el: Element) => void +): void { + for (let i = 0; i < nodes.length; i += 1) { + const node = nodes[i]; + if (!node || node.nodeType !== Node.ELEMENT_NODE) continue; + fn(node as Element); + walkElements(childElements(node as Element), fn); + } +} + +export function isCellToken(tag: string): boolean { + return CELL_TOKENS.has(tag); +} + +export function isListOrOtslContainer(el: Element): boolean { + const tag = localName(el); + return tag === 'list' || OTSL_CONTAINER_TAGS.has(tag); +} + +export function skipContainerLevelHead(nodes: ChildNode[], startIdx: number): number { + let i = startIdx; + while (i < nodes.length) { + const node = nodes[i]; + if (!node) { + i += 1; + continue; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + i += 1; + continue; + } + const tag = localName(node as Element); + if (tag === 'ldiv' || isCellToken(tag)) break; + if (HEAD_TAGS.has(tag) || tag === 'location') { + i += 1; + continue; + } + break; + } + return i; +} + +export function skipUntilListItemBoundary(nodes: ChildNode[], startIdx: number): number { + let i = startIdx; + while (i < nodes.length) { + const node = nodes[i]; + if ( + node && + node.nodeType === Node.ELEMENT_NODE && + localName(node as Element) === 'ldiv' + ) + break; + i += 1; + } + return i; +} + +export function skipUntilCellBoundary(nodes: ChildNode[], startIdx: number): number { + let i = startIdx; + while (i < nodes.length) { + const node = nodes[i]; + if ( + node && + node.nodeType === Node.ELEMENT_NODE && + isCellToken(localName(node as Element)) + ) + break; + i += 1; + } + return i; +} + +export function locationResolution(el: Element, axisDefault: number): number { + const r = parseInt(el.getAttribute('resolution') ?? String(axisDefault), 10); + return Number.isFinite(r) && r > 0 ? r : axisDefault; +} + +export function headingLevel(el: Element): number { + return Math.min( + Math.max(parseInt(el.getAttribute('level') ?? '1', 10) || 1, 1), + 6 + ); +} + +export function firstHeadChild(el: Element, tag: string): Element | null { + return childElements(el).find(child => localName(child) === tag) ?? null; +} + +export function xmlContains(target: Node, ancestor: Node): boolean { + let node: Node | null = target; + while (node) { + if (node === ancestor) return true; + node = node.parentNode; + } + return false; +} + +export function formatMarkupTextNode(node: Node): string { + if (node.nodeType === Node.CDATA_SECTION_NODE) { + return ``; + } + return node.textContent?.trim() ?? ''; +} + +export function serializeMarkupTextNodes(nodes: ArrayLike): string { + return Array.from(nodes) + .filter(n => isTextLikeNode(n) && !isWhitespaceOnlyText(n)) + .map(formatMarkupTextNode) + .join(''); +} + +export function escapeHtml(text: string): string { + return String(text) + .replace(/&/g, '&') + .replace(//g, '>'); +} + +export function normalizeArchivePath(path: string): string { + return path.replace(/\\/g, '/').replace(/^\.\//, ''); +} + +export function archiveRelativeAssetPath(path: string): string | null { + const norm = normalizeArchivePath(path); + const idx = norm.indexOf('assets/'); + return idx === -1 ? null : norm.slice(idx); +} + +export function mimeFromAssetPath(path: string): string { + const ext = path.split('.').pop()?.toLowerCase() ?? ''; + switch (ext) { + case 'png': + return 'image/png'; + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'webp': + return 'image/webp'; + case 'gif': + return 'image/gif'; + case 'svg': + return 'image/svg+xml'; + default: + return 'application/octet-stream'; + } +} + +export function skipOtslContainerHead(nodes: ChildNode[], startIdx: number): number { + let i = startIdx; + while (i < nodes.length) { + const node = nodes[i]; + if (!node) { + i += 1; + continue; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + i += 1; + continue; + } + const tag = localName(node as Element); + if (isCellToken(tag)) break; + if (HEAD_TAGS.has(tag) || tag === 'h_thread' || tag === 'location') { + i += 1; + continue; + } + break; + } + return i; +} + +export function skipElementHeadNodes(nodes: ChildNode[], startIdx: number): number { + let i = startIdx; + while (i < nodes.length && isWhitespaceOnlyText(nodes[i]!)) i += 1; + while (i < nodes.length) { + const node = nodes[i]; + if ( + node && + node.nodeType === Node.ELEMENT_NODE && + HEAD_TAGS.has(localName(node as Element)) + ) { + i += 1; + while (i < nodes.length && isWhitespaceOnlyText(nodes[i]!)) i += 1; + continue; + } + break; + } + return i; +} + +export function isSemanticElement(el: Element): boolean { + return SEMANTIC_TAGS.has(localName(el)); +} + +export function isVirtualTextHost(el: Element): boolean { + const tag = localName(el); + return tag === 'ldiv' || CELL_CONTENT_TAGS.has(tag); +} + +export function elementThreadId(el: Element): string | null { + return firstHeadChild(el, 'thread')?.getAttribute('thread_id') ?? null; +} + +export function elementLayer(el: Element): string { + const layerEl = firstHeadChild(el, 'layer'); + if (layerEl) { + const value = layerEl.getAttribute('value') ?? 'body'; + return ELEMENT_LAYERS.has(value) ? value : 'body'; + } + if (headLocations(el).length === 4) return 'body'; + const parent = el.parentElement; + if (!parent) return 'body'; + const nodes = [...parent.childNodes]; + const idx = nodes.indexOf(el); + if (idx < 0) return 'body'; + return layerFromHeadNodes(nodes, idx + 1); +} + +export const ELEMENT_LAYERS = new Set(['body', 'background', 'furniture']); + +export function layerFromHeadNodes(nodes: ChildNode[], startIdx: number): string { + let i = startIdx; + while (i < nodes.length) { + const node = nodes[i]; + if (!node) { i += 1; continue; } + if (node.nodeType !== Node.ELEMENT_NODE) { + i += 1; + continue; + } + const tag = localName(node as Element); + if (tag === 'layer') { + const value = (node as Element).getAttribute('value') ?? 'body'; + return ELEMENT_LAYERS.has(value) ? value : 'body'; + } + if (tag === 'location') break; + if (HEAD_TAGS.has(tag)) { + i += 1; + continue; + } + break; + } + return 'body'; +} + +export function layerClassForValue(layer: string): string { + if (layer === 'furniture') return 'layer-furniture'; + if (layer === 'background') return 'layer-background'; + return ''; +} + +export function headingLevelFromEl(el: Element): number { + return headingLevel(el); +} + +export function elementLabel(el: Element): string { + if (isVirtualTextOverlayUnit(el)) return 'text'; + const tag = localName(el); + if (tag === 'heading' || tag === 'field_heading') return `${tag}[${headingLevel(el)}]`; + const level = el.getAttribute('level'); + if (level) return `${tag}[${level}]`; + const cls = el.getAttribute('class'); + if (cls) return `${tag}.${cls}`; + return tag; +} + +export function isVirtualTextOverlayUnit(el: Element): boolean { + if (headLocations(el).length === 4) return false; + if (!hasVirtualTextLocations(el)) return false; + const tag = localName(el); + if (tag === 'ldiv' && el.parentElement && localName(el.parentElement) === 'list') return true; + if (isCellToken(tag) && tag !== 'nl' && !CELL_SPAN_TAGS.has(tag)) { + return el.parentElement ? OTSL_CONTAINER_TAGS.has(localName(el.parentElement)) : false; + } + return false; +} + +export function hasVirtualTextLocations(el: Element): boolean { + const parent = el.parentElement; + if (!parent) return false; + const nodes = [...parent.childNodes]; + const idx = nodes.indexOf(el); + if (idx < 0) return false; + return parseElementHeadAt(nodes, idx + 1) !== null; +} + +export function virtualTextHeadLocations(el: Element): Element[] { + const parent = el.parentElement; + if (!parent) return []; + const nodes = [...parent.childNodes]; + const idx = nodes.indexOf(el); + if (idx < 0) return []; + return parseElementHeadAt(nodes, idx + 1)?.locs ?? []; +} + +export function elementHeadLocations(el: Element): Element[] { + const own = headLocations(el); + return own.length === 4 ? own : virtualTextHeadLocations(el); +} diff --git a/core/src/xml_overlay.ts b/core/src/xml_overlay.ts new file mode 100644 index 0000000..92cc794 --- /dev/null +++ b/core/src/xml_overlay.ts @@ -0,0 +1,76 @@ +// Re-exports from xml.ts plus overlay-specific helpers +export { + childElements, + localName, + headLocations, + firstHeadChild, + walkElements, + locationResolution, + isCellToken, + skipContainerLevelHead, + skipUntilListItemBoundary, + skipUntilCellBoundary, + parseElementHeadAt, + elementLayer, + elementLabel, + layerClassForValue, + elementThreadId, + isVirtualTextOverlayUnit, +} from './xml'; +import { localName } from './xml'; +import { OTSL_CONTAINER_TAGS } from './constants'; + +export function isPictureContentElement(el: Element | null): boolean { + if (!el) return false; + const tag = localName(el); + if (tag === 'picture' || tag === 'caption') return false; + let node: Element | null = el.parentElement; + while (node) { + if (localName(node) === 'picture') return true; + node = node.parentElement; + } + return false; +} + +export function isTableContentElement(el: Element | null): boolean { + if (!el) return false; + const tag = localName(el); + if (OTSL_CONTAINER_TAGS.has(tag) || tag === 'caption') return false; + let node: Element | null = el.parentElement; + while (node) { + if (OTSL_CONTAINER_TAGS.has(localName(node))) return true; + node = node.parentElement; + } + return false; +} + +export function elementKindKey(kind: string): string { + if (kind.startsWith('field_') || kind === 'key' || kind === 'value' || kind === 'hint') + return 'field'; + if (kind === 'tabular') return 'table'; + const known = new Set([ + 'text', + 'heading', + 'list', + 'ldiv', + 'table', + 'index', + 'formula', + 'code', + 'picture', + 'group', + 'footnote', + 'page_header', + 'page_footer', + 'caption', + ]); + return known.has(kind) ? kind : 'default'; +} + +export function kindClassForTag(tag: string): string { + return `kind-${elementKindKey(tag)}`; +} + +export function bboxClassForKind(kind: string): string { + return elementKindKey(kind); +} diff --git a/core/src/zip.ts b/core/src/zip.ts new file mode 100644 index 0000000..f4e4fb9 --- /dev/null +++ b/core/src/zip.ts @@ -0,0 +1,248 @@ +import { + ZIP_MAX_ENTRIES, + ZIP_MAX_ENTRY_COMPRESSED_BYTES, + ZIP_MAX_ENTRY_UNCOMPRESSED_BYTES, + ZIP_MAX_TOTAL_UNCOMPRESSED_BYTES, + ZIP_MAX_COMPRESSION_RATIO, +} from './constants'; +export interface ZipEntry { + name: string; + data: Uint8Array; +} + +export async function unzip( + buffer: ArrayBuffer, + { shouldExtract }: { shouldExtract?: (name: string) => boolean } = {} +): Promise { + if (typeof DecompressionStream === 'undefined') { + throw new Error( + 'ZIP decompression requires a browser with DecompressionStream support' + ); + } + + const bytes = new Uint8Array(buffer); + const view = new DataView(buffer); + const eocdOffset = findEndOfCentralDirectory(bytes); + const entryCount = view.getUint16(eocdOffset + 10, true); + const centralDirOffset = view.getUint32(eocdOffset + 16, true); + + if (entryCount > ZIP_MAX_ENTRIES) { + throw new Error('ZIP archive has too many entries'); + } + if (centralDirOffset >= bytes.length) { + throw new Error('Invalid ZIP central directory offset'); + } + + const entries: ZipEntry[] = []; + let offset = centralDirOffset; + let totalUncompressed = 0; + + for (let i = 0; i < entryCount; i += 1) { + if (offset + 46 > bytes.length || view.getUint32(offset, true) !== 0x02014b50) { + throw new Error('Invalid ZIP central directory'); + } + + const compression = view.getUint16(offset + 10, true); + const compressedSize = view.getUint32(offset + 20, true); + const uncompressedSize = view.getUint32(offset + 24, true); + const nameLength = view.getUint16(offset + 28, true); + const extraLength = view.getUint16(offset + 30, true); + const commentLength = view.getUint16(offset + 32, true); + const localHeaderOffset = view.getUint32(offset + 42, true); + const nameEnd = offset + 46 + nameLength; + if (nameEnd > bytes.length) { + throw new Error('Invalid ZIP entry name'); + } + const rawName = new TextDecoder().decode(bytes.subarray(offset + 46, nameEnd)); + const name = normalizeZipEntryName(rawName); + + offset = nameEnd + extraLength + commentLength; + if (offset > bytes.length) { + throw new Error('Invalid ZIP central directory'); + } + + if (!name || name.endsWith('/') || isIgnoredArchiveEntry(name)) continue; + if (shouldExtract && !shouldExtract(name)) continue; + + if ( + compressedSize > ZIP_MAX_ENTRY_COMPRESSED_BYTES || + uncompressedSize > ZIP_MAX_ENTRY_UNCOMPRESSED_BYTES + ) { + throw new Error(`ZIP entry exceeds size limit: ${name}`); + } + if ( + compressedSize > 0 && + uncompressedSize > 0 && + uncompressedSize / compressedSize > ZIP_MAX_COMPRESSION_RATIO + ) { + throw new Error(`ZIP entry compression ratio exceeds limit: ${name}`); + } + if (totalUncompressed + uncompressedSize > ZIP_MAX_TOTAL_UNCOMPRESSED_BYTES) { + throw new Error('ZIP archive exceeds total uncompressed size limit'); + } + + if ( + localHeaderOffset + 30 > bytes.length || + view.getUint32(localHeaderOffset, true) !== 0x04034b50 + ) { + throw new Error(`Invalid ZIP local header: ${name}`); + } + const localNameLength = view.getUint16(localHeaderOffset + 26, true); + const localExtraLength = view.getUint16(localHeaderOffset + 28, true); + const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength; + if (dataOffset + compressedSize > bytes.length) { + throw new Error(`ZIP entry data out of bounds: ${name}`); + } + const compressed = bytes.subarray(dataOffset, dataOffset + compressedSize); + const data = await decompressZipEntry(compressed, compression, uncompressedSize); + totalUncompressed += data.length; + if (totalUncompressed > ZIP_MAX_TOTAL_UNCOMPRESSED_BYTES) { + throw new Error('ZIP archive exceeds total uncompressed size limit'); + } + entries.push({ name, data }); + } + + return entries; +} + +function findEndOfCentralDirectory(bytes: Uint8Array): number { + for (let i = bytes.length - 22; i >= 0; i -= 1) { + if ( + bytes[i] === 0x50 && + bytes[i + 1] === 0x4b && + bytes[i + 2] === 0x05 && + bytes[i + 3] === 0x06 + ) { + return i; + } + } + throw new Error('Invalid ZIP archive'); +} + +function concatUint8Arrays(chunks: Uint8Array[], totalLength: number): Uint8Array { + const out = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; +} + +async function decompressZipEntry( + data: Uint8Array, + method: number, + uncompressedSize: number +): Promise { + if (method === 0) { + if (data.length > ZIP_MAX_ENTRY_UNCOMPRESSED_BYTES) { + throw new Error('ZIP entry exceeds size limit'); + } + return data; + } + if (method === 8) { + const stream = new Blob([data as BlobPart]) + .stream() + .pipeThrough(new DecompressionStream('deflate-raw')); + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > ZIP_MAX_ENTRY_UNCOMPRESSED_BYTES) { + throw new Error('ZIP entry exceeds size limit'); + } + if (data.length > 0 && total / data.length > ZIP_MAX_COMPRESSION_RATIO) { + throw new Error('ZIP entry compression ratio exceeds limit'); + } + chunks.push(value); + } + } catch (err) { + try { + await reader.cancel(); + } catch { + /* ignore cancel errors */ + } + throw err; + } + + const out = concatUint8Arrays(chunks, total); + if (uncompressedSize && out.length !== uncompressedSize) { + return out.slice(0, Math.min(out.length, uncompressedSize)); + } + return out; + } + throw new Error(`Unsupported ZIP compression method ${method}`); +} + +function normalizeZipEntryName(name: string): string { + return name.replace(/\\/g, '/').replace(/^\.\//, ''); +} + +export function findArchiveEntry( + entries: ZipEntry[], + fileName: string +): ZipEntry | undefined { + return entries.find(e => e.name === fileName); +} + +export function isIgnoredArchiveEntry(name: string): boolean { + if (name === '.DS_Store' || name.endsWith('/.DS_Store')) return true; + return name.split('/').some(part => part.startsWith('._') || part === '__MACOSX'); +} + +export async function extractArchiveFromZipBuffer(buffer: ArrayBuffer): Promise<{ + markupXml: string; + pageImages: Map; + assetUrls: Map; +}> { + const entries = await unzip(buffer); + const markupEntry = findArchiveEntry(entries, 'document.xml'); + if (!markupEntry) { + throw new Error('Archive must contain document.xml'); + } + const markupXml = new TextDecoder().decode(markupEntry.data); + const pageImages = new Map(); + const assetUrls = new Map(); + for (const e of entries) { + const m = e.name.match(/^pages\/(\d+)\.(png|jpe?g|webp)$/i); + if (m) { + const ext = m[2]!.toLowerCase().replace('jpeg', 'jpg'); + const mime = + ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg'; + pageImages.set(Number(m[1]), URL.createObjectURL(new Blob([e.data as BlobPart], { type: mime }))); + continue; + } + if (e.name.startsWith('assets/') && !e.name.endsWith('/')) { + assetUrls.set( + e.name, + URL.createObjectURL( + new Blob([e.data as BlobPart], { type: mimeFromAssetPath(e.name) }) + ) + ); + } + } + return { markupXml, pageImages, assetUrls }; +} + +function mimeFromAssetPath(path: string): string { + const ext = path.split('.').pop()?.toLowerCase() ?? ''; + switch (ext) { + case 'png': + return 'image/png'; + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'webp': + return 'image/webp'; + case 'gif': + return 'image/gif'; + case 'svg': + return 'image/svg+xml'; + default: + return 'application/octet-stream'; + } +} diff --git a/core/tsconfig.json b/core/tsconfig.json new file mode 100644 index 0000000..b5909cc --- /dev/null +++ b/core/tsconfig.json @@ -0,0 +1,14 @@ +{ + "include": ["src"], + "compilerOptions": { + "module": "ESNext", + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "moduleResolution": "Bundler", + "skipLibCheck": true, + "esModuleInterop": true, + "noUncheckedIndexedAccess": true, + "noEmit": true + } +} diff --git a/core/vite.config.ts b/core/vite.config.ts new file mode 100644 index 0000000..69061e4 --- /dev/null +++ b/core/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite'; +import path from 'path'; + +export default defineConfig({ + build: { + lib: { + entry: path.resolve(import.meta.dirname, 'src/main.ts'), + formats: ['iife'], + name: 'DocLangViewer', + fileName: () => 'viewer.js', + }, + outDir: path.resolve(import.meta.dirname, '..'), + emptyOutDir: false, + minify: false, + rollupOptions: { + output: { + // Unwrap the IIFE wrapper — the original viewer.js runs top-level code + // so the bundle is already self-contained without an export surface. + extend: true + }, + }, + }, +}); diff --git a/viewer.js b/viewer.js index 872111b..e5811ef 100644 --- a/viewer.js +++ b/viewer.js @@ -1,5446 +1,4679 @@ -/* DocLang Archive Viewer — archive format: github.com/doclang-project/doclang spec.md#doclang-archive-format */ - -const SUPPORTED_FILE_EXTENSIONS = [".dclx", ".dclg"]; -const OPEN_FILE_HINT = `Open a DocLang file (${SUPPORTED_FILE_EXTENSIONS.join(", ")})`; -const VIRTUAL_TEXT_TAG_HINT = "DocLang virtual ; wrapping tags not included in source"; -const FRAGMENT_LINK_LABEL_CROSS_PAGE = "cross-page content"; -const FRAGMENT_LINK_LABEL_SAME_PAGE = "fragmented content"; -const FRAGMENT_NAV_HINT_PREV = "Previous fragment"; -const FRAGMENT_NAV_HINT_NEXT = "Next fragment"; -const DOCLANG_NS = "https://www.doclang.ai/ns/v0"; -const PAGE_IMAGE_RE = /^(\d+)\.(png|jpe?g|webp)$/i; -const NO_MARKUP = "(No markup to be shown.)"; -const NO_IMAGE = "(No page image available.)"; -const FILE_THUMB_PLACEHOLDER_SVG = ``; -const PICTURE_UNAVAILABLE_ALT = "Picture asset not available"; -const INVALID_PICTURE_SRC = "data:image/png;base64,NOT_A_VALID_IMAGE"; -/** Allow small embedded raster data URIs only; remote/blob/other schemes are rejected. */ -const SAFE_DATA_IMAGE_RE = /^data:image\/(png|jpe?g|webp|gif);base64,/i; -const MAX_DATA_IMAGE_URI_LENGTH = 2 * 1024 * 1024; // 2 MiB -const ZIP_MAX_ENTRIES = 5000; -const ZIP_MAX_ENTRY_COMPRESSED_BYTES = 128 * 1024 * 1024; // 128 MiB -const ZIP_MAX_ENTRY_UNCOMPRESSED_BYTES = 128 * 1024 * 1024; // 128 MiB -const ZIP_MAX_TOTAL_UNCOMPRESSED_BYTES = 512 * 1024 * 1024; // 512 MiB -const ZIP_MAX_COMPRESSION_RATIO = 100; // uncompressed / compressed -const LONG_EMBEDDED_URI_PREVIEW_LENGTH = 30; -const HEAD_TAGS = new Set([ - "label", "thread", "xref", "href", "layer", "location", "caption", "description", "summary", "custom", -]); -const SEMANTIC_TAGS = new Set([ - "text", "heading", "footnote", "page_header", "page_footer", "field_region", "list", "table", "index", - "formula", "code", "picture", "marker", "group", "field_heading", "field_item", "key", "value", "hint", - "caption", "page_break", -]); -const CELL_TOKENS = new Set(["fcel", "ecel", "ched", "rhed", "corn", "srow", "lcel", "ucel", "xcel", "nl"]); -const CELL_CONTENT_TAGS = new Set(["fcel", "ecel", "ched", "rhed", "corn", "srow"]); -const CELL_SPAN_TAGS = new Set(["lcel", "ucel", "xcel"]); -const OTSL_CONTAINER_TAGS = new Set(["table", "index", "tabular"]); -const RENDER_BLOCK_TAGS = new Set([ - "text", "heading", "field_heading", "footnote", "page_header", "page_footer", "list", "code", "formula", "picture", "group", - "field_region", "field_item", - "table", "index", "tabular", -]); -const RENDER_FORMAT_TAGS = new Set([ - "bold", "italic", "underline", "strikethrough", "superscript", "subscript", "handwriting", "rtl", "content", -]); -const FORMAT_HTML_TAG = { - bold: "strong", - italic: "em", - underline: "u", - strikethrough: "s", - superscript: "sup", - subscript: "sub", -}; - -function isTextLikeNode(node) { - return node.nodeType === Node.TEXT_NODE || node.nodeType === Node.CDATA_SECTION_NODE; -} - -function isWhitespaceOnlyText(node) { - return isTextLikeNode(node) && !node.textContent.trim(); -} - -function markupAttributes(el) { - return [...el.attributes] - .filter((a) => a.name !== "xmlns" || a.value !== DOCLANG_NS) - .map((a) => ({ name: a.name, value: a.value })); -} - -function xmlSpan(className, text, { ghost = false } = {}) { - const span = document.createElement("span"); - span.className = ghost ? `${className} markup-ghost-tag-part` : className; - span.textContent = text; - return span; -} - -function createMarkupLineRow(depth, { foldToggle = false } = {}) { - const line = createMarkupLine(); - line.style.setProperty("--markup-depth", String(depth)); - - const row = document.createElement("span"); - row.className = "markup-line-content"; - - const gutter = document.createElement("span"); - gutter.className = "markup-gutter"; - gutter.setAttribute("aria-hidden", "true"); - if (foldToggle) gutter.appendChild(createMarkupFoldToggle()); - row.appendChild(gutter); - - const body = document.createElement("span"); - body.className = "markup-line-body"; - row.appendChild(body); - - line.appendChild(row); - return { line, content: body }; -} - -function isTruncatableEmbeddedImageUri(value) { - if (!value || value.length <= LONG_EMBEDDED_URI_PREVIEW_LENGTH) return false; - return /^(data:image\/|blob:)/i.test(value); -} - -function formatCompactByteSize(bytes) { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) { - const kb = bytes / 1024; - return kb < 10 ? `${kb.toFixed(1)} KB` : `${Math.round(kb)} KB`; - } - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -function formatEmbeddedUriSizeLabel(value) { - if (/^blob:/i.test(value)) { - return value.length >= 1024 ? `${Math.round(value.length / 1024)} KB URL` : `${value.length} char URL`; - } - const comma = value.indexOf(","); - if (comma === -1) return "embedded data"; - const header = value.slice(0, comma); - const payload = value.slice(comma + 1).replace(/\s/g, ""); - const mime = /^data:([^;,]+)/i.exec(header)?.[1] ?? ""; - const shortMime = mime.startsWith("image/") ? mime.slice(6) : (mime || "data"); - const bytes = Math.floor(payload.replace(/=+$/, "").length * 3 / 4); - return `${shortMime} · ${formatCompactByteSize(bytes)}`; -} - -function createEmbeddedUriContinuationPanel(value, depth) { - const { line, content } = createMarkupLineRow(depth); - line.className = "markup-line markup-embedded-uri-panel"; - const body = document.createElement("div"); - body.className = "markup-embedded-uri-panel-body"; - body.textContent = value; - body.addEventListener("click", (e) => e.stopPropagation()); - content.appendChild(body); - return line; -} - -function createTruncatableMarkupAttrValue(value) { - const wrapper = document.createElement("span"); - wrapper.className = "xml-attr-value xml-attr-value-truncatable"; - wrapper.dataset.fullValue = value; - - const text = document.createElement("span"); - text.className = "xml-attr-value-text"; - text.textContent = value.slice(0, LONG_EMBEDDED_URI_PREVIEW_LENGTH); - - const toggle = document.createElement("button"); - toggle.type = "button"; - toggle.className = "xml-attr-value-chip"; - const sizeLabel = formatEmbeddedUriSizeLabel(value); - toggle.dataset.collapsedLabel = sizeLabel; - toggle.setAttribute("aria-expanded", "false"); - toggle.setAttribute("aria-label", `Show full value (${sizeLabel})`); - - const label = document.createElement("span"); - label.className = "xml-attr-value-chip-label"; - label.textContent = sizeLabel; - toggle.appendChild(label); - - wrapper.append(text, toggle); - return wrapper; -} - -function appendMarkupAttrValue(line, value) { - if (isTruncatableEmbeddedImageUri(value)) { - line.appendChild(createTruncatableMarkupAttrValue(value)); - } else { - line.appendChild(xmlSpan("xml-attr-value", value)); - } -} - -function toggleTruncatableMarkupAttrValue(toggle) { - const wrapper = toggle.closest(".xml-attr-value-truncatable"); - if (!wrapper) return; - - const markupLine = wrapper.closest(".markup-line"); - const fullValue = wrapper.dataset.fullValue ?? ""; - const label = toggle.querySelector(".xml-attr-value-chip-label"); - const collapsedLabel = toggle.dataset.collapsedLabel ?? label?.textContent ?? ""; - const expanded = toggle.getAttribute("aria-expanded") === "true"; - - if (expanded) { - toggle.setAttribute("aria-expanded", "false"); - toggle.setAttribute("aria-label", `Show full value (${collapsedLabel})`); - if (label) label.textContent = collapsedLabel; - markupLine?.nextElementSibling?.classList.contains("markup-embedded-uri-panel") - && markupLine.nextElementSibling.remove(); - return; - } - - toggle.setAttribute("aria-expanded", "true"); - toggle.setAttribute("aria-label", "Hide full value"); - if (label) label.textContent = "hide"; - if (!markupLine || markupLine.nextElementSibling?.classList.contains("markup-embedded-uri-panel")) { - return; - } - const depth = Number(markupLine.style.getPropertyValue("--markup-depth") || 0); - markupLine.insertAdjacentElement("afterend", createEmbeddedUriContinuationPanel(fullValue, depth)); -} - -function appendMarkupAttributes(line, attributes) { - for (const { name, value } of attributes) { - line.appendChild(document.createTextNode(" ")); - line.appendChild(xmlSpan("xml-attr-name", name)); - line.appendChild(xmlSpan("xml-bracket", '="')); - appendMarkupAttrValue(line, value); - line.appendChild(xmlSpan("xml-bracket", '"')); - } -} - -function appendMarkupTextContent(line, text) { - const cdataMatch = /^$/.exec(text); - if (cdataMatch) { - line.appendChild(xmlSpan("xml-cdata-delimiter", "")); - return; - } - line.appendChild(xmlSpan("xml-text", text)); -} - -function createMarkupLine() { - const line = document.createElement("div"); - line.className = "markup-line"; - return line; -} - -function appendOpenTagContent(line, tag, attributes, ghost = false) { - line.appendChild(xmlSpan("xml-bracket", "<", { ghost })); - line.appendChild(xmlSpan("xml-tag", tag, { ghost })); - appendMarkupAttributes(line, attributes); - line.appendChild(xmlSpan("xml-bracket", ">", { ghost })); -} - -function createMarkupFoldToggle() { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "markup-fold-toggle"; - btn.setAttribute("aria-expanded", "true"); - btn.setAttribute("aria-label", "Collapse"); - return btn; -} - -function appendMarkupFoldableOpen(parent, depth, tag, attributes, tagHint) { - const ghost = Boolean(tagHint); - const { line, content } = createMarkupLineRow(depth, { foldToggle: true }); - line.classList.add("markup-line-open"); - appendOpenTagContent(content, tag, attributes, ghost); - - const suffix = document.createElement("span"); - suffix.className = "markup-fold-suffix"; - suffix.appendChild(xmlSpan("xml-bracket", "...", { ghost })); - suffix.appendChild(xmlSpan("xml-bracket", "", { ghost })); - content.appendChild(suffix); - parent.appendChild(line); -} - -function appendMarkupCloseTag(parent, depth, tag, tagHint) { - const ghost = Boolean(tagHint); - const { line, content } = createMarkupLineRow(depth); - content.appendChild(xmlSpan("xml-bracket", "", { ghost })); - parent.appendChild(line); -} - -function appendMarkupSelfClosingTag(parent, depth, tag, attributes) { - const { line, content } = createMarkupLineRow(depth); - content.appendChild(xmlSpan("xml-bracket", "<")); - content.appendChild(xmlSpan("xml-tag", tag)); - appendMarkupAttributes(content, attributes); - content.appendChild(xmlSpan("xml-bracket", "/>")); - parent.appendChild(line); -} - -function appendMarkupInlineElement(parent, depth, tag, attributes, text) { - const { line, content } = createMarkupLineRow(depth); - appendOpenTagContent(content, tag, attributes); - appendMarkupTextContent(content, text); - content.appendChild(xmlSpan("xml-bracket", "")); - parent.appendChild(line); -} - -function appendMarkupTextLine(parent, depth, text) { - const { line, content } = createMarkupLineRow(depth); - appendMarkupTextContent(content, text); - parent.appendChild(line); -} - -function formatMarkupTextNode(node) { - if (node.nodeType === Node.CDATA_SECTION_NODE) { - return ``; - } - return node.textContent.trim(); -} - -function serializeMarkupTextNodes(nodes) { - return [...nodes] - .filter((n) => isTextLikeNode(n) && !isWhitespaceOnlyText(n)) - .map(formatMarkupTextNode) - .join(""); -} -const PAGE_WHEEL_COOLDOWN_MS = 200; -const PAGE_WHEEL_PIXEL_THRESHOLD = 4; -const PAGE_WHEEL_GESTURE_MS = 100; -const OVERLAY_BADGE_FONT_SIZE = 11 * 1.5 * 0.8; -const OVERLAY_BADGE_PAD_X = 3; -const OVERLAY_BADGE_PAD_Y = 2; -const OVERLAY_BADGE_RADIUS_SCREEN_PX = 3; -/** Demo page size; overlay lengths are calibrated to match pre-fix sizing on these images. */ -const OVERLAY_REF_IMAGE_WIDTH = 1224; -const OVERLAY_REF_IMAGE_HEIGHT = 1584; -const PAGE_ZOOM_DEFAULT = 100; -const PAGE_PAN_DRAG_THRESHOLD = 5; -const PAGE_VIEW_BORDER_PX = 2; -const LAYOUT_STORAGE_KEY = "doclang-viewer-pane-layout"; -const PANE_MIN_RATIO = 0.12; -const PANE_KEYS = ["file", "page", "markup", "reading"]; -const DEFAULT_PANE_RATIOS = [1, 1, 1, 1]; -const DEFAULT_USER_PANE_VISIBLE = { file: false, page: true, markup: true, reading: true }; -const LAYOUT_STACK_BREAKPOINT_PX = 1200; - -/** @type {{ pageImages: Map, assetUrls: Map, currentPage: number, pageCount: number, segments: Element[][], defaultResolution: { width: number, height: number }, elementIds: Map, idToElement: Map, hasPageView: boolean, markupOnly: boolean, docRoot: Element, threadPagesById: Map>, elementPageByEl: Map, threadNavByElement: Map, pendingSelectElement: Element | null, readingOrder: Element[], readingOrderDisplayNumbers: Map, pageViewOverlay: { boxes: object[], readingOrderSteps: { order: number, box: object, elementId: string }[] } | null } | null} */ -let state = null; -let fileCatalog = []; -let activeFileIndex = -1; -let filePaneUserToggled = false; -/** @type {ResizeObserver | null} */ -let pagePaneResizeObserver = null; -/** @type {string | null} */ -let selectedElementId = null; -let showAllBboxes = true; -let showLayoutBadges = true; -let showCaptionLinks = false; -let showPictureContents = false; -let showTableContents = false; -let showFragmentLinks = false; -let showXrefLinks = false; -let showReadingOrder = false; -let showReadingOrderArrows = true; -let readingOrderGlobalNumbering = false; -let showReadingFurniture = true; -let showReadingBackground = true; -let pageSettingsOpen = false; -let readingSettingsOpen = false; -let pageZoomPercent = PAGE_ZOOM_DEFAULT; -/** @type {{ pointerId: number, startX: number, startY: number, scrollLeft: number, scrollTop: number, moved: boolean } | null} */ -let pagePanDrag = null; -let pagePanSuppressClick = false; -/** @type {{ paneW: number, paneH: number, imgW: number, imgH: number, fitScale: number } | null} */ -let pageLayoutCache = null; -/** @type {{ file: boolean, page: boolean, markup: boolean, reading: boolean }} */ -let userPaneVisible = { ...DEFAULT_USER_PANE_VISIBLE }; -/** @type {number[]} */ -let paneRatios = [...DEFAULT_PANE_RATIOS]; -/** @type {number | null} */ -let filePaneWidthPx = null; -let toolbarOptionsOpen = false; -/** @type {{ physicalSplitterIndex: number, leftKey: string, rightKey: string, startX: number, leftStart: number, rightStart: number, pointerId: number } | null} */ -let paneDrag = null; -/** @type {MediaQueryList | null} */ -let layoutStackQuery = null; -const els = { - openFileBtn: document.getElementById("open-file-btn"), - emptyStateFileTypes: document.getElementById("empty-state-file-types"), - docLabel: document.getElementById("doc-label"), - filePane: document.getElementById("file-pane"), - filePaneCloseAll: document.getElementById("btn-file-pane-close-all"), - pageNav: document.getElementById("page-nav"), - pageIndicator: document.getElementById("page-indicator"), - pageNumberInput: document.getElementById("page-number-input"), - pageCountIndicator: document.getElementById("page-count-indicator"), - btnPrev: document.getElementById("btn-prev"), - btnNext: document.getElementById("btn-next"), - showAllBboxes: document.getElementById("show-all-bboxes"), - showLayoutBadges: document.getElementById("show-layout-badges"), - showLayoutBadgesLabel: document.getElementById("show-layout-badges-label"), - settingsToggle: document.getElementById("btn-settings-toggle"), - readingSettingsToggle: document.getElementById("btn-reading-settings-toggle"), - pageSettingsLayer: document.getElementById("page-settings-layer"), - pageSettingsScrim: document.getElementById("page-settings-scrim"), - pageSettingsClose: document.getElementById("btn-page-settings-close"), - readingSettingsLayer: document.getElementById("reading-settings-layer"), - readingSettingsScrim: document.getElementById("reading-settings-scrim"), - readingSettingsClose: document.getElementById("btn-reading-settings-close"), - showReadingFurniture: document.getElementById("show-reading-furniture"), - showReadingFurnitureLabel: document.getElementById("show-reading-furniture-label"), - showReadingBackground: document.getElementById("show-reading-background"), - showReadingBackgroundLabel: document.getElementById("show-reading-background-label"), - showCaptionLinks: document.getElementById("show-caption-links"), - showCaptionLinksLabel: document.getElementById("show-caption-links-label"), - showPictureContents: document.getElementById("show-picture-contents"), - showPictureContentsLabel: document.getElementById("show-picture-contents-label"), - showTableContents: document.getElementById("show-table-contents"), - showTableContentsLabel: document.getElementById("show-table-contents-label"), - showFragmentLinks: document.getElementById("show-fragment-links"), - showFragmentLinksLabel: document.getElementById("show-fragment-links-label"), - showXrefLinks: document.getElementById("show-xref-links"), - showXrefLinksLabel: document.getElementById("show-xref-links-label"), - showReadingOrder: document.getElementById("show-reading-order"), - showReadingOrderLabel: document.getElementById("show-reading-order-label"), - readingOrderArrows: document.getElementById("reading-order-arrows"), - readingOrderArrowsLabel: document.getElementById("reading-order-arrows-label"), - readingOrderGlobal: document.getElementById("reading-order-global"), - readingOrderGlobalLabel: document.getElementById("reading-order-global-label"), - pageZoom: document.getElementById("page-zoom"), - pageZoomLabel: document.getElementById("page-zoom-label"), - pageZoomReset: document.getElementById("page-zoom-reset"), - main: document.getElementById("main"), - emptyState: document.getElementById("empty-state"), - emptyStateLoading: document.getElementById("empty-state-loading"), - emptyStatePrompt: document.getElementById("empty-state-prompt"), - markupPane: document.getElementById("markup-pane"), - renderedPane: document.getElementById("rendered-pane"), - pagePane: document.getElementById("page-pane"), - paneFile: document.querySelector(".pane-file"), - panePageView: document.querySelector(".pane-page-view"), - paneMarkup: document.querySelector(".pane-markup"), - paneReading: document.querySelector(".pane-reading"), - splitters: [ - document.getElementById("splitter-0"), - document.getElementById("splitter-1"), - document.getElementById("splitter-2"), - ], - toolbarOptionsBtn: document.getElementById("btn-toolbar-options"), - toolbarOptionsPanel: document.getElementById("toolbar-options-panel"), - toggleFilePane: document.getElementById("toggle-file-pane"), - toggleFilePaneLabel: document.getElementById("toggle-file-pane-label"), - togglePagePane: document.getElementById("toggle-page-pane"), - toggleMarkupPane: document.getElementById("toggle-markup-pane"), - toggleReadingPane: document.getElementById("toggle-reading-pane"), - togglePagePaneLabel: document.getElementById("toggle-page-pane-label"), - resetPaneLayoutBtn: document.getElementById("btn-reset-pane-layout"), -}; - -document.getElementById("btn-demo")?.addEventListener("click", loadDemo); -document.getElementById("demo-empty-link")?.addEventListener("click", (e) => { - e.preventDefault(); - loadDemo(); -}); -document.getElementById("home-link")?.addEventListener("click", (e) => { - e.preventDefault(); - resetViewer(); -}); -document.getElementById("input-archive")?.addEventListener("change", async (e) => { - const files = [...e.target.files].filter((f) => isArchiveFile(f) || isMarkupFile(f)); - if (!files.length) return; - await addFilesToCatalog(files, { replace: true }); - e.target.value = ""; -}); -els.btnPrev?.addEventListener("click", () => goToPage(state.currentPage - 1)); -els.btnNext?.addEventListener("click", () => goToPage(state.currentPage + 1)); -els.pageNumberInput?.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - commitPageNumberInput(); - els.pageNumberInput?.blur(); - } else if (e.key === "Escape") { - e.preventDefault(); - resetPageNumberInput(); - els.pageNumberInput?.blur(); - } -}); -els.pageNumberInput?.addEventListener("blur", resetPageNumberInput); -els.pageNumberInput?.addEventListener("focus", (e) => { - e.target.select(); -}); -els.showAllBboxes?.addEventListener("change", () => { - showAllBboxes = els.showAllBboxes.checked; - syncLayoutSubtoggles(); - const img = els.pagePane?.querySelector(".page-view img"); - if (img) syncOverlayBadges(img); - applyBboxVisibility(); -}); -els.showLayoutBadges?.addEventListener("change", () => { - showLayoutBadges = els.showLayoutBadges.checked; - const img = els.pagePane?.querySelector(".page-view img"); - if (img) syncOverlayBadges(img); - applyBboxVisibility(); -}); -els.showCaptionLinks?.addEventListener("change", () => { - showCaptionLinks = els.showCaptionLinks.checked; - applyBboxVisibility(); -}); -els.showPictureContents?.addEventListener("change", () => { - showPictureContents = els.showPictureContents.checked; - applyBboxVisibility(); -}); -els.showTableContents?.addEventListener("change", () => { - showTableContents = els.showTableContents.checked; - applyBboxVisibility(); -}); -els.showFragmentLinks?.addEventListener("change", () => { - showFragmentLinks = els.showFragmentLinks.checked; - applyBboxVisibility(); -}); -els.showXrefLinks?.addEventListener("change", () => { - showXrefLinks = els.showXrefLinks.checked; - applyBboxVisibility(); -}); -els.showReadingOrder?.addEventListener("change", () => { - showReadingOrder = els.showReadingOrder.checked; - syncLayoutSubtoggles(); - const img = els.pagePane?.querySelector(".page-view img"); - if (img) syncOverlayBadges(img); - applyBboxVisibility(); -}); -els.readingOrderArrows?.addEventListener("change", () => { - showReadingOrderArrows = els.readingOrderArrows.checked; - applyBboxVisibility(); -}); -els.readingOrderGlobal?.addEventListener("change", () => { - readingOrderGlobalNumbering = els.readingOrderGlobal.checked; - if (state) renderPage(state.currentPage); -}); -els.settingsToggle?.addEventListener("click", () => setPageSettingsOpen(!pageSettingsOpen)); -els.readingSettingsToggle?.addEventListener("click", () => setReadingSettingsOpen(!readingSettingsOpen)); -els.pageSettingsClose?.addEventListener("click", () => setPageSettingsOpen(false)); -els.pageSettingsScrim?.addEventListener("click", () => setPageSettingsOpen(false)); -els.readingSettingsClose?.addEventListener("click", () => setReadingSettingsOpen(false)); -els.readingSettingsScrim?.addEventListener("click", () => setReadingSettingsOpen(false)); -document.addEventListener("keydown", (e) => { - if (e.key !== "Escape") return; - if (toolbarOptionsOpen) setToolbarOptionsOpen(false); - else if (pageSettingsOpen) setPageSettingsOpen(false); - else if (readingSettingsOpen) setReadingSettingsOpen(false); -}); -els.showReadingFurniture?.addEventListener("change", () => { - showReadingFurniture = els.showReadingFurniture.checked; - syncReadingLayerVisibility(); -}); -els.showReadingBackground?.addEventListener("change", () => { - showReadingBackground = els.showReadingBackground.checked; - syncReadingLayerVisibility(); -}); -loadLayoutPrefs(); -normalizePaneRatios(); -initToolbarOptions(); -initPaneSplitters(); -initLayoutStackListener(); -initFileTypeHints(); -initCursorHints(); -initBboxHints(); -initDragDrop(); -initFilePaneCloseAll(); -initPageWheelNav(); -initPageViewControls(); -let demoLoadInProgress = false; - -function setDemoLoading(loading) { - document.body.classList.toggle("demo-loading", loading); - const btnDemo = document.getElementById("btn-demo"); - if (btnDemo) btnDemo.disabled = loading; -} - -if (document.getElementById("btn-demo")) loadDemo(); - -async function loadDemo() { - if (demoLoadInProgress) return; - demoLoadInProgress = true; - setDemoLoading(true); - try { - const res = await fetch(DEMO_ARCHIVE_URL); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const label = DEMO_ARCHIVE_URL.split("/").pop() || "demo.dclx"; - await addArchiveBufferToCatalog(await res.arrayBuffer(), label, { replace: true }); - } catch (err) { - alert( - `Failed to load demo: ${err.message}\n\nServe this directory over HTTP (e.g. python3 -m http.server) and open the viewer from localhost.`, - ); - } finally { - demoLoadInProgress = false; - if (!document.body.classList.contains("viewer-loaded")) setDemoLoading(false); - } -} - -async function loadFromFileList(fileList) { - if (!fileList?.length) return; - const files = [...fileList]; - if (files.some((f) => f.name === "document.xml")) { - await appendFolderArchive(files); - return; - } - const supported = files.filter((f) => isArchiveFile(f) || isMarkupFile(f)); - if (supported.length) await addFilesToCatalog(supported, { replace: false }); -} - -function createFileCatalogEntry(file) { - return { - id: crypto.randomUUID(), - label: file.name, - kind: isMarkupFile(file) ? "markup" : "archive", - source: file, - currentPage: 1, - pageZoom: PAGE_ZOOM_DEFAULT, - snapshot: null, - thumbnailUrl: null, - }; -} - -function pageImageMimeFromExt(ext) { - const normalized = ext.toLowerCase().replace("jpeg", "jpg"); - if (normalized === "png") return "image/png"; - if (normalized === "webp") return "image/webp"; - return "image/jpeg"; -} - -function createPageImageObjectUrl(data, ext) { - return URL.createObjectURL(new Blob([data], { type: pageImageMimeFromExt(ext) })); -} - -function createFirstPageImageUrlFromFiles(files) { - let bestPage = Infinity; - /** @type {File | null} */ - let bestFile = null; - for (const f of files) { - const relPath = f.webkitRelativePath || f.name; - const parts = relPath.split("/"); - if (parts.length < 2 || parts[parts.length - 2] !== "pages") continue; - const m = PAGE_IMAGE_RE.exec(f.name); - if (!m) continue; - const pageNum = Number(m[1]); - if (pageNum < bestPage) { - bestPage = pageNum; - bestFile = f; - } - } - return bestFile ? URL.createObjectURL(bestFile) : null; -} - -async function createFirstPageImageUrlFromZip(source) { - const buffer = source instanceof File ? await source.arrayBuffer() : source; - const entries = await unzip(buffer, { - shouldExtract: (name) => /^pages\/\d+\.(png|jpe?g|webp)$/i.test(name), - }); - let bestPage = Infinity; - /** @type {{ name: string, data: Uint8Array } | null} */ - let bestEntry = null; - for (const e of entries) { - const m = e.name.match(/^pages\/(\d+)\.(png|jpe?g|webp)$/i); - if (!m) continue; - const pageNum = Number(m[1]); - if (pageNum < bestPage) { - bestPage = pageNum; - bestEntry = e; - } - } - if (!bestEntry) return null; - const ext = bestEntry.name.split(".").pop() ?? "png"; - return createPageImageObjectUrl(bestEntry.data, ext); -} - -async function resolveCatalogEntryThumbnail(entry) { - if (entry.thumbnailUrl) return entry.thumbnailUrl; - if (entry.kind === "markup") return null; - try { - if (entry.kind === "folder") { - entry.thumbnailUrl = createFirstPageImageUrlFromFiles(entry.source); - } else if (entry.kind === "archive") { - entry.thumbnailUrl = await createFirstPageImageUrlFromZip(entry.source); - } - } catch { - entry.thumbnailUrl = null; - } - return entry.thumbnailUrl; -} - -function enrichCatalogEntryThumbnail(entry) { - resolveCatalogEntryThumbnail(entry).then((url) => { - if (!fileCatalog.includes(entry)) { - if (url?.startsWith("blob:")) URL.revokeObjectURL(url); - return; - } - if (url) renderFileView(); - }); -} - -function revokeCatalogEntry(entry) { - if (entry?.thumbnailUrl?.startsWith("blob:")) { - URL.revokeObjectURL(entry.thumbnailUrl); - } - if (entry) entry.thumbnailUrl = null; -} - -function createFileViewThumbnail(entry) { - const thumb = document.createElement("span"); - thumb.className = "file-view-thumb"; - thumb.setAttribute("aria-hidden", "true"); - if (entry.thumbnailUrl) { - const img = document.createElement("img"); - img.src = entry.thumbnailUrl; - img.alt = ""; - thumb.appendChild(img); - } else { - const placeholder = document.createElement("span"); - placeholder.className = "file-view-thumb-placeholder"; - placeholder.innerHTML = FILE_THUMB_PLACEHOLDER_SVG; - thumb.appendChild(placeholder); - } - return thumb; -} - -async function addFilesToCatalog(files, { replace = false } = {}) { - if (replace) { - clearFileCatalog(); - filePaneUserToggled = false; - } - const startIndex = fileCatalog.length; - for (const file of files) { - const entry = createFileCatalogEntry(file); - fileCatalog.push(entry); - enrichCatalogEntryThumbnail(entry); - } - if (!fileCatalog.length) return; - await switchToFile(replace ? 0 : startIndex); -} - -async function appendFolderArchive(files) { - if (!files.some((f) => f.name === "document.xml")) { - alert("Archive must contain document.xml at its root."); - return; - } - const rootName = (files[0].webkitRelativePath || files[0].name).split("/")[0] || "archive"; - const entry = { - id: crypto.randomUUID(), - label: rootName, - kind: "folder", - source: files, - currentPage: 1, - pageZoom: PAGE_ZOOM_DEFAULT, - snapshot: null, - thumbnailUrl: null, - }; - fileCatalog.push(entry); - enrichCatalogEntryThumbnail(entry); - await switchToFile(fileCatalog.length - 1); -} - -async function addArchiveBufferToCatalog(buffer, label, { replace = false } = {}) { - if (replace) { - clearFileCatalog(); - filePaneUserToggled = false; - } - const entry = { - id: crypto.randomUUID(), - label, - kind: "archive", - source: buffer, - currentPage: 1, - pageZoom: PAGE_ZOOM_DEFAULT, - snapshot: null, - thumbnailUrl: null, - }; - fileCatalog.push(entry); - enrichCatalogEntryThumbnail(entry); - await switchToFile(replace ? 0 : fileCatalog.length - 1); -} - -async function extractArchiveFromFiles(files) { - const markupFile = files.find((f) => f.name === "document.xml"); - if (!markupFile) throw new Error("Archive must contain document.xml at its root."); - const markupXml = await markupFile.text(); - const pageImages = new Map(); - const assetUrls = new Map(); - for (const f of files) { - const relPath = f.webkitRelativePath || f.name; - const parts = relPath.split("/"); - if (parts.length >= 2 && parts[parts.length - 2] === "pages") { - const m = PAGE_IMAGE_RE.exec(f.name); - if (m) pageImages.set(Number(m[1]), URL.createObjectURL(f)); - } - const assetPath = archiveRelativeAssetPath(relPath); - if (assetPath) assetUrls.set(assetPath, URL.createObjectURL(f)); - } - return { markupXml, pageImages, assetUrls }; -} - -async function parseCatalogEntry(entry) { - try { - if (entry.kind === "markup") { - const text = entry.source instanceof File - ? await entry.source.text() - : new TextDecoder().decode(entry.source); - return buildDocumentState(text, new Map(), entry.label, new Map(), { markupOnly: true }); - } - if (entry.kind === "archive") { - const buffer = entry.source instanceof File - ? await entry.source.arrayBuffer() - : entry.source; - const { markupXml, pageImages, assetUrls } = await extractArchiveFromZipBuffer(buffer); - return buildDocumentState(markupXml, pageImages, entry.label, assetUrls, { markupOnly: false }); - } - if (entry.kind === "folder") { - const { markupXml, pageImages, assetUrls } = await extractArchiveFromFiles(entry.source); - return buildDocumentState(markupXml, pageImages, entry.label, assetUrls, { markupOnly: false }); - } - } catch (err) { - alert(`Failed to read ${entry.label}: ${err.message}`); - } - return null; -} - -function persistActiveFileViewState() { - if (activeFileIndex < 0 || !state) return; - const entry = fileCatalog[activeFileIndex]; - entry.currentPage = state.currentPage; - entry.pageZoom = pageZoomPercent; -} - -function releaseActiveDocument() { - if (activeFileIndex >= 0) { - const entry = fileCatalog[activeFileIndex]; - if (entry?.snapshot) { - revokeDocumentState(entry.snapshot); - entry.snapshot = null; - } - } - if (state) revokeDocumentState(state); - state = null; - selectedElementId = null; - pagePanDrag = null; - pagePanSuppressClick = false; -} - -function clearFileCatalog() { - releaseActiveDocument(); - for (const entry of fileCatalog) revokeCatalogEntry(entry); - fileCatalog = []; - activeFileIndex = -1; -} - -async function switchToFile(index) { - if (index < 0 || index >= fileCatalog.length) return; - - persistActiveFileViewState(); - releaseActiveDocument(); - - activeFileIndex = index; - const entry = fileCatalog[index]; - const docState = await parseCatalogEntry(entry); - if (!docState) { - revokeCatalogEntry(entry); - fileCatalog.splice(index, 1); - activeFileIndex = -1; - if (fileCatalog.length) { - await switchToFile(Math.min(index, fileCatalog.length - 1)); - } else { - resetViewer(); - } - return; - } - - entry.snapshot = docState; - docState.currentPage = entry.currentPage ?? 1; - activateDocument(docState, entry); -} - -function defaultFilePaneVisible() { - return fileCatalog.length > 1; -} - -function syncFilePaneDefault() { - if (!filePaneUserToggled) { - const wasVisible = userPaneVisible.file; - const shouldBeVisible = defaultFilePaneVisible(); - userPaneVisible.file = shouldBeVisible; - if (!wasVisible && shouldBeVisible) { - paneRatios = [...DEFAULT_PANE_RATIOS]; - normalizePaneRatios(); - filePaneWidthPx = null; - } - } -} - -async function closeCatalogFile(index) { - if (index < 0 || index >= fileCatalog.length) return; - - const wasActive = index === activeFileIndex; - const entry = fileCatalog[index]; - - if (wasActive) { - releaseActiveDocument(); - activeFileIndex = -1; - } - - revokeCatalogEntry(entry); - fileCatalog.splice(index, 1); - - if (!fileCatalog.length) { - resetViewer(); - return; - } - - if (wasActive) { - await switchToFile(Math.min(index, fileCatalog.length - 1)); - return; - } - - if (index < activeFileIndex) { - activeFileIndex -= 1; - } - updateFileView(); -} - -function renderFileView() { - if (!els.filePane) return; - els.filePane.replaceChildren(); - if (!fileCatalog.length) return; - - const list = document.createElement("ul"); - list.className = "file-view-list"; - list.setAttribute("role", "listbox"); - list.setAttribute("aria-label", "Open files"); - - fileCatalog.forEach((entry, index) => { - const item = document.createElement("li"); - const card = document.createElement("div"); - card.className = "file-view-item"; - card.title = entry.label; - card.tabIndex = 0; - card.setAttribute("role", "option"); - if (index === activeFileIndex) { - card.classList.add("is-active"); - card.setAttribute("aria-selected", "true"); - } else { - card.setAttribute("aria-selected", "false"); - } - - const thumbWrap = document.createElement("div"); - thumbWrap.className = "file-view-thumb-wrap"; - - const closeBtn = document.createElement("button"); - closeBtn.type = "button"; - closeBtn.className = "file-view-close"; - closeBtn.setAttribute("aria-label", `Close ${entry.label}`); - closeBtn.textContent = "×"; - closeBtn.addEventListener("click", (e) => { - e.stopPropagation(); - closeCatalogFile(index); - }); - - thumbWrap.append(createFileViewThumbnail(entry), closeBtn); - - const label = document.createElement("span"); - label.className = "file-view-label"; - label.textContent = entry.label; - - card.append(thumbWrap, label); - card.addEventListener("click", (e) => { - if (e.target.closest(".file-view-close")) return; - switchToFile(index); - }); - card.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - switchToFile(index); - } - }); - - item.appendChild(card); - list.appendChild(item); - }); - - els.filePane.appendChild(list); -} - -function updateFileView() { - syncFilePaneDefault(); - syncFilePaneCloseAllButton(); - renderFileView(); - syncToolbarPaneCheckboxes(); - applyPaneLayout(); -} - -function syncFilePaneCloseAllButton() { - if (!els.filePaneCloseAll) return; - els.filePaneCloseAll.hidden = fileCatalog.length === 0; -} - -function initFilePaneCloseAll() { - els.filePaneCloseAll?.addEventListener("click", () => { - if (!fileCatalog.length) return; - const count = fileCatalog.length; - const message = count === 1 - ? `Remove "${fileCatalog[0].label}" from the viewer?` - : `Remove all ${count} open files from the viewer?`; - if (confirm(message)) resetViewer(); - }); -} - -function initPageWheelNav() { - if (!els.pagePane) return; - - let pixelAccum = 0; - let pixelGestureUntil = 0; - let lastFlipAt = 0; - - function wheelDir(e) { - if (e.deltaMode === 1) { - return e.deltaY > 0 ? 1 : e.deltaY < 0 ? -1 : 0; - } - if (e.deltaMode === 2) { - return Math.sign(e.deltaY); - } - const now = performance.now(); - if (now > pixelGestureUntil) pixelAccum = 0; - pixelGestureUntil = now + PAGE_WHEEL_GESTURE_MS; - pixelAccum += e.deltaY; - if (Math.abs(pixelAccum) >= PAGE_WHEEL_PIXEL_THRESHOLD) { - const dir = pixelAccum > 0 ? 1 : -1; - pixelAccum = 0; - return dir; - } - return 0; - } - - function tryFlipPage(dir) { - if (!dir || !state) return false; - const now = performance.now(); - if (now - lastFlipAt < PAGE_WHEEL_COOLDOWN_MS) return false; - const before = state.currentPage; - goToPage(state.currentPage + dir); - if (state.currentPage !== before) { - lastFlipAt = now; - return true; - } - return false; - } - - function isScrollAtTop(pane) { - return pane.scrollTop <= 0; - } - - function isScrollAtBottom(pane) { - return pane.scrollTop + pane.clientHeight >= pane.scrollHeight - 1; - } - - function onScrollPaneWheel(e, pane) { - if (!state || state.markupOnly || state.pageCount <= 1) return; - const dir = wheelDir(e); - if (!dir) return; - - const atTop = isScrollAtTop(pane); - const atBottom = isScrollAtBottom(pane); - const wantPrev = dir < 0 && atTop; - const wantNext = dir > 0 && atBottom; - if (!wantPrev && !wantNext) return; - - e.preventDefault(); - if (!tryFlipPage(dir)) return; - - requestAnimationFrame(() => { - pane.scrollTop = dir > 0 ? 0 : pane.scrollHeight; - }); - } - - els.pagePane.addEventListener( - "wheel", - (e) => { - if (!state?.hasPageView) return; - const pane = pageViewScrollPane(); - if (!pane) return; - const scrollable = pane.scrollHeight > pane.clientHeight || pane.scrollWidth > pane.clientWidth; - if (scrollable) { - onScrollPaneWheel(e, pane); - return; - } - e.preventDefault(); - const dir = wheelDir(e); - if (dir) tryFlipPage(dir); - }, - { passive: false }, - ); - - for (const pane of [els.markupPane, els.renderedPane]) { - if (!pane) continue; - pane.addEventListener("wheel", (e) => onScrollPaneWheel(e, pane), { passive: false }); - } - - els.pagePane.tabIndex = 0; - els.pagePane.setAttribute("role", "region"); - els.pagePane.setAttribute("aria-label", "Original page"); - - els.pagePane.addEventListener("pointerdown", (e) => { - if (state?.hasPageView) els.pagePane.focus({ preventScroll: true }); - }); - - els.pagePane.addEventListener("keydown", (e) => { - if (!state?.hasPageView) return; - - let dir = 0; - switch (e.key) { - case "ArrowDown": - case "PageDown": - case "ArrowRight": - dir = 1; - break; - case "ArrowUp": - case "PageUp": - case "ArrowLeft": - dir = -1; - break; - default: - return; - } - - e.preventDefault(); - tryFlipPage(dir); - }); -} - -function pageViewScrollPane() { - if (!els.pagePane) return null; - return els.pagePane.querySelector(".page-view-port") ?? els.pagePane; -} - -function isPagePaneScrollable() { - const pane = pageViewScrollPane(); - if (!pane) return false; - return pane.scrollWidth > pane.clientWidth || pane.scrollHeight > pane.clientHeight; -} - -function canStartPagePan(event) { - if (!(event.target instanceof Element)) return false; - if (!event.target.closest(".page-view")) return false; - return isPagePaneScrollable(); -} - -function updatePagePanePanCursor() { - if (!els.pagePane) return; - els.pagePane.classList.toggle("can-pan", isPagePaneScrollable() && !pagePanDrag); -} - -function initPageViewControls() { - if (!els.pagePane) return; - - els.pageZoom?.addEventListener("input", () => { - pageZoomPercent = Math.max(PAGE_ZOOM_DEFAULT, Number(els.pageZoom.value)); - if (Number(els.pageZoom.value) < PAGE_ZOOM_DEFAULT) { - els.pageZoom.value = String(pageZoomPercent); - } - els.pageZoom.setAttribute("aria-valuenow", String(pageZoomPercent)); - updatePageZoomResetButton(); - refreshPageViewLayout(); - }); - - els.pageZoomReset?.addEventListener("click", () => { - if (pageZoomPercent === PAGE_ZOOM_DEFAULT) return; - resetPageZoom(); - }); - - els.pagePane.addEventListener("pointerdown", (e) => { - if (e.button !== 0 || !canStartPagePan(e)) return; - const scrollPane = pageViewScrollPane(); - if (!scrollPane) return; - pagePanDrag = { - pointerId: e.pointerId, - startX: e.clientX, - startY: e.clientY, - scrollLeft: scrollPane.scrollLeft, - scrollTop: scrollPane.scrollTop, - moved: false, - }; - }); - - els.pagePane.addEventListener("pointermove", (e) => { - if (!pagePanDrag || e.pointerId !== pagePanDrag.pointerId) return; - const dx = e.clientX - pagePanDrag.startX; - const dy = e.clientY - pagePanDrag.startY; - if (!pagePanDrag.moved && Math.hypot(dx, dy) >= PAGE_PAN_DRAG_THRESHOLD) { - pagePanDrag.moved = true; - els.pagePane.classList.add("is-panning"); - els.pagePane.classList.remove("can-pan"); - els.pagePane.setPointerCapture(e.pointerId); - } - if (!pagePanDrag.moved) return; - const scrollPane = pageViewScrollPane(); - if (!scrollPane) return; - scrollPane.scrollLeft = pagePanDrag.scrollLeft + pagePanDrag.startX - e.clientX; - scrollPane.scrollTop = pagePanDrag.scrollTop + pagePanDrag.startY - e.clientY; - e.preventDefault(); - }); - - function endPagePan(e) { - if (!pagePanDrag || e.pointerId !== pagePanDrag.pointerId) return; - if (pagePanDrag.moved) pagePanSuppressClick = true; - pagePanDrag = null; - els.pagePane.classList.remove("is-panning"); - if (els.pagePane.hasPointerCapture(e.pointerId)) { - els.pagePane.releasePointerCapture(e.pointerId); - } - updatePagePanePanCursor(); - } - - els.pagePane.addEventListener("pointerup", endPagePan); - els.pagePane.addEventListener("pointercancel", endPagePan); -} - -function escapeHtml(text) { - return String(text) - .replace(/&/g, "&") - .replace(//g, ">"); -} - -function headTextPreview(el, maxLen = 72) { - const text = el.textContent?.replace(/\s+/g, " ").trim() ?? ""; - if (!text) return "—"; - return text.length > maxLen ? `${text.slice(0, maxLen)}…` : text; -} - -function virtualTextHeadLocations(el) { - const parent = el.parentElement; - if (!parent) return []; - const nodes = [...parent.childNodes]; - const idx = nodes.indexOf(el); - if (idx < 0) return []; - return parseElementHeadAt(nodes, idx + 1)?.locs ?? []; -} - -function elementHeadLocations(el) { - const own = headLocations(el); - return own.length === 4 ? own : virtualTextHeadLocations(el); -} - -function firstHeadChild(el, tag) { - return childElements(el).find((child) => localName(child) === tag) ?? null; -} - -const ELEMENT_LAYERS = new Set(["body", "background", "furniture"]); - -function layerFromHeadNodes(nodes, startIdx) { - let i = startIdx; - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType !== Node.ELEMENT_NODE) { - i += 1; - continue; - } - const tag = localName(node); - if (tag === "layer") { - const value = node.getAttribute("value") ?? "body"; - return ELEMENT_LAYERS.has(value) ? value : "body"; - } - if (tag === "location") break; - if (HEAD_TAGS.has(tag)) { - i += 1; - continue; - } - break; - } - return "body"; -} - -function elementLayer(el) { - const layerEl = firstHeadChild(el, "layer"); - if (layerEl) { - const value = layerEl.getAttribute("value") ?? "body"; - return ELEMENT_LAYERS.has(value) ? value : "body"; - } - if (headLocations(el).length === 4) return "body"; - const parent = el.parentElement; - if (!parent) return "body"; - const nodes = [...parent.childNodes]; - const idx = nodes.indexOf(el); - if (idx < 0) return "body"; - return layerFromHeadNodes(nodes, idx + 1); -} - -function layerClassForValue(layer) { - if (layer === "furniture") return "layer-furniture"; - if (layer === "background") return "layer-background"; - return ""; -} - -function applyElementLayerAttr(sourceEl, domEl) { - domEl.setAttribute("data-doclang-layer", elementLayer(sourceEl)); -} - -function syncReadingLayerCheckboxes() { - if (els.showReadingFurniture) els.showReadingFurniture.checked = showReadingFurniture; - if (els.showReadingBackground) els.showReadingBackground.checked = showReadingBackground; -} - -/** @returns {{ key: string, value: string, isDefault: boolean }[]} */ -function collectElementHeadInfo(el, defaultResolution) { - const labelEl = firstHeadChild(el, "label"); - const threadEl = firstHeadChild(el, "thread"); - const xrefEl = firstHeadChild(el, "xref"); - const hrefEl = firstHeadChild(el, "href"); - const layerEl = firstHeadChild(el, "layer"); - const captionEl = firstHeadChild(el, "caption"); - const descriptionEl = firstHeadChild(el, "description"); - const summaryEl = firstHeadChild(el, "summary"); - const customEl = firstHeadChild(el, "custom"); - const locs = elementHeadLocations(el); - const rows = [{ key: "element", value: elementLabel(el), isDefault: false }]; - - rows.push({ - key: "label", - value: labelEl?.getAttribute("value") ?? "undefined", - isDefault: !labelEl?.hasAttribute("value"), - }); - - if (threadEl) { - rows.push({ - key: "thread_id", - value: threadEl.getAttribute("thread_id") ?? "—", - isDefault: false, - }); - } else { - rows.push({ key: "thread", value: "—", isDefault: true }); - } - - if (xrefEl) { - rows.push({ - key: "xref", - value: `thread_id ${xrefEl.getAttribute("thread_id") ?? "—"}`, - isDefault: false, - }); - } else { - rows.push({ key: "xref", value: "—", isDefault: true }); - } - - if (hrefEl) { - rows.push({ - key: "href", - value: hrefEl.getAttribute("uri") ?? "—", - isDefault: false, - }); - } else { - rows.push({ key: "href", value: "—", isDefault: true }); - } - - rows.push({ - key: "layer", - value: layerEl?.getAttribute("value") ?? "body", - isDefault: !layerEl?.hasAttribute("value"), - }); - - const cornerLabels = ["x_min", "y_min", "x_max", "y_max"]; - if (locs.length === 4) { - for (let idx = 0; idx < 4; idx += 1) { - const loc = locs[idx]; - const axisDefault = idx % 2 === 0 ? defaultResolution.width : defaultResolution.height; - const resolution = locationResolution(loc, axisDefault); - const value = loc.getAttribute("value") ?? "0"; - rows.push({ - key: cornerLabels[idx], - value: `${value} @ ${resolution}`, - isDefault: false, - }); - } - } else { - for (const key of cornerLabels) { - rows.push({ key, value: "—", isDefault: false }); - } - } - - rows.push({ - key: "caption", - value: captionEl ? headTextPreview(captionEl) : "—", - isDefault: !captionEl, - }); - rows.push({ - key: "description", - value: descriptionEl ? headTextPreview(descriptionEl) : "—", - isDefault: !descriptionEl, - }); - rows.push({ - key: "summary", - value: summaryEl ? headTextPreview(summaryEl) : "—", - isDefault: !summaryEl, - }); - rows.push({ - key: "custom", - value: customEl ? headTextPreview(customEl) : "—", - isDefault: !customEl, - }); - - return rows; -} - -function elementHeadTooltipHtml(el, defaultResolution) { - const rows = collectElementHeadInfo(el, defaultResolution); - const body = rows - .map(({ key, value, isDefault }) => { - const rendered = escapeHtml(value); - const suffix = isDefault ? ' (default)' : ""; - return `${escapeHtml(key)}${rendered}${suffix}`; - }) - .join(""); - return `${body}
`; -} - -const cursorHintEl = document.getElementById("cursor-hint"); -const CURSOR_HINT_OFFSET = 10; -const CURSOR_HINT_MARGIN = 8; - -function hideCursorHint() { - cursorHintEl.hidden = true; - cursorHintEl.classList.remove("cursor-hint-detail"); - cursorHintEl.replaceChildren(); -} - -function showCursorHint(content, clientX, clientY, { detail = false } = {}) { - cursorHintEl.replaceChildren(); - if (typeof content === "string") { - cursorHintEl.textContent = content; - } else { - cursorHintEl.appendChild(content); - } - cursorHintEl.classList.toggle("cursor-hint-detail", detail); - cursorHintEl.hidden = false; - - let left = clientX + CURSOR_HINT_OFFSET; - let top = clientY + CURSOR_HINT_OFFSET; - const rect = cursorHintEl.getBoundingClientRect(); - if (left + rect.width > window.innerWidth - CURSOR_HINT_MARGIN) { - left = clientX - rect.width - CURSOR_HINT_OFFSET; - } - if (top + rect.height > window.innerHeight - CURSOR_HINT_MARGIN) { - top = clientY - rect.height - CURSOR_HINT_OFFSET; - } - cursorHintEl.style.left = `${Math.max(CURSOR_HINT_MARGIN, left)}px`; - cursorHintEl.style.top = `${Math.max(CURSOR_HINT_MARGIN, top)}px`; -} - -function showCursorHintHtml(html, clientX, clientY) { - cursorHintEl.innerHTML = html; - cursorHintEl.classList.add("cursor-hint-detail"); - cursorHintEl.hidden = false; - - let left = clientX + CURSOR_HINT_OFFSET; - let top = clientY + CURSOR_HINT_OFFSET; - const rect = cursorHintEl.getBoundingClientRect(); - if (left + rect.width > window.innerWidth - CURSOR_HINT_MARGIN) { - left = clientX - rect.width - CURSOR_HINT_OFFSET; - } - if (top + rect.height > window.innerHeight - CURSOR_HINT_MARGIN) { - top = clientY - rect.height - CURSOR_HINT_OFFSET; - } - cursorHintEl.style.left = `${Math.max(CURSOR_HINT_MARGIN, left)}px`; - cursorHintEl.style.top = `${Math.max(CURSOR_HINT_MARGIN, top)}px`; -} - -function initFileTypeHints() { - if (!els.emptyStateFileTypes) return; - const markup = SUPPORTED_FILE_EXTENSIONS.map((ext) => `${ext}`).join(", "); - els.emptyStateFileTypes.innerHTML = markup; -} - -function initCursorHints() { - els.markupPane?.addEventListener("mousemove", (e) => { - if (!e.target.closest(".markup-ghost-tag-part")) { - hideCursorHint(); - return; - } - showCursorHint(VIRTUAL_TEXT_TAG_HINT, e.clientX, e.clientY); - }); - els.markupPane?.addEventListener("mouseleave", hideCursorHint); - - els.openFileBtn?.addEventListener("mousemove", (e) => { - showCursorHint(OPEN_FILE_HINT, e.clientX, e.clientY); - }); - els.openFileBtn?.addEventListener("mouseleave", hideCursorHint); -} - -function initBboxHints() { - if (!els.pagePane) return; - - els.pagePane.addEventListener("mousemove", (e) => { - if (pagePanDrag?.moved || els.pagePane.classList.contains("is-panning")) { - hideCursorHint(); - return; - } - const navBtn = e.target.closest(".fragment-nav-btn:not(.fragment-nav-btn-disabled)"); - if (navBtn) { - const hint = navBtn.getAttribute("data-nav") === "prev" - ? FRAGMENT_NAV_HINT_PREV - : FRAGMENT_NAV_HINT_NEXT; - showCursorHint(hint, e.clientX, e.clientY); - return; - } - const badge = e.target.closest(".element-badge[data-element-id]"); - if (!badge || !state?.idToElement) { - hideCursorHint(); - return; - } - const elementId = badge.getAttribute("data-element-id"); - const xmlEl = state.idToElement.get(elementId); - if (!xmlEl) { - hideCursorHint(); - return; - } - showCursorHintHtml(elementHeadTooltipHtml(xmlEl, state.defaultResolution), e.clientX, e.clientY); - }); - els.pagePane.addEventListener("mouseleave", hideCursorHint); -} - -function initDragDrop() { - document.body.addEventListener("dragenter", (e) => { - if (!hasArchiveTransfer(e.dataTransfer)) return; - e.preventDefault(); - document.body.classList.add("drag-over"); - }); - - document.body.addEventListener("dragover", (e) => { - if (!hasArchiveTransfer(e.dataTransfer)) return; - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - }); - - document.body.addEventListener("dragleave", (e) => { - if (!hasArchiveTransfer(e.dataTransfer)) return; - if (e.relatedTarget && document.body.contains(e.relatedTarget)) return; - document.body.classList.remove("drag-over"); - }); - - document.body.addEventListener("drop", async (e) => { - if (!hasArchiveTransfer(e.dataTransfer)) return; - e.preventDefault(); - document.body.classList.remove("drag-over"); - await loadFromDrop(e.dataTransfer); - }); -} - -function hasArchiveTransfer(dataTransfer) { - return [...dataTransfer.types].includes("Files"); -} - -function isArchiveFile(file) { - return /\.dclx$/i.test(file.name) || /\.zip$/i.test(file.name); -} - -function isMarkupFile(file) { - return /\.(?:dclg(?:\.xml)?|xml)$/i.test(file.name); -} - -async function loadFromDrop(dataTransfer) { - const files = [...dataTransfer.files]; - if (files.some((f) => f.name === "document.xml")) { - await appendFolderArchive(files); - return; - } - const supported = files.filter((f) => isArchiveFile(f) || isMarkupFile(f)); - if (supported.length) await addFilesToCatalog(supported, { replace: false }); -} - -function buildDocumentState(markupXml, pageImages, label, assetUrls, { markupOnly }) { - const doc = new DOMParser().parseFromString(markupXml, "application/xml"); - const parseError = doc.querySelector("parsererror"); - if (parseError) { - alert(`Invalid XML in ${label}`); - return null; - } - const root = doc.documentElement; - if (localName(root) !== "doclang") { - alert(`${label}: root element must be `); - return null; - } - - const head = childElements(root).find((el) => localName(el) === "head") ?? null; - const defaultResolution = readDefaultResolution(head); - const segments = markupOnly - ? [childElements(root).filter((el) => localName(el) !== "head")] - : splitIntoSegments(root); - const hasPageView = !markupOnly && pageImages.size > 0; - const maxImagePage = hasPageView ? Math.max(...pageImages.keys()) : 0; - const pageCount = markupOnly ? 1 : Math.max(segments.length, maxImagePage, 1); - const readingOrder = computeReadingOrder(root); - const elementPageByEl = buildElementPageMap(segments); - - return { - pageImages, - assetUrls, - currentPage: 1, - pageCount, - segments, - defaultResolution, - hasPageView, - markupOnly, - docRoot: root, - threadPagesById: buildThreadPagesById(root, elementPageByEl), - elementPageByEl, - threadNavByElement: buildThreadNavByElement(root), - pendingSelectElement: null, - readingOrder, - readingOrderDisplayNumbers: computeReadingOrderDisplayNumbers(readingOrder), - pageViewOverlay: null, - }; -} - -function activateDocument(docState, entry) { - state = docState; - resetPageLayoutCache(); - closeAllSettings(); - - pageZoomPercent = entry.pageZoom ?? PAGE_ZOOM_DEFAULT; - if (els.pageZoom) { - els.pageZoom.value = String(pageZoomPercent); - els.pageZoom.setAttribute("aria-valuenow", String(pageZoomPercent)); - } - updatePageZoomResetButton(); - const port = pageViewScrollPane(); - if (port) { - port.scrollLeft = 0; - port.scrollTop = 0; - } - - setDocLabel(entry.label); - setDocumentOpen(true, { markupOnly: state.markupOnly }); - setPageViewVisible(state.hasPageView); - renderPage(state.currentPage); - updateFileView(); -} - -function setDocLabel(label) { - if (!els.docLabel) return; - if (label) { - els.docLabel.textContent = label; - els.docLabel.hidden = false; - } else { - els.docLabel.textContent = ""; - els.docLabel.hidden = true; - } -} - -function setDocumentOpen(open, { markupOnly = false } = {}) { - document.body.classList.toggle("viewer-loaded", open); - document.body.classList.toggle("markup-only", open && markupOnly); - if (els.pageNav) els.pageNav.hidden = !open || markupOnly; - syncToolbarPaneCheckboxes(); - applyPaneLayout(); -} - -function applyReadingLayerClasses(root) { - root.classList.toggle("show-reading-furniture", showReadingFurniture); - root.classList.toggle("show-reading-background", showReadingBackground); -} - -function syncReadingLayerVisibility() { - const root = els.renderedPane?.querySelector(".rendered-doc"); - if (root) applyReadingLayerClasses(root); -} - -function closeAllSettings() { - setPageSettingsOpen(false); - setReadingSettingsOpen(false); -} - -function resetPageZoom() { - pageZoomPercent = PAGE_ZOOM_DEFAULT; - if (els.pageZoom) { - els.pageZoom.value = String(PAGE_ZOOM_DEFAULT); - els.pageZoom.setAttribute("aria-valuenow", String(PAGE_ZOOM_DEFAULT)); - } - updatePageZoomResetButton(); - const port = pageViewScrollPane(); - if (port) { - port.scrollLeft = 0; - port.scrollTop = 0; - } - refreshPageViewLayout(); -} - -function updatePageZoomResetButton() { - if (!els.pageZoomReset) return; - els.pageZoomReset.textContent = `${pageZoomPercent}%`; - els.pageZoomReset.disabled = pageZoomPercent === PAGE_ZOOM_DEFAULT; -} - -function resetViewer() { - setDemoLoading(false); - clearFileCatalog(); - filePaneUserToggled = false; - selectedElementId = null; - pagePanDrag = null; - pagePanSuppressClick = false; - showReadingFurniture = true; - showReadingBackground = true; - syncReadingLayerCheckboxes(); - resetPageZoom(); - setDocLabel(null); - setDocumentOpen(false); - document.body.classList.remove("has-page-view"); - closeAllSettings(); - setToolbarOptionsOpen(false); - if (els.markupPane) els.markupPane.innerHTML = ""; - if (els.renderedPane) els.renderedPane.innerHTML = ""; - if (els.pagePane) els.pagePane.innerHTML = ""; - setPageIndicator(1, 1); - if (els.btnPrev) els.btnPrev.disabled = true; - if (els.btnNext) els.btnNext.disabled = true; - if (els.filePane) els.filePane.replaceChildren(); - updateFileView(); - applyPaneLayout(); -} - -function setPageViewVisible(visible) { - document.body.classList.toggle("has-page-view", visible); - syncPagePaneControls(); - syncToolbarPaneCheckboxes(); - if (!visible) setPageSettingsOpen(false); - applyPaneLayout(); - syncLayoutSubtoggles(); -} - -function syncPagePaneControls() { - const pageVisible = isPaneVisible("page"); - if (els.settingsToggle) els.settingsToggle.hidden = !pageVisible; - if (els.pageZoomLabel) els.pageZoomLabel.hidden = !pageVisible; - if (pageVisible) updatePageZoomResetButton(); - if (els.pagePane) els.pagePane.tabIndex = pageVisible ? 0 : -1; -} - -function setPageSettingsOpen(open) { - pageSettingsOpen = open; - if (els.pageSettingsLayer) els.pageSettingsLayer.hidden = !open; - if (els.settingsToggle) els.settingsToggle.setAttribute("aria-expanded", String(open)); -} - -function setReadingSettingsOpen(open) { - readingSettingsOpen = open; - if (els.readingSettingsLayer) els.readingSettingsLayer.hidden = !open; - if (els.readingSettingsToggle) els.readingSettingsToggle.setAttribute("aria-expanded", String(open)); -} - -function syncLayoutSubtoggles() { - const layoutEnabled = Boolean(state?.hasPageView && showAllBboxes); - for (const label of [ - els.showLayoutBadgesLabel, - els.showPictureContentsLabel, - els.showTableContentsLabel, - els.showFragmentLinksLabel, - els.showCaptionLinksLabel, - els.showXrefLinksLabel, - els.showReadingOrderLabel, - ]) { - if (!label) continue; - label.classList.toggle("settings-option-disabled", !layoutEnabled); - const input = label.querySelector("input"); - if (input) input.disabled = !layoutEnabled; - } - - const readingOrderEnabled = layoutEnabled && showReadingOrder; - for (const label of [els.readingOrderArrowsLabel, els.readingOrderGlobalLabel]) { - if (!label) continue; - label.classList.toggle("settings-option-disabled", !readingOrderEnabled); - const input = label.querySelector("input"); - if (input) input.disabled = !readingOrderEnabled; - } -} - -function paneDef(key) { - const defs = { - file: { key: "file", el: els.paneFile, canShow: () => fileCatalog.length > 0 }, - page: { key: "page", el: els.panePageView, canShow: () => Boolean(state?.hasPageView) }, - markup: { key: "markup", el: els.paneMarkup, canShow: () => Boolean(state) }, - reading: { key: "reading", el: els.paneReading, canShow: () => Boolean(state) }, - }; - return defs[key]; -} - -function isPaneAvailable(key) { - const def = paneDef(key); - return def?.canShow() ?? false; -} - -function isPaneVisible(key) { - if (!isPaneAvailable(key)) return false; - return Boolean(userPaneVisible[key]); -} - -function visiblePaneKeys() { - return PANE_KEYS.filter((key) => isPaneVisible(key)); -} - -function paneMinRatio(key) { - return PANE_MIN_RATIO; -} - -function filePaneFitWidthPx() { - const probe = document.createElement("div"); - probe.style.cssText = "position:absolute;visibility:hidden;width:var(--file-pane-fit-width);"; - document.documentElement.appendChild(probe); - const px = probe.getBoundingClientRect().width; - probe.remove(); - return Math.ceil(px) || 108; -} - -function resolvedFilePaneWidthPx() { - const fit = filePaneFitWidthPx(); - return Math.max(fit, filePaneWidthPx ?? fit); -} - -function contentPaneFrWeights(keys) { - const contentKeys = keys.filter((key) => key !== "file"); - const weights = contentKeys.map((key) => paneRatios[paneRatioIndex(key)]); - const sum = weights.reduce((a, b) => a + b, 0) || contentKeys.length; - return weights.map((weight) => weight / sum); -} - -function paneRatioIndex(key) { - return PANE_KEYS.indexOf(key); -} - -function normalizePaneRatios() { - const sum = paneRatios.reduce((a, b) => a + b, 0); - if (sum <= 0) { - paneRatios = [...DEFAULT_PANE_RATIOS]; - return; - } - paneRatios = paneRatios.map((r) => r / sum); -} - -function visiblePaneRatios(keys) { - const weights = keys.map((key) => paneRatios[paneRatioIndex(key)]); - const sum = weights.reduce((a, b) => a + b, 0) || keys.length; - return weights.map((w) => w / sum); -} - -function loadLayoutPrefs() { - try { - const raw = localStorage.getItem(LAYOUT_STORAGE_KEY); - if (!raw) return; - const data = JSON.parse(raw); - if (data?.visible && typeof data.visible === "object") { - for (const key of PANE_KEYS) { - if (typeof data.visible[key] === "boolean") userPaneVisible[key] = data.visible[key]; - } - if (typeof data.visible.file === "boolean") filePaneUserToggled = true; - } - if (Array.isArray(data?.ratios)) { - const valid = data.ratios.every((n) => typeof n === "number" && n > 0); - if (valid && data.ratios.length === 4) { - paneRatios = [...data.ratios]; - normalizePaneRatios(); - } else if (valid && data.ratios.length === 3) { - paneRatios = [1, ...data.ratios]; - normalizePaneRatios(); - } - } - if (typeof data?.filePaneWidthPx === "number" && data.filePaneWidthPx > 0) { - filePaneWidthPx = data.filePaneWidthPx; - } - } catch { - /* ignore invalid stored layout */ - } -} - -function saveLayoutPrefs() { - try { - localStorage.setItem( - LAYOUT_STORAGE_KEY, - JSON.stringify({ visible: userPaneVisible, ratios: paneRatios, filePaneWidthPx }), - ); - } catch { - /* ignore quota / private mode */ - } -} - -function resetPaneLayout() { - filePaneUserToggled = false; - userPaneVisible = { - file: defaultFilePaneVisible(), - page: true, - markup: true, - reading: true, - }; - paneRatios = [...DEFAULT_PANE_RATIOS]; - normalizePaneRatios(); - filePaneWidthPx = null; - setReadingSettingsOpen(false); - syncPagePaneControls(); - syncToolbarPaneCheckboxes(); - saveLayoutPrefs(); - applyPaneLayout(); -} - -function isLayoutStacked() { - return Boolean(layoutStackQuery?.matches); -} - -function initLayoutStackListener() { - if (!els.main) return; - layoutStackQuery = window.matchMedia(`(max-width: ${LAYOUT_STACK_BREAKPOINT_PX}px)`); - const onChange = () => applyPaneLayout(); - layoutStackQuery.addEventListener("change", onChange); - onChange(); -} - -function setToolbarOptionsOpen(open) { - toolbarOptionsOpen = open; - if (els.toolbarOptionsPanel) els.toolbarOptionsPanel.hidden = !open; - if (els.toolbarOptionsBtn) els.toolbarOptionsBtn.setAttribute("aria-expanded", String(open)); -} - -function syncToolbarPaneCheckboxes() { - if (els.toggleFilePane) { - const available = isPaneAvailable("file"); - els.toggleFilePane.checked = available && userPaneVisible.file; - els.toggleFilePane.disabled = !available; - } - if (els.toggleFilePaneLabel) { - els.toggleFilePaneLabel.classList.toggle("toolbar-options-item-disabled", !isPaneAvailable("file")); - } - if (els.togglePagePane) { - const available = isPaneAvailable("page"); - els.togglePagePane.checked = available && userPaneVisible.page; - els.togglePagePane.disabled = !available; - } - if (els.togglePagePaneLabel) { - els.togglePagePaneLabel.classList.toggle("toolbar-options-item-disabled", !isPaneAvailable("page")); - } - if (els.toggleMarkupPane) { - els.toggleMarkupPane.checked = userPaneVisible.markup; - els.toggleMarkupPane.disabled = !state; - } - if (els.toggleReadingPane) { - els.toggleReadingPane.checked = userPaneVisible.reading; - els.toggleReadingPane.disabled = !state; - } - for (const label of [ - els.toggleFilePaneLabel, - els.togglePagePaneLabel, - document.getElementById("toggle-markup-pane-label"), - document.getElementById("toggle-reading-pane-label"), - ]) { - if (!label) continue; - const input = label.querySelector("input"); - label.classList.toggle("toolbar-options-item-disabled", Boolean(input?.disabled)); - } - if (els.resetPaneLayoutBtn) els.resetPaneLayoutBtn.disabled = !state; -} - -function paneKeysAdjacent(leftKey, rightKey) { - const leftIdx = PANE_KEYS.indexOf(leftKey); - const rightIdx = PANE_KEYS.indexOf(rightKey); - return leftIdx >= 0 && rightIdx === leftIdx + 1; -} - -function onlyHiddenPanesBetween(leftKey, rightKey) { - const leftIdx = PANE_KEYS.indexOf(leftKey); - const rightIdx = PANE_KEYS.indexOf(rightKey); - if (leftIdx < 0 || rightIdx <= leftIdx) return false; - for (let i = leftIdx + 1; i < rightIdx; i++) { - if (isPaneVisible(PANE_KEYS[i])) return false; - } - return true; -} - -function shouldShowSplitterBetween(leftKey, rightKey) { - if (!isPaneVisible(leftKey) || !isPaneVisible(rightKey)) return false; - if (paneKeysAdjacent(leftKey, rightKey)) return true; - return onlyHiddenPanesBetween(leftKey, rightKey); -} - -function splitterForLayoutGap(leftKey, rightKey) { - if (!shouldShowSplitterBetween(leftKey, rightKey)) return null; - const leftIdx = PANE_KEYS.indexOf(leftKey); - if (leftIdx < 0) return null; - return els.splitters[leftIdx] ?? null; -} - -function visiblePaneNeighborAfter(key) { - const idx = PANE_KEYS.indexOf(key); - if (idx < 0) return null; - for (let i = idx + 1; i < PANE_KEYS.length; i++) { - const neighbor = PANE_KEYS[i]; - if (isPaneVisible(neighbor)) return neighbor; - } - return null; -} - -function visiblePaneNeighborBefore(key) { - const idx = PANE_KEYS.indexOf(key); - if (idx < 0) return null; - for (let i = idx - 1; i >= 0; i--) { - const neighbor = PANE_KEYS[i]; - if (isPaneVisible(neighbor)) return neighbor; - } - return null; -} - -function resolvedPhysicalSplitterKeys(physicalSplitterIndex) { - const leftPhysical = PANE_KEYS[physicalSplitterIndex]; - const rightPhysical = PANE_KEYS[physicalSplitterIndex + 1]; - if (!leftPhysical || !rightPhysical) return null; - - const leftKey = isPaneVisible(leftPhysical) ? leftPhysical : visiblePaneNeighborBefore(rightPhysical); - const rightKey = isPaneVisible(rightPhysical) ? rightPhysical : visiblePaneNeighborAfter(leftPhysical); - if (!leftKey || !rightKey || leftKey === rightKey) return null; - if (!shouldShowSplitterBetween(leftKey, rightKey)) return null; - - const canonical = splitterForLayoutGap(leftKey, rightKey); - if (!canonical || canonical !== els.splitters[physicalSplitterIndex]) return null; - return { leftKey, rightKey }; -} - -function resetPaneGridStyles() { - for (const key of PANE_KEYS) { - const el = paneDef(key)?.el; - if (el) { - el.style.gridColumn = ""; - el.style.gridRow = ""; - } - } - for (const splitter of els.splitters) { - if (!splitter) continue; - splitter.style.gridColumn = ""; - splitter.style.gridRow = ""; - splitter.hidden = true; - } -} - -function setUserPaneVisible(key, visible) { - userPaneVisible[key] = visible; - if (key === "file") filePaneUserToggled = true; - if (key === "page") syncPagePaneControls(); - if (key === "reading" && !visible) setReadingSettingsOpen(false); - syncToolbarPaneCheckboxes(); - saveLayoutPrefs(); - applyPaneLayout(); -} - -function applyPaneLayout() { - if (!els.main) return; - - const stacked = document.body.classList.contains("viewer-loaded") && isLayoutStacked(); - els.main.classList.toggle("layout-stacked", stacked); - - for (const key of PANE_KEYS) { - const def = paneDef(key); - if (!def?.el) continue; - def.el.hidden = !isPaneVisible(key); - def.el.classList.remove("pane-layout-last"); - } - - if (!document.body.classList.contains("viewer-loaded")) { - resetPaneGridStyles(); - els.main.style.gridTemplateColumns = ""; - els.main.style.gridTemplateRows = ""; - return; - } - - let keys = visiblePaneKeys(); - if (!keys.length) { - userPaneVisible.markup = true; - keys = visiblePaneKeys(); - } - - const lastKey = keys[keys.length - 1]; - paneDef(lastKey)?.el?.classList.add("pane-layout-last"); - - resetPaneGridStyles(); - - if (stacked) { - els.main.style.gridTemplateRows = ""; - els.main.style.gridTemplateColumns = "1fr"; - let row = 1; - for (const key of keys) { - const def = paneDef(key); - if (!def?.el) continue; - def.el.style.gridRow = String(row++); - } - refreshPageViewLayout(); - if (els.readingSettingsToggle) { - els.readingSettingsToggle.hidden = !state || !isPaneVisible("reading"); - } - return; - } - - const contentFr = contentPaneFrWeights(keys); - const cols = []; - let contentFrIndex = 0; - keys.forEach((key, index) => { - if (key === "file") { - cols.push(`${resolvedFilePaneWidthPx()}px`); - } else { - cols.push(`minmax(0, ${contentFr[contentFrIndex++].toFixed(6)}fr)`); - } - if (index < keys.length - 1 && shouldShowSplitterBetween(keys[index], keys[index + 1])) { - cols.push("1px"); - } - }); - els.main.style.gridTemplateColumns = cols.join(" "); - els.main.style.gridTemplateRows = "minmax(0, 1fr)"; - - let col = 1; - keys.forEach((key, index) => { - const def = paneDef(key); - if (!def?.el) return; - def.el.style.gridColumn = String(col); - col += 1; - if (index < keys.length - 1) { - const leftKey = keys[index]; - const rightKey = keys[index + 1]; - if (!shouldShowSplitterBetween(leftKey, rightKey)) return; - const splitter = splitterForLayoutGap(leftKey, rightKey); - if (splitter) { - splitter.hidden = false; - splitter.style.gridColumn = String(col); - col += 1; - } - } - }); - - refreshPageViewLayout(); - if (els.readingSettingsToggle) { - els.readingSettingsToggle.hidden = !state || !isPaneVisible("reading"); - } -} - -function initToolbarOptions() { - if (!els.toolbarOptionsBtn || !els.toolbarOptionsPanel) return; - - els.toolbarOptionsBtn.addEventListener("click", (e) => { - e.stopPropagation(); - setToolbarOptionsOpen(!toolbarOptionsOpen); - }); - - document.addEventListener("click", (e) => { - if (!toolbarOptionsOpen) return; - if (e.target instanceof Node && els.toolbarOptionsPanel.contains(e.target)) return; - if (e.target instanceof Node && els.toolbarOptionsBtn.contains(e.target)) return; - setToolbarOptionsOpen(false); - }); - - const onToggle = (key, input) => { - input?.addEventListener("change", () => { - if (!state) return; - const nextKeys = PANE_KEYS.filter((k) => (k === key ? input.checked : userPaneVisible[k] && isPaneAvailable(k))); - if (!nextKeys.length) { - input.checked = true; - return; - } - setUserPaneVisible(key, input.checked); - }); - }; - - onToggle("file", els.toggleFilePane); - onToggle("page", els.togglePagePane); - onToggle("markup", els.toggleMarkupPane); - onToggle("reading", els.toggleReadingPane); - els.resetPaneLayoutBtn?.addEventListener("click", () => { - if (!state) return; - resetPaneLayout(); - }); - syncToolbarPaneCheckboxes(); -} - -function initPaneSplitters() { - for (const [index, splitter] of els.splitters.entries()) { - if (!splitter) continue; - splitter.addEventListener("pointerdown", (e) => startPaneDrag(e, index)); - } - window.addEventListener("pointermove", onPaneDragMove); - window.addEventListener("pointerup", endPaneDrag); - window.addEventListener("pointercancel", endPaneDrag); -} - -function startPaneDrag(e, physicalSplitterIndex) { - if (e.button !== 0 || isLayoutStacked() || !document.body.classList.contains("viewer-loaded")) return; - - const resolved = resolvedPhysicalSplitterKeys(physicalSplitterIndex); - if (!resolved) return; - - const { leftKey, rightKey } = resolved; - - normalizePaneRatios(); - const leftIndex = paneRatioIndex(leftKey); - const rightIndex = paneRatioIndex(rightKey); - const dragState = { - physicalSplitterIndex, - leftKey, - rightKey, - startX: e.clientX, - leftStart: paneRatios[leftIndex], - rightStart: paneRatios[rightIndex], - pointerId: e.pointerId, - }; - if (leftKey === "file") { - dragState.leftStartPx = resolvedFilePaneWidthPx(); - } else if (rightKey === "file") { - dragState.rightStartPx = resolvedFilePaneWidthPx(); - } - paneDrag = dragState; - - e.preventDefault(); - e.currentTarget.setPointerCapture(e.pointerId); - e.currentTarget.classList.add("is-dragging"); - document.body.classList.add("pane-drag-active"); -} - -function contentPaneAvailableWidthPx() { - if (!els.main) return 1; - const rect = els.main.getBoundingClientRect(); - const keys = visiblePaneKeys(); - let reserved = 0; - if (keys.includes("file")) reserved += resolvedFilePaneWidthPx(); - for (let i = 0; i < keys.length - 1; i++) { - if (shouldShowSplitterBetween(keys[i], keys[i + 1])) reserved += 1; - } - return Math.max(rect.width - reserved, 1); -} - -function onPaneDragMove(e) { - if (!paneDrag || e.pointerId !== paneDrag.pointerId || !els.main) return; - - if (paneDrag.leftKey === "file" && typeof paneDrag.leftStartPx === "number") { - filePaneWidthPx = Math.max(filePaneFitWidthPx(), paneDrag.leftStartPx + (e.clientX - paneDrag.startX)); - applyPaneLayout(); - return; - } - if (paneDrag.rightKey === "file" && typeof paneDrag.rightStartPx === "number") { - filePaneWidthPx = Math.max(filePaneFitWidthPx(), paneDrag.rightStartPx - (e.clientX - paneDrag.startX)); - applyPaneLayout(); - return; - } - - const keys = visiblePaneKeys(); - const contentFr = contentPaneFrWeights(keys); - const contentKeys = keys.filter((key) => key !== "file"); - const leftContentIndex = contentKeys.indexOf(paneDrag.leftKey); - if (leftContentIndex < 0 || leftContentIndex + 1 >= contentFr.length) return; - - const pairFrTotal = contentFr[leftContentIndex] + contentFr[leftContentIndex + 1]; - if (!(pairFrTotal > 0)) return; - - const pairPixels = Math.max(contentPaneAvailableWidthPx() * pairFrTotal, 1); - const deltaRatio = (e.clientX - paneDrag.startX) / pairPixels; - - const leftIndex = paneRatioIndex(paneDrag.leftKey); - const rightIndex = paneRatioIndex(paneDrag.rightKey); - const pairStoredTotal = paneDrag.leftStart + paneDrag.rightStart; - if (!(pairStoredTotal > 0) || leftIndex < 0 || rightIndex < 0) return; - - let nextLeft = paneDrag.leftStart + deltaRatio * pairStoredTotal; - const leftMin = Math.min(paneMinRatio(paneDrag.leftKey), pairStoredTotal / 2); - const rightMin = Math.min(paneMinRatio(paneDrag.rightKey), pairStoredTotal / 2); - nextLeft = Math.min(Math.max(nextLeft, leftMin), pairStoredTotal - rightMin); - paneRatios[leftIndex] = nextLeft; - paneRatios[rightIndex] = pairStoredTotal - nextLeft; - applyPaneLayout(); -} - -function endPaneDrag(e) { - if (!paneDrag || e.pointerId !== paneDrag.pointerId) return; - const splitter = els.splitters[paneDrag.physicalSplitterIndex]; - splitter?.classList.remove("is-dragging"); - if (splitter?.hasPointerCapture(e.pointerId)) splitter.releasePointerCapture(e.pointerId); - paneDrag = null; - document.body.classList.remove("pane-drag-active"); - normalizePaneRatios(); - saveLayoutPrefs(); -} - -function goToPage(n) { - if (!state) return; - setPageSettingsOpen(false); - const page = Math.min(Math.max(1, n), state.pageCount); - state.currentPage = page; - renderPage(page); -} - -function resetPageNumberInput() { - if (!els.pageNumberInput) return; - els.pageNumberInput.value = state ? String(state.currentPage) : "1"; -} - -function commitPageNumberInput() { - if (!state || !els.pageNumberInput) return; - const n = Number.parseInt(els.pageNumberInput.value.trim(), 10); - if (!Number.isFinite(n)) { - resetPageNumberInput(); - return; - } - goToPage(n); -} - -function setPageIndicator(pageNum, pageCount) { - if (!els.pageIndicator) return; - if (els.pageCountIndicator) els.pageCountIndicator.textContent = `\u00A0of ${pageCount}`; - if (els.pageNumberInput) { - const digits = Math.max(1, String(pageCount).length); - els.pageNumberInput.style.setProperty("--page-num-digits", String(digits)); - els.pageNumberInput.disabled = !state; - if (document.activeElement !== els.pageNumberInput) { - els.pageNumberInput.value = String(pageNum); - } - } -} - -function renderPage(pageNum) { - if (!state) return; - const { segments, pageImages, pageCount, defaultResolution } = state; - const idx = pageNum - 1; - const segment = segments[idx] ?? []; - selectedElementId = null; - - setPageIndicator(pageNum, pageCount); - if (els.btnPrev) els.btnPrev.disabled = pageNum <= 1; - if (els.btnNext) els.btnNext.disabled = pageNum >= pageCount; - - if (!els.markupPane || !els.renderedPane || !els.pagePane) return; - - els.markupPane.innerHTML = ""; - const elementIds = assignElementIds(segment); - state.elementIds = elementIds; - state.idToElement = invertElementIds(elementIds); - - if (segmentHasMarkup(segment)) { - els.markupPane.appendChild(buildMarkupView(segment, elementIds)); - els.renderedPane.innerHTML = ""; - els.renderedPane.appendChild(buildRenderedView(segment, elementIds)); - } else { - els.markupPane.innerHTML = `
${NO_MARKUP}
`; - els.renderedPane.innerHTML = `
${NO_MARKUP}
`; - } - - els.pagePane.innerHTML = ""; - if (state.hasPageView) { - const imageUrl = pageImages.get(pageNum); - if (imageUrl) { - const port = document.createElement("div"); - port.className = "page-view-port"; - const wrap = document.createElement("div"); - wrap.className = "page-view"; - const img = document.createElement("img"); - img.alt = `Page ${pageNum}`; - - const onImageReady = () => { - if (img.dataset.layoutGeneration === String(pageNum)) return; - img.dataset.layoutGeneration = String(pageNum); - - applyPageImageSize(img, els.pagePane); - - const boxes = collectBoundingBoxes(segment, defaultResolution, elementIds); - const existing = wrap.querySelector("svg.overlay"); - if (existing) existing.remove(); - const readingOrderSteps = collectReadingOrderSteps( - segment, - elementIds, - boxes, - state.readingOrder, - readingOrderGlobalNumbering, - state.readingOrderDisplayNumbers, - ); - state.pageViewOverlay = { boxes, readingOrderSteps }; - if (boxes.length) { - wrap.appendChild( - buildOverlay( - img, - boxes, - collectCaptionLinks(segment, elementIds, boxes), - collectXrefLinks(segment, elementIds, boxes), - readingOrderSteps, - collectFragmentLinks(segment, elementIds, boxes, pageNum, state.threadPagesById), - collectFragmentNavItems(segment, elementIds, boxes), - defaultResolution, - ), - ); - } - syncPageViewChrome(img); - const pending = state.pendingSelectElement; - if (pending) { - state.pendingSelectElement = null; - const id = findElementIdOnPage(pending); - if (id) selectElement(id); - } - }; - - img.addEventListener("load", onImageReady, { once: true }); - wrap.appendChild(img); - port.appendChild(wrap); - els.pagePane.appendChild(port); - img.src = imageUrl; - if (img.complete) onImageReady(); - - if (!pagePaneResizeObserver) { - pagePaneResizeObserver = new ResizeObserver(() => { - refreshPageViewLayout(); - }); - pagePaneResizeObserver.observe(els.pagePane); - } - } else { - els.pagePane.innerHTML = `
${NO_IMAGE}
`; - } - } -} - -function pageZoomFactor() { - return pageZoomPercent / PAGE_ZOOM_DEFAULT; -} - -/** - * Convert a legacy overlay length (image pixels as tuned on the demo pages) to SVG user - * units for the current page. Matches the old demo on-screen size, stays zoom-stable, - * and scales with page resolution so other images look the same. - */ -function overlayUserLength(baseUserPx, img = els.pagePane?.querySelector(".page-view img")) { - const zoom = pageZoomFactor(); - if (!(zoom > 0)) return baseUserPx; - if (!img?.naturalWidth || !img.naturalHeight || !els.pagePane) { - return baseUserPx / zoom; - } - - const { w: paneW, h: paneH } = paneContentSize(els.pagePane); - if (!(paneW > 0 && paneH > 0)) return baseUserPx / zoom; - - const refFit = Math.min( - (paneW - PAGE_VIEW_BORDER_PX) / OVERLAY_REF_IMAGE_WIDTH, - (paneH - PAGE_VIEW_BORDER_PX) / OVERLAY_REF_IMAGE_HEIGHT, - ); - const fitScale = getCachedFitScale(img, els.pagePane); - if (!(refFit > 0) || !(fitScale > 0)) return baseUserPx / zoom; - - return (baseUserPx * refFit) / (fitScale * zoom); -} - -function computePageFitScale(img, pane) { - return getCachedFitScale(img, pane); -} - -function resetPageLayoutCache() { - pageLayoutCache = null; -} - -function paneContentSize(pane) { - const style = getComputedStyle(pane); - const padX = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); - const padY = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom); - return { - w: pane.clientWidth - padX, - h: pane.clientHeight - padY, - }; -} - -function getCachedFitScale(img, pane) { - const { w: paneW, h: paneH } = paneContentSize(pane); - const imgW = img.naturalWidth; - const imgH = img.naturalHeight; - if ( - pageLayoutCache && - pageLayoutCache.paneW === paneW && - pageLayoutCache.paneH === paneH && - pageLayoutCache.imgW === imgW && - pageLayoutCache.imgH === imgH - ) { - return pageLayoutCache.fitScale; - } - const fitScale = - paneW > 0 && paneH > 0 && imgW > 0 && imgH > 0 - ? Math.min( - (paneW - PAGE_VIEW_BORDER_PX) / imgW, - (paneH - PAGE_VIEW_BORDER_PX) / imgH, - ) - : 1; - pageLayoutCache = { paneW, paneH, imgW, imgH, fitScale }; - return fitScale; -} - -function applyPageImageSize(img, pane = els.pagePane) { - if (!img?.naturalWidth || !img.naturalHeight) return false; - pageZoomPercent = Math.max(PAGE_ZOOM_DEFAULT, pageZoomPercent); - const fitScale = getCachedFitScale(img, pane); - const scale = fitScale * (pageZoomPercent / PAGE_ZOOM_DEFAULT); - const w = Math.floor(img.naturalWidth * scale); - const h = Math.floor(img.naturalHeight * scale); - const nextW = `${w}px`; - const nextH = `${h}px`; - const unchanged = img.style.width === nextW && img.style.height === nextH; - if (!unchanged) { - img.style.width = nextW; - img.style.height = nextH; - img.style.maxWidth = "none"; - img.style.maxHeight = "none"; - } - img.dataset.layoutReady = "1"; - return !unchanged; -} - -function syncPageViewChrome(img) { - syncOverlayBadges(img); - updatePagePanePanCursor(); - applyBboxVisibility(); -} - -function applyPageLayout(img, pane = els.pagePane) { - applyPageImageSize(img, pane); - syncPageViewChrome(img); -} - -let pageLayoutFrame = 0; - -function refreshPageViewLayout() { - if (!els.pagePane) return; - cancelAnimationFrame(pageLayoutFrame); - pageLayoutFrame = requestAnimationFrame(() => { - pageLayoutFrame = 0; - const img = els.pagePane?.querySelector(".page-view img"); - if (img?.naturalWidth) applyPageLayout(img); - }); -} - -function overlayBadgeLayout(svg, text, fontSizeUser) { - const padXUser = overlayUserLength(OVERLAY_BADGE_PAD_X); - const padYUser = overlayUserLength(OVERLAY_BADGE_PAD_Y); - const probe = document.createElementNS("http://www.w3.org/2000/svg", "text"); - probe.setAttribute("class", "overlay-badge-label"); - probe.setAttribute("font-size", String(fontSizeUser)); - probe.setAttribute("font-weight", "700"); - probe.setAttribute("text-anchor", "start"); - probe.setAttribute("dominant-baseline", "text-before-edge"); - probe.setAttribute("visibility", "hidden"); - probe.textContent = String(text); - svg.appendChild(probe); - - let width; - let height; - try { - const bbox = probe.getBBox(); - width = bbox.width + padXUser * 2; - height = bbox.height + padYUser * 2; - } catch { - const label = String(text); - width = overlayUserLength(label.length * OVERLAY_BADGE_FONT_SIZE * 0.55 + OVERLAY_BADGE_PAD_X * 2); - height = overlayUserLength(OVERLAY_BADGE_FONT_SIZE * 1.1 + OVERLAY_BADGE_PAD_Y * 2); - } - probe.remove(); - return { width, height }; -} - -function appendOverlayBadge(svg, anchorX, anchorY, text, { extraClass, elementId }) { - const fontSize = overlayUserLength(OVERLAY_BADGE_FONT_SIZE); - const { width, height } = overlayBadgeLayout(svg, text, fontSize); - const radius = overlayUserLength(OVERLAY_BADGE_RADIUS_SCREEN_PX); - - const badge = document.createElementNS("http://www.w3.org/2000/svg", "g"); - badge.setAttribute("class", `overlay-badge ${extraClass}`); - badge.setAttribute("data-element-id", elementId); - - const bg = document.createElementNS("http://www.w3.org/2000/svg", "rect"); - bg.setAttribute("class", "overlay-badge-bg"); - bg.setAttribute("x", String(anchorX - width / 2)); - bg.setAttribute("y", String(anchorY - height / 2)); - bg.setAttribute("width", String(width)); - bg.setAttribute("height", String(height)); - bg.setAttribute("rx", String(radius)); - bg.setAttribute("ry", String(radius)); - badge.appendChild(bg); - - const label = document.createElementNS("http://www.w3.org/2000/svg", "text"); - label.setAttribute("class", "overlay-badge-label"); - label.setAttribute("x", String(anchorX)); - label.setAttribute("y", String(anchorY)); - label.setAttribute("font-size", String(fontSize)); - label.textContent = String(text); - badge.appendChild(label); - - svg.appendChild(badge); - return badge; -} - -function syncOverlayBadges(img) { - const svg = img.parentElement?.querySelector("svg.overlay"); - const meta = state?.pageViewOverlay; - if (!svg || !img.naturalWidth || !meta) return; - - svg.querySelectorAll(".element-badge, .reading-order-badge").forEach((badge) => badge.remove()); - - const fontSize = overlayUserLength(OVERLAY_BADGE_FONT_SIZE); - const badgeGap = overlayUserLength(2); - const readingOrderByElementId = new Map(); - if (showAllBboxes && showReadingOrder) { - for (const step of meta.readingOrderSteps ?? []) { - readingOrderByElementId.set(step.elementId, step); - } - } - - const boxes = sortedOverlayBoxes(meta.boxes ?? []); - - for (const b of boxes) { - const { x, y } = boxPixelRect(b, img); - let tagLayout = { width: 0 }; - if (showAllBboxes && showLayoutBadges) { - tagLayout = overlayBadgeLayout(svg, b.tag, fontSize); - appendOverlayBadge(svg, x, y, b.tag, { - extraClass: `element-badge ${kindClassForTag(b.kind)}`, - elementId: b.elementId, - }); - } - - const step = readingOrderByElementId.get(b.elementId); - if (step) { - const orderText = String(step.order); - const orderLayout = overlayBadgeLayout(svg, orderText, fontSize); - const orderAnchorX = showAllBboxes && showLayoutBadges - ? x + tagLayout.width / 2 + badgeGap + orderLayout.width / 2 - : x; - appendOverlayBadge(svg, orderAnchorX, y, orderText, { - extraClass: "reading-order-badge", - elementId: b.elementId, - }); - } - } -} - -function splitIntoSegments(root) { - const body = childElements(root).filter((el) => localName(el) !== "head"); - /** @type {Element[][]} */ - const segments = [[]]; - for (const el of body) { - if (localName(el) === "page_break") { - segments.push([]); - } else { - segments[segments.length - 1].push(el); - } - } - return segments.length ? segments : [[]]; -} - -function segmentHasMarkup(segment) { - return segment.some((el) => el.nodeType === Node.ELEMENT_NODE); -} - -function readDefaultResolution(head) { - const fallback = { width: 512, height: 512 }; - if (!head) return fallback; - const dr = childElements(head).find((el) => localName(el) === "default_resolution"); - if (!dr) return fallback; - const w = parseInt(dr.getAttribute("width") ?? "512", 10); - const h = parseInt(dr.getAttribute("height") ?? "512", 10); - return { - width: Number.isFinite(w) && w > 0 ? w : 512, - height: Number.isFinite(h) && h > 0 ? h : 512, - }; -} - -/** @returns {Map} */ -function assignElementIds(segment) { - const ids = new Map(); - let counter = 0; - walkElements(segment, (el) => { - ids.set(el, `el-${counter++}`); - }); - return ids; -} - -/** @returns {Map} */ -function invertElementIds(elementIds) { - const idToElement = new Map(); - for (const [el, id] of elementIds) idToElement.set(id, el); - return idToElement; -} - -function isSemanticElement(el) { - return SEMANTIC_TAGS.has(localName(el)); -} - -function isVirtualTextHost(el) { - const tag = localName(el); - return tag === "ldiv" || CELL_CONTENT_TAGS.has(tag); -} - -function xmlContains(target, ancestor) { - let node = target; - while (node) { - if (node === ancestor) return true; - node = node.parentNode; - } - return false; -} - -function findListVirtualTextHost(list, target) { - const nodes = [...list.childNodes]; - let i = skipContainerLevelHead(nodes, 0); - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType !== Node.ELEMENT_NODE || localName(node) !== "ldiv") { - i += 1; - continue; - } - const ldiv = node; - i += 1; - const end = skipUntilListItemBoundary(nodes, i); - if (target === ldiv || nodes.slice(i, end).some((n) => xmlContains(target, n))) { - return ldiv; - } - i = end; - } - return null; -} - -function findTableVirtualTextHost(container, target) { - const nodes = [...container.childNodes]; - let i = skipContainerLevelHead(nodes, 0); - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType !== Node.ELEMENT_NODE) { - i += 1; - continue; - } - const tag = localName(node); - if (tag === "nl" || CELL_SPAN_TAGS.has(tag) || !isCellToken(tag)) { - i += 1; - continue; - } - const cell = node; - i += 1; - const end = skipUntilCellBoundary(nodes, i); - if (target === cell || nodes.slice(i, end).some((n) => xmlContains(target, n))) { - return cell; - } - i = end; - } - return null; -} - -function isListOrOtslContainer(el) { - const tag = localName(el); - return tag === "list" || OTSL_CONTAINER_TAGS.has(tag); -} - -function findVirtualTextHost(xmlEl) { - let node = xmlEl; - while (node) { - const parent = node.parentElement; - if (!parent) return null; - const tag = localName(parent); - if (tag === "list") { - const host = findListVirtualTextHost(parent, xmlEl); - if (host) return host; - } - if (OTSL_CONTAINER_TAGS.has(tag)) { - const host = findTableVirtualTextHost(parent, xmlEl); - if (host) return host; - } - node = parent; - } - return null; -} - -function resolveSelectionElement(xmlEl) { - if (!xmlEl) return null; - if (isSemanticElement(xmlEl) || isVirtualTextHost(xmlEl)) return xmlEl; - - let node = xmlEl.parentElement; - while (node) { - const tag = localName(node); - if (tag === "doclang") break; - if (isSemanticElement(node) && !isListOrOtslContainer(node)) return node; - node = node.parentElement; - } - - const virtualHost = findVirtualTextHost(xmlEl); - if (virtualHost) return virtualHost; - - node = xmlEl.parentElement; - while (node) { - const tag = localName(node); - if (tag === "doclang") break; - if (isSemanticElement(node) || isVirtualTextHost(node)) return node; - node = node.parentElement; - } - - return null; -} - -function resolveSelectionElementId(rawElementId) { - if (!rawElementId || !state?.idToElement || !state.elementIds) return null; - const xmlEl = state.idToElement.get(rawElementId); - if (!xmlEl) return null; - const resolved = resolveSelectionElement(xmlEl); - return resolved ? state.elementIds.get(resolved) ?? null : null; -} - -function resolveMarkupClickTarget(eventTarget) { - if (!state) return null; - - const ghostText = eventTarget.closest(".markup-el-virtual-text"); - if (ghostText?.hasAttribute("data-element-id")) { - return ghostText.getAttribute("data-element-id"); - } - - const markupEl = eventTarget.closest(".markup-el[data-element-id]"); - if (!markupEl) return null; - return resolveSelectionElementId(markupEl.getAttribute("data-element-id")); -} - -function resolveRenderedClickTarget(eventTarget) { - const ghostText = eventTarget.closest(".rendered-el-virtual-text"); - if (ghostText?.hasAttribute("data-element-id")) { - return ghostText.getAttribute("data-element-id"); - } - - const renderedEl = eventTarget.closest(".rendered-el[data-element-id]"); - if (!renderedEl) return null; - return resolveSelectionElementId(renderedEl.getAttribute("data-element-id")); -} - -/** @returns {{ kind: string, tag: string, elementId: string, layer: string, x0: number, y0: number, x1: number, y1: number, resW: number, resH: number }[]} */ -function collectBoundingBoxes(segment, defaultResolution, elementIds) { - /** @type {{ kind: string, tag: string, elementId: string, layer: string, x0: number, y0: number, x1: number, y1: number, resW: number, resH: number }[]} */ - const boxes = []; - walkElements(segment, (el) => { - const locs = headLocations(el); - if (locs.length !== 4) return; - const elementId = elementIds.get(el); - if (!elementId) return; - pushBoundingBox( - boxes, - locs, - defaultResolution, - localName(el), - elementLabel(el), - elementId, - elementLayer(el), - ); - }); - walkElements(segment, (el) => { - const tag = localName(el); - if (tag === "list") collectListVirtualTextBoxes(el, defaultResolution, boxes, elementIds); - else if (tag === "table" || tag === "index" || tag === "tabular") { - collectTableVirtualTextBoxes(el, defaultResolution, boxes, elementIds); - } - }); - return boxes; -} - -/** @returns {{ captionBox: object, hostBox: object, captionElementId: string, hostElementId: string }[]} */ -function collectCaptionLinks(segment, elementIds, boxes) { - const boxById = new Map(boxes.map((b) => [b.elementId, b])); - /** @type {{ captionBox: object, hostBox: object, captionElementId: string, hostElementId: string }[]} */ - const links = []; - walkElements(segment, (el) => { - if (localName(el) !== "caption") return; - const captionId = elementIds.get(el); - const captionBox = captionId ? boxById.get(captionId) : null; - if (!captionBox) return; - const host = el.parentElement; - if (!host || headLocations(host).length !== 4) return; - const hostId = elementIds.get(host); - const hostBox = hostId ? boxById.get(hostId) : null; - if (!hostBox) return; - links.push({ - captionBox, - hostBox, - captionElementId: captionId, - hostElementId: hostId, - }); - }); - return links; -} - -/** @returns {{ fromBox: object, toBox: object, fromElementId: string, toElementId: string }[]} */ -function collectXrefLinks(segment, elementIds, boxes) { - const boxById = new Map(boxes.map((b) => [b.elementId, b])); - /** @type {Map} */ - const threadsById = new Map(); - - walkElements(segment, (el) => { - const elementId = elementIds.get(el); - const box = elementId ? boxById.get(elementId) : null; - if (!box) return; - for (const thread of childElements(el)) { - if (localName(thread) !== "thread") continue; - const threadId = thread.getAttribute("thread_id"); - if (!threadId) continue; - if (!threadsById.has(threadId)) threadsById.set(threadId, []); - threadsById.get(threadId).push({ elementId, box }); - } - }); - - /** @type {{ fromBox: object, toBox: object, fromElementId: string, toElementId: string }[]} */ - const links = []; - walkElements(segment, (el) => { - const xrefs = childElements(el).filter((c) => localName(c) === "xref"); - if (!xrefs.length) return; - - const from = findNearestLocatedBox(el, elementIds, boxById); - if (!from) return; - - for (const xref of xrefs) { - const threadId = xref.getAttribute("thread_id"); - if (!threadId) continue; - for (const { elementId: toId, box: toBox } of threadsById.get(threadId) ?? []) { - if (toId === from.elementId) continue; - links.push({ - fromBox: from.box, - toBox, - fromElementId: from.elementId, - toElementId: toId, - }); - } - } - }); - return links; -} - -/** @returns {{ fromBox: object, toBox: object | null, fromElementId: string, toElementId: string | null, threadId: string, targetCorner?: "tl" | "br" }[]} */ -function collectFragmentLinks(segment, elementIds, boxes, pageNum, threadPagesById) { - const boxById = new Map(boxes.map((b) => [b.elementId, b])); - /** @type {Map} */ - const onPage = new Map(); - - walkElements(segment, (el) => { - const thread = firstHeadChild(el, "thread"); - const threadId = thread?.getAttribute("thread_id"); - if (!threadId) return; - const elementId = elementIds.get(el); - const box = elementId ? boxById.get(elementId) : null; - if (!box) return; - if (!onPage.has(threadId)) onPage.set(threadId, []); - onPage.get(threadId).push({ elementId, box }); - }); - - const segmentEls = new Set(); - walkElements(segment, (el) => segmentEls.add(el)); - - /** @type {{ fromBox: object, toBox: object | null, fromElementId: string, toElementId: string | null, threadId: string, targetCorner?: "tl" | "br" }[]} */ - const links = []; - - for (const [threadId, members] of onPage) { - if (members.length >= 2) { - for (let i = 0; i < members.length - 1; i += 1) { - links.push({ - fromBox: members[i].box, - toBox: members[i + 1].box, - fromElementId: members[i].elementId, - toElementId: members[i + 1].elementId, - threadId, - }); - } - continue; - } - - if (members.length !== 1) continue; - const threadPages = threadPagesById.get(threadId); - if (!threadPages) continue; - const hasPrevious = [...threadPages].some((p) => p < pageNum); - const hasFollowing = [...threadPages].some((p) => p > pageNum); - if (!hasPrevious && !hasFollowing) continue; - - const base = { - fromBox: members[0].box, - toBox: null, - fromElementId: members[0].elementId, - toElementId: null, - threadId, - }; - if (hasPrevious) links.push({ ...base, targetCorner: "tl" }); - if (hasFollowing) links.push({ ...base, targetCorner: "br" }); - } - - return links; -} - -function isReadingOrderUnit(el) { - const tag = localName(el); - if (tag === "caption") return true; - if (HEAD_TAGS.has(tag) || tag === "location" || tag === "h_thread" || tag === "page_break") return false; - if (tag === "nl" || CELL_SPAN_TAGS.has(tag)) return false; - if (RENDER_FORMAT_TAGS.has(tag) || tag === "src" || tag === "checkbox") return false; - return true; -} - -/** @returns {Map} */ -function buildThreadsById(docRoot) { - const map = new Map(); - const roots = childElements(docRoot).filter((el) => localName(el) !== "head"); - walkElements(roots, (el) => { - for (const child of childElements(el)) { - if (localName(child) !== "thread") continue; - const threadId = child.getAttribute("thread_id"); - if (!threadId) continue; - if (!map.has(threadId)) map.set(threadId, []); - map.get(threadId).push(el); - } - }); - return map; -} - -/** @returns {Map} */ -function buildElementPageMap(segments) { - const elementPage = new Map(); - segments.forEach((segment, idx) => { - const pageNum = idx + 1; - walkElements(segment, (el) => elementPage.set(el, pageNum)); - }); - return elementPage; -} - -/** @returns {Map>} */ -function buildThreadPagesById(docRoot, elementPageByEl) { - /** @type {Map>} */ - const threadPagesById = new Map(); - for (const [threadId, elements] of buildThreadsById(docRoot)) { - const pages = new Set(); - for (const el of elements) { - const page = elementPageByEl.get(el); - if (page) pages.add(page); - } - if (pages.size) threadPagesById.set(threadId, pages); - } - return threadPagesById; -} - -/** @returns {Map} */ -function buildThreadNavByElement(docRoot) { - /** @type {Map} */ - const nav = new Map(); - for (const [, elements] of buildThreadsById(docRoot)) { - for (let i = 0; i < elements.length; i += 1) { - nav.set(elements[i], { - prev: i > 0 ? elements[i - 1] : null, - next: i < elements.length - 1 ? elements[i + 1] : null, - }); - } - } - return nav; -} - -/** @returns {{ elementId: string, box: object, hasPrev: boolean, hasNext: boolean }[]} */ -function collectFragmentNavItems(segment, elementIds, boxes) { - if (!state?.threadNavByElement) return []; - const boxById = new Map(boxes.map((b) => [b.elementId, b])); - /** @type {{ elementId: string, box: object, hasPrev: boolean, hasNext: boolean }[]} */ - const items = []; - walkElements(segment, (el) => { - const nav = state.threadNavByElement.get(el); - if (!nav || (!nav.prev && !nav.next)) return; - const elementId = elementIds.get(el); - const box = elementId ? boxById.get(elementId) : null; - if (!box) return; - items.push({ - elementId, - box, - hasPrev: nav.prev !== null, - hasNext: nav.next !== null, - }); - }); - return items; -} - -function findElementIdOnPage(el) { - if (!state?.elementIds) return null; - for (const [node, id] of state.elementIds) { - if (node === el) return id; - } - return null; -} - -function navigateThreadFragment(elementId, direction) { - const el = state?.idToElement?.get(elementId); - if (!el) return; - const nav = state?.threadNavByElement?.get(el); - if (!nav) return; - const target = direction === "prev" ? nav.prev : nav.next; - if (!target) return; - const page = state.elementPageByEl.get(target); - if (!page) return; - if (page === state.currentPage) { - const id = findElementIdOnPage(target); - if (id) selectElement(id); - return; - } - state.pendingSelectElement = target; - goToPage(page); -} - -/** @returns {Element[]} */ -function computeReadingOrder(docRoot) { - const bodyChildren = childElements(docRoot).filter((el) => localName(el) !== "head"); - const threadsById = buildThreadsById(docRoot); - const consumedViaXref = new Set(); - /** @type {Element[]} */ - const order = []; - - function record(el) { - if (!isReadingOrderUnit(el)) return; - order.push(el); - } - - function consumeThread(threadId) { - for (const target of threadsById.get(threadId) ?? []) { - if (consumedViaXref.has(target)) continue; - consumedViaXref.add(target); - visitElement(target); - } - } - - function walkChildren(parent) { - for (const child of parent.childNodes) { - if (child.nodeType !== Node.ELEMENT_NODE) continue; - const tag = localName(child); - if (tag === "xref") { - const threadId = child.getAttribute("thread_id"); - if (threadId) consumeThread(threadId); - continue; - } - if (tag === "page_break") continue; - visitElement(child); - } - } - - function visitElement(el) { - record(el); - walkChildren(el); - } - - for (const el of bodyChildren) { - if (localName(el) === "page_break") continue; - if (consumedViaXref.has(el)) continue; - visitElement(el); - } - - return order; -} - -function hasVirtualTextLocations(el) { - const parent = el.parentElement; - if (!parent) return false; - const nodes = [...parent.childNodes]; - const idx = nodes.indexOf(el); - if (idx < 0) return false; - return parseElementHeadAt(nodes, idx + 1) !== null; -} - -function isVirtualTextOverlayUnit(el) { - if (headLocations(el).length === 4) return false; - if (!hasVirtualTextLocations(el)) return false; - const tag = localName(el); - if (tag === "ldiv" && localName(el.parentElement) === "list") return true; - if (isCellToken(tag) && tag !== "nl" && !CELL_SPAN_TAGS.has(tag)) { - return OTSL_CONTAINER_TAGS.has(localName(el.parentElement)); - } - return false; -} - -function isPictureContentElement(el) { - if (!el) return false; - const tag = localName(el); - if (tag === "picture" || tag === "caption") return false; - let node = el.parentElement; - while (node) { - if (localName(node) === "picture") return true; - node = node.parentElement; - } - return false; -} - -function isTableContentElement(el) { - if (!el) return false; - const tag = localName(el); - if (OTSL_CONTAINER_TAGS.has(tag) || tag === "caption") return false; - let node = el.parentElement; - while (node) { - if (OTSL_CONTAINER_TAGS.has(localName(node))) return true; - node = node.parentElement; - } - return false; -} - -function isPictureOrTableContentElement(el) { - return isPictureContentElement(el) || isTableContentElement(el); -} - -function isReadingOrderOverlayUnit(el) { - if (!isReadingOrderUnit(el)) return false; - if (isPictureOrTableContentElement(el)) return false; - if (headLocations(el).length === 4) return true; - return isVirtualTextOverlayUnit(el); -} - -/** @returns {Map} */ -function computeReadingOrderDisplayNumbers(readingOrder) { - const numbers = new Map(); - let n = 0; - for (const el of readingOrder) { - if (!isReadingOrderOverlayUnit(el)) continue; - n += 1; - numbers.set(el, n); - } - return numbers; -} - -/** @returns {{ order: number, box: object, elementId: string }[]} */ -function collectReadingOrderSteps( - segment, - elementIds, - boxes, - readingOrder, - globalNumbering = true, - displayNumbers = null, -) { - const boxById = new Map(boxes.map((b) => [b.elementId, b])); - /** @type {{ order: number, box: object, elementId: string }[]} */ - const steps = []; - let pageOrder = 0; - - readingOrder.forEach((el) => { - if (isPictureOrTableContentElement(el)) return; - const elementId = elementIds.get(el); - if (!elementId) return; - const box = boxById.get(elementId); - if (!box) return; - pageOrder += 1; - steps.push({ - order: globalNumbering ? (displayNumbers?.get(el) ?? pageOrder) : pageOrder, - box, - elementId, - }); - }); - - return steps; -} - -function pushBoundingBox(boxes, locs, defaultResolution, kind, tag, elementId, layer = "body") { - const [x0el, y0el, x1el, y1el] = locs; - const resW = locationResolution(x0el, defaultResolution.width); - const resH = locationResolution(y0el, defaultResolution.height); - boxes.push({ - kind, - tag, - elementId, - layer, - x0: parseInt(x0el.getAttribute("value") ?? "0", 10), - y0: parseInt(y0el.getAttribute("value") ?? "0", 10), - x1: parseInt(x1el.getAttribute("value") ?? "0", 10), - y1: parseInt(y1el.getAttribute("value") ?? "0", 10), - resW, - resH, - }); -} - -function collectListVirtualTextBoxes(list, defaultResolution, boxes, elementIds) { - const nodes = [...list.childNodes]; - let i = skipContainerLevelHead(nodes, 0); - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType !== Node.ELEMENT_NODE || localName(node) !== "ldiv") { - i += 1; - continue; - } - i += 1; - const head = parseElementHeadAt(nodes, i); - if (head) { - const elementId = elementIds.get(node); - if (elementId) { - pushBoundingBox(boxes, head.locs, defaultResolution, "text", "text", elementId, elementLayer(node)); - } - i = head.nextIndex; - } - i = skipUntilListItemBoundary(nodes, i); - } -} - -function collectTableVirtualTextBoxes(container, defaultResolution, boxes, elementIds) { - const nodes = [...container.childNodes]; - let i = skipContainerLevelHead(nodes, 0); - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType !== Node.ELEMENT_NODE) { - i += 1; - continue; - } - const tag = localName(node); - if (!isCellToken(tag)) { - i += 1; - continue; - } - if (tag === "nl") { - i += 1; - continue; - } - i += 1; - const head = parseElementHeadAt(nodes, i); - if (head) { - const elementId = elementIds.get(node); - if (elementId) { - pushBoundingBox(boxes, head.locs, defaultResolution, "text", "text", elementId, elementLayer(node)); - } - i = head.nextIndex; - } - i = skipUntilCellBoundary(nodes, i); - } -} - -function skipContainerLevelHead(nodes, startIdx) { - let i = startIdx; - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType !== Node.ELEMENT_NODE) { - i += 1; - continue; - } - const tag = localName(node); - if (tag === "ldiv" || isCellToken(tag)) break; - if (HEAD_TAGS.has(tag) || tag === "location") { - i += 1; - continue; - } - break; - } - return i; -} - -function skipUntilListItemBoundary(nodes, startIdx) { - let i = startIdx; - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType === Node.ELEMENT_NODE && localName(node) === "ldiv") break; - i += 1; - } - return i; -} - -function skipUntilCellBoundary(nodes, startIdx) { - let i = startIdx; - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType === Node.ELEMENT_NODE && isCellToken(localName(node))) break; - i += 1; - } - return i; -} - -function isCellToken(tag) { - return CELL_TOKENS.has(tag); -} - -/** @returns {{ locs: Element[], nextIndex: number } | null} */ -function parseElementHeadAt(nodes, startIdx) { - const locs = []; - let i = startIdx; - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType !== Node.ELEMENT_NODE) { - i += 1; - continue; - } - const tag = localName(node); - if (tag === "location") { - locs.push(node); - i += 1; - if (locs.length === 4) return { locs, nextIndex: i }; - continue; - } - if (locs.length) break; - if (HEAD_TAGS.has(tag)) { - i += 1; - continue; - } - break; - } - return locs.length === 4 ? { locs, nextIndex: i } : null; -} - -function walkElements(nodes, fn) { - for (const node of nodes) { - if (node.nodeType !== Node.ELEMENT_NODE) continue; - fn(node); - walkElements(childElements(node), fn); - } -} - -function headLocations(el) { - return parseElementHeadAt([...el.childNodes], 0)?.locs ?? []; -} - -/** @returns {{ elementId: string, box: object } | null} */ -function findNearestLocatedBox(el, elementIds, boxById) { - let node = el; - while (node) { - const elementId = elementIds.get(node); - const box = elementId ? boxById.get(elementId) : null; - if (box) return { elementId, box }; - if (localName(node) === "doclang") break; - node = node.parentElement; - } - return null; -} - -function locationResolution(el, axisDefault) { - const r = parseInt(el.getAttribute("resolution") ?? String(axisDefault), 10); - return Number.isFinite(r) && r > 0 ? r : axisDefault; -} - -function headingLevel(el) { - return Math.min(Math.max(parseInt(el.getAttribute("level") ?? "1", 10) || 1, 1), 6); -} - -function elementLabel(el) { - if (isVirtualTextOverlayUnit(el)) return "text"; - const tag = localName(el); - if (tag === "heading" || tag === "field_heading") return `${tag}[${headingLevel(el)}]`; - const level = el.getAttribute("level"); - if (level) return `${tag}[${level}]`; - const cls = el.getAttribute("class"); - if (cls) return `${tag}.${cls}`; - return tag; -} - -function elementKindKey(kind) { - if (kind.startsWith("field_") || kind === "key" || kind === "value" || kind === "hint") return "field"; - if (kind === "tabular") return "table"; - const known = new Set([ - "text", "heading", "list", "ldiv", "table", "index", "formula", "code", "picture", - "group", "footnote", "page_header", "page_footer", "caption", - ]); - return known.has(kind) ? kind : "default"; -} - -function kindClassForTag(tag) { - return `kind-${elementKindKey(tag)}`; -} - -function bboxClassForKind(kind) { - return elementKindKey(kind); -} - -function boxPixelRect(b, img) { - const x = (b.x0 / b.resW) * img.naturalWidth; - const y = (b.y0 / b.resH) * img.naturalHeight; - const w = ((b.x1 - b.x0) / b.resW) * img.naturalWidth; - const h = ((b.y1 - b.y0) / b.resH) * img.naturalHeight; - return { x, y, w, h, area: w * h }; -} - -function overlayBoxPaintPriority(box) { - if (box.kind === "text") return 0; - if (box.kind === "list" || box.kind === "table" || box.kind === "index" || box.kind === "tabular") { - return 2; - } - return 1; -} - -function overlayLayerPriority(layer) { - if (layer === "background") return 0; - if (layer === "furniture") return 1; - return 2; -} - -function compareOverlayBoxPaintOrder(a, b) { - const byLayer = overlayLayerPriority(a.layer ?? "body") - overlayLayerPriority(b.layer ?? "body"); - if (byLayer !== 0) return byLayer; - const byPriority = overlayBoxPaintPriority(a) - overlayBoxPaintPriority(b); - if (byPriority !== 0) return byPriority; - if (selectedElementId) { - const aSelected = a.elementId === selectedElementId; - const bSelected = b.elementId === selectedElementId; - if (aSelected !== bSelected) return aSelected ? 1 : -1; - } - return 0; -} - -function sortedOverlayBoxes(boxes) { - return [...boxes].sort(compareOverlayBoxPaintOrder); -} - -function boxCenter(rect) { - return { x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 }; -} - -function ensureArrowMarker(defs, markerId) { - if (defs.querySelector(`#${markerId}`)) return; - const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker"); - marker.setAttribute("id", markerId); - marker.setAttribute("viewBox", "0 0 6 6"); - marker.setAttribute("refX", "6"); - marker.setAttribute("refY", "3"); - marker.setAttribute("markerWidth", "5"); - marker.setAttribute("markerHeight", "5"); - marker.setAttribute("orient", "auto"); - const arrowPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); - arrowPath.setAttribute("d", "M0,0 L6,3 L0,6 Z"); - arrowPath.setAttribute("fill", "currentColor"); - marker.appendChild(arrowPath); - defs.appendChild(marker); -} - -function alignDashedLineToEnd(line, start, end) { - const dash = 6; - const gap = 4; - const period = dash + gap; - const len = Math.hypot(end.x - start.x, end.y - start.y); - if (!len) return; - line.setAttribute("stroke-dasharray", `${dash} ${gap}`); - const offset = len % period; - if (offset > 0.01) line.setAttribute("stroke-dashoffset", String(offset)); -} - -function ensureLayerHatchPatterns(defs) { - if (defs.querySelector("#layer-hatch")) return; - - const pattern = document.createElementNS("http://www.w3.org/2000/svg", "pattern"); - pattern.setAttribute("id", "layer-hatch"); - pattern.setAttribute("width", "16"); - pattern.setAttribute("height", "16"); - pattern.setAttribute("patternUnits", "userSpaceOnUse"); - pattern.setAttribute("patternTransform", "rotate(45 8 8)"); - const stripe = document.createElementNS("http://www.w3.org/2000/svg", "rect"); - stripe.setAttribute("class", "layer-hatch-stripe"); - stripe.setAttribute("x", "10"); - stripe.setAttribute("y", "-4"); - stripe.setAttribute("width", "6"); - stripe.setAttribute("height", "24"); - pattern.appendChild(stripe); - defs.appendChild(pattern); -} - -function ensureOverlayDefs(svg) { - let defs = svg.querySelector("defs"); - if (!defs) { - defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); - svg.insertBefore(defs, svg.firstChild); - } - return defs; -} - -function appendOverlayLinks(svg, img, links, { markerId, linkClass, fromIdAttr, toIdAttr }) { - if (!links.length) return; - - const defs = ensureOverlayDefs(svg); - ensureArrowMarker(defs, markerId); - - for (const link of links) { - const from = boxPixelRect(link.fromBox ?? link.captionBox, img); - const to = boxPixelRect(link.toBox ?? link.hostBox, img); - const start = boxCenter(from); - const end = boxCenter(to); - - const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); - line.setAttribute("class", linkClass); - line.setAttribute("x1", String(start.x)); - line.setAttribute("y1", String(start.y)); - line.setAttribute("x2", String(end.x)); - line.setAttribute("y2", String(end.y)); - line.setAttribute("marker-end", `url(#${markerId})`); - line.setAttribute(fromIdAttr, link.fromElementId ?? link.captionElementId); - line.setAttribute(toIdAttr, link.toElementId ?? link.hostElementId); - svg.appendChild(line); - } -} - -function appendCaptionLinks(svg, img, captionLinks) { - appendOverlayLinks(svg, img, captionLinks, { - markerId: "caption-arrowhead", - linkClass: "caption-link", - fromIdAttr: "data-caption-id", - toIdAttr: "data-host-id", - }); -} - -function appendXrefLinks(svg, img, xrefLinks) { - appendOverlayLinks(svg, img, xrefLinks, { - markerId: "xref-arrowhead", - linkClass: "xref-link", - fromIdAttr: "data-xref-from-id", - toIdAttr: "data-xref-to-id", - }); -} - -function docPointToPixel(xDoc, yDoc, resW, resH, img) { - return { - x: (xDoc / resW) * img.naturalWidth, - y: (yDoc / resH) * img.naturalHeight, - }; -} - -function pageCornerTarget(img, defaultResolution, corner) { - const { width: resW, height: resH } = defaultResolution; - const inset = overlayUserLength(5); - const tl = docPointToPixel(0, 0, resW, resH, img); - const br = docPointToPixel(resW, resH, resW, resH, img); - if (corner === "tl") { - return { - x: Math.min(tl.x + inset, br.x - inset), - y: Math.min(tl.y + inset, br.y - inset), - }; - } - return { - x: Math.max(br.x - inset, tl.x + inset), - y: Math.max(br.y - inset, tl.y + inset), - }; -} - -function elementThreadId(el) { - return firstHeadChild(el, "thread")?.getAttribute("thread_id") ?? null; -} - -/** @returns {Set} */ -function fragmentPeerElementIds(elementId) { - const peers = new Set(); - if (!elementId || !state?.elementIds || !state.idToElement) return peers; - const el = state.idToElement.get(elementId); - const threadId = el ? elementThreadId(el) : null; - if (!threadId) return peers; - for (const [node, id] of state.elementIds) { - if (elementThreadId(node) === threadId) peers.add(id); - } - return peers; -} - -function isFragmentLinkRelevant(linkEl, peerIds) { - const fromId = linkEl.getAttribute("data-fragment-from-id"); - const toId = linkEl.getAttribute("data-fragment-to-id"); - if (fromId && peerIds.has(fromId)) return true; - if (toId && peerIds.has(toId)) return true; - return false; -} - -function appendFragmentLinks(svg, img, links, defaultResolution) { - if (!links.length) return; - - const defs = ensureOverlayDefs(svg); - ensureArrowMarker(defs, "fragment-arrowhead"); - const fontSize = overlayUserLength(OVERLAY_BADGE_FONT_SIZE); - - for (const link of links) { - const fromRect = boxPixelRect(link.fromBox, img); - - const group = document.createElementNS("http://www.w3.org/2000/svg", "g"); - group.setAttribute("class", "fragment-link"); - group.setAttribute("data-thread-id", link.threadId); - group.setAttribute("data-fragment-from-id", link.fromElementId); - if (link.toElementId) group.setAttribute("data-fragment-to-id", link.toElementId); - - let labelAt; - if (link.toBox) { - const toRect = boxPixelRect(link.toBox, img); - const start = boxCenter(fromRect); - const end = boxCenter(toRect); - - const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); - line.setAttribute("class", "fragment-link-path fragment-link-path-dashed"); - line.setAttribute("x1", String(start.x)); - line.setAttribute("y1", String(start.y)); - line.setAttribute("x2", String(end.x)); - line.setAttribute("y2", String(end.y)); - line.setAttribute("marker-end", "url(#fragment-arrowhead)"); - alignDashedLineToEnd(line, start, end); - group.appendChild(line); - labelAt = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; - } else { - const corner = link.targetCorner ?? "br"; - const cornerPoint = pageCornerTarget(img, defaultResolution, corner); - const elementAnchor = boxCenter(fromRect); - const incoming = corner === "tl"; - const start = incoming ? cornerPoint : elementAnchor; - const end = incoming ? elementAnchor : cornerPoint; - - const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); - line.setAttribute("class", "fragment-link-path fragment-link-path-dashed"); - line.setAttribute("x1", String(start.x)); - line.setAttribute("y1", String(start.y)); - line.setAttribute("x2", String(end.x)); - line.setAttribute("y2", String(end.y)); - line.setAttribute("marker-end", "url(#fragment-arrowhead)"); - alignDashedLineToEnd(line, start, end); - group.appendChild(line); - labelAt = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; - } - - const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); - text.setAttribute("class", "fragment-link-label"); - text.setAttribute("x", String(labelAt.x)); - text.setAttribute("y", String(labelAt.y)); - text.setAttribute("font-size", String(fontSize)); - text.textContent = link.toBox ? FRAGMENT_LINK_LABEL_SAME_PAGE : FRAGMENT_LINK_LABEL_CROSS_PAGE; - group.appendChild(text); - - svg.appendChild(group); - } -} - -function appendFragmentNavButtons(svg, img, items) { - if (!items.length) return; - - const btnSize = overlayUserLength(13 * 1.5); - const gap = overlayUserLength(2); - const inset = overlayUserLength(3); - const fontSize = overlayUserLength(10 * 1.5); - const radius = overlayUserLength(2 * 1.5); - - for (const item of items) { - const { x, y, w, h } = boxPixelRect(item.box, img); - const group = document.createElementNS("http://www.w3.org/2000/svg", "g"); - group.setAttribute("class", "fragment-nav"); - group.setAttribute("data-element-id", item.elementId); - - const rowY = y + h - inset - btnSize; - const nextX = x + w - inset - btnSize; - const prevX = nextX - gap - btnSize; - - appendFragmentNavButton(group, prevX, rowY, btnSize, radius, fontSize, "prev", "‹", item.hasPrev); - appendFragmentNavButton(group, nextX, rowY, btnSize, radius, fontSize, "next", "›", item.hasNext); - - svg.appendChild(group); - } -} - -function appendFragmentNavButton(group, x, y, size, radius, fontSize, direction, label, enabled) { - const btn = document.createElementNS("http://www.w3.org/2000/svg", "g"); - btn.setAttribute("class", `fragment-nav-btn fragment-nav-btn-${direction}${enabled ? "" : " fragment-nav-btn-disabled"}`); - btn.setAttribute("data-nav", direction); - if (enabled) { - btn.setAttribute("role", "button"); - btn.setAttribute( - "aria-label", - direction === "prev" ? FRAGMENT_NAV_HINT_PREV : FRAGMENT_NAV_HINT_NEXT, - ); - } - - const bg = document.createElementNS("http://www.w3.org/2000/svg", "rect"); - bg.setAttribute("class", "fragment-nav-btn-bg"); - bg.setAttribute("x", String(x)); - bg.setAttribute("y", String(y)); - bg.setAttribute("width", String(size)); - bg.setAttribute("height", String(size)); - bg.setAttribute("rx", String(radius)); - bg.setAttribute("ry", String(radius)); - btn.appendChild(bg); - - const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); - text.setAttribute("class", "fragment-nav-btn-label"); - text.setAttribute("x", String(x + size / 2)); - text.setAttribute("y", String(y + size / 2)); - text.setAttribute("font-size", String(fontSize)); - text.textContent = label; - btn.appendChild(text); - - group.appendChild(btn); -} - -function appendReadingOrderOverlay(svg, img, steps) { - if (!steps.length) return; - - if (steps.length >= 2) { - const defs = ensureOverlayDefs(svg); - ensureArrowMarker(defs, "reading-order-arrowhead"); - - for (let i = 0; i < steps.length - 1; i += 1) { - const from = boxPixelRect(steps[i].box, img); - const to = boxPixelRect(steps[i + 1].box, img); - const start = boxCenter(from); - const end = boxCenter(to); - - const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); - line.setAttribute("class", "reading-order-step"); - line.setAttribute("x1", String(start.x)); - line.setAttribute("y1", String(start.y)); - line.setAttribute("x2", String(end.x)); - line.setAttribute("y2", String(end.y)); - line.setAttribute("marker-end", "url(#reading-order-arrowhead)"); - svg.appendChild(line); - } - } -} - -function imageCoordsFromEvent(svg, evt) { - const pt = svg.createSVGPoint(); - pt.x = evt.clientX; - pt.y = evt.clientY; - const ctm = svg.getScreenCTM()?.inverse(); - if (!ctm) return null; - const p = pt.matrixTransform(ctm); - return { x: p.x, y: p.y }; -} - -function hitTestBoxes(boxes, img, x, y) { - let best = null; - let bestArea = Infinity; - for (const b of boxes) { - const { x: bx, y: by, w, h, area } = boxPixelRect(b, img); - if (x >= bx && x <= bx + w && y >= by && y <= by + h && area < bestArea) { - best = b; - bestArea = area; - } - } - return best; -} - -function buildOverlay(img, boxes, captionLinks = [], xrefLinks = [], readingOrderSteps = [], fragmentLinks = [], fragmentNavItems = [], defaultResolution = { width: 512, height: 512 }) { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.classList.add("overlay"); - svg.setAttribute("viewBox", `0 0 ${img.naturalWidth} ${img.naturalHeight}`); - svg.setAttribute("overflow", "hidden"); - - appendCaptionLinks(svg, img, captionLinks); - appendXrefLinks(svg, img, xrefLinks); - appendFragmentLinks(svg, img, fragmentLinks, defaultResolution); - appendReadingOrderOverlay(svg, img, readingOrderSteps); - - const defs = ensureOverlayDefs(svg); - ensureLayerHatchPatterns(defs); - - for (const b of sortedOverlayBoxes(boxes)) { - const { x, y, w, h } = boxPixelRect(b, img); - const cls = bboxClassForKind(b.kind); - const kindClass = kindClassForTag(b.kind); - const layerClass = layerClassForValue(b.layer ?? "body"); - - const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); - rect.setAttribute( - "class", - `bbox bbox-${cls} ${kindClass}${layerClass ? ` ${layerClass}` : ""}`, - ); - rect.setAttribute("x", String(x)); - rect.setAttribute("y", String(y)); - rect.setAttribute("width", String(Math.max(w, 1))); - rect.setAttribute("height", String(Math.max(h, 1))); - rect.setAttribute("data-element-id", b.elementId); - svg.appendChild(rect); - } - - appendFragmentNavButtons(svg, img, fragmentNavItems); - - svg.addEventListener("click", (e) => { - if (pagePanSuppressClick) { - pagePanSuppressClick = false; - return; - } - const navBtn = e.target.closest(".fragment-nav-btn:not(.fragment-nav-btn-disabled)"); - if (navBtn) { - e.stopPropagation(); - const navGroup = navBtn.closest(".fragment-nav"); - const elementId = navGroup?.getAttribute("data-element-id"); - const direction = navBtn.getAttribute("data-nav"); - if (elementId && direction) navigateThreadFragment(elementId, direction); - return; - } - const badge = e.target.closest(".overlay-badge[data-element-id]"); - if (badge) { - const elementId = resolveSelectionElementId(badge.getAttribute("data-element-id")); - if (elementId) selectElement(elementId); - return; - } - const coords = imageCoordsFromEvent(svg, e); - if (!coords) return; - const hit = hitTestBoxes(boxes, img, coords.x, coords.y); - if (hit) selectElement(hit.elementId); - else clearSelection(); - }); - - svg.addEventListener("mousemove", (e) => { - const coords = imageCoordsFromEvent(svg, e); - svg.style.cursor = coords && hitTestBoxes(boxes, img, coords.x, coords.y) ? "pointer" : ""; - }); - svg.addEventListener("mouseleave", () => { - svg.style.cursor = ""; - }); - - return svg; -} - -function selectElement(elementId) { - if (!elementId) return; - selectedElementId = elementId; - applySelection(); -} - -function clearSelection() { - selectedElementId = null; - applySelection(); -} - -function isPictureContentOverlayElement(elementId) { - return isPictureContentElement(state?.idToElement?.get(elementId) ?? null); -} - -function isTableContentOverlayElement(elementId) { - return isTableContentElement(state?.idToElement?.get(elementId) ?? null); -} - -function isContentsOptionHidden(elementId, clickVisible) { - if (clickVisible) return false; - if (!showPictureContents && isPictureContentOverlayElement(elementId)) return true; - if (!showTableContents && isTableContentOverlayElement(elementId)) return true; - return false; -} - -function applyBboxVisibility() { - if (!state?.hasPageView || !els.pagePane) return; - - const peerIds = selectedElementId ? fragmentPeerElementIds(selectedElementId) : new Set(); - - for (const el of els.pagePane.querySelectorAll(".bbox")) { - el.classList.remove("related"); - const elementId = el.getAttribute("data-element-id"); - const clickVisible = elementId === selectedElementId || peerIds.has(elementId); - if (showAllBboxes) { - if (isContentsOptionHidden(elementId, clickVisible)) { - el.classList.add("bbox-hidden"); - } else { - el.classList.remove("bbox-hidden"); - if (peerIds.has(elementId)) { - el.classList.add("related"); - } - } - continue; - } - if (elementId === selectedElementId) { - el.classList.remove("bbox-hidden"); - } else if (peerIds.has(elementId)) { - el.classList.remove("bbox-hidden"); - el.classList.add("related"); - } else { - el.classList.add("bbox-hidden"); - } - } - - for (const el of els.pagePane.querySelectorAll(".element-badge")) { - const elementId = el.getAttribute("data-element-id"); - const clickVisible = elementId === selectedElementId || peerIds.has(elementId); - if (!showAllBboxes || !showLayoutBadges) { - el.classList.add("bbox-hidden"); - continue; - } - if (isContentsOptionHidden(elementId, clickVisible)) { - el.classList.add("bbox-hidden"); - } else { - el.classList.remove("bbox-hidden"); - } - } - - for (const el of els.pagePane.querySelectorAll(".caption-link")) { - if (!showAllBboxes || !showCaptionLinks) { - el.classList.add("bbox-hidden"); - continue; - } - el.classList.remove("bbox-hidden"); - } - - for (const el of els.pagePane.querySelectorAll(".xref-link")) { - if (!showAllBboxes || !showXrefLinks) { - el.classList.add("bbox-hidden"); - continue; - } - el.classList.remove("bbox-hidden"); - } - - for (const el of els.pagePane.querySelectorAll(".fragment-link")) { - const clickVisible = Boolean(selectedElementId && isFragmentLinkRelevant(el, peerIds)); - const optionVisible = showAllBboxes && showFragmentLinks; - el.classList.toggle("bbox-hidden", !(clickVisible || optionVisible)); - } - - for (const el of els.pagePane.querySelectorAll(".fragment-nav")) { - const elementId = el.getAttribute("data-element-id"); - const clickVisible = elementId === selectedElementId || peerIds.has(elementId); - const optionVisible = showAllBboxes && showFragmentLinks; - el.classList.toggle("bbox-hidden", !(clickVisible || optionVisible)); - } - - for (const el of els.pagePane.querySelectorAll(".reading-order-badge")) { - const elementId = el.getAttribute("data-element-id"); - const clickVisible = elementId === selectedElementId || peerIds.has(elementId); - if (!showAllBboxes || !showReadingOrder) { - el.classList.add("bbox-hidden"); - continue; - } - if ( - isPictureContentOverlayElement(elementId) - || isTableContentOverlayElement(elementId) - || isContentsOptionHidden(elementId, clickVisible) - ) { - el.classList.add("bbox-hidden"); - continue; - } - el.classList.remove("bbox-hidden"); - } - - for (const el of els.pagePane.querySelectorAll(".reading-order-step")) { - if (!showAllBboxes || !showReadingOrder || !showReadingOrderArrows) { - el.classList.add("bbox-hidden"); - continue; - } - el.classList.remove("bbox-hidden"); - } -} - -function findMarkupElementForSelection(elementId) { - if (!els.markupPane) return null; - return ( - els.markupPane.querySelector(`.markup-el-virtual-text[data-element-id="${elementId}"]`) || - els.markupPane.querySelector(`[data-element-id="${elementId}"]`) - ); -} - -function findRenderedElementForSelection(elementId) { - if (!els.renderedPane) return null; - const direct = - els.renderedPane.querySelector(`.rendered-el-virtual-text[data-element-id="${elementId}"]`) || - els.renderedPane.querySelector(`.rendered-el[data-element-id="${elementId}"]`); - if (direct) return direct; - - const xmlEl = state?.idToElement?.get(elementId); - const threadId = xmlEl ? elementThreadId(xmlEl) : null; - if (!threadId) return null; - - const merged = els.renderedPane.querySelector( - `.rendered-fragment-merged[data-thread-id="${threadId}"]`, - ); - if (!merged) return null; - - const primaryId = merged.getAttribute("data-element-id"); - if (!primaryId || primaryId === elementId) return merged; - return fragmentPeerElementIds(primaryId).has(elementId) ? merged : null; -} - -function revealRenderedSelectionContext(renderedEl) { - const pictureContents = renderedEl.closest(".rendered-picture-contents"); - if (pictureContents && !pictureContents.open) { - pictureContents.open = true; - } - revealReadingLayerForSelection(renderedEl); -} - -function revealReadingLayerForSelection(renderedEl) { - const layer = renderedEl.getAttribute("data-doclang-layer"); - if (!layer || layer === "body") return; - let changed = false; - if (layer === "furniture" && !showReadingFurniture) { - showReadingFurniture = true; - changed = true; - } else if (layer === "background" && !showReadingBackground) { - showReadingBackground = true; - changed = true; - } - if (!changed) return; - syncReadingLayerCheckboxes(); - syncReadingLayerVisibility(); -} - -function applySelection() { - els.markupPane?.querySelectorAll(".markup-el.selected").forEach((el) => { - el.classList.remove("selected"); - }); - els.renderedPane?.querySelectorAll(".rendered-el.selected").forEach((el) => { - el.classList.remove("selected"); - }); - if (!els.pagePane) return; - els.pagePane.querySelectorAll(".bbox.selected, .overlay-badge.selected").forEach((el) => { - el.classList.remove("selected"); - }); - - if (!selectedElementId) { - const img = els.pagePane.querySelector(".page-view img"); - if (img) syncOverlayBadges(img); - applyBboxVisibility(); - return; - } - - const markupEl = findMarkupElementForSelection(selectedElementId); - if (markupEl) { - markupEl.classList.add("selected"); - markupEl.scrollIntoView({ block: "nearest", behavior: "smooth" }); - } - - const renderedEl = findRenderedElementForSelection(selectedElementId); - if (renderedEl) { - revealRenderedSelectionContext(renderedEl); - renderedEl.classList.add("selected"); - renderedEl.scrollIntoView({ block: "nearest", behavior: "smooth" }); - } - - if (state?.hasPageView) { - for (const el of els.pagePane.querySelectorAll(`[data-element-id="${selectedElementId}"]`)) { - el.classList.add("selected"); - } - } - - const img = els.pagePane.querySelector(".page-view img"); - if (img) syncOverlayBadges(img); - applyBboxVisibility(); -} - -function buildMarkupView(segment, elementIds) { - const root = document.createElement("div"); - root.className = "markup"; - for (const el of segment) { - if (el.nodeType === Node.ELEMENT_NODE) { - root.appendChild(buildMarkupElement(el, 0, elementIds)); - } - } - root.addEventListener("click", (e) => { - const attrToggle = e.target.closest(".xml-attr-value-chip"); - if (attrToggle) { - e.stopPropagation(); - toggleTruncatableMarkupAttrValue(attrToggle); - return; - } - const toggle = e.target.closest(".markup-fold-toggle"); - if (toggle) { - e.stopPropagation(); - const block = toggle.closest(".markup-el-foldable"); - if (block) { - const collapsed = block.classList.toggle("markup-collapsed"); - toggle.setAttribute("aria-expanded", String(!collapsed)); - toggle.setAttribute("aria-label", collapsed ? "Expand" : "Collapse"); - } - return; - } - const elementId = resolveMarkupClickTarget(e.target); - if (elementId) selectElement(elementId); - }); - return root; -} - -function sliceHasMarkupContent(nodes) { - for (const node of nodes) { - if (isTextLikeNode(node) && !isWhitespaceOnlyText(node)) return true; - if (node.nodeType === Node.ELEMENT_NODE) return true; - } - return false; -} - -function isVirtualTextSkippableNode(node) { - if (isWhitespaceOnlyText(node)) return true; - if (node.nodeType !== Node.ELEMENT_NODE) return false; - const tag = localName(node); - return tag === "location" || HEAD_TAGS.has(tag); -} - -function shouldWrapVirtualText(contentNodes) { - if (!sliceHasMarkupContent(contentNodes)) return false; - - for (const node of contentNodes) { - if (isVirtualTextSkippableNode(node)) continue; - if (isTextLikeNode(node)) return true; - if (node.nodeType === Node.ELEMENT_NODE && !isSemanticElement(node)) return true; - } - return false; -} - -function appendRenderedSliceContent(container, hostEl, contentNodes, elementIds) { - if (!sliceHasMarkupContent(contentNodes)) return; - if (shouldWrapVirtualText(contentNodes)) { - container.appendChild(renderVirtualTextBlock(hostEl, contentNodes, elementIds)); - return; - } - for (const node of contentNodes) { - if (isVirtualTextSkippableNode(node)) continue; - if (node.nodeType === Node.ELEMENT_NODE && RENDER_BLOCK_TAGS.has(localName(node))) { - const rendered = renderBlockElement(node, elementIds, { inline: false }); - if (rendered) container.appendChild(rendered); - } else { - appendRenderedNode(container, node, elementIds, { inline: true }); - } - } -} - -function appendMarkupNodesFromSlice(parent, depth, nodes, elementIds) { - for (const child of nodes) { - if (isTextLikeNode(child)) { - const text = formatMarkupTextNode(child); - if (text) appendMarkupTextLine(parent, depth, text); - } else if (child.nodeType === Node.ELEMENT_NODE) { - parent.appendChild(buildMarkupElement(child, depth, elementIds)); - } - } -} - -function appendMarkupVirtualText(parent, depth, hostEl, contentNodes, elementIds) { - if (!shouldWrapVirtualText(contentNodes)) { - appendMarkupNodesFromSlice(parent, depth, contentNodes, elementIds); - return; - } - - const block = document.createElement("div"); - block.className = "markup-el markup-el-virtual-text"; - const elementId = elementIds.get(hostEl); - if (elementId) block.setAttribute("data-element-id", elementId); - - appendMarkupFoldableOpen(block, depth, "text", [], VIRTUAL_TEXT_TAG_HINT); - block.classList.add("markup-el-foldable"); - - const foldBody = document.createElement("div"); - foldBody.className = "markup-fold-body"; - const children = document.createElement("div"); - children.className = "markup-children"; - appendMarkupNodesFromSlice(children, depth + 1, contentNodes, elementIds); - foldBody.appendChild(children); - appendMarkupCloseTag(foldBody, depth, "text", VIRTUAL_TEXT_TAG_HINT); - block.appendChild(foldBody); - parent.appendChild(block); -} - -function buildMarkupFoldableElement(el, depth, elementIds, buildBody) { - const tag = localName(el); - const block = document.createElement("div"); - block.className = "markup-el"; - const elementId = elementIds.get(el); - if (elementId) block.setAttribute("data-element-id", elementId); - - const attributes = markupAttributes(el); - appendMarkupFoldableOpen(block, depth, tag, attributes); - block.classList.add("markup-el-foldable"); - - const foldBody = document.createElement("div"); - foldBody.className = "markup-fold-body"; - const children = document.createElement("div"); - children.className = "markup-children"; - buildBody(children, depth + 1); - foldBody.appendChild(children); - appendMarkupCloseTag(foldBody, depth, tag); - block.appendChild(foldBody); - return block; -} - -function buildMarkupList(el, depth, elementIds) { - return buildMarkupFoldableElement(el, depth, elementIds, (children, childDepth) => { - const nodes = [...el.childNodes]; - let i = 0; - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType === Node.ELEMENT_NODE && localName(node) === "ldiv") break; - if (isTextLikeNode(node) && isWhitespaceOnlyText(node)) { - i += 1; - continue; - } - if (isTextLikeNode(node)) break; - if (node.nodeType === Node.ELEMENT_NODE) { - const tag = localName(node); - if (HEAD_TAGS.has(tag) || tag === "location") { - children.appendChild(buildMarkupElement(node, childDepth, elementIds)); - i += 1; - continue; - } - } - break; - } - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType !== Node.ELEMENT_NODE || localName(node) !== "ldiv") { - appendMarkupNodesFromSlice(children, childDepth, [node], elementIds); - i += 1; - continue; - } - const ldiv = node; - children.appendChild(buildMarkupElement(ldiv, childDepth, elementIds)); - i += 1; - const end = skipUntilListItemBoundary(nodes, i); - appendMarkupVirtualText(children, childDepth, ldiv, nodes.slice(i, end), elementIds); - i = end; - } - }); -} - -function buildMarkupOtslContainer(el, depth, elementIds) { - return buildMarkupFoldableElement(el, depth, elementIds, (children, childDepth) => { - const nodes = [...el.childNodes]; - let i = 0; - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType === Node.ELEMENT_NODE && isCellToken(localName(node))) break; - if (isTextLikeNode(node) && isWhitespaceOnlyText(node)) { - i += 1; - continue; - } - if (isTextLikeNode(node)) break; - if (node.nodeType === Node.ELEMENT_NODE) { - const tag = localName(node); - if (HEAD_TAGS.has(tag) || tag === "location" || tag === "h_thread") { - children.appendChild(buildMarkupElement(node, childDepth, elementIds)); - i += 1; - continue; - } - } - break; - } - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType !== Node.ELEMENT_NODE) { - appendMarkupNodesFromSlice(children, childDepth, [node], elementIds); - i += 1; - continue; - } - const tag = localName(node); - if (tag === "nl") { - appendMarkupSelfClosingTag(children, childDepth, "nl", []); - i += 1; - continue; - } - if (!isCellToken(tag)) { - appendMarkupNodesFromSlice(children, childDepth, [node], elementIds); - i += 1; - continue; - } - const cell = node; - children.appendChild(buildMarkupElement(cell, childDepth, elementIds)); - i += 1; - if (CELL_SPAN_TAGS.has(tag)) continue; - const end = skipUntilCellBoundary(nodes, i); - appendMarkupVirtualText(children, childDepth, cell, nodes.slice(i, end), elementIds); - i = end; - } - }); -} - -function buildMarkupElement(el, depth, elementIds) { - const tag = localName(el); - if (tag === "list") return buildMarkupList(el, depth, elementIds); - if (OTSL_CONTAINER_TAGS.has(tag)) return buildMarkupOtslContainer(el, depth, elementIds); - - const block = document.createElement("div"); - block.className = "markup-el"; - const elementId = elementIds.get(el); - if (elementId) block.setAttribute("data-element-id", elementId); - - const attributes = markupAttributes(el); - - if (!el.childNodes.length) { - appendMarkupSelfClosingTag(block, depth, tag, attributes); - return block; - } - - const meaningfulText = [...el.childNodes].filter((n) => isTextLikeNode(n) && !isWhitespaceOnlyText(n)); - const textOnly = meaningfulText.length > 0 && meaningfulText.every(isTextLikeNode) && !childElements(el).length; - if (textOnly) { - const text = serializeMarkupTextNodes(el.childNodes); - if (text) { - appendMarkupInlineElement(block, depth, tag, attributes, text); - return block; - } - } - - appendMarkupFoldableOpen(block, depth, tag, attributes); - block.classList.add("markup-el-foldable"); - - const foldBody = document.createElement("div"); - foldBody.className = "markup-fold-body"; - const children = document.createElement("div"); - children.className = "markup-children"; - for (const child of el.childNodes) { - if (isTextLikeNode(child)) { - const text = formatMarkupTextNode(child); - if (text) appendMarkupTextLine(children, depth + 1, text); - } else if (child.nodeType === Node.ELEMENT_NODE) { - children.appendChild(buildMarkupElement(child, depth + 1, elementIds)); - } - } - foldBody.appendChild(children); - appendMarkupCloseTag(foldBody, depth, tag); - block.appendChild(foldBody); - return block; -} - -/** @returns {Map} */ -function collectIntraPageThreads(segment) { - /** @type {Map} */ - const byThread = new Map(); - for (const el of segment) { - if (el.nodeType !== Node.ELEMENT_NODE) continue; - if (localName(el) === "page_break") continue; - const threadId = elementThreadId(el); - if (!threadId) continue; - if (!byThread.has(threadId)) byThread.set(threadId, []); - byThread.get(threadId).push(el); - } - for (const [threadId, members] of byThread) { - if (members.length < 2) byThread.delete(threadId); - } - return byThread; -} - -function findLastTextNode(node) { - if (isTextLikeNode(node)) return node; - if (node.nodeType !== Node.ELEMENT_NODE) return null; - for (let i = node.childNodes.length - 1; i >= 0; i -= 1) { - const found = findLastTextNode(node.childNodes[i]); - if (found) return found; - } - return null; -} - -function trimParentTrailingForFragmentJoin(parent) { - const lastText = findLastTextNode(parent); - if (!lastText) return; - let value = lastText.textContent ?? ""; - value = value.replace(/\s+$/u, ""); - if (value.endsWith("-")) value = value.slice(0, -1); - if (!value) { - lastText.parentNode?.removeChild(lastText); - trimParentTrailingForFragmentJoin(parent); - return; - } - lastText.textContent = value; -} - -function appendMergedTextFragments(parent, fragments, elementIds) { - for (let i = 0; i < fragments.length; i += 1) { - if (i > 0) trimParentTrailingForFragmentJoin(parent); - appendRenderedBody(parent, fragments[i], elementIds, { - inline: true, - trimLeading: i > 0, - }); - } -} - -function renderMergedIntraPageFragments(fragments, elementIds) { - const first = fragments[0]; - const tag = localName(first); - const firstId = elementIds.get(first); - const threadId = elementThreadId(first); - - let node; - if (tag === "text") { - node = document.createElement("p"); - appendMergedTextFragments(node, fragments, elementIds); - } else if (tag === "list") { - const listClass = first.getAttribute("class") ?? "unordered"; - node = document.createElement(listClass === "ordered" ? "ol" : "ul"); - for (const el of fragments) { - appendListItemsFromElement(node, el, elementIds); - } - } else { - node = document.createElement("div"); - node.className = "rendered-fragment-merged-body"; - for (const el of fragments) { - appendRenderedBodyBlocks(node, el, elementIds); - } - } - - const wrap = wrapRendered(first, node, firstId, "rendered-fragment-merged"); - if (threadId) wrap.setAttribute("data-thread-id", threadId); - return wrap; -} - -function buildRenderedView(segment, elementIds) { - const intraPageThreads = collectIntraPageThreads(segment); - const skipElements = new Set(); - /** @type {Map} */ - const mergeGroups = new Map(); - - for (const [, members] of intraPageThreads) { - mergeGroups.set(members[0], members); - for (let i = 1; i < members.length; i += 1) { - skipElements.add(members[i]); - } - } - - const root = document.createElement("article"); - root.className = "rendered-doc"; - for (const el of segment) { - if (el.nodeType !== Node.ELEMENT_NODE) continue; - if (localName(el) === "page_break") continue; - if (skipElements.has(el)) continue; - - const mergeGroup = mergeGroups.get(el); - const rendered = mergeGroup - ? renderMergedIntraPageFragments(mergeGroup, elementIds) - : renderBlockElement(el, elementIds, { inline: false }); - if (rendered) root.appendChild(rendered); - } - root.addEventListener("click", (e) => { - const elementId = resolveRenderedClickTarget(e.target); - if (elementId) selectElement(elementId); - }); - applyReadingLayerClasses(root); - return root; -} - -function wrapRendered(el, node, elementId, extraClass) { - const tag = localName(el); - const wrap = document.createElement("div"); - wrap.className = `rendered-el rendered-${tag}${extraClass ? ` ${extraClass}` : ""}`; - if (elementId) wrap.setAttribute("data-element-id", elementId); - applyElementLayerAttr(el, wrap); - wrap.appendChild(node); - return wrap; -} - -function renderBlockElement(el, elementIds, ctx) { - const tag = localName(el); - const elementId = elementIds.get(el); - - switch (tag) { - case "text": { - const p = document.createElement("p"); - appendRenderedBody(p, el, elementIds, { inline: true }); - return wrapRendered(el, p, elementId); - } - case "heading": { - const h = document.createElement(`h${headingLevel(el)}`); - appendRenderedBody(h, el, elementIds, { inline: true }); - return wrapRendered(el, h, elementId); - } - case "field_heading": { - const h = document.createElement(`h${headingLevel(el)}`); - h.className = "rendered-field-heading"; - appendRenderedBody(h, el, elementIds, { inline: true }); - return wrapRendered(el, h, elementId); - } - case "footnote": { - const aside = document.createElement("aside"); - appendRenderedBody(aside, el, elementIds, { inline: false }); - return wrapRendered(el, aside, elementId); - } - case "page_header": { - const header = document.createElement("header"); - header.className = "rendered-page-header"; - appendRenderedBody(header, el, elementIds, { inline: true }); - return wrapRendered(el, header, elementId); - } - case "page_footer": { - const footer = document.createElement("footer"); - footer.className = "rendered-page-footer"; - appendRenderedBody(footer, el, elementIds, { inline: true }); - return wrapRendered(el, footer, elementId); - } - case "list": - return renderList(el, elementIds); - case "table": - case "index": - case "tabular": - return renderOtslContainer(el, elementIds); - case "code": - return renderCode(el, elementIds, ctx); - case "formula": - return renderFormula(el, elementIds, ctx); - case "picture": - return renderPicture(el, elementIds); - case "group": { - const figure = document.createElement("figure"); - figure.className = "rendered-group"; - appendRenderedBodyBlocks(figure, el, elementIds); - const captionEl = readCaptionElement(el); - if (captionEl) { - figure.appendChild(renderEmbeddedCaption(captionEl, elementIds, "figcaption")); - } - return wrapRendered(el, figure, elementId); - } - case "field_region": { - const div = document.createElement("div"); - div.className = "rendered-field-region"; - appendRenderedBodyBlocks(div, el, elementIds); - return wrapRendered(el, div, elementId); - } - case "field_item": { - const div = document.createElement("div"); - div.className = "rendered-field-item"; - appendRenderedBodyBlocks(div, el, elementIds); - return wrapRendered(el, div, elementId); - } - default: - return renderUnsupported(el, elementIds); - } -} - -function renderUnsupported(el, elementIds) { - const stub = document.createElement("div"); - stub.className = "rendered-unsupported"; - stub.textContent = `<${localName(el)}> — not yet rendered`; - return wrapRendered(el, stub, elementIds.get(el)); -} - -function skipElementHeadNodes(nodes, startIdx) { - let i = startIdx; - while (i < nodes.length && isWhitespaceOnlyText(nodes[i])) i += 1; - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType === Node.ELEMENT_NODE && HEAD_TAGS.has(localName(node))) { - i += 1; - while (i < nodes.length && isWhitespaceOnlyText(nodes[i])) i += 1; - continue; - } - break; - } - return i; -} - -function readCaptionElement(el) { - return childElements(el).find((c) => localName(c) === "caption") ?? null; -} - -function renderEmbeddedCaption(captionEl, elementIds, tagName) { - const node = document.createElement(tagName); - node.classList.add("rendered-el", "rendered-caption"); - const elementId = elementIds.get(captionEl); - if (elementId) node.setAttribute("data-element-id", elementId); - appendRenderedBody(node, captionEl, elementIds, { inline: true }); - return node; -} - -function appendRenderedBody(parent, el, elementIds, ctx) { - const nodes = [...el.childNodes]; - let i = skipElementHeadNodes(nodes, 0); - while (i < nodes.length) { - appendRenderedNode(parent, nodes[i], elementIds, ctx); - i += 1; - } -} - -function appendRenderedBodyBlocks(parent, el, elementIds) { - const nodes = [...el.childNodes]; - let i = skipElementHeadNodes(nodes, 0); - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType === Node.ELEMENT_NODE && RENDER_BLOCK_TAGS.has(localName(node))) { - const rendered = renderBlockElement(node, elementIds, { inline: false }); - if (rendered) parent.appendChild(rendered); - } else { - appendRenderedNode(parent, node, elementIds, { inline: false }); - } - i += 1; - } -} - -function renderMarkerElement(el, elementIds, ctx) { - const marker = document.createElement("span"); - marker.className = "rendered-marker rendered-el"; - const elementId = elementIds.get(el); - if (elementId) marker.setAttribute("data-element-id", elementId); - appendRenderedBody(marker, el, elementIds, ctx); - return marker; -} - -function renderCheckboxElement(el, elementIds) { - const cb = document.createElement("input"); - cb.type = "checkbox"; - cb.disabled = true; - cb.checked = (el.getAttribute("class") ?? "unselected") === "selected"; - cb.className = "rendered-checkbox"; - - const wrap = document.createElement("span"); - wrap.className = "rendered-checkbox-wrap rendered-el"; - const elementId = elementIds.get(el); - if (elementId) wrap.setAttribute("data-element-id", elementId); - applyElementLayerAttr(el, wrap); - wrap.appendChild(cb); - return wrap; -} - -function renderFieldKeyElement(el, elementIds, ctx) { - const node = document.createElement("span"); - node.className = "rendered-field-key rendered-el"; - const elementId = elementIds.get(el); - if (elementId) node.setAttribute("data-element-id", elementId); - applyElementLayerAttr(el, node); - appendRenderedBody(node, el, elementIds, { ...ctx, inline: true }); - return node; -} - -function renderFieldValueElement(el, elementIds, ctx) { - const valueClass = el.getAttribute("class") ?? "read_only"; - const node = document.createElement("span"); - node.className = `rendered-field-value rendered-field-value-${valueClass} rendered-el`; - const elementId = elementIds.get(el); - if (elementId) node.setAttribute("data-element-id", elementId); - applyElementLayerAttr(el, node); - appendRenderedBody(node, el, elementIds, { ...ctx, inline: true }); - if ( - valueClass === "fillable" - && !node.textContent.trim() - && !node.querySelector(".rendered-checkbox-wrap, img, .rendered-marker") - ) { - const slot = document.createElement("span"); - slot.className = "rendered-field-fillable-slot"; - slot.setAttribute("aria-hidden", "true"); - node.appendChild(slot); - } - return node; -} - -function renderFieldHintElement(el, elementIds, ctx) { - const node = document.createElement("span"); - node.className = "rendered-field-hint rendered-el"; - const elementId = elementIds.get(el); - if (elementId) node.setAttribute("data-element-id", elementId); - applyElementLayerAttr(el, node); - appendRenderedBody(node, el, elementIds, { ...ctx, inline: true }); - return node; -} - -function appendRenderedNode(parent, node, elementIds, ctx) { - if (isTextLikeNode(node)) { - let text = node.textContent; - if (!text || !text.trim()) return; - if (ctx.trimLeading) { - text = text.replace(/^\s+/u, ""); - ctx.trimLeading = false; - if (!text) return; - } - parent.appendChild(document.createTextNode(text)); - return; - } - if (node.nodeType !== Node.ELEMENT_NODE) return; - - const tag = localName(node); - if (HEAD_TAGS.has(tag)) return; - - if (RENDER_FORMAT_TAGS.has(tag)) { - parent.appendChild(renderFormatElement(node, elementIds, ctx)); - return; - } - - if (tag === "code" || tag === "formula") { - const rendered = renderBlockElement(node, elementIds, { inline: true }); - if (rendered) parent.appendChild(rendered); - return; - } - - if (RENDER_BLOCK_TAGS.has(tag)) { - const rendered = renderBlockElement(node, elementIds, ctx); - if (rendered) parent.appendChild(rendered); - return; - } - - if (tag === "marker") { - parent.appendChild(renderMarkerElement(node, elementIds, ctx)); - return; - } - - if (tag === "checkbox") { - parent.appendChild(renderCheckboxElement(node, elementIds)); - return; - } - - if (tag === "key") { - parent.appendChild(renderFieldKeyElement(node, elementIds, ctx)); - return; - } - - if (tag === "value") { - parent.appendChild(renderFieldValueElement(node, elementIds, ctx)); - return; - } - - if (tag === "hint") { - parent.appendChild(renderFieldHintElement(node, elementIds, ctx)); - return; - } - - if (tag === "ldiv") return; - if (isCellToken(tag) || tag === "src" || tag === "tabular") return; - - appendRenderedBody(parent, node, elementIds, ctx); -} - -function renderFormatElement(el, elementIds, ctx) { - const tag = localName(el); - if (tag === "content") { - const span = document.createElement("span"); - span.textContent = el.textContent ?? ""; - return span; - } - - let node; - if (tag === "handwriting") { - node = document.createElement("span"); - node.className = "rendered-handwriting"; - } else if (tag === "rtl") { - node = document.createElement("bdi"); - node.setAttribute("dir", "rtl"); - } else { - node = document.createElement(FORMAT_HTML_TAG[tag] ?? "span"); - } - - appendRenderedBody(node, el, elementIds, ctx); - return node; -} - -function renderCode(el, elementIds, ctx) { - const labelEl = childElements(el).find((c) => localName(c) === "label"); - const labelValue = labelEl?.getAttribute("value"); - - const code = document.createElement("code"); - appendRenderedBody(code, el, elementIds, { inline: ctx.inline }); - - if (ctx.inline) { - code.classList.add("rendered-el"); - const id = elementIds.get(el); - if (id) code.setAttribute("data-element-id", id); - return code; - } - - const pre = document.createElement("pre"); - if (labelValue && labelValue !== "undefined") { - const label = document.createElement("span"); - label.className = "rendered-code-label"; - label.textContent = labelValue; - pre.appendChild(label); - } - pre.appendChild(code); - return wrapRendered(el, pre, elementIds.get(el)); -} - -function renderFormula(el, elementIds, ctx) { - const span = document.createElement("span"); - span.className = ctx.inline ? "rendered-formula-inline" : "rendered-formula"; - appendRenderedBody(span, el, elementIds, { inline: true }); - if (ctx.inline) { - span.classList.add("rendered-el"); - const id = elementIds.get(el); - if (id) span.setAttribute("data-element-id", id); - return span; - } - return wrapRendered(el, span, elementIds.get(el)); -} - -function markPictureUnavailable(img) { - img.classList.add("rendered-picture-unavailable"); - img.alt = "\u00A0"; - img.setAttribute("aria-label", PICTURE_UNAVAILABLE_ALT); -} - -function appendPictureFigureImage(figure, uri, captionEl, elementIds) { - const img = document.createElement("img"); - figure.appendChild(img); - - const resolved = uri ? resolveArchiveUri(uri) : null; - if (resolved) { - img.alt = ""; - img.src = resolved; - img.addEventListener("error", () => markPictureUnavailable(img), { once: true }); - } else { - markPictureUnavailable(img); - img.src = INVALID_PICTURE_SRC; - } - - if (captionEl) { - figure.appendChild(renderEmbeddedCaption(captionEl, elementIds, "figcaption")); - } -} - -function renderPicture(el, elementIds) { - const figure = document.createElement("figure"); - const captionEl = readCaptionElement(el); - const srcEl = childElements(el).find((c) => localName(c) === "src") ?? null; - const uri = srcEl?.getAttribute("uri")?.trim() || null; - - appendPictureFigureImage(figure, uri, captionEl, elementIds); - - const nodes = [...el.childNodes]; - let i = skipElementHeadNodes(nodes, 0); - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType !== Node.ELEMENT_NODE) { - i += 1; - continue; - } - const tag = localName(node); - if (tag === "src") { - i += 1; - continue; - } - if (tag === "tabular") { - const rendered = renderOtslContainer(node, elementIds); - if (rendered) figure.appendChild(rendered); - i += 1; - continue; - } - break; - } - - const bodyInner = document.createElement("div"); - bodyInner.className = "rendered-picture-contents-body"; - appendPictureBodyContent(bodyInner, nodes, i, elementIds); - if (bodyInner.textContent.trim()) { - const details = document.createElement("details"); - details.className = "rendered-picture-contents"; - const summary = document.createElement("summary"); - summary.textContent = "Picture contents"; - details.appendChild(summary); - details.appendChild(bodyInner); - figure.appendChild(details); - } - - return wrapRendered(el, figure, elementIds.get(el)); -} - -function appendPictureBodyContent(container, nodes, startIdx, elementIds) { - let i = startIdx; - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType === Node.ELEMENT_NODE && RENDER_BLOCK_TAGS.has(localName(node))) { - const rendered = renderBlockElement(node, elementIds, { inline: false }); - if (rendered) container.appendChild(rendered); - } else { - appendRenderedNode(container, node, elementIds, { inline: false }); - } - i += 1; - } -} - -function renderVirtualTextBlock(hostEl, contentNodes, elementIds) { - const hasBlock = contentNodes.some( - (n) => n.nodeType === Node.ELEMENT_NODE && RENDER_BLOCK_TAGS.has(localName(n)), - ); - const inner = document.createElement(hasBlock ? "div" : "p"); - for (const node of contentNodes) { - if (node.nodeType === Node.ELEMENT_NODE && RENDER_BLOCK_TAGS.has(localName(node))) { - const rendered = renderBlockElement(node, elementIds, { inline: false }); - if (rendered) inner.appendChild(rendered); - } else { - appendRenderedNode(inner, node, elementIds, { inline: true }); - } - } - const wrap = document.createElement("div"); - wrap.className = "rendered-el rendered-text rendered-el-virtual-text"; - const elementId = elementIds.get(hostEl); - if (elementId) wrap.setAttribute("data-element-id", elementId); - applyElementLayerAttr(hostEl, wrap); - wrap.appendChild(inner); - return wrap; -} - -function appendListItemsFromElement(list, el, elementIds) { - const nodes = [...el.childNodes]; - let i = skipContainerLevelHead(nodes, 0); - - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType !== Node.ELEMENT_NODE || localName(node) !== "ldiv") { - i += 1; - continue; - } - - const ldiv = node; - i += 1; - const li = document.createElement("li"); - - for (const child of childElements(ldiv)) { - const childTag = localName(child); - if (childTag === "marker") { - li.appendChild(renderMarkerElement(child, elementIds, { inline: true })); - } else if (childTag === "checkbox") { - li.appendChild(renderCheckboxElement(child, elementIds)); - } - } - - const contentStart = i; - const head = parseElementHeadAt(nodes, i); - if (head) i = head.nextIndex; - - while (i < nodes.length) { - const contentNode = nodes[i]; - if (contentNode.nodeType === Node.ELEMENT_NODE && localName(contentNode) === "ldiv") break; - i += 1; - } - - const contentNodes = nodes.slice(contentStart, i); - appendRenderedSliceContent(li, ldiv, contentNodes, elementIds); - - list.appendChild(li); - } -} - -function renderList(el, elementIds) { - const listClass = el.getAttribute("class") ?? "unordered"; - const list = document.createElement(listClass === "ordered" ? "ol" : "ul"); - appendListItemsFromElement(list, el, elementIds); - return wrapRendered(el, list, elementIds.get(el)); -} - -function isHeaderCellKind(kind) { - return kind === "ched" || kind === "rhed" || kind === "corn" || kind === "srow"; -} - -function skipOtslContainerHead(nodes, startIdx) { - let i = startIdx; - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType !== Node.ELEMENT_NODE) { - i += 1; - continue; - } - const tag = localName(node); - if (isCellToken(tag)) break; - if (HEAD_TAGS.has(tag) || tag === "h_thread" || tag === "location") { - i += 1; - continue; - } - break; - } - return i; -} - -/** @returns {{ kind: string, token: Element, contentNodes: Node[] }[][]} */ -function parseOtslRows(container) { - const nodes = [...container.childNodes]; - let i = skipOtslContainerHead(nodes, 0); - /** @type {{ kind: string, token: Element, contentNodes: Node[] }[][]} */ - const rows = []; - /** @type {{ kind: string, token: Element, contentNodes: Node[] }[]} */ - let currentRow = []; - - while (i < nodes.length) { - const node = nodes[i]; - if (node.nodeType !== Node.ELEMENT_NODE) { - i += 1; - continue; - } - const tag = localName(node); - if (tag === "nl") { - rows.push(currentRow); - currentRow = []; - i += 1; - continue; - } - if (!isCellToken(tag)) { - i += 1; - continue; - } - - if (CELL_SPAN_TAGS.has(tag)) { - currentRow.push({ kind: tag, token: node, contentNodes: [] }); - i += 1; - continue; - } - - i += 1; - const head = parseElementHeadAt(nodes, i); - if (head) i = head.nextIndex; - const contentStart = i; - i = skipUntilCellBoundary(nodes, i); - currentRow.push({ kind: tag, token: node, contentNodes: nodes.slice(contentStart, i) }); - } - - if (currentRow.length) rows.push(currentRow); - return rows; -} - -function findVerticalCellOrigin(grid, row, col) { - for (let r = row - 1; r >= 0; r -= 1) { - const cell = grid[r]?.[col]; - if (!cell || cell.covered) continue; - return { cell, row: r, col }; - } - return null; -} - -function findHorizontalCellOrigin(grid, row, col) { - for (let c = col - 1; c >= 0; c -= 1) { - const cell = grid[row]?.[c]; - if (!cell || cell.covered) continue; - return { cell, row, col: c }; - } - return null; -} - -function nextFreeColumn(grid, row, col) { - let c = col; - while (grid[row]?.[c]?.covered) c += 1; - return c; -} - -/** @returns {{ kind: string, token: Element, contentNodes: Node[], colspan: number, rowspan: number, covered?: boolean }[][]} */ -function buildOtslGrid(rows) { - /** @type {{ kind: string, token: Element, contentNodes: Node[], colspan: number, rowspan: number, covered?: boolean }[][]} */ - const grid = []; - - for (let rowIdx = 0; rowIdx < rows.length; rowIdx += 1) { - if (!grid[rowIdx]) grid[rowIdx] = []; - let col = 0; - - for (const parsed of rows[rowIdx]) { - col = nextFreeColumn(grid, rowIdx, col); - - if (parsed.kind === "lcel") { - const origin = findHorizontalCellOrigin(grid, rowIdx, col); - if (origin) origin.cell.colspan += 1; - grid[rowIdx][col] = { kind: "lcel", token: parsed.token, contentNodes: [], colspan: 0, rowspan: 0, covered: true }; - col += 1; - continue; - } - - if (parsed.kind === "ucel") { - const origin = findVerticalCellOrigin(grid, rowIdx, col); - if (origin) origin.cell.rowspan += 1; - grid[rowIdx][col] = { kind: "ucel", token: parsed.token, contentNodes: [], colspan: 0, rowspan: 0, covered: true }; - col += 1; - continue; - } - - if (parsed.kind === "xcel") { - const vOrigin = findVerticalCellOrigin(grid, rowIdx, col); - const hOrigin = findHorizontalCellOrigin(grid, rowIdx, col); - if (vOrigin && hOrigin && vOrigin.cell === hOrigin.cell) { - vOrigin.cell.rowspan += 1; - vOrigin.cell.colspan += 1; - } else { - if (vOrigin) vOrigin.cell.rowspan += 1; - if (hOrigin) hOrigin.cell.colspan += 1; - } - grid[rowIdx][col] = { kind: "xcel", token: parsed.token, contentNodes: [], colspan: 0, rowspan: 0, covered: true }; - col += 1; - continue; - } - - grid[rowIdx][col] = { - kind: parsed.kind, - token: parsed.token, - contentNodes: parsed.contentNodes, - colspan: 1, - rowspan: 1, - covered: false, - }; - col += 1; - } - } - - return grid; -} - -function appendTableCellContent(container, nodes, elementIds, cellToken) { - appendRenderedSliceContent(container, cellToken, nodes, elementIds); -} - -function renderOtslContainer(el, elementIds) { - const table = document.createElement("table"); - table.className = "rendered-table"; - - const captionEl = readCaptionElement(el); - if (captionEl) { - table.appendChild(renderEmbeddedCaption(captionEl, elementIds, "caption")); - } - - const grid = buildOtslGrid(parseOtslRows(el)); - const tbody = document.createElement("tbody"); - - for (const row of grid) { - const tr = document.createElement("tr"); - for (const cell of row) { - if (!cell || cell.covered) continue; - const cellTag = isHeaderCellKind(cell.kind) ? "th" : "td"; - const td = document.createElement(cellTag); - if (cell.colspan > 1) td.colSpan = cell.colspan; - if (cell.rowspan > 1) td.rowSpan = cell.rowspan; - appendTableCellContent(td, cell.contentNodes, elementIds, cell.token); - tr.appendChild(td); - } - if (tr.childNodes.length) tbody.appendChild(tr); - } - - if (tbody.childNodes.length) table.appendChild(tbody); - return wrapRendered(el, table, elementIds.get(el)); -} - -function serializeSegment(segment) { - return segment.map((el) => serializeElement(el, 0)).join("\n"); -} - -function serializeElement(el, depth) { - const pad = " ".repeat(depth); - const tag = localName(el); - const attrs = [...el.attributes] - .filter((a) => a.name !== "xmlns" || a.value !== DOCLANG_NS) - .map((a) => `${a.name}="${a.value}"`) - .join(" "); - const attrStr = attrs ? ` ${attrs}` : ""; - - if (!el.childNodes.length) return `${pad}<${tag}${attrStr}/>`; - - const meaningfulText = [...el.childNodes].filter((n) => isTextLikeNode(n) && !isWhitespaceOnlyText(n)); - const textOnly = meaningfulText.length > 0 && meaningfulText.every(isTextLikeNode) && !childElements(el).length; - if (textOnly) { - const text = serializeMarkupTextNodes(el.childNodes); - if (text) { - return `${pad}<${tag}${attrStr}>${text}`; - } - } - - const parts = [`${pad}<${tag}${attrStr}>`]; - for (const child of el.childNodes) { - if (isTextLikeNode(child)) { - const text = formatMarkupTextNode(child); - if (text) parts.push(" ".repeat(depth + 1) + text); - } else if (child.nodeType === Node.ELEMENT_NODE) { - parts.push(serializeElement(child, depth + 1)); - } - } - parts.push(`${pad}`); - return parts.join("\n"); -} - -function childElements(el) { - return [...el.children]; -} - -function localName(el) { - return el.localName || el.tagName.replace(/^.*:/, ""); -} - -function normalizeArchivePath(path) { - return path.replace(/\\/g, "/").replace(/^\.\//, ""); -} - -function archiveRelativeAssetPath(path) { - const norm = normalizeArchivePath(path); - const idx = norm.indexOf("assets/"); - return idx === -1 ? null : norm.slice(idx); -} - -function mimeFromAssetPath(path) { - const ext = path.split(".").pop()?.toLowerCase() ?? ""; - switch (ext) { - case "png": - return "image/png"; - case "jpg": - case "jpeg": - return "image/jpeg"; - case "webp": - return "image/webp"; - case "gif": - return "image/gif"; - case "svg": - return "image/svg+xml"; - default: - return "application/octet-stream"; - } -} - -/** - * Resolve a picture @uri to a safe img src. - * Only archive asset blob URLs and small raster data: URIs are allowed. - * Remote, protocol-relative, blob:, and unresolved relative URIs are rejected. - * @returns {string | null} - */ -function resolveArchiveUri(uri) { - if (!uri) return null; - const trimmed = uri.trim(); - if (!trimmed) return null; - - if (SAFE_DATA_IMAGE_RE.test(trimmed)) { - return trimmed.length <= MAX_DATA_IMAGE_URI_LENGTH ? trimmed : null; - } - - // Reject any other scheme (http, https, blob, javascript, …) and protocol-relative URLs. - if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed) || trimmed.startsWith("//")) return null; - - return state?.assetUrls?.get(normalizeArchivePath(trimmed)) ?? null; -} - -function revokeDocumentState(docState) { - if (!docState) return; - for (const url of docState.pageImages.values()) { - if (url.startsWith("blob:")) URL.revokeObjectURL(url); - } - for (const url of docState.assetUrls.values()) { - if (url.startsWith("blob:")) URL.revokeObjectURL(url); - } -} - -function revokeArchiveUrls() { - revokeDocumentState(state); -} - -async function extractArchiveFromZipBuffer(buffer) { - const entries = await unzip(buffer); - const markupEntry = findArchiveEntry(entries, "document.xml"); - if (!markupEntry) { - throw new Error("Archive must contain document.xml"); - } - const markupXml = new TextDecoder().decode(markupEntry.data); - const pageImages = new Map(); - const assetUrls = new Map(); - for (const e of entries) { - const m = e.name.match(/^pages\/(\d+)\.(png|jpe?g|webp)$/i); - if (m) { - const ext = m[2].toLowerCase().replace("jpeg", "jpg"); - const mime = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg"; - pageImages.set(Number(m[1]), URL.createObjectURL(new Blob([e.data], { type: mime }))); - continue; - } - if (e.name.startsWith("assets/") && !e.name.endsWith("/")) { - assetUrls.set( - e.name, - URL.createObjectURL(new Blob([e.data], { type: mimeFromAssetPath(e.name) })), - ); - } - } - return { markupXml, pageImages, assetUrls }; -} - -async function unzip(buffer, { shouldExtract } = {}) { - if (typeof DecompressionStream === "undefined") { - throw new Error("ZIP decompression requires a browser with DecompressionStream support"); - } - - const bytes = new Uint8Array(buffer); - const view = new DataView(buffer); - const eocdOffset = findEndOfCentralDirectory(bytes); - const entryCount = view.getUint16(eocdOffset + 10, true); - const centralDirOffset = view.getUint32(eocdOffset + 16, true); - - if (entryCount > ZIP_MAX_ENTRIES) { - throw new Error("ZIP archive has too many entries"); - } - if (centralDirOffset >= bytes.length) { - throw new Error("Invalid ZIP central directory offset"); - } - - /** @type {{ name: string, data: Uint8Array }[]} */ - const entries = []; - let offset = centralDirOffset; - let totalUncompressed = 0; - - for (let i = 0; i < entryCount; i += 1) { - if (offset + 46 > bytes.length || view.getUint32(offset, true) !== 0x02014b50) { - throw new Error("Invalid ZIP central directory"); - } - - const compression = view.getUint16(offset + 10, true); - const compressedSize = view.getUint32(offset + 20, true); - const uncompressedSize = view.getUint32(offset + 24, true); - const nameLength = view.getUint16(offset + 28, true); - const extraLength = view.getUint16(offset + 30, true); - const commentLength = view.getUint16(offset + 32, true); - const localHeaderOffset = view.getUint32(offset + 42, true); - const nameEnd = offset + 46 + nameLength; - if (nameEnd > bytes.length) { - throw new Error("Invalid ZIP entry name"); - } - const rawName = new TextDecoder().decode(bytes.subarray(offset + 46, nameEnd)); - const name = normalizeZipEntryName(rawName); - - offset = nameEnd + extraLength + commentLength; - if (offset > bytes.length) { - throw new Error("Invalid ZIP central directory"); - } - - if (!name || name.endsWith("/") || isIgnoredArchiveEntry(name)) continue; - if (shouldExtract && !shouldExtract(name)) continue; - - if (compressedSize > ZIP_MAX_ENTRY_COMPRESSED_BYTES || uncompressedSize > ZIP_MAX_ENTRY_UNCOMPRESSED_BYTES) { - throw new Error(`ZIP entry exceeds size limit: ${name}`); - } - if ( - compressedSize > 0 && - uncompressedSize > 0 && - uncompressedSize / compressedSize > ZIP_MAX_COMPRESSION_RATIO - ) { - throw new Error(`ZIP entry compression ratio exceeds limit: ${name}`); - } - if (totalUncompressed + uncompressedSize > ZIP_MAX_TOTAL_UNCOMPRESSED_BYTES) { - throw new Error("ZIP archive exceeds total uncompressed size limit"); - } - - if (localHeaderOffset + 30 > bytes.length || view.getUint32(localHeaderOffset, true) !== 0x04034b50) { - throw new Error(`Invalid ZIP local header: ${name}`); - } - const localNameLength = view.getUint16(localHeaderOffset + 26, true); - const localExtraLength = view.getUint16(localHeaderOffset + 28, true); - const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength; - if (dataOffset + compressedSize > bytes.length) { - throw new Error(`ZIP entry data out of bounds: ${name}`); - } - const compressed = bytes.subarray(dataOffset, dataOffset + compressedSize); - const data = await decompressZipEntry(compressed, compression, uncompressedSize); - totalUncompressed += data.length; - if (totalUncompressed > ZIP_MAX_TOTAL_UNCOMPRESSED_BYTES) { - throw new Error("ZIP archive exceeds total uncompressed size limit"); - } - entries.push({ name, data }); - } - - return entries; -} - -function findEndOfCentralDirectory(bytes) { - for (let i = bytes.length - 22; i >= 0; i -= 1) { - if ( - bytes[i] === 0x50 && - bytes[i + 1] === 0x4b && - bytes[i + 2] === 0x05 && - bytes[i + 3] === 0x06 - ) { - return i; - } - } - throw new Error("Invalid ZIP archive"); -} - -function concatUint8Arrays(chunks, totalLength) { - const out = new Uint8Array(totalLength); - let offset = 0; - for (const chunk of chunks) { - out.set(chunk, offset); - offset += chunk.byteLength; - } - return out; -} - -async function decompressZipEntry(data, method, uncompressedSize) { - if (method === 0) { - if (data.length > ZIP_MAX_ENTRY_UNCOMPRESSED_BYTES) { - throw new Error("ZIP entry exceeds size limit"); - } - return data; - } - if (method === 8) { - const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream("deflate-raw")); - const reader = stream.getReader(); - const chunks = []; - let total = 0; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - total += value.byteLength; - if (total > ZIP_MAX_ENTRY_UNCOMPRESSED_BYTES) { - throw new Error("ZIP entry exceeds size limit"); - } - if (data.length > 0 && total / data.length > ZIP_MAX_COMPRESSION_RATIO) { - throw new Error("ZIP entry compression ratio exceeds limit"); - } - chunks.push(value); - } - } catch (err) { - try { - await reader.cancel(); - } catch { - /* ignore cancel errors */ - } - throw err; - } - - const out = concatUint8Arrays(chunks, total); - if (uncompressedSize && out.length !== uncompressedSize) { - return out.slice(0, Math.min(out.length, uncompressedSize)); - } - return out; - } - throw new Error(`Unsupported ZIP compression method ${method}`); -} - -function normalizeZipEntryName(name) { - return name.replace(/\\/g, "/").replace(/^\.\//, ""); -} - -/** Match archive root files by exact name; skip macOS metadata entries. */ -function findArchiveEntry(entries, fileName) { - return entries.find((e) => e.name === fileName); -} - -function isIgnoredArchiveEntry(name) { - if (name === ".DS_Store" || name.endsWith("/.DS_Store")) return true; - return name.split("/").some((part) => part.startsWith("._") || part === "__MACOSX"); -} +(function() { + //#region src/constants.ts + var SUPPORTED_FILE_EXTENSIONS = [".dclx", ".dclg"]; + var OPEN_FILE_HINT = `Open a DocLang file (${SUPPORTED_FILE_EXTENSIONS.join(", ")})`; + var VIRTUAL_TEXT_TAG_HINT = "DocLang virtual ; wrapping tags not included in source"; + var FRAGMENT_LINK_LABEL_CROSS_PAGE = "cross-page content"; + var FRAGMENT_LINK_LABEL_SAME_PAGE = "fragmented content"; + var FRAGMENT_NAV_HINT_PREV = "Previous fragment"; + var FRAGMENT_NAV_HINT_NEXT = "Next fragment"; + var PAGE_IMAGE_RE = /^(\d+)\.(png|jpe?g|webp)$/i; + var NO_MARKUP = "(No markup to be shown.)"; + var NO_IMAGE = "(No page image available.)"; + var FILE_THUMB_PLACEHOLDER_SVG = ``; + var PICTURE_UNAVAILABLE_ALT = "Picture asset not available"; + var INVALID_PICTURE_SRC = "data:image/png;base64,NOT_A_VALID_IMAGE"; + /** Allow small embedded raster data URIs only; remote/blob/other schemes are rejected. */ + var SAFE_DATA_IMAGE_RE = /^data:image\/(png|jpe?g|webp|gif);base64,/i; + var HEAD_TAGS = /* @__PURE__ */ new Set([ + "label", + "thread", + "xref", + "href", + "layer", + "location", + "caption", + "description", + "summary", + "custom" + ]); + var SEMANTIC_TAGS = /* @__PURE__ */ new Set([ + "text", + "heading", + "footnote", + "page_header", + "page_footer", + "field_region", + "list", + "table", + "index", + "formula", + "code", + "picture", + "marker", + "group", + "field_heading", + "field_item", + "key", + "value", + "hint", + "caption", + "page_break" + ]); + var CELL_TOKENS = /* @__PURE__ */ new Set([ + "fcel", + "ecel", + "ched", + "rhed", + "corn", + "srow", + "lcel", + "ucel", + "xcel", + "nl" + ]); + var CELL_CONTENT_TAGS = /* @__PURE__ */ new Set([ + "fcel", + "ecel", + "ched", + "rhed", + "corn", + "srow" + ]); + var CELL_SPAN_TAGS = /* @__PURE__ */ new Set([ + "lcel", + "ucel", + "xcel" + ]); + var OTSL_CONTAINER_TAGS = /* @__PURE__ */ new Set([ + "table", + "index", + "tabular" + ]); + var RENDER_BLOCK_TAGS = /* @__PURE__ */ new Set([ + "text", + "heading", + "field_heading", + "footnote", + "page_header", + "page_footer", + "list", + "code", + "formula", + "picture", + "group", + "field_region", + "field_item", + "table", + "index", + "tabular" + ]); + var RENDER_FORMAT_TAGS = /* @__PURE__ */ new Set([ + "bold", + "italic", + "underline", + "strikethrough", + "superscript", + "subscript", + "handwriting", + "rtl", + "content" + ]); + var FORMAT_HTML_TAG = { + bold: "strong", + italic: "em", + underline: "u", + strikethrough: "s", + superscript: "sup", + subscript: "sub" + }; + var OVERLAY_BADGE_FONT_SIZE = 16.5 * .8; + /** Demo page size; overlay lengths are calibrated to match pre-fix sizing on these images. */ + var OVERLAY_REF_IMAGE_WIDTH = 1224; + var OVERLAY_REF_IMAGE_HEIGHT = 1584; + var LAYOUT_STORAGE_KEY = "doclang-viewer-pane-layout"; + var PANE_MIN_RATIO = .12; + var PANE_KEYS = [ + "file", + "page", + "markup", + "reading" + ]; + var DEFAULT_PANE_RATIOS = [ + 1, + 1, + 1, + 1 + ]; + var DEFAULT_USER_PANE_VISIBLE = { + file: false, + page: true, + markup: true, + reading: true + }; + var LAYOUT_STACK_BREAKPOINT_PX = 1200; + //#endregion + //#region src/xml.ts + function isTextLikeNode(node) { + return node.nodeType === Node.TEXT_NODE || node.nodeType === Node.CDATA_SECTION_NODE; + } + function isWhitespaceOnlyText(node) { + return isTextLikeNode(node) && !node.textContent?.trim(); + } + function markupAttributes(el) { + return [...el.attributes].filter((a) => a.name !== "xmlns" || a.value !== "https://www.doclang.ai/ns/v0").map((a) => ({ + name: a.name, + value: a.value + })); + } + function childElements(el) { + return [...el.children]; + } + function localName(el) { + return el.localName || el.tagName.replace(/^.*:/, ""); + } + function headLocations(el) { + return parseElementHeadAt([...el.childNodes], 0)?.locs ?? []; + } + function parseElementHeadAt(nodes, startIdx) { + const locs = []; + let i = startIdx; + while (i < nodes.length) { + const node = nodes[i]; + if (!node) { + i += 1; + continue; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + i += 1; + continue; + } + const tag = localName(node); + if (tag === "location") { + locs.push(node); + i += 1; + if (locs.length === 4) return { + locs, + nextIndex: i + }; + continue; + } + if (locs.length) break; + if (HEAD_TAGS.has(tag)) { + i += 1; + continue; + } + break; + } + return locs.length === 4 ? { + locs, + nextIndex: i + } : null; + } + function walkElements(nodes, fn) { + for (let i = 0; i < nodes.length; i += 1) { + const node = nodes[i]; + if (!node || node.nodeType !== Node.ELEMENT_NODE) continue; + fn(node); + walkElements(childElements(node), fn); + } + } + function isCellToken(tag) { + return CELL_TOKENS.has(tag); + } + function isListOrOtslContainer(el) { + const tag = localName(el); + return tag === "list" || OTSL_CONTAINER_TAGS.has(tag); + } + function skipContainerLevelHead(nodes, startIdx) { + let i = startIdx; + while (i < nodes.length) { + const node = nodes[i]; + if (!node) { + i += 1; + continue; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + i += 1; + continue; + } + const tag = localName(node); + if (tag === "ldiv" || isCellToken(tag)) break; + if (HEAD_TAGS.has(tag) || tag === "location") { + i += 1; + continue; + } + break; + } + return i; + } + function skipUntilListItemBoundary(nodes, startIdx) { + let i = startIdx; + while (i < nodes.length) { + const node = nodes[i]; + if (node && node.nodeType === Node.ELEMENT_NODE && localName(node) === "ldiv") break; + i += 1; + } + return i; + } + function skipUntilCellBoundary(nodes, startIdx) { + let i = startIdx; + while (i < nodes.length) { + const node = nodes[i]; + if (node && node.nodeType === Node.ELEMENT_NODE && isCellToken(localName(node))) break; + i += 1; + } + return i; + } + function locationResolution(el, axisDefault) { + const r = parseInt(el.getAttribute("resolution") ?? String(axisDefault), 10); + return Number.isFinite(r) && r > 0 ? r : axisDefault; + } + function headingLevel(el) { + return Math.min(Math.max(parseInt(el.getAttribute("level") ?? "1", 10) || 1, 1), 6); + } + function firstHeadChild(el, tag) { + return childElements(el).find((child) => localName(child) === tag) ?? null; + } + function xmlContains(target, ancestor) { + let node = target; + while (node) { + if (node === ancestor) return true; + node = node.parentNode; + } + return false; + } + function formatMarkupTextNode(node) { + if (node.nodeType === Node.CDATA_SECTION_NODE) return ``; + return node.textContent?.trim() ?? ""; + } + function serializeMarkupTextNodes(nodes) { + return Array.from(nodes).filter((n) => isTextLikeNode(n) && !isWhitespaceOnlyText(n)).map(formatMarkupTextNode).join(""); + } + function escapeHtml(text) { + return String(text).replace(/&/g, "&").replace(//g, ">"); + } + function normalizeArchivePath(path) { + return path.replace(/\\/g, "/").replace(/^\.\//, ""); + } + function archiveRelativeAssetPath(path) { + const norm = normalizeArchivePath(path); + const idx = norm.indexOf("assets/"); + return idx === -1 ? null : norm.slice(idx); + } + function skipOtslContainerHead(nodes, startIdx) { + let i = startIdx; + while (i < nodes.length) { + const node = nodes[i]; + if (!node) { + i += 1; + continue; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + i += 1; + continue; + } + const tag = localName(node); + if (isCellToken(tag)) break; + if (HEAD_TAGS.has(tag) || tag === "h_thread" || tag === "location") { + i += 1; + continue; + } + break; + } + return i; + } + function skipElementHeadNodes(nodes, startIdx) { + let i = startIdx; + while (i < nodes.length && isWhitespaceOnlyText(nodes[i])) i += 1; + while (i < nodes.length) { + const node = nodes[i]; + if (node && node.nodeType === Node.ELEMENT_NODE && HEAD_TAGS.has(localName(node))) { + i += 1; + while (i < nodes.length && isWhitespaceOnlyText(nodes[i])) i += 1; + continue; + } + break; + } + return i; + } + function isSemanticElement(el) { + return SEMANTIC_TAGS.has(localName(el)); + } + function isVirtualTextHost(el) { + const tag = localName(el); + return tag === "ldiv" || CELL_CONTENT_TAGS.has(tag); + } + function elementThreadId(el) { + return firstHeadChild(el, "thread")?.getAttribute("thread_id") ?? null; + } + function elementLayer(el) { + const layerEl = firstHeadChild(el, "layer"); + if (layerEl) { + const value = layerEl.getAttribute("value") ?? "body"; + return ELEMENT_LAYERS.has(value) ? value : "body"; + } + if (headLocations(el).length === 4) return "body"; + const parent = el.parentElement; + if (!parent) return "body"; + const nodes = [...parent.childNodes]; + const idx = nodes.indexOf(el); + if (idx < 0) return "body"; + return layerFromHeadNodes(nodes, idx + 1); + } + var ELEMENT_LAYERS = /* @__PURE__ */ new Set([ + "body", + "background", + "furniture" + ]); + function layerFromHeadNodes(nodes, startIdx) { + let i = startIdx; + while (i < nodes.length) { + const node = nodes[i]; + if (!node) { + i += 1; + continue; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + i += 1; + continue; + } + const tag = localName(node); + if (tag === "layer") { + const value = node.getAttribute("value") ?? "body"; + return ELEMENT_LAYERS.has(value) ? value : "body"; + } + if (tag === "location") break; + if (HEAD_TAGS.has(tag)) { + i += 1; + continue; + } + break; + } + return "body"; + } + function layerClassForValue(layer) { + if (layer === "furniture") return "layer-furniture"; + if (layer === "background") return "layer-background"; + return ""; + } + function elementLabel(el) { + if (isVirtualTextOverlayUnit(el)) return "text"; + const tag = localName(el); + if (tag === "heading" || tag === "field_heading") return `${tag}[${headingLevel(el)}]`; + const level = el.getAttribute("level"); + if (level) return `${tag}[${level}]`; + const cls = el.getAttribute("class"); + if (cls) return `${tag}.${cls}`; + return tag; + } + function isVirtualTextOverlayUnit(el) { + if (headLocations(el).length === 4) return false; + if (!hasVirtualTextLocations(el)) return false; + const tag = localName(el); + if (tag === "ldiv" && el.parentElement && localName(el.parentElement) === "list") return true; + if (isCellToken(tag) && tag !== "nl" && !CELL_SPAN_TAGS.has(tag)) return el.parentElement ? OTSL_CONTAINER_TAGS.has(localName(el.parentElement)) : false; + return false; + } + function hasVirtualTextLocations(el) { + const parent = el.parentElement; + if (!parent) return false; + const nodes = [...parent.childNodes]; + const idx = nodes.indexOf(el); + if (idx < 0) return false; + return parseElementHeadAt(nodes, idx + 1) !== null; + } + function virtualTextHeadLocations(el) { + const parent = el.parentElement; + if (!parent) return []; + const nodes = [...parent.childNodes]; + const idx = nodes.indexOf(el); + if (idx < 0) return []; + return parseElementHeadAt(nodes, idx + 1)?.locs ?? []; + } + function elementHeadLocations(el) { + const own = headLocations(el); + return own.length === 4 ? own : virtualTextHeadLocations(el); + } + //#endregion + //#region src/zip.ts + async function unzip(buffer, { shouldExtract } = {}) { + if (typeof DecompressionStream === "undefined") throw new Error("ZIP decompression requires a browser with DecompressionStream support"); + const bytes = new Uint8Array(buffer); + const view = new DataView(buffer); + const eocdOffset = findEndOfCentralDirectory(bytes); + const entryCount = view.getUint16(eocdOffset + 10, true); + const centralDirOffset = view.getUint32(eocdOffset + 16, true); + if (entryCount > 5e3) throw new Error("ZIP archive has too many entries"); + if (centralDirOffset >= bytes.length) throw new Error("Invalid ZIP central directory offset"); + const entries = []; + let offset = centralDirOffset; + let totalUncompressed = 0; + for (let i = 0; i < entryCount; i += 1) { + if (offset + 46 > bytes.length || view.getUint32(offset, true) !== 33639248) throw new Error("Invalid ZIP central directory"); + const compression = view.getUint16(offset + 10, true); + const compressedSize = view.getUint32(offset + 20, true); + const uncompressedSize = view.getUint32(offset + 24, true); + const nameLength = view.getUint16(offset + 28, true); + const extraLength = view.getUint16(offset + 30, true); + const commentLength = view.getUint16(offset + 32, true); + const localHeaderOffset = view.getUint32(offset + 42, true); + const nameEnd = offset + 46 + nameLength; + if (nameEnd > bytes.length) throw new Error("Invalid ZIP entry name"); + const name = normalizeZipEntryName(new TextDecoder().decode(bytes.subarray(offset + 46, nameEnd))); + offset = nameEnd + extraLength + commentLength; + if (offset > bytes.length) throw new Error("Invalid ZIP central directory"); + if (!name || name.endsWith("/") || isIgnoredArchiveEntry(name)) continue; + if (shouldExtract && !shouldExtract(name)) continue; + if (compressedSize > 134217728 || uncompressedSize > 134217728) throw new Error(`ZIP entry exceeds size limit: ${name}`); + if (compressedSize > 0 && uncompressedSize > 0 && uncompressedSize / compressedSize > 100) throw new Error(`ZIP entry compression ratio exceeds limit: ${name}`); + if (totalUncompressed + uncompressedSize > 536870912) throw new Error("ZIP archive exceeds total uncompressed size limit"); + if (localHeaderOffset + 30 > bytes.length || view.getUint32(localHeaderOffset, true) !== 67324752) throw new Error(`Invalid ZIP local header: ${name}`); + const localNameLength = view.getUint16(localHeaderOffset + 26, true); + const localExtraLength = view.getUint16(localHeaderOffset + 28, true); + const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength; + if (dataOffset + compressedSize > bytes.length) throw new Error(`ZIP entry data out of bounds: ${name}`); + const data = await decompressZipEntry(bytes.subarray(dataOffset, dataOffset + compressedSize), compression, uncompressedSize); + totalUncompressed += data.length; + if (totalUncompressed > 536870912) throw new Error("ZIP archive exceeds total uncompressed size limit"); + entries.push({ + name, + data + }); + } + return entries; + } + function findEndOfCentralDirectory(bytes) { + for (let i = bytes.length - 22; i >= 0; i -= 1) if (bytes[i] === 80 && bytes[i + 1] === 75 && bytes[i + 2] === 5 && bytes[i + 3] === 6) return i; + throw new Error("Invalid ZIP archive"); + } + function concatUint8Arrays(chunks, totalLength) { + const out = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; + } + async function decompressZipEntry(data, method, uncompressedSize) { + if (method === 0) { + if (data.length > 134217728) throw new Error("ZIP entry exceeds size limit"); + return data; + } + if (method === 8) { + const reader = new Blob([data]).stream().pipeThrough(new DecompressionStream("deflate-raw")).getReader(); + const chunks = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > 134217728) throw new Error("ZIP entry exceeds size limit"); + if (data.length > 0 && total / data.length > 100) throw new Error("ZIP entry compression ratio exceeds limit"); + chunks.push(value); + } + } catch (err) { + try { + await reader.cancel(); + } catch {} + throw err; + } + const out = concatUint8Arrays(chunks, total); + if (uncompressedSize && out.length !== uncompressedSize) return out.slice(0, Math.min(out.length, uncompressedSize)); + return out; + } + throw new Error(`Unsupported ZIP compression method ${method}`); + } + function normalizeZipEntryName(name) { + return name.replace(/\\/g, "/").replace(/^\.\//, ""); + } + function findArchiveEntry(entries, fileName) { + return entries.find((e) => e.name === fileName); + } + function isIgnoredArchiveEntry(name) { + if (name === ".DS_Store" || name.endsWith("/.DS_Store")) return true; + return name.split("/").some((part) => part.startsWith("._") || part === "__MACOSX"); + } + async function extractArchiveFromZipBuffer(buffer) { + const entries = await unzip(buffer); + const markupEntry = findArchiveEntry(entries, "document.xml"); + if (!markupEntry) throw new Error("Archive must contain document.xml"); + const markupXml = new TextDecoder().decode(markupEntry.data); + const pageImages = /* @__PURE__ */ new Map(); + const assetUrls = /* @__PURE__ */ new Map(); + for (const e of entries) { + const m = e.name.match(/^pages\/(\d+)\.(png|jpe?g|webp)$/i); + if (m) { + const ext = m[2].toLowerCase().replace("jpeg", "jpg"); + const mime = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg"; + pageImages.set(Number(m[1]), URL.createObjectURL(new Blob([e.data], { type: mime }))); + continue; + } + if (e.name.startsWith("assets/") && !e.name.endsWith("/")) assetUrls.set(e.name, URL.createObjectURL(new Blob([e.data], { type: mimeFromAssetPath(e.name) }))); + } + return { + markupXml, + pageImages, + assetUrls + }; + } + function mimeFromAssetPath(path) { + switch (path.split(".").pop()?.toLowerCase() ?? "") { + case "png": return "image/png"; + case "jpg": + case "jpeg": return "image/jpeg"; + case "webp": return "image/webp"; + case "gif": return "image/gif"; + case "svg": return "image/svg+xml"; + default: return "application/octet-stream"; + } + } + //#endregion + //#region src/xml_overlay.ts + function isPictureContentElement(el) { + if (!el) return false; + const tag = localName(el); + if (tag === "picture" || tag === "caption") return false; + let node = el.parentElement; + while (node) { + if (localName(node) === "picture") return true; + node = node.parentElement; + } + return false; + } + function isTableContentElement(el) { + if (!el) return false; + const tag = localName(el); + if (OTSL_CONTAINER_TAGS.has(tag) || tag === "caption") return false; + let node = el.parentElement; + while (node) { + if (OTSL_CONTAINER_TAGS.has(localName(node))) return true; + node = node.parentElement; + } + return false; + } + function elementKindKey(kind) { + if (kind.startsWith("field_") || kind === "key" || kind === "value" || kind === "hint") return "field"; + if (kind === "tabular") return "table"; + return (/* @__PURE__ */ new Set([ + "text", + "heading", + "list", + "ldiv", + "table", + "index", + "formula", + "code", + "picture", + "group", + "footnote", + "page_header", + "page_footer", + "caption" + ])).has(kind) ? kind : "default"; + } + function kindClassForTag(tag) { + return `kind-${elementKindKey(tag)}`; + } + function bboxClassForKind(kind) { + return elementKindKey(kind); + } + //#endregion + //#region src/document.ts + function splitIntoSegments(root) { + const body = childElements(root).filter((el) => localName(el) !== "head"); + const segments = [[]]; + for (const el of body) if (localName(el) === "page_break") segments.push([]); + else segments[segments.length - 1].push(el); + return segments.length ? segments : [[]]; + } + function segmentHasMarkup(segment) { + return segment.some((el) => el.nodeType === Node.ELEMENT_NODE); + } + function readDefaultResolution(head) { + const fallback = { + width: 512, + height: 512 + }; + if (!head) return fallback; + const dr = childElements(head).find((el) => localName(el) === "default_resolution"); + if (!dr) return fallback; + const w = parseInt(dr.getAttribute("width") ?? "512", 10); + const h = parseInt(dr.getAttribute("height") ?? "512", 10); + return { + width: Number.isFinite(w) && w > 0 ? w : 512, + height: Number.isFinite(h) && h > 0 ? h : 512 + }; + } + function assignElementIds(segment) { + const ids = /* @__PURE__ */ new Map(); + let counter = 0; + walkElements(segment, (el) => { + ids.set(el, `el-${counter++}`); + }); + return ids; + } + function invertElementIds(elementIds) { + const idToElement = /* @__PURE__ */ new Map(); + for (const [el, id] of elementIds) idToElement.set(id, el); + return idToElement; + } + function buildThreadsById(docRoot) { + const map = /* @__PURE__ */ new Map(); + walkElements(childElements(docRoot).filter((el) => localName(el) !== "head"), (el) => { + for (const child of childElements(el)) { + if (localName(child) !== "thread") continue; + const threadId = child.getAttribute("thread_id"); + if (!threadId) continue; + if (!map.has(threadId)) map.set(threadId, []); + map.get(threadId).push(el); + } + }); + return map; + } + function buildElementPageMap(segments) { + const elementPage = /* @__PURE__ */ new Map(); + segments.forEach((segment, idx) => { + const pageNum = idx + 1; + walkElements(segment, (el) => elementPage.set(el, pageNum)); + }); + return elementPage; + } + function buildThreadPagesById(docRoot, elementPageByEl) { + const threadPagesById = /* @__PURE__ */ new Map(); + for (const [threadId, elements] of buildThreadsById(docRoot)) { + const pages = /* @__PURE__ */ new Set(); + for (const el of elements) { + const page = elementPageByEl.get(el); + if (page) pages.add(page); + } + if (pages.size) threadPagesById.set(threadId, pages); + } + return threadPagesById; + } + function buildThreadNavByElement(docRoot) { + const nav = /* @__PURE__ */ new Map(); + for (const [, elements] of buildThreadsById(docRoot)) for (let i = 0; i < elements.length; i += 1) nav.set(elements[i], { + prev: i > 0 ? elements[i - 1] : null, + next: i < elements.length - 1 ? elements[i + 1] : null + }); + return nav; + } + function isReadingOrderUnit(el) { + const tag = localName(el); + if (tag === "caption") return true; + if (HEAD_TAGS.has(tag) || tag === "location" || tag === "h_thread" || tag === "page_break") return false; + if (tag === "nl" || CELL_SPAN_TAGS.has(tag)) return false; + if (RENDER_FORMAT_TAGS.has(tag) || tag === "src" || tag === "checkbox") return false; + return true; + } + function computeReadingOrder(docRoot) { + const bodyChildren = childElements(docRoot).filter((el) => localName(el) !== "head"); + const threadsById = buildThreadsById(docRoot); + const consumedViaXref = /* @__PURE__ */ new Set(); + const order = []; + function record(el) { + if (!isReadingOrderUnit(el)) return; + order.push(el); + } + function consumeThread(threadId) { + for (const target of threadsById.get(threadId) ?? []) { + if (consumedViaXref.has(target)) continue; + consumedViaXref.add(target); + visitElement(target); + } + } + function walkChildren(parent) { + for (const child of parent.childNodes) { + if (child.nodeType !== Node.ELEMENT_NODE) continue; + const el = child; + const tag = localName(el); + if (tag === "xref") { + const threadId = el.getAttribute("thread_id"); + if (threadId) consumeThread(threadId); + continue; + } + if (tag === "page_break") continue; + visitElement(el); + } + } + function visitElement(el) { + record(el); + walkChildren(el); + } + for (const el of bodyChildren) { + if (localName(el) === "page_break") continue; + if (consumedViaXref.has(el)) continue; + visitElement(el); + } + return order; + } + function isReadingOrderOverlayUnit(el) { + if (!isReadingOrderUnit(el)) return false; + if (isPictureContentElement(el) || isTableContentElement(el)) return false; + if (headLocations(el).length === 4) return true; + return isVirtualTextOverlayUnit(el); + } + function computeReadingOrderDisplayNumbers(readingOrder) { + const numbers = /* @__PURE__ */ new Map(); + let n = 0; + for (const el of readingOrder) { + if (!isReadingOrderOverlayUnit(el)) continue; + n += 1; + numbers.set(el, n); + } + return numbers; + } + function pushBoundingBox(boxes, locs, defaultResolution, kind, tag, elementId, layer = "body") { + const [x0el, y0el, x1el, y1el] = locs; + const resW = locationResolution(x0el, defaultResolution.width); + const resH = locationResolution(y0el, defaultResolution.height); + boxes.push({ + kind, + tag, + elementId, + layer, + x0: parseInt(x0el.getAttribute("value") ?? "0", 10), + y0: parseInt(y0el.getAttribute("value") ?? "0", 10), + x1: parseInt(x1el.getAttribute("value") ?? "0", 10), + y1: parseInt(y1el.getAttribute("value") ?? "0", 10), + resW, + resH + }); + } + function collectListVirtualTextBoxes(list, defaultResolution, boxes, elementIds) { + const nodes = [...list.childNodes]; + let i = skipContainerLevelHead(nodes, 0); + while (i < nodes.length) { + const node = nodes[i]; + if (node.nodeType !== Node.ELEMENT_NODE || localName(node) !== "ldiv") { + i += 1; + continue; + } + i += 1; + const head = parseElementHeadAt(nodes, i); + if (head) { + const elementId = elementIds.get(node); + if (elementId) pushBoundingBox(boxes, head.locs, defaultResolution, "text", "text", elementId, elementLayer(node)); + i = head.nextIndex; + } + i = skipUntilListItemBoundary(nodes, i); + } + } + function collectTableVirtualTextBoxes(container, defaultResolution, boxes, elementIds) { + const nodes = [...container.childNodes]; + let i = skipContainerLevelHead(nodes, 0); + while (i < nodes.length) { + const node = nodes[i]; + if (node.nodeType !== Node.ELEMENT_NODE) { + i += 1; + continue; + } + const tag = localName(node); + if (!isCellToken(tag)) { + i += 1; + continue; + } + if (tag === "nl") { + i += 1; + continue; + } + i += 1; + const head = parseElementHeadAt(nodes, i); + if (head) { + const elementId = elementIds.get(node); + if (elementId) pushBoundingBox(boxes, head.locs, defaultResolution, "text", "text", elementId, elementLayer(node)); + i = head.nextIndex; + } + i = skipUntilCellBoundary(nodes, i); + } + } + function collectBoundingBoxes(segment, defaultResolution, elementIds) { + const boxes = []; + walkElements(segment, (el) => { + const locs = headLocations(el); + if (locs.length !== 4) return; + const elementId = elementIds.get(el); + if (!elementId) return; + pushBoundingBox(boxes, locs, defaultResolution, localName(el), elementLabel(el), elementId, elementLayer(el)); + }); + walkElements(segment, (el) => { + const tag = localName(el); + if (tag === "list") collectListVirtualTextBoxes(el, defaultResolution, boxes, elementIds); + else if (OTSL_CONTAINER_TAGS.has(tag)) collectTableVirtualTextBoxes(el, defaultResolution, boxes, elementIds); + }); + return boxes; + } + function collectCaptionLinks(segment, elementIds, boxes) { + const boxById = new Map(boxes.map((b) => [b.elementId, b])); + const links = []; + walkElements(segment, (el) => { + if (localName(el) !== "caption") return; + const captionId = elementIds.get(el); + const captionBox = captionId ? boxById.get(captionId) : null; + if (!captionBox) return; + const host = el.parentElement; + if (!host || headLocations(host).length !== 4) return; + const hostId = elementIds.get(host); + const hostBox = hostId ? boxById.get(hostId) : null; + if (!hostBox) return; + links.push({ + captionBox, + hostBox, + captionElementId: captionId, + hostElementId: hostId + }); + }); + return links; + } + function collectXrefLinks(segment, elementIds, boxes) { + const boxById = new Map(boxes.map((b) => [b.elementId, b])); + const threadsById = /* @__PURE__ */ new Map(); + walkElements(segment, (el) => { + const elementId = elementIds.get(el); + const box = elementId ? boxById.get(elementId) : null; + if (!box) return; + for (const thread of childElements(el)) { + if (localName(thread) !== "thread") continue; + const threadId = thread.getAttribute("thread_id"); + if (!threadId) continue; + if (!threadsById.has(threadId)) threadsById.set(threadId, []); + threadsById.get(threadId).push({ + elementId, + box + }); + } + }); + const links = []; + walkElements(segment, (el) => { + const xrefs = childElements(el).filter((c) => localName(c) === "xref"); + if (!xrefs.length) return; + const from = findNearestLocatedBox(el, elementIds, boxById); + if (!from) return; + for (const xref of xrefs) { + const threadId = xref.getAttribute("thread_id"); + if (!threadId) continue; + for (const { elementId: toId, box: toBox } of threadsById.get(threadId) ?? []) { + if (toId === from.elementId) continue; + links.push({ + fromBox: from.box, + toBox, + fromElementId: from.elementId, + toElementId: toId + }); + } + } + }); + return links; + } + function findNearestLocatedBox(el, elementIds, boxById) { + let node = el; + while (node) { + const elementId = elementIds.get(node); + const box = elementId ? boxById.get(elementId) : null; + if (box) return { + elementId, + box + }; + if (localName(node) === "doclang") break; + node = node.parentElement; + } + return null; + } + function collectFragmentLinks(segment, elementIds, boxes, pageNum, threadPagesById) { + const boxById = new Map(boxes.map((b) => [b.elementId, b])); + const onPage = /* @__PURE__ */ new Map(); + walkElements(segment, (el) => { + const threadId = firstHeadChild(el, "thread")?.getAttribute("thread_id"); + if (!threadId) return; + const elementId = elementIds.get(el); + const box = elementId ? boxById.get(elementId) : null; + if (!box) return; + if (!onPage.has(threadId)) onPage.set(threadId, []); + onPage.get(threadId).push({ + elementId, + box + }); + }); + const links = []; + for (const [threadId, members] of onPage) { + if (members.length >= 2) { + for (let i = 0; i < members.length - 1; i += 1) links.push({ + fromBox: members[i].box, + toBox: members[i + 1].box, + fromElementId: members[i].elementId, + toElementId: members[i + 1].elementId, + threadId + }); + continue; + } + if (members.length !== 1) continue; + const threadPages = threadPagesById.get(threadId); + if (!threadPages) continue; + const hasPrevious = [...threadPages].some((p) => p < pageNum); + const hasFollowing = [...threadPages].some((p) => p > pageNum); + if (!hasPrevious && !hasFollowing) continue; + const base = { + fromBox: members[0].box, + toBox: null, + fromElementId: members[0].elementId, + toElementId: null, + threadId + }; + if (hasPrevious) links.push({ + ...base, + targetCorner: "tl" + }); + if (hasFollowing) links.push({ + ...base, + targetCorner: "br" + }); + } + return links; + } + function collectFragmentNavItems(segment, elementIds, boxes, threadNavByElement) { + const boxById = new Map(boxes.map((b) => [b.elementId, b])); + const items = []; + walkElements(segment, (el) => { + const nav = threadNavByElement.get(el); + if (!nav || !nav.prev && !nav.next) return; + const elementId = elementIds.get(el); + const box = elementId ? boxById.get(elementId) : null; + if (!box) return; + items.push({ + elementId, + box, + hasPrev: nav.prev !== null, + hasNext: nav.next !== null + }); + }); + return items; + } + function collectReadingOrderSteps(segment, elementIds, boxes, readingOrder, globalNumbering = true, displayNumbers = null) { + const boxById = new Map(boxes.map((b) => [b.elementId, b])); + const steps = []; + let pageOrder = 0; + readingOrder.forEach((el) => { + if (isPictureContentElement(el) || isTableContentElement(el)) return; + const elementId = elementIds.get(el); + if (!elementId) return; + const box = boxById.get(elementId); + if (!box) return; + pageOrder += 1; + steps.push({ + order: globalNumbering ? displayNumbers?.get(el) ?? pageOrder : pageOrder, + box, + elementId + }); + }); + return steps; + } + function buildDocumentState(markupXml, pageImages, label, assetUrls, { markupOnly }) { + const doc = new DOMParser().parseFromString(markupXml, "application/xml"); + if (doc.querySelector("parsererror")) { + alert(`Invalid XML in ${label}`); + return null; + } + const root = doc.documentElement; + if (localName(root) !== "doclang") { + alert(`${label}: root element must be `); + return null; + } + const defaultResolution = readDefaultResolution(childElements(root).find((el) => localName(el) === "head") ?? null); + const segments = markupOnly ? [childElements(root).filter((el) => localName(el) !== "head")] : splitIntoSegments(root); + const hasPageView = !markupOnly && pageImages.size > 0; + const maxImagePage = hasPageView ? Math.max(...pageImages.keys()) : 0; + const pageCount = markupOnly ? 1 : Math.max(segments.length, maxImagePage, 1); + const readingOrder = computeReadingOrder(root); + const elementPageByEl = buildElementPageMap(segments); + return { + pageImages, + assetUrls, + currentPage: 1, + pageCount, + segments, + defaultResolution, + elementIds: /* @__PURE__ */ new Map(), + idToElement: /* @__PURE__ */ new Map(), + hasPageView, + markupOnly, + docRoot: root, + threadPagesById: buildThreadPagesById(root, elementPageByEl), + elementPageByEl, + threadNavByElement: buildThreadNavByElement(root), + pendingSelectElement: null, + readingOrder, + readingOrderDisplayNumbers: computeReadingOrderDisplayNumbers(readingOrder), + pageViewOverlay: null + }; + } + async function extractArchiveFromFiles(files) { + const markupFile = files.find((f) => f.name === "document.xml"); + if (!markupFile) throw new Error("Archive must contain document.xml at its root."); + const markupXml = await markupFile.text(); + const pageImages = /* @__PURE__ */ new Map(); + const assetUrls = /* @__PURE__ */ new Map(); + for (const f of files) { + const relPath = f.webkitRelativePath || f.name; + const parts = relPath.split("/"); + if (parts.length >= 2 && parts[parts.length - 2] === "pages") { + const m = PAGE_IMAGE_RE.exec(f.name); + if (m) pageImages.set(Number(m[1]), URL.createObjectURL(f)); + } + const assetPath = archiveRelativeAssetPath(relPath); + if (assetPath) assetUrls.set(assetPath, URL.createObjectURL(f)); + } + return { + markupXml, + pageImages, + assetUrls + }; + } + function revokeDocumentState(docState) { + if (!docState) return; + for (const url of docState.pageImages.values()) if (url.startsWith("blob:")) URL.revokeObjectURL(url); + for (const url of docState.assetUrls.values()) if (url.startsWith("blob:")) URL.revokeObjectURL(url); + } + //#endregion + //#region src/markup.ts + function isTruncatableEmbeddedImageUri(value) { + if (!value || value.length <= 30) return false; + return /^(data:image\/|blob:)/i.test(value); + } + function formatCompactByteSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1048576) { + const kb = bytes / 1024; + return kb < 10 ? `${kb.toFixed(1)} KB` : `${Math.round(kb)} KB`; + } + return `${(bytes / 1048576).toFixed(1)} MB`; + } + function formatEmbeddedUriSizeLabel(value) { + if (/^blob:/i.test(value)) return value.length >= 1024 ? `${Math.round(value.length / 1024)} KB URL` : `${value.length} char URL`; + const comma = value.indexOf(","); + if (comma === -1) return "embedded data"; + const header = value.slice(0, comma); + const payload = value.slice(comma + 1).replace(/\s/g, ""); + const mime = /^data:([^;,]+)/i.exec(header)?.[1] ?? ""; + return `${mime.startsWith("image/") ? mime.slice(6) : mime || "data"} · ${formatCompactByteSize(Math.floor(payload.replace(/=+$/, "").length * 3 / 4))}`; + } + function createEmbeddedUriContinuationPanel(value, depth) { + const { line, content } = createMarkupLineRow(depth); + line.className = "markup-line markup-embedded-uri-panel"; + const body = document.createElement("div"); + body.className = "markup-embedded-uri-panel-body"; + body.textContent = value; + body.addEventListener("click", (e) => e.stopPropagation()); + content.appendChild(body); + return line; + } + function createTruncatableMarkupAttrValue(value) { + const wrapper = document.createElement("span"); + wrapper.className = "xml-attr-value xml-attr-value-truncatable"; + wrapper.dataset.fullValue = value; + const text = document.createElement("span"); + text.className = "xml-attr-value-text"; + text.textContent = value.slice(0, 30); + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "xml-attr-value-chip"; + const sizeLabel = formatEmbeddedUriSizeLabel(value); + toggle.dataset.collapsedLabel = sizeLabel; + toggle.setAttribute("aria-expanded", "false"); + toggle.setAttribute("aria-label", `Show full value (${sizeLabel})`); + const label = document.createElement("span"); + label.className = "xml-attr-value-chip-label"; + label.textContent = sizeLabel; + toggle.appendChild(label); + wrapper.append(text, toggle); + return wrapper; + } + function toggleTruncatableMarkupAttrValue(toggle) { + const wrapper = toggle.closest(".xml-attr-value-truncatable"); + if (!wrapper) return; + const markupLine = wrapper.closest(".markup-line"); + const fullValue = wrapper.dataset.fullValue ?? ""; + const label = toggle.querySelector(".xml-attr-value-chip-label"); + const collapsedLabel = toggle.dataset.collapsedLabel ?? label?.textContent ?? ""; + if (toggle.getAttribute("aria-expanded") === "true") { + toggle.setAttribute("aria-expanded", "false"); + toggle.setAttribute("aria-label", `Show full value (${collapsedLabel})`); + if (label) label.textContent = collapsedLabel; + if (markupLine?.nextElementSibling?.classList.contains("markup-embedded-uri-panel")) markupLine.nextElementSibling.remove(); + return; + } + toggle.setAttribute("aria-expanded", "true"); + toggle.setAttribute("aria-label", "Hide full value"); + if (label) label.textContent = "hide"; + if (!markupLine || markupLine.nextElementSibling?.classList.contains("markup-embedded-uri-panel")) return; + const depth = Number(markupLine.style.getPropertyValue("--markup-depth") || 0); + markupLine.insertAdjacentElement("afterend", createEmbeddedUriContinuationPanel(fullValue, depth)); + } + function xmlSpan(className, text, { ghost = false } = {}) { + const span = document.createElement("span"); + span.className = ghost ? `${className} markup-ghost-tag-part` : className; + span.textContent = text; + return span; + } + function createMarkupLine() { + const line = document.createElement("div"); + line.className = "markup-line"; + return line; + } + function createMarkupFoldToggle() { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "markup-fold-toggle"; + btn.setAttribute("aria-expanded", "true"); + btn.setAttribute("aria-label", "Collapse"); + return btn; + } + function createMarkupLineRow(depth, { foldToggle = false } = {}) { + const line = createMarkupLine(); + line.style.setProperty("--markup-depth", String(depth)); + const row = document.createElement("span"); + row.className = "markup-line-content"; + const gutter = document.createElement("span"); + gutter.className = "markup-gutter"; + gutter.setAttribute("aria-hidden", "true"); + if (foldToggle) gutter.appendChild(createMarkupFoldToggle()); + row.appendChild(gutter); + const body = document.createElement("span"); + body.className = "markup-line-body"; + row.appendChild(body); + line.appendChild(row); + return { + line, + content: body + }; + } + function appendMarkupAttrValue(line, value) { + if (isTruncatableEmbeddedImageUri(value)) line.appendChild(createTruncatableMarkupAttrValue(value)); + else line.appendChild(xmlSpan("xml-attr-value", value)); + } + function appendMarkupAttributes(line, attributes) { + for (const { name, value } of attributes) { + line.appendChild(document.createTextNode(" ")); + line.appendChild(xmlSpan("xml-attr-name", name)); + line.appendChild(xmlSpan("xml-bracket", "=\"")); + appendMarkupAttrValue(line, value); + line.appendChild(xmlSpan("xml-bracket", "\"")); + } + } + function appendMarkupTextContent(line, text) { + const cdataMatch = /^$/.exec(text); + if (cdataMatch) { + line.appendChild(xmlSpan("xml-cdata-delimiter", "")); + return; + } + line.appendChild(xmlSpan("xml-text", text)); + } + function appendOpenTagContent(line, tag, attributes, ghost = false) { + line.appendChild(xmlSpan("xml-bracket", "<", { ghost })); + line.appendChild(xmlSpan("xml-tag", tag, { ghost })); + appendMarkupAttributes(line, attributes); + line.appendChild(xmlSpan("xml-bracket", ">", { ghost })); + } + function appendMarkupFoldableOpen(parent, depth, tag, attributes, tagHint) { + const ghost = Boolean(tagHint); + const { line, content } = createMarkupLineRow(depth, { foldToggle: true }); + line.classList.add("markup-line-open"); + appendOpenTagContent(content, tag, attributes, ghost); + const suffix = document.createElement("span"); + suffix.className = "markup-fold-suffix"; + suffix.appendChild(xmlSpan("xml-bracket", "...", { ghost })); + suffix.appendChild(xmlSpan("xml-bracket", "", { ghost })); + content.appendChild(suffix); + parent.appendChild(line); + } + function appendMarkupCloseTag(parent, depth, tag, tagHint) { + const ghost = Boolean(tagHint); + const { line, content } = createMarkupLineRow(depth); + content.appendChild(xmlSpan("xml-bracket", "", { ghost })); + parent.appendChild(line); + } + function appendMarkupSelfClosingTag(parent, depth, tag, attributes) { + const { line, content } = createMarkupLineRow(depth); + content.appendChild(xmlSpan("xml-bracket", "<")); + content.appendChild(xmlSpan("xml-tag", tag)); + appendMarkupAttributes(content, attributes); + content.appendChild(xmlSpan("xml-bracket", "/>")); + parent.appendChild(line); + } + function appendMarkupInlineElement(parent, depth, tag, attributes, text) { + const { line, content } = createMarkupLineRow(depth); + appendOpenTagContent(content, tag, attributes); + appendMarkupTextContent(content, text); + content.appendChild(xmlSpan("xml-bracket", "")); + parent.appendChild(line); + } + function appendMarkupTextLine(parent, depth, text) { + const { line, content } = createMarkupLineRow(depth); + appendMarkupTextContent(content, text); + parent.appendChild(line); + } + function appendMarkupNodesFromSlice(parent, depth, nodes, elementIds) { + for (let i = 0; i < nodes.length; i += 1) { + const child = nodes[i]; + if (!child) continue; + if (isTextLikeNode(child)) { + const text = formatMarkupTextNode(child); + if (text) appendMarkupTextLine(parent, depth, text); + } else if (child.nodeType === Node.ELEMENT_NODE) parent.appendChild(buildMarkupElement(child, depth, elementIds)); + } + } + function appendMarkupVirtualText(parent, depth, hostEl, contentNodes, elementIds) { + if (!shouldWrapVirtualText$1(contentNodes)) { + appendMarkupNodesFromSlice(parent, depth, contentNodes, elementIds); + return; + } + const block = document.createElement("div"); + block.className = "markup-el markup-el-virtual-text"; + const elementId = elementIds.get(hostEl); + if (elementId) block.setAttribute("data-element-id", elementId); + appendMarkupFoldableOpen(block, depth, "text", [], VIRTUAL_TEXT_TAG_HINT); + block.classList.add("markup-el-foldable"); + const foldBody = document.createElement("div"); + foldBody.className = "markup-fold-body"; + const children = document.createElement("div"); + children.className = "markup-children"; + appendMarkupNodesFromSlice(children, depth + 1, contentNodes, elementIds); + foldBody.appendChild(children); + appendMarkupCloseTag(foldBody, depth, "text", VIRTUAL_TEXT_TAG_HINT); + block.appendChild(foldBody); + parent.appendChild(block); + } + function buildMarkupFoldableElement(el, depth, elementIds, buildBody) { + const tag = localName(el); + const block = document.createElement("div"); + block.className = "markup-el"; + const elementId = elementIds.get(el); + if (elementId) block.setAttribute("data-element-id", elementId); + appendMarkupFoldableOpen(block, depth, tag, markupAttributes(el)); + block.classList.add("markup-el-foldable"); + const foldBody = document.createElement("div"); + foldBody.className = "markup-fold-body"; + const children = document.createElement("div"); + children.className = "markup-children"; + buildBody(children, depth + 1); + foldBody.appendChild(children); + appendMarkupCloseTag(foldBody, depth, tag); + block.appendChild(foldBody); + return block; + } + function buildMarkupList(el, depth, elementIds) { + return buildMarkupFoldableElement(el, depth, elementIds, (children, childDepth) => { + const nodes = [...el.childNodes]; + let i = 0; + while (i < nodes.length) { + const node = nodes[i]; + if (!node) { + i += 1; + continue; + } + if (node.nodeType === Node.ELEMENT_NODE && localName(node) === "ldiv") break; + if (isTextLikeNode(node) && isWhitespaceOnlyText(node)) { + i += 1; + continue; + } + if (isTextLikeNode(node)) break; + if (node.nodeType === Node.ELEMENT_NODE) { + const tag = localName(node); + if (HEAD_TAGS.has(tag) || tag === "location") { + children.appendChild(buildMarkupElement(node, childDepth, elementIds)); + i += 1; + continue; + } + } + break; + } + while (i < nodes.length) { + const node = nodes[i]; + if (!node) { + i += 1; + continue; + } + if (node.nodeType !== Node.ELEMENT_NODE || localName(node) !== "ldiv") { + appendMarkupNodesFromSlice(children, childDepth, [node], elementIds); + i += 1; + continue; + } + const ldiv = node; + children.appendChild(buildMarkupElement(ldiv, childDepth, elementIds)); + i += 1; + const end = skipUntilListItemBoundary(nodes, i); + appendMarkupVirtualText(children, childDepth, ldiv, nodes.slice(i, end), elementIds); + i = end; + } + }); + } + function buildMarkupOtslContainer(el, depth, elementIds) { + return buildMarkupFoldableElement(el, depth, elementIds, (children, childDepth) => { + const nodes = [...el.childNodes]; + let i = 0; + while (i < nodes.length) { + const node = nodes[i]; + if (!node) { + i += 1; + continue; + } + if (node.nodeType === Node.ELEMENT_NODE && isCellToken(localName(node))) break; + if (isTextLikeNode(node) && isWhitespaceOnlyText(node)) { + i += 1; + continue; + } + if (isTextLikeNode(node)) break; + if (node.nodeType === Node.ELEMENT_NODE) { + const tag = localName(node); + if (HEAD_TAGS.has(tag) || tag === "location" || tag === "h_thread") { + children.appendChild(buildMarkupElement(node, childDepth, elementIds)); + i += 1; + continue; + } + } + break; + } + while (i < nodes.length) { + const node = nodes[i]; + if (!node) { + i += 1; + continue; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + appendMarkupNodesFromSlice(children, childDepth, [node], elementIds); + i += 1; + continue; + } + const tag = localName(node); + if (tag === "nl") { + appendMarkupSelfClosingTag(children, childDepth, "nl", []); + i += 1; + continue; + } + if (!isCellToken(tag)) { + appendMarkupNodesFromSlice(children, childDepth, [node], elementIds); + i += 1; + continue; + } + const cell = node; + children.appendChild(buildMarkupElement(cell, childDepth, elementIds)); + i += 1; + if (CELL_SPAN_TAGS.has(tag)) continue; + const end = skipUntilCellBoundary(nodes, i); + appendMarkupVirtualText(children, childDepth, cell, nodes.slice(i, end), elementIds); + i = end; + } + }); + } + function buildMarkupElement(el, depth, elementIds) { + const tag = localName(el); + if (tag === "list") return buildMarkupList(el, depth, elementIds); + if (OTSL_CONTAINER_TAGS.has(tag)) return buildMarkupOtslContainer(el, depth, elementIds); + const block = document.createElement("div"); + block.className = "markup-el"; + const elementId = elementIds.get(el); + if (elementId) block.setAttribute("data-element-id", elementId); + const attributes = markupAttributes(el); + if (!el.childNodes.length) { + appendMarkupSelfClosingTag(block, depth, tag, attributes); + return block; + } + const meaningfulText = [...el.childNodes].filter((n) => isTextLikeNode(n) && !isWhitespaceOnlyText(n)); + if (meaningfulText.length > 0 && meaningfulText.every(isTextLikeNode) && !childElements(el).length) { + const text = serializeMarkupTextNodes(el.childNodes); + if (text) { + appendMarkupInlineElement(block, depth, tag, attributes, text); + return block; + } + } + appendMarkupFoldableOpen(block, depth, tag, attributes); + block.classList.add("markup-el-foldable"); + const foldBody = document.createElement("div"); + foldBody.className = "markup-fold-body"; + const children = document.createElement("div"); + children.className = "markup-children"; + for (const child of el.childNodes) if (isTextLikeNode(child)) { + const text = formatMarkupTextNode(child); + if (text) appendMarkupTextLine(children, depth + 1, text); + } else if (child.nodeType === Node.ELEMENT_NODE) children.appendChild(buildMarkupElement(child, depth + 1, elementIds)); + foldBody.appendChild(children); + appendMarkupCloseTag(foldBody, depth, tag); + block.appendChild(foldBody); + return block; + } + function sliceHasMarkupContent$1(nodes) { + for (let i = 0; i < nodes.length; i += 1) { + const node = nodes[i]; + if (!node) continue; + if (isTextLikeNode(node) && !isWhitespaceOnlyText(node)) return true; + if (node.nodeType === Node.ELEMENT_NODE) return true; + } + return false; + } + function isVirtualTextSkippableNode$1(node) { + if (isWhitespaceOnlyText(node)) return true; + if (node.nodeType !== Node.ELEMENT_NODE) return false; + const tag = localName(node); + return tag === "location" || HEAD_TAGS.has(tag); + } + function shouldWrapVirtualText$1(contentNodes) { + if (!sliceHasMarkupContent$1(contentNodes)) return false; + for (const node of contentNodes) { + if (isVirtualTextSkippableNode$1(node)) continue; + if (isTextLikeNode(node)) return true; + if (node.nodeType === Node.ELEMENT_NODE && !isSemanticElement(node)) return true; + } + return false; + } + function buildMarkupView(segment, elementIds, onSelectElement) { + const root = document.createElement("div"); + root.className = "markup"; + for (const el of segment) if (el.nodeType === Node.ELEMENT_NODE) root.appendChild(buildMarkupElement(el, 0, elementIds)); + root.addEventListener("click", (e) => { + const target = e.target; + const attrToggle = target.closest(".xml-attr-value-chip"); + if (attrToggle) { + e.stopPropagation(); + toggleTruncatableMarkupAttrValue(attrToggle); + return; + } + const toggle = target.closest(".markup-fold-toggle"); + if (toggle) { + e.stopPropagation(); + const block = toggle.closest(".markup-el-foldable"); + if (block) { + const collapsed = block.classList.toggle("markup-collapsed"); + toggle.setAttribute("aria-expanded", String(!collapsed)); + toggle.setAttribute("aria-label", collapsed ? "Expand" : "Collapse"); + } + return; + } + const ghostText = target.closest(".markup-el-virtual-text"); + const elementId = ghostText?.hasAttribute("data-element-id") ? ghostText.getAttribute("data-element-id") : target.closest(".markup-el[data-element-id]")?.getAttribute("data-element-id") ?? null; + if (elementId) onSelectElement(elementId); + }); + return root; + } + //#endregion + //#region src/rendered.ts + var getState = () => null; + function setStateAccessor(fn) { + getState = fn; + } + function applyElementLayerAttr(sourceEl, domEl) { + domEl.setAttribute("data-doclang-layer", elementLayer(sourceEl)); + } + function resolveArchiveUri(uri) { + if (!uri) return null; + const trimmed = uri.trim(); + if (!trimmed) return null; + if (SAFE_DATA_IMAGE_RE.test(trimmed)) return trimmed.length <= 2097152 ? trimmed : null; + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed) || trimmed.startsWith("//")) return null; + return getState()?.assetUrls?.get(normalizeArchivePath(trimmed)) ?? null; + } + function markPictureUnavailable(img) { + img.classList.add("rendered-picture-unavailable"); + img.alt = "\xA0"; + img.setAttribute("aria-label", PICTURE_UNAVAILABLE_ALT); + } + function appendPictureFigureImage(figure, uri, captionEl, elementIds) { + const img = document.createElement("img"); + figure.appendChild(img); + const resolved = uri ? resolveArchiveUri(uri) : null; + if (resolved) { + img.alt = ""; + img.src = resolved; + img.addEventListener("error", () => markPictureUnavailable(img), { once: true }); + } else { + markPictureUnavailable(img); + img.src = INVALID_PICTURE_SRC; + } + if (captionEl) figure.appendChild(renderEmbeddedCaption(captionEl, elementIds, "figcaption")); + } + function readCaptionElement(el) { + return childElements(el).find((c) => localName(c) === "caption") ?? null; + } + function renderEmbeddedCaption(captionEl, elementIds, tagName) { + const node = document.createElement(tagName); + node.classList.add("rendered-el", "rendered-caption"); + const elementId = elementIds.get(captionEl); + if (elementId) node.setAttribute("data-element-id", elementId); + appendRenderedBody(node, captionEl, elementIds, { inline: true }); + return node; + } + function wrapRendered(el, node, elementId, extraClass) { + const tag = localName(el); + const wrap = document.createElement("div"); + wrap.className = `rendered-el rendered-${tag}${extraClass ? ` ${extraClass}` : ""}`; + if (elementId) wrap.setAttribute("data-element-id", elementId); + applyElementLayerAttr(el, wrap); + wrap.appendChild(node); + return wrap; + } + function appendRenderedBody(parent, el, elementIds, ctx) { + const nodes = [...el.childNodes]; + let i = skipElementHeadNodes(nodes, 0); + while (i < nodes.length) { + appendRenderedNode(parent, nodes[i], elementIds, ctx); + i += 1; + } + } + function appendRenderedBodyBlocks(parent, el, elementIds) { + const nodes = [...el.childNodes]; + let i = skipElementHeadNodes(nodes, 0); + while (i < nodes.length) { + const node = nodes[i]; + if (node.nodeType === Node.ELEMENT_NODE && RENDER_BLOCK_TAGS.has(localName(node))) { + const rendered = renderBlockElement(node, elementIds, { inline: false }); + if (rendered) parent.appendChild(rendered); + } else appendRenderedNode(parent, node, elementIds, { inline: false }); + i += 1; + } + } + function renderMarkerElement(el, elementIds, ctx) { + const marker = document.createElement("span"); + marker.className = "rendered-marker rendered-el"; + const elementId = elementIds.get(el); + if (elementId) marker.setAttribute("data-element-id", elementId); + appendRenderedBody(marker, el, elementIds, ctx); + return marker; + } + function renderCheckboxElement(el, elementIds) { + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.disabled = true; + cb.checked = (el.getAttribute("class") ?? "unselected") === "selected"; + cb.className = "rendered-checkbox"; + const wrap = document.createElement("span"); + wrap.className = "rendered-checkbox-wrap rendered-el"; + const elementId = elementIds.get(el); + if (elementId) wrap.setAttribute("data-element-id", elementId); + applyElementLayerAttr(el, wrap); + wrap.appendChild(cb); + return wrap; + } + function renderFieldKeyElement(el, elementIds, ctx) { + const node = document.createElement("span"); + node.className = "rendered-field-key rendered-el"; + const elementId = elementIds.get(el); + if (elementId) node.setAttribute("data-element-id", elementId); + applyElementLayerAttr(el, node); + appendRenderedBody(node, el, elementIds, { + ...ctx, + inline: true + }); + return node; + } + function renderFieldValueElement(el, elementIds, ctx) { + const valueClass = el.getAttribute("class") ?? "read_only"; + const node = document.createElement("span"); + node.className = `rendered-field-value rendered-field-value-${valueClass} rendered-el`; + const elementId = elementIds.get(el); + if (elementId) node.setAttribute("data-element-id", elementId); + applyElementLayerAttr(el, node); + appendRenderedBody(node, el, elementIds, { + ...ctx, + inline: true + }); + if (valueClass === "fillable" && !node.textContent?.trim() && !node.querySelector(".rendered-checkbox-wrap, img, .rendered-marker")) { + const slot = document.createElement("span"); + slot.className = "rendered-field-fillable-slot"; + slot.setAttribute("aria-hidden", "true"); + node.appendChild(slot); + } + return node; + } + function renderFieldHintElement(el, elementIds, ctx) { + const node = document.createElement("span"); + node.className = "rendered-field-hint rendered-el"; + const elementId = elementIds.get(el); + if (elementId) node.setAttribute("data-element-id", elementId); + applyElementLayerAttr(el, node); + appendRenderedBody(node, el, elementIds, { + ...ctx, + inline: true + }); + return node; + } + function renderFormatElement(el, elementIds, ctx) { + const tag = localName(el); + if (tag === "content") { + const span = document.createElement("span"); + span.textContent = el.textContent ?? ""; + return span; + } + let node; + if (tag === "handwriting") { + node = document.createElement("span"); + node.className = "rendered-handwriting"; + } else if (tag === "rtl") { + node = document.createElement("bdi"); + node.setAttribute("dir", "rtl"); + } else node = document.createElement(FORMAT_HTML_TAG[tag] ?? "span"); + appendRenderedBody(node, el, elementIds, ctx); + return node; + } + function renderCode(el, elementIds, ctx) { + const labelValue = childElements(el).find((c) => localName(c) === "label")?.getAttribute("value"); + const code = document.createElement("code"); + appendRenderedBody(code, el, elementIds, { inline: ctx.inline }); + if (ctx.inline) { + code.classList.add("rendered-el"); + const id = elementIds.get(el); + if (id) code.setAttribute("data-element-id", id); + return code; + } + const pre = document.createElement("pre"); + if (labelValue && labelValue !== "undefined") { + const label = document.createElement("span"); + label.className = "rendered-code-label"; + label.textContent = labelValue; + pre.appendChild(label); + } + pre.appendChild(code); + return wrapRendered(el, pre, elementIds.get(el)); + } + function renderFormula(el, elementIds, ctx) { + const span = document.createElement("span"); + span.className = ctx.inline ? "rendered-formula-inline" : "rendered-formula"; + appendRenderedBody(span, el, elementIds, { inline: true }); + if (ctx.inline) { + span.classList.add("rendered-el"); + const id = elementIds.get(el); + if (id) span.setAttribute("data-element-id", id); + return span; + } + return wrapRendered(el, span, elementIds.get(el)); + } + function renderPicture(el, elementIds) { + const figure = document.createElement("figure"); + const captionEl = readCaptionElement(el); + appendPictureFigureImage(figure, (childElements(el).find((c) => localName(c) === "src") ?? null)?.getAttribute("uri")?.trim() || null, captionEl, elementIds); + const nodes = [...el.childNodes]; + let i = skipElementHeadNodes(nodes, 0); + while (i < nodes.length) { + const node = nodes[i]; + if (node.nodeType !== Node.ELEMENT_NODE) { + i += 1; + continue; + } + const tag = localName(node); + if (tag === "src") { + i += 1; + continue; + } + if (tag === "tabular") { + const rendered = renderOtslContainer(node, elementIds); + if (rendered) figure.appendChild(rendered); + i += 1; + continue; + } + break; + } + const bodyInner = document.createElement("div"); + bodyInner.className = "rendered-picture-contents-body"; + appendPictureBodyContent(bodyInner, nodes, i, elementIds); + if (bodyInner.textContent?.trim()) { + const details = document.createElement("details"); + details.className = "rendered-picture-contents"; + const summary = document.createElement("summary"); + summary.textContent = "Picture contents"; + details.appendChild(summary); + details.appendChild(bodyInner); + figure.appendChild(details); + } + return wrapRendered(el, figure, elementIds.get(el)); + } + function appendPictureBodyContent(container, nodes, startIdx, elementIds) { + let i = startIdx; + while (i < nodes.length) { + const node = nodes[i]; + if (node.nodeType === Node.ELEMENT_NODE && RENDER_BLOCK_TAGS.has(localName(node))) { + const rendered = renderBlockElement(node, elementIds, { inline: false }); + if (rendered) container.appendChild(rendered); + } else appendRenderedNode(container, node, elementIds, { inline: false }); + i += 1; + } + } + function renderVirtualTextBlock(hostEl, contentNodes, elementIds) { + const hasBlock = contentNodes.some((n) => n.nodeType === Node.ELEMENT_NODE && RENDER_BLOCK_TAGS.has(localName(n))); + const inner = document.createElement(hasBlock ? "div" : "p"); + for (const node of contentNodes) if (node.nodeType === Node.ELEMENT_NODE && RENDER_BLOCK_TAGS.has(localName(node))) { + const rendered = renderBlockElement(node, elementIds, { inline: false }); + if (rendered) inner.appendChild(rendered); + } else appendRenderedNode(inner, node, elementIds, { inline: true }); + const wrap = document.createElement("div"); + wrap.className = "rendered-el rendered-text rendered-el-virtual-text"; + const elementId = elementIds.get(hostEl); + if (elementId) wrap.setAttribute("data-element-id", elementId); + applyElementLayerAttr(hostEl, wrap); + wrap.appendChild(inner); + return wrap; + } + function sliceHasMarkupContent(nodes) { + for (let i = 0; i < nodes.length; i += 1) { + const node = nodes[i]; + if (!node) continue; + if (isTextLikeNode(node) && !isWhitespaceOnlyText(node)) return true; + if (node.nodeType === Node.ELEMENT_NODE) return true; + } + return false; + } + function isVirtualTextSkippableNode(node) { + if (isWhitespaceOnlyText(node)) return true; + if (node.nodeType !== Node.ELEMENT_NODE) return false; + const tag = localName(node); + return tag === "location" || HEAD_TAGS.has(tag); + } + function shouldWrapVirtualText(contentNodes) { + if (!sliceHasMarkupContent(contentNodes)) return false; + for (const node of contentNodes) { + if (isVirtualTextSkippableNode(node)) continue; + if (isTextLikeNode(node)) return true; + if (node.nodeType === Node.ELEMENT_NODE && !isSemanticElement(node)) return true; + } + return false; + } + function appendRenderedSliceContent(container, hostEl, contentNodes, elementIds) { + if (!sliceHasMarkupContent(contentNodes)) return; + if (shouldWrapVirtualText(contentNodes)) { + container.appendChild(renderVirtualTextBlock(hostEl, contentNodes, elementIds)); + return; + } + for (const node of contentNodes) { + if (isVirtualTextSkippableNode(node)) continue; + if (node.nodeType === Node.ELEMENT_NODE && RENDER_BLOCK_TAGS.has(localName(node))) { + const rendered = renderBlockElement(node, elementIds, { inline: false }); + if (rendered) container.appendChild(rendered); + } else appendRenderedNode(container, node, elementIds, { inline: true }); + } + } + function appendListItemsFromElement(list, el, elementIds) { + const nodes = [...el.childNodes]; + let i = skipContainerLevelHead(nodes, 0); + while (i < nodes.length) { + const node = nodes[i]; + if (node.nodeType !== Node.ELEMENT_NODE || localName(node) !== "ldiv") { + i += 1; + continue; + } + const ldiv = node; + i += 1; + const li = document.createElement("li"); + for (const child of childElements(ldiv)) { + const childTag = localName(child); + if (childTag === "marker") li.appendChild(renderMarkerElement(child, elementIds, { inline: true })); + else if (childTag === "checkbox") li.appendChild(renderCheckboxElement(child, elementIds)); + } + const contentStart = i; + const head = parseElementHeadAt(nodes, i); + if (head) i = head.nextIndex; + while (i < nodes.length) { + const contentNode = nodes[i]; + if (contentNode.nodeType === Node.ELEMENT_NODE && localName(contentNode) === "ldiv") break; + i += 1; + } + appendRenderedSliceContent(li, ldiv, nodes.slice(contentStart, i), elementIds); + list.appendChild(li); + } + } + function renderList(el, elementIds) { + const listClass = el.getAttribute("class") ?? "unordered"; + const list = document.createElement(listClass === "ordered" ? "ol" : "ul"); + appendListItemsFromElement(list, el, elementIds); + return wrapRendered(el, list, elementIds.get(el)); + } + function isHeaderCellKind(kind) { + return kind === "ched" || kind === "rhed" || kind === "corn" || kind === "srow"; + } + function parseOtslRows(container) { + const nodes = [...container.childNodes]; + let i = skipOtslContainerHead(nodes, 0); + const rows = []; + let currentRow = []; + while (i < nodes.length) { + const node = nodes[i]; + if (node.nodeType !== Node.ELEMENT_NODE) { + i += 1; + continue; + } + const tag = localName(node); + if (tag === "nl") { + rows.push(currentRow); + currentRow = []; + i += 1; + continue; + } + if (!isCellToken(tag)) { + i += 1; + continue; + } + if (CELL_SPAN_TAGS.has(tag)) { + currentRow.push({ + kind: tag, + token: node, + contentNodes: [] + }); + i += 1; + continue; + } + i += 1; + const head = parseElementHeadAt(nodes, i); + if (head) i = head.nextIndex; + const contentStart = i; + i = skipUntilCellBoundary(nodes, i); + currentRow.push({ + kind: tag, + token: node, + contentNodes: nodes.slice(contentStart, i) + }); + } + if (currentRow.length) rows.push(currentRow); + return rows; + } + function findVerticalCellOrigin(grid, row, col) { + for (let r = row - 1; r >= 0; r -= 1) { + const cell = grid[r]?.[col]; + if (!cell || cell.covered) continue; + return { + cell, + row: r, + col + }; + } + return null; + } + function findHorizontalCellOrigin(grid, row, col) { + for (let c = col - 1; c >= 0; c -= 1) { + const cell = grid[row]?.[c]; + if (!cell || cell.covered) continue; + return { + cell, + row, + col: c + }; + } + return null; + } + function nextFreeColumn(grid, row, col) { + let c = col; + while (grid[row]?.[c]?.covered) c += 1; + return c; + } + function buildOtslGrid(rows) { + const grid = []; + for (let rowIdx = 0; rowIdx < rows.length; rowIdx += 1) { + if (!grid[rowIdx]) grid[rowIdx] = []; + let col = 0; + for (const parsed of rows[rowIdx]) { + col = nextFreeColumn(grid, rowIdx, col); + if (parsed.kind === "lcel") { + const origin = findHorizontalCellOrigin(grid, rowIdx, col); + if (origin) origin.cell.colspan += 1; + grid[rowIdx][col] = { + kind: "lcel", + token: parsed.token, + contentNodes: [], + colspan: 0, + rowspan: 0, + covered: true + }; + col += 1; + continue; + } + if (parsed.kind === "ucel") { + const origin = findVerticalCellOrigin(grid, rowIdx, col); + if (origin) origin.cell.rowspan += 1; + grid[rowIdx][col] = { + kind: "ucel", + token: parsed.token, + contentNodes: [], + colspan: 0, + rowspan: 0, + covered: true + }; + col += 1; + continue; + } + if (parsed.kind === "xcel") { + const vOrigin = findVerticalCellOrigin(grid, rowIdx, col); + const hOrigin = findHorizontalCellOrigin(grid, rowIdx, col); + if (vOrigin && hOrigin && vOrigin.cell === hOrigin.cell) { + vOrigin.cell.rowspan += 1; + vOrigin.cell.colspan += 1; + } else { + if (vOrigin) vOrigin.cell.rowspan += 1; + if (hOrigin) hOrigin.cell.colspan += 1; + } + grid[rowIdx][col] = { + kind: "xcel", + token: parsed.token, + contentNodes: [], + colspan: 0, + rowspan: 0, + covered: true + }; + col += 1; + continue; + } + grid[rowIdx][col] = { + kind: parsed.kind, + token: parsed.token, + contentNodes: parsed.contentNodes, + colspan: 1, + rowspan: 1, + covered: false + }; + col += 1; + } + } + return grid; + } + function renderOtslContainer(el, elementIds) { + const table = document.createElement("table"); + table.className = "rendered-table"; + const captionEl = readCaptionElement(el); + if (captionEl) table.appendChild(renderEmbeddedCaption(captionEl, elementIds, "caption")); + const grid = buildOtslGrid(parseOtslRows(el)); + const tbody = document.createElement("tbody"); + for (const row of grid) { + const tr = document.createElement("tr"); + for (const cell of row ?? []) { + if (!cell || cell.covered) continue; + const cellTag = isHeaderCellKind(cell.kind) ? "th" : "td"; + const td = document.createElement(cellTag); + if (cell.colspan > 1) td.colSpan = cell.colspan; + if (cell.rowspan > 1) td.rowSpan = cell.rowspan; + appendRenderedSliceContent(td, cell.token, cell.contentNodes, elementIds); + tr.appendChild(td); + } + if (tr.childNodes.length) tbody.appendChild(tr); + } + if (tbody.childNodes.length) table.appendChild(tbody); + return wrapRendered(el, table, elementIds.get(el)); + } + function renderUnsupported(el, elementIds) { + const stub = document.createElement("div"); + stub.className = "rendered-unsupported"; + stub.textContent = `<${localName(el)}> — not yet rendered`; + return wrapRendered(el, stub, elementIds.get(el)); + } + function renderBlockElement(el, elementIds, ctx) { + const tag = localName(el); + const elementId = elementIds.get(el); + switch (tag) { + case "text": { + const p = document.createElement("p"); + appendRenderedBody(p, el, elementIds, { inline: true }); + return wrapRendered(el, p, elementId); + } + case "heading": { + const h = document.createElement(`h${headingLevel(el)}`); + appendRenderedBody(h, el, elementIds, { inline: true }); + return wrapRendered(el, h, elementId); + } + case "field_heading": { + const h = document.createElement(`h${headingLevel(el)}`); + h.className = "rendered-field-heading"; + appendRenderedBody(h, el, elementIds, { inline: true }); + return wrapRendered(el, h, elementId); + } + case "footnote": { + const aside = document.createElement("aside"); + appendRenderedBody(aside, el, elementIds, { inline: false }); + return wrapRendered(el, aside, elementId); + } + case "page_header": { + const header = document.createElement("header"); + header.className = "rendered-page-header"; + appendRenderedBody(header, el, elementIds, { inline: true }); + return wrapRendered(el, header, elementId); + } + case "page_footer": { + const footer = document.createElement("footer"); + footer.className = "rendered-page-footer"; + appendRenderedBody(footer, el, elementIds, { inline: true }); + return wrapRendered(el, footer, elementId); + } + case "list": return renderList(el, elementIds); + case "table": + case "index": + case "tabular": return renderOtslContainer(el, elementIds); + case "code": return renderCode(el, elementIds, ctx); + case "formula": return renderFormula(el, elementIds, ctx); + case "picture": return renderPicture(el, elementIds); + case "group": { + const figure = document.createElement("figure"); + figure.className = "rendered-group"; + appendRenderedBodyBlocks(figure, el, elementIds); + const cap = readCaptionElement(el); + if (cap) figure.appendChild(renderEmbeddedCaption(cap, elementIds, "figcaption")); + return wrapRendered(el, figure, elementId); + } + case "field_region": { + const div = document.createElement("div"); + div.className = "rendered-field-region"; + appendRenderedBodyBlocks(div, el, elementIds); + return wrapRendered(el, div, elementId); + } + case "field_item": { + const div = document.createElement("div"); + div.className = "rendered-field-item"; + appendRenderedBodyBlocks(div, el, elementIds); + return wrapRendered(el, div, elementId); + } + default: return renderUnsupported(el, elementIds); + } + } + function appendRenderedNode(parent, node, elementIds, ctx) { + if (isTextLikeNode(node)) { + let text = node.textContent; + if (!text || !text.trim()) return; + if (ctx.trimLeading) { + text = text.replace(/^\s+/u, ""); + ctx.trimLeading = false; + if (!text) return; + } + parent.appendChild(document.createTextNode(text)); + return; + } + if (node.nodeType !== Node.ELEMENT_NODE) return; + const el = node; + const tag = localName(el); + if (HEAD_TAGS.has(tag)) return; + if (RENDER_FORMAT_TAGS.has(tag)) { + parent.appendChild(renderFormatElement(el, elementIds, ctx)); + return; + } + if (tag === "code" || tag === "formula") { + const rendered = renderBlockElement(el, elementIds, { inline: true }); + if (rendered) parent.appendChild(rendered); + return; + } + if (RENDER_BLOCK_TAGS.has(tag)) { + const rendered = renderBlockElement(el, elementIds, ctx); + if (rendered) parent.appendChild(rendered); + return; + } + if (tag === "marker") { + parent.appendChild(renderMarkerElement(el, elementIds, ctx)); + return; + } + if (tag === "checkbox") { + parent.appendChild(renderCheckboxElement(el, elementIds)); + return; + } + if (tag === "key") { + parent.appendChild(renderFieldKeyElement(el, elementIds, ctx)); + return; + } + if (tag === "value") { + parent.appendChild(renderFieldValueElement(el, elementIds, ctx)); + return; + } + if (tag === "hint") { + parent.appendChild(renderFieldHintElement(el, elementIds, ctx)); + return; + } + if (tag === "ldiv") return; + if (isCellToken(tag) || tag === "src" || tag === "tabular") return; + appendRenderedBody(parent, el, elementIds, ctx); + } + function findLastTextNode(node) { + if (isTextLikeNode(node)) return node; + if (node.nodeType !== Node.ELEMENT_NODE) return null; + for (let i = node.childNodes.length - 1; i >= 0; i -= 1) { + const found = findLastTextNode(node.childNodes[i]); + if (found) return found; + } + return null; + } + function trimParentTrailingForFragmentJoin(parent) { + const lastText = findLastTextNode(parent); + if (!lastText) return; + let value = lastText.textContent ?? ""; + value = value.replace(/\s+$/u, ""); + if (value.endsWith("-")) value = value.slice(0, -1); + if (!value) { + lastText.parentNode?.removeChild(lastText); + trimParentTrailingForFragmentJoin(parent); + return; + } + lastText.textContent = value; + } + function appendMergedTextFragments(parent, fragments, elementIds) { + for (let i = 0; i < fragments.length; i += 1) { + if (i > 0) trimParentTrailingForFragmentJoin(parent); + appendRenderedBody(parent, fragments[i], elementIds, { + inline: true, + trimLeading: i > 0 + }); + } + } + function renderMergedIntraPageFragments(fragments, elementIds) { + const first = fragments[0]; + const tag = localName(first); + const firstId = elementIds.get(first); + const threadId = elementThreadId(first); + let node; + if (tag === "text") { + node = document.createElement("p"); + appendMergedTextFragments(node, fragments, elementIds); + } else if (tag === "list") { + const listClass = first.getAttribute("class") ?? "unordered"; + node = document.createElement(listClass === "ordered" ? "ol" : "ul"); + for (const el of fragments) appendListItemsFromElement(node, el, elementIds); + } else { + node = document.createElement("div"); + node.className = "rendered-fragment-merged-body"; + for (const el of fragments) appendRenderedBodyBlocks(node, el, elementIds); + } + const wrap = wrapRendered(first, node, firstId, "rendered-fragment-merged"); + if (threadId) wrap.setAttribute("data-thread-id", threadId); + return wrap; + } + function collectIntraPageThreads(segment) { + const byThread = /* @__PURE__ */ new Map(); + for (const el of segment) { + if (el.nodeType !== Node.ELEMENT_NODE) continue; + if (localName(el) === "page_break") continue; + const tid = elementThreadId(el); + if (!tid) continue; + if (!byThread.has(tid)) byThread.set(tid, []); + byThread.get(tid).push(el); + } + for (const [tid, members] of byThread) if (members.length < 2) byThread.delete(tid); + return byThread; + } + function applyReadingLayerClasses(root, showFurniture, showBackground) { + root.classList.toggle("show-reading-furniture", showFurniture); + root.classList.toggle("show-reading-background", showBackground); + } + function buildRenderedView(segment, elementIds, showFurniture, showBackground, onSelectElement) { + const intraPageThreads = collectIntraPageThreads(segment); + const skipElements = /* @__PURE__ */ new Set(); + const mergeGroups = /* @__PURE__ */ new Map(); + for (const [, members] of intraPageThreads) { + mergeGroups.set(members[0], members); + for (let i = 1; i < members.length; i += 1) skipElements.add(members[i]); + } + const root = document.createElement("article"); + root.className = "rendered-doc"; + for (const el of segment) { + if (el.nodeType !== Node.ELEMENT_NODE) continue; + if (localName(el) === "page_break") continue; + if (skipElements.has(el)) continue; + const mergeGroup = mergeGroups.get(el); + const rendered = mergeGroup ? renderMergedIntraPageFragments(mergeGroup, elementIds) : renderBlockElement(el, elementIds, { inline: false }); + if (rendered) root.appendChild(rendered); + } + root.addEventListener("click", (e) => { + const target = e.target; + const ghostText = target.closest(".rendered-el-virtual-text"); + const elementId = ghostText?.hasAttribute("data-element-id") ? ghostText.getAttribute("data-element-id") : target.closest(".rendered-el[data-element-id]")?.getAttribute("data-element-id") ?? null; + if (elementId) onSelectElement(elementId); + }); + applyReadingLayerClasses(root, showFurniture, showBackground); + return root; + } + //#endregion + //#region src/overlay.ts + var getPageZoomPercent = () => 100; + var getPagePane = () => null; + var getPageLayoutCache = () => null; + var setPageLayoutCache = () => {}; + var getSelectedElementId = () => null; + function setOverlayAccessors(opts) { + getPageZoomPercent = opts.getPageZoomPercent; + getPagePane = opts.getPagePane; + getPageLayoutCache = opts.getPageLayoutCache; + setPageLayoutCache = opts.setPageLayoutCache; + getSelectedElementId = opts.getSelectedElementId; + } + function pageZoomFactor() { + return getPageZoomPercent() / 100; + } + function paneContentSize(pane) { + const style = getComputedStyle(pane); + const padX = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); + const padY = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom); + return { + w: pane.clientWidth - padX, + h: pane.clientHeight - padY + }; + } + function getCachedFitScale(img, pane) { + const { w: paneW, h: paneH } = paneContentSize(pane); + const imgW = img.naturalWidth; + const imgH = img.naturalHeight; + const cache = getPageLayoutCache(); + if (cache && cache.paneW === paneW && cache.paneH === paneH && cache.imgW === imgW && cache.imgH === imgH) return cache.fitScale; + const fitScale = paneW > 0 && paneH > 0 && imgW > 0 && imgH > 0 ? Math.min((paneW - 2) / imgW, (paneH - 2) / imgH) : 1; + setPageLayoutCache({ + paneW, + paneH, + imgW, + imgH, + fitScale + }); + return fitScale; + } + function overlayUserLength(baseUserPx, img) { + const resolvedImg = img ?? getPagePane()?.querySelector(".page-view img"); + const zoom = pageZoomFactor(); + if (!(zoom > 0)) return baseUserPx; + const pane = getPagePane(); + if (!resolvedImg?.naturalWidth || !resolvedImg.naturalHeight || !pane) return baseUserPx / zoom; + const { w: paneW, h: paneH } = paneContentSize(pane); + if (!(paneW > 0 && paneH > 0)) return baseUserPx / zoom; + const refFit = Math.min((paneW - 2) / OVERLAY_REF_IMAGE_WIDTH, (paneH - 2) / OVERLAY_REF_IMAGE_HEIGHT); + const fitScale = getCachedFitScale(resolvedImg, pane); + if (!(refFit > 0) || !(fitScale > 0)) return baseUserPx / zoom; + return baseUserPx * refFit / (fitScale * zoom); + } + function applyPageImageSize(img, pane) { + if (!img?.naturalWidth || !img.naturalHeight) return false; + const zoomPct = Math.max(100, getPageZoomPercent()); + const scale = getCachedFitScale(img, pane) * (zoomPct / 100); + const w = Math.floor(img.naturalWidth * scale); + const h = Math.floor(img.naturalHeight * scale); + const nextW = `${w}px`; + const nextH = `${h}px`; + const unchanged = img.style.width === nextW && img.style.height === nextH; + if (!unchanged) { + img.style.width = nextW; + img.style.height = nextH; + img.style.maxWidth = "none"; + img.style.maxHeight = "none"; + } + img.dataset.layoutReady = "1"; + return !unchanged; + } + function boxPixelRect(b, img) { + const x = b.x0 / b.resW * img.naturalWidth; + const y = b.y0 / b.resH * img.naturalHeight; + const w = (b.x1 - b.x0) / b.resW * img.naturalWidth; + const h = (b.y1 - b.y0) / b.resH * img.naturalHeight; + return { + x, + y, + w, + h, + area: w * h + }; + } + function boxCenter(rect) { + return { + x: rect.x + rect.w / 2, + y: rect.y + rect.h / 2 + }; + } + function overlayBoxPaintPriority(box) { + if (box.kind === "text") return 0; + if (box.kind === "list" || box.kind === "table" || box.kind === "index" || box.kind === "tabular") return 2; + return 1; + } + function overlayLayerPriority(layer) { + if (layer === "background") return 0; + if (layer === "furniture") return 1; + return 2; + } + function compareOverlayBoxPaintOrder(a, b) { + const byLayer = overlayLayerPriority(a.layer ?? "body") - overlayLayerPriority(b.layer ?? "body"); + if (byLayer !== 0) return byLayer; + const byPriority = overlayBoxPaintPriority(a) - overlayBoxPaintPriority(b); + if (byPriority !== 0) return byPriority; + const selectedId = getSelectedElementId(); + if (selectedId) { + const aSelected = a.elementId === selectedId; + if (aSelected !== (b.elementId === selectedId)) return aSelected ? 1 : -1; + } + return 0; + } + function sortedOverlayBoxes(boxes) { + return [...boxes].sort(compareOverlayBoxPaintOrder); + } + function ensureArrowMarker(defs, markerId) { + if (defs.querySelector(`#${markerId}`)) return; + const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker"); + marker.setAttribute("id", markerId); + marker.setAttribute("viewBox", "0 0 6 6"); + marker.setAttribute("refX", "6"); + marker.setAttribute("refY", "3"); + marker.setAttribute("markerWidth", "5"); + marker.setAttribute("markerHeight", "5"); + marker.setAttribute("orient", "auto"); + const arrowPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); + arrowPath.setAttribute("d", "M0,0 L6,3 L0,6 Z"); + arrowPath.setAttribute("fill", "currentColor"); + marker.appendChild(arrowPath); + defs.appendChild(marker); + } + function alignDashedLineToEnd(line, start, end) { + const dash = 6; + const gap = 4; + const period = 10; + const len = Math.hypot(end.x - start.x, end.y - start.y); + if (!len) return; + line.setAttribute("stroke-dasharray", `${dash} ${gap}`); + const offset = len % period; + if (offset > .01) line.setAttribute("stroke-dashoffset", String(offset)); + } + function ensureLayerHatchPatterns(defs) { + if (defs.querySelector("#layer-hatch")) return; + const pattern = document.createElementNS("http://www.w3.org/2000/svg", "pattern"); + pattern.setAttribute("id", "layer-hatch"); + pattern.setAttribute("width", "16"); + pattern.setAttribute("height", "16"); + pattern.setAttribute("patternUnits", "userSpaceOnUse"); + pattern.setAttribute("patternTransform", "rotate(45 8 8)"); + const stripe = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + stripe.setAttribute("class", "layer-hatch-stripe"); + stripe.setAttribute("x", "10"); + stripe.setAttribute("y", "-4"); + stripe.setAttribute("width", "6"); + stripe.setAttribute("height", "24"); + pattern.appendChild(stripe); + defs.appendChild(pattern); + } + function ensureOverlayDefs(svg) { + let defs = svg.querySelector("defs"); + if (!defs) { + defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); + svg.insertBefore(defs, svg.firstChild); + } + return defs; + } + function overlayBadgeLayout(svg, text, fontSizeUser) { + const padXUser = overlayUserLength(3); + const padYUser = overlayUserLength(2); + const probe = document.createElementNS("http://www.w3.org/2000/svg", "text"); + probe.setAttribute("class", "overlay-badge-label"); + probe.setAttribute("font-size", String(fontSizeUser)); + probe.setAttribute("font-weight", "700"); + probe.setAttribute("text-anchor", "start"); + probe.setAttribute("dominant-baseline", "text-before-edge"); + probe.setAttribute("visibility", "hidden"); + probe.textContent = String(text); + svg.appendChild(probe); + let width; + let height; + try { + const bbox = probe.getBBox(); + width = bbox.width + padXUser * 2; + height = bbox.height + padYUser * 2; + } catch { + width = overlayUserLength(String(text).length * OVERLAY_BADGE_FONT_SIZE * .55 + 6); + height = overlayUserLength(OVERLAY_BADGE_FONT_SIZE * 1.1 + 4); + } + probe.remove(); + return { + width, + height + }; + } + function appendOverlayBadge(svg, anchorX, anchorY, text, { extraClass, elementId }) { + const fontSize = overlayUserLength(OVERLAY_BADGE_FONT_SIZE); + const { width, height } = overlayBadgeLayout(svg, text, fontSize); + const radius = overlayUserLength(3); + const badge = document.createElementNS("http://www.w3.org/2000/svg", "g"); + badge.setAttribute("class", `overlay-badge ${extraClass}`); + badge.setAttribute("data-element-id", elementId); + const bg = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + bg.setAttribute("class", "overlay-badge-bg"); + bg.setAttribute("x", String(anchorX - width / 2)); + bg.setAttribute("y", String(anchorY - height / 2)); + bg.setAttribute("width", String(width)); + bg.setAttribute("height", String(height)); + bg.setAttribute("rx", String(radius)); + bg.setAttribute("ry", String(radius)); + badge.appendChild(bg); + const label = document.createElementNS("http://www.w3.org/2000/svg", "text"); + label.setAttribute("class", "overlay-badge-label"); + label.setAttribute("x", String(anchorX)); + label.setAttribute("y", String(anchorY)); + label.setAttribute("font-size", String(fontSize)); + label.textContent = String(text); + badge.appendChild(label); + svg.appendChild(badge); + return badge; + } + function syncOverlayBadges(img, svg, boxes, readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder) { + if (!img.naturalWidth) return; + svg.querySelectorAll(".element-badge, .reading-order-badge").forEach((b) => b.remove()); + const fontSize = overlayUserLength(OVERLAY_BADGE_FONT_SIZE); + const badgeGap = overlayUserLength(2); + const readingOrderByElementId = /* @__PURE__ */ new Map(); + if (showAllBboxes && showReadingOrder) for (const step of readingOrderSteps) readingOrderByElementId.set(step.elementId, step); + const sorted = sortedOverlayBoxes(boxes); + for (const b of sorted) { + const { x, y } = boxPixelRect(b, img); + let tagLayout = { width: 0 }; + if (showAllBboxes && showLayoutBadges) { + tagLayout = overlayBadgeLayout(svg, b.tag, fontSize); + appendOverlayBadge(svg, x, y, b.tag, { + extraClass: `element-badge ${kindClassForTag(b.kind)}`, + elementId: b.elementId + }); + } + const step = readingOrderByElementId.get(b.elementId); + if (step) { + const orderText = String(step.order); + const orderLayout = overlayBadgeLayout(svg, orderText, fontSize); + appendOverlayBadge(svg, showAllBboxes && showLayoutBadges ? x + tagLayout.width / 2 + badgeGap + orderLayout.width / 2 : x, y, orderText, { + extraClass: "reading-order-badge", + elementId: b.elementId + }); + } + } + } + function appendOverlayLinks(svg, img, links, { markerId, linkClass, fromIdAttr, toIdAttr }) { + if (!links.length) return; + ensureArrowMarker(ensureOverlayDefs(svg), markerId); + for (const link of links) { + const fromBox = link.captionBox ?? link.fromBox; + const toBox = link.hostBox ?? link.toBox; + const from = boxPixelRect(fromBox, img); + const to = boxPixelRect(toBox, img); + const start = boxCenter(from); + const end = boxCenter(to); + const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); + line.setAttribute("class", linkClass); + line.setAttribute("x1", String(start.x)); + line.setAttribute("y1", String(start.y)); + line.setAttribute("x2", String(end.x)); + line.setAttribute("y2", String(end.y)); + line.setAttribute("marker-end", `url(#${markerId})`); + const fromId = link.captionElementId ?? link.fromElementId; + const toId = link.hostElementId ?? link.toElementId; + line.setAttribute(fromIdAttr, fromId); + line.setAttribute(toIdAttr, toId); + svg.appendChild(line); + } + } + function appendCaptionLinks(svg, img, captionLinks) { + appendOverlayLinks(svg, img, captionLinks, { + markerId: "caption-arrowhead", + linkClass: "caption-link", + fromIdAttr: "data-caption-id", + toIdAttr: "data-host-id" + }); + } + function appendXrefLinks(svg, img, xrefLinks) { + appendOverlayLinks(svg, img, xrefLinks, { + markerId: "xref-arrowhead", + linkClass: "xref-link", + fromIdAttr: "data-xref-from-id", + toIdAttr: "data-xref-to-id" + }); + } + function docPointToPixel(xDoc, yDoc, resW, resH, img) { + return { + x: xDoc / resW * img.naturalWidth, + y: yDoc / resH * img.naturalHeight + }; + } + function pageCornerTarget(img, defaultResolution, corner) { + const { width: resW, height: resH } = defaultResolution; + const inset = overlayUserLength(5); + const tl = docPointToPixel(0, 0, resW, resH, img); + const br = docPointToPixel(resW, resH, resW, resH, img); + if (corner === "tl") return { + x: Math.min(tl.x + inset, br.x - inset), + y: Math.min(tl.y + inset, br.y - inset) + }; + return { + x: Math.max(br.x - inset, tl.x + inset), + y: Math.max(br.y - inset, tl.y + inset) + }; + } + function appendFragmentLinks(svg, img, links, defaultResolution) { + if (!links.length) return; + ensureArrowMarker(ensureOverlayDefs(svg), "fragment-arrowhead"); + const fontSize = overlayUserLength(OVERLAY_BADGE_FONT_SIZE); + for (const link of links) { + const fromRect = boxPixelRect(link.fromBox, img); + const group = document.createElementNS("http://www.w3.org/2000/svg", "g"); + group.setAttribute("class", "fragment-link"); + group.setAttribute("data-thread-id", link.threadId); + group.setAttribute("data-fragment-from-id", link.fromElementId); + if (link.toElementId) group.setAttribute("data-fragment-to-id", link.toElementId); + let labelAt; + if (link.toBox) { + const toRect = boxPixelRect(link.toBox, img); + const start = boxCenter(fromRect); + const end = boxCenter(toRect); + const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); + line.setAttribute("class", "fragment-link-path fragment-link-path-dashed"); + line.setAttribute("x1", String(start.x)); + line.setAttribute("y1", String(start.y)); + line.setAttribute("x2", String(end.x)); + line.setAttribute("y2", String(end.y)); + line.setAttribute("marker-end", "url(#fragment-arrowhead)"); + alignDashedLineToEnd(line, start, end); + group.appendChild(line); + labelAt = { + x: (start.x + end.x) / 2, + y: (start.y + end.y) / 2 + }; + } else { + const corner = link.targetCorner ?? "br"; + const cornerPoint = pageCornerTarget(img, defaultResolution, corner); + const elementAnchor = boxCenter(fromRect); + const incoming = corner === "tl"; + const start = incoming ? cornerPoint : elementAnchor; + const end = incoming ? elementAnchor : cornerPoint; + const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); + line.setAttribute("class", "fragment-link-path fragment-link-path-dashed"); + line.setAttribute("x1", String(start.x)); + line.setAttribute("y1", String(start.y)); + line.setAttribute("x2", String(end.x)); + line.setAttribute("y2", String(end.y)); + line.setAttribute("marker-end", "url(#fragment-arrowhead)"); + alignDashedLineToEnd(line, start, end); + group.appendChild(line); + labelAt = { + x: (start.x + end.x) / 2, + y: (start.y + end.y) / 2 + }; + } + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("class", "fragment-link-label"); + text.setAttribute("x", String(labelAt.x)); + text.setAttribute("y", String(labelAt.y)); + text.setAttribute("font-size", String(fontSize)); + text.textContent = link.toBox ? FRAGMENT_LINK_LABEL_SAME_PAGE : FRAGMENT_LINK_LABEL_CROSS_PAGE; + group.appendChild(text); + svg.appendChild(group); + } + } + function appendFragmentNavButtons(svg, img, items) { + if (!items.length) return; + const btnSize = overlayUserLength(19.5); + const gap = overlayUserLength(2); + const inset = overlayUserLength(3); + const fontSize = overlayUserLength(15); + const radius = overlayUserLength(3); + for (const item of items) { + const { x, y, w, h } = boxPixelRect(item.box, img); + const group = document.createElementNS("http://www.w3.org/2000/svg", "g"); + group.setAttribute("class", "fragment-nav"); + group.setAttribute("data-element-id", item.elementId); + const rowY = y + h - inset - btnSize; + const nextX = x + w - inset - btnSize; + appendFragmentNavButton(group, nextX - gap - btnSize, rowY, btnSize, radius, fontSize, "prev", "‹", item.hasPrev); + appendFragmentNavButton(group, nextX, rowY, btnSize, radius, fontSize, "next", "›", item.hasNext); + svg.appendChild(group); + } + } + function appendFragmentNavButton(group, x, y, size, radius, fontSize, direction, label, enabled) { + const btn = document.createElementNS("http://www.w3.org/2000/svg", "g"); + btn.setAttribute("class", `fragment-nav-btn fragment-nav-btn-${direction}${enabled ? "" : " fragment-nav-btn-disabled"}`); + btn.setAttribute("data-nav", direction); + if (enabled) { + btn.setAttribute("role", "button"); + btn.setAttribute("aria-label", direction === "prev" ? FRAGMENT_NAV_HINT_PREV : FRAGMENT_NAV_HINT_NEXT); + } + const bg = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + bg.setAttribute("class", "fragment-nav-btn-bg"); + bg.setAttribute("x", String(x)); + bg.setAttribute("y", String(y)); + bg.setAttribute("width", String(size)); + bg.setAttribute("height", String(size)); + bg.setAttribute("rx", String(radius)); + bg.setAttribute("ry", String(radius)); + btn.appendChild(bg); + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("class", "fragment-nav-btn-label"); + text.setAttribute("x", String(x + size / 2)); + text.setAttribute("y", String(y + size / 2)); + text.setAttribute("font-size", String(fontSize)); + text.textContent = label; + btn.appendChild(text); + group.appendChild(btn); + } + function appendReadingOrderOverlay(svg, img, steps) { + if (!steps.length) return; + if (steps.length >= 2) { + ensureArrowMarker(ensureOverlayDefs(svg), "reading-order-arrowhead"); + for (let i = 0; i < steps.length - 1; i += 1) { + const from = boxPixelRect(steps[i].box, img); + const to = boxPixelRect(steps[i + 1].box, img); + const start = boxCenter(from); + const end = boxCenter(to); + const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); + line.setAttribute("class", "reading-order-step"); + line.setAttribute("x1", String(start.x)); + line.setAttribute("y1", String(start.y)); + line.setAttribute("x2", String(end.x)); + line.setAttribute("y2", String(end.y)); + line.setAttribute("marker-end", "url(#reading-order-arrowhead)"); + svg.appendChild(line); + } + } + } + function buildOverlay(img, boxes, captionLinks = [], xrefLinks = [], readingOrderSteps = [], fragmentLinks = [], fragmentNavItems = [], defaultResolution = { + width: 512, + height: 512 + }, onSelectElement, onNavigateFragment, onClearSelection, getPagPanSuppressClick, setPagPanSuppressClick) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.classList.add("overlay"); + svg.setAttribute("viewBox", `0 0 ${img.naturalWidth} ${img.naturalHeight}`); + svg.setAttribute("overflow", "hidden"); + appendCaptionLinks(svg, img, captionLinks); + appendXrefLinks(svg, img, xrefLinks); + appendFragmentLinks(svg, img, fragmentLinks, defaultResolution); + appendReadingOrderOverlay(svg, img, readingOrderSteps); + ensureLayerHatchPatterns(ensureOverlayDefs(svg)); + for (const b of sortedOverlayBoxes(boxes)) { + const { x, y, w, h } = boxPixelRect(b, img); + const cls = bboxClassForKind(b.kind); + const kindClass = kindClassForTag(b.kind); + const layerClass = layerClassForValue(b.layer ?? "body"); + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + rect.setAttribute("class", `bbox bbox-${cls} ${kindClass}${layerClass ? ` ${layerClass}` : ""}`); + rect.setAttribute("x", String(x)); + rect.setAttribute("y", String(y)); + rect.setAttribute("width", String(Math.max(w, 1))); + rect.setAttribute("height", String(Math.max(h, 1))); + rect.setAttribute("data-element-id", b.elementId); + svg.appendChild(rect); + } + appendFragmentNavButtons(svg, img, fragmentNavItems); + svg.addEventListener("click", (e) => { + if (getPagPanSuppressClick()) { + setPagPanSuppressClick(false); + return; + } + const target = e.target; + const navBtn = target.closest(".fragment-nav-btn:not(.fragment-nav-btn-disabled)"); + if (navBtn) { + e.stopPropagation(); + const elementId = navBtn.closest(".fragment-nav")?.getAttribute("data-element-id"); + const direction = navBtn.getAttribute("data-nav"); + if (elementId && direction) onNavigateFragment(elementId, direction); + return; + } + const badge = target.closest(".overlay-badge[data-element-id]"); + if (badge) { + const id = badge.getAttribute("data-element-id"); + if (id) onSelectElement(id); + return; + } + const coords = imageCoordsFromEvent(svg, e); + if (!coords) return; + const hit = hitTestBoxes(boxes, img, coords.x, coords.y); + if (hit) onSelectElement(hit.elementId); + else onClearSelection(); + }); + svg.addEventListener("mousemove", (e) => { + const coords = imageCoordsFromEvent(svg, e); + svg.style.cursor = coords && hitTestBoxes(boxes, img, coords.x, coords.y) ? "pointer" : ""; + }); + svg.addEventListener("mouseleave", () => { + svg.style.cursor = ""; + }); + return svg; + } + function imageCoordsFromEvent(svg, evt) { + const pt = svg.createSVGPoint(); + pt.x = evt.clientX; + pt.y = evt.clientY; + const ctm = svg.getScreenCTM()?.inverse(); + if (!ctm) return null; + const p = pt.matrixTransform(ctm); + return { + x: p.x, + y: p.y + }; + } + function hitTestBoxes(boxes, img, x, y) { + let best = null; + let bestArea = Infinity; + for (const b of boxes) { + const { x: bx, y: by, w, h, area } = boxPixelRect(b, img); + if (x >= bx && x <= bx + w && y >= by && y <= by + h && area < bestArea) { + best = b; + bestArea = area; + } + } + return best; + } + //#endregion + //#region src/main.ts + var state = null; + var fileCatalog = []; + var activeFileIndex = -1; + var filePaneUserToggled = false; + var pagePaneResizeObserver = null; + var selectedElementId = null; + var showAllBboxes = true; + var showLayoutBadges = true; + var showCaptionLinks = false; + var showPictureContents = false; + var showTableContents = false; + var showFragmentLinks = false; + var showXrefLinks = false; + var showReadingOrder = false; + var showReadingOrderArrows = true; + var readingOrderGlobalNumbering = false; + var showReadingFurniture = true; + var showReadingBackground = true; + var pageSettingsOpen = false; + var readingSettingsOpen = false; + var pageZoomPercent = 100; + var pagePanDrag = null; + var pagePanSuppressClick = false; + var pageLayoutCache = null; + var userPaneVisible = { ...DEFAULT_USER_PANE_VISIBLE }; + var paneRatios = [...DEFAULT_PANE_RATIOS]; + var filePaneWidthPx = null; + var toolbarOptionsOpen = false; + var paneDrag = null; + var layoutStackQuery = null; + var pageLayoutFrame = 0; + var demoLoadInProgress = false; + setStateAccessor(() => state); + setOverlayAccessors({ + getPageZoomPercent: () => pageZoomPercent, + getPagePane: () => els.pagePane, + getPageLayoutCache: () => pageLayoutCache, + setPageLayoutCache: (c) => { + pageLayoutCache = c; + }, + getSelectedElementId: () => selectedElementId + }); + var els = { + openFileBtn: document.getElementById("open-file-btn"), + emptyStateFileTypes: document.getElementById("empty-state-file-types"), + docLabel: document.getElementById("doc-label"), + filePane: document.getElementById("file-pane"), + filePaneCloseAll: document.getElementById("btn-file-pane-close-all"), + pageNav: document.getElementById("page-nav"), + pageIndicator: document.getElementById("page-indicator"), + pageNumberInput: document.getElementById("page-number-input"), + pageCountIndicator: document.getElementById("page-count-indicator"), + btnPrev: document.getElementById("btn-prev"), + btnNext: document.getElementById("btn-next"), + showAllBboxes: document.getElementById("show-all-bboxes"), + showLayoutBadges: document.getElementById("show-layout-badges"), + showLayoutBadgesLabel: document.getElementById("show-layout-badges-label"), + settingsToggle: document.getElementById("btn-settings-toggle"), + readingSettingsToggle: document.getElementById("btn-reading-settings-toggle"), + pageSettingsLayer: document.getElementById("page-settings-layer"), + pageSettingsScrim: document.getElementById("page-settings-scrim"), + pageSettingsClose: document.getElementById("btn-page-settings-close"), + readingSettingsLayer: document.getElementById("reading-settings-layer"), + readingSettingsScrim: document.getElementById("reading-settings-scrim"), + readingSettingsClose: document.getElementById("btn-reading-settings-close"), + showReadingFurniture: document.getElementById("show-reading-furniture"), + showReadingFurnitureLabel: document.getElementById("show-reading-furniture-label"), + showReadingBackground: document.getElementById("show-reading-background"), + showReadingBackgroundLabel: document.getElementById("show-reading-background-label"), + showCaptionLinks: document.getElementById("show-caption-links"), + showCaptionLinksLabel: document.getElementById("show-caption-links-label"), + showPictureContents: document.getElementById("show-picture-contents"), + showPictureContentsLabel: document.getElementById("show-picture-contents-label"), + showTableContents: document.getElementById("show-table-contents"), + showTableContentsLabel: document.getElementById("show-table-contents-label"), + showFragmentLinks: document.getElementById("show-fragment-links"), + showFragmentLinksLabel: document.getElementById("show-fragment-links-label"), + showXrefLinks: document.getElementById("show-xref-links"), + showXrefLinksLabel: document.getElementById("show-xref-links-label"), + showReadingOrder: document.getElementById("show-reading-order"), + showReadingOrderLabel: document.getElementById("show-reading-order-label"), + readingOrderArrows: document.getElementById("reading-order-arrows"), + readingOrderArrowsLabel: document.getElementById("reading-order-arrows-label"), + readingOrderGlobal: document.getElementById("reading-order-global"), + readingOrderGlobalLabel: document.getElementById("reading-order-global-label"), + pageZoom: document.getElementById("page-zoom"), + pageZoomLabel: document.getElementById("page-zoom-label"), + pageZoomReset: document.getElementById("page-zoom-reset"), + main: document.getElementById("main"), + emptyState: document.getElementById("empty-state"), + emptyStateLoading: document.getElementById("empty-state-loading"), + emptyStatePrompt: document.getElementById("empty-state-prompt"), + markupPane: document.getElementById("markup-pane"), + renderedPane: document.getElementById("rendered-pane"), + pagePane: document.getElementById("page-pane"), + paneFile: document.querySelector(".pane-file"), + panePageView: document.querySelector(".pane-page-view"), + paneMarkup: document.querySelector(".pane-markup"), + paneReading: document.querySelector(".pane-reading"), + splitters: [ + document.getElementById("splitter-0"), + document.getElementById("splitter-1"), + document.getElementById("splitter-2") + ], + toolbarOptionsBtn: document.getElementById("btn-toolbar-options"), + toolbarOptionsPanel: document.getElementById("toolbar-options-panel"), + toggleFilePane: document.getElementById("toggle-file-pane"), + toggleFilePaneLabel: document.getElementById("toggle-file-pane-label"), + togglePagePane: document.getElementById("toggle-page-pane"), + toggleMarkupPane: document.getElementById("toggle-markup-pane"), + toggleReadingPane: document.getElementById("toggle-reading-pane"), + togglePagePaneLabel: document.getElementById("toggle-page-pane-label"), + resetPaneLayoutBtn: document.getElementById("btn-reset-pane-layout") + }; + var cursorHintEl = document.getElementById("cursor-hint"); + var CURSOR_HINT_OFFSET = 10; + var CURSOR_HINT_MARGIN = 8; + function hideCursorHint() { + if (!cursorHintEl) return; + cursorHintEl.hidden = true; + cursorHintEl.classList.remove("cursor-hint-detail"); + cursorHintEl.replaceChildren(); + } + function showCursorHint(content, clientX, clientY, { detail = false } = {}) { + if (!cursorHintEl) return; + cursorHintEl.replaceChildren(); + if (typeof content === "string") cursorHintEl.textContent = content; + else cursorHintEl.appendChild(content); + cursorHintEl.classList.toggle("cursor-hint-detail", detail); + cursorHintEl.hidden = false; + let left = clientX + CURSOR_HINT_OFFSET; + let top = clientY + CURSOR_HINT_OFFSET; + const rect = cursorHintEl.getBoundingClientRect(); + if (left + rect.width > window.innerWidth - CURSOR_HINT_MARGIN) left = clientX - rect.width - CURSOR_HINT_OFFSET; + if (top + rect.height > window.innerHeight - CURSOR_HINT_MARGIN) top = clientY - rect.height - CURSOR_HINT_OFFSET; + cursorHintEl.style.left = `${Math.max(CURSOR_HINT_MARGIN, left)}px`; + cursorHintEl.style.top = `${Math.max(CURSOR_HINT_MARGIN, top)}px`; + } + function showCursorHintHtml(html, clientX, clientY) { + if (!cursorHintEl) return; + cursorHintEl.innerHTML = html; + cursorHintEl.classList.add("cursor-hint-detail"); + cursorHintEl.hidden = false; + let left = clientX + CURSOR_HINT_OFFSET; + let top = clientY + CURSOR_HINT_OFFSET; + const rect = cursorHintEl.getBoundingClientRect(); + if (left + rect.width > window.innerWidth - CURSOR_HINT_MARGIN) left = clientX - rect.width - CURSOR_HINT_OFFSET; + if (top + rect.height > window.innerHeight - CURSOR_HINT_MARGIN) top = clientY - rect.height - CURSOR_HINT_OFFSET; + cursorHintEl.style.left = `${Math.max(CURSOR_HINT_MARGIN, left)}px`; + cursorHintEl.style.top = `${Math.max(CURSOR_HINT_MARGIN, top)}px`; + } + function headTextPreview(el, maxLen = 72) { + const text = el.textContent?.replace(/\s+/g, " ").trim() ?? ""; + if (!text) return "—"; + return text.length > maxLen ? `${text.slice(0, maxLen)}…` : text; + } + function collectElementHeadInfo(el, defaultResolution) { + const labelEl = firstHeadChild(el, "label"); + const threadEl = firstHeadChild(el, "thread"); + const xrefEl = firstHeadChild(el, "xref"); + const hrefEl = firstHeadChild(el, "href"); + const layerEl = firstHeadChild(el, "layer"); + const captionEl = firstHeadChild(el, "caption"); + const descriptionEl = firstHeadChild(el, "description"); + const summaryEl = firstHeadChild(el, "summary"); + const customEl = firstHeadChild(el, "custom"); + const locs = elementHeadLocations(el); + const rows = [{ + key: "element", + value: elementLabel(el), + isDefault: false + }]; + rows.push({ + key: "label", + value: labelEl?.getAttribute("value") ?? "undefined", + isDefault: !labelEl?.hasAttribute("value") + }); + if (threadEl) rows.push({ + key: "thread_id", + value: threadEl.getAttribute("thread_id") ?? "—", + isDefault: false + }); + else rows.push({ + key: "thread", + value: "—", + isDefault: true + }); + if (xrefEl) rows.push({ + key: "xref", + value: `thread_id ${xrefEl.getAttribute("thread_id") ?? "—"}`, + isDefault: false + }); + else rows.push({ + key: "xref", + value: "—", + isDefault: true + }); + if (hrefEl) rows.push({ + key: "href", + value: hrefEl.getAttribute("uri") ?? "—", + isDefault: false + }); + else rows.push({ + key: "href", + value: "—", + isDefault: true + }); + rows.push({ + key: "layer", + value: layerEl?.getAttribute("value") ?? "body", + isDefault: !layerEl?.hasAttribute("value") + }); + const cornerLabels = [ + "x_min", + "y_min", + "x_max", + "y_max" + ]; + if (locs.length === 4) for (let idx = 0; idx < 4; idx += 1) { + const loc = locs[idx]; + const resolution = locationResolution(loc, idx % 2 === 0 ? defaultResolution.width : defaultResolution.height); + const value = loc.getAttribute("value") ?? "0"; + rows.push({ + key: cornerLabels[idx], + value: `${value} @ ${resolution}`, + isDefault: false + }); + } + else for (const key of cornerLabels) rows.push({ + key, + value: "—", + isDefault: false + }); + rows.push({ + key: "caption", + value: captionEl ? headTextPreview(captionEl) : "—", + isDefault: !captionEl + }); + rows.push({ + key: "description", + value: descriptionEl ? headTextPreview(descriptionEl) : "—", + isDefault: !descriptionEl + }); + rows.push({ + key: "summary", + value: summaryEl ? headTextPreview(summaryEl) : "—", + isDefault: !summaryEl + }); + rows.push({ + key: "custom", + value: customEl ? headTextPreview(customEl) : "—", + isDefault: !customEl + }); + return rows; + } + function elementHeadTooltipHtml(el, defaultResolution) { + return `${collectElementHeadInfo(el, defaultResolution).map(({ key, value, isDefault }) => { + const rendered = escapeHtml(value); + const suffix = isDefault ? " (default)" : ""; + return ``; + }).join("")}
${escapeHtml(key)}${rendered}${suffix}
`; + } + function syncReadingLayerCheckboxes() { + if (els.showReadingFurniture) els.showReadingFurniture.checked = showReadingFurniture; + if (els.showReadingBackground) els.showReadingBackground.checked = showReadingBackground; + } + function syncReadingLayerVisibility() { + const root = els.renderedPane?.querySelector(".rendered-doc"); + if (root) applyReadingLayerClasses(root, showReadingFurniture, showReadingBackground); + } + function updatePageZoomResetButton() { + if (!els.pageZoomReset) return; + els.pageZoomReset.textContent = `${pageZoomPercent}%`; + els.pageZoomReset.disabled = pageZoomPercent === 100; + } + function pageViewScrollPane() { + if (!els.pagePane) return null; + return els.pagePane.querySelector(".page-view-port") ?? els.pagePane; + } + function isPagePaneScrollable() { + const pane = pageViewScrollPane(); + if (!pane) return false; + return pane.scrollWidth > pane.clientWidth || pane.scrollHeight > pane.clientHeight; + } + function resetPageZoom() { + pageZoomPercent = 100; + if (els.pageZoom) { + els.pageZoom.value = String(100); + els.pageZoom.setAttribute("aria-valuenow", String(100)); + } + updatePageZoomResetButton(); + const port = pageViewScrollPane(); + if (port) { + port.scrollLeft = 0; + port.scrollTop = 0; + } + refreshPageViewLayout(); + } + function refreshPageViewLayout() { + if (!els.pagePane) return; + cancelAnimationFrame(pageLayoutFrame); + pageLayoutFrame = requestAnimationFrame(() => { + pageLayoutFrame = 0; + const img = els.pagePane?.querySelector(".page-view img"); + if (img?.naturalWidth) applyPageLayout(img); + }); + } + function applyPageLayout(img, pane = els.pagePane) { + applyPageImageSize(img, pane); + syncPageViewChrome(img); + } + function syncPageViewChrome(img) { + const svg = img.parentElement?.querySelector("svg.overlay"); + if (svg && state?.pageViewOverlay) syncOverlayBadges(img, svg, state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); + updatePagePanePanCursor(); + applyBboxVisibility(); + } + function updatePagePanePanCursor() { + if (!els.pagePane) return; + els.pagePane.classList.toggle("can-pan", isPagePaneScrollable() && !pagePanDrag); + } + function setPageSettingsOpen(open) { + pageSettingsOpen = open; + if (els.pageSettingsLayer) els.pageSettingsLayer.hidden = !open; + if (els.settingsToggle) els.settingsToggle.setAttribute("aria-expanded", String(open)); + } + function setReadingSettingsOpen(open) { + readingSettingsOpen = open; + if (els.readingSettingsLayer) els.readingSettingsLayer.hidden = !open; + if (els.readingSettingsToggle) els.readingSettingsToggle.setAttribute("aria-expanded", String(open)); + } + function closeAllSettings() { + setPageSettingsOpen(false); + setReadingSettingsOpen(false); + } + function syncLayoutSubtoggles() { + const layoutEnabled = Boolean(state?.hasPageView && showAllBboxes); + for (const label of [ + els.showLayoutBadgesLabel, + els.showPictureContentsLabel, + els.showTableContentsLabel, + els.showFragmentLinksLabel, + els.showCaptionLinksLabel, + els.showXrefLinksLabel, + els.showReadingOrderLabel + ]) { + if (!label) continue; + label.classList.toggle("settings-option-disabled", !layoutEnabled); + const input = label.querySelector("input"); + if (input) input.disabled = !layoutEnabled; + } + const readingOrderEnabled = layoutEnabled && showReadingOrder; + for (const label of [els.readingOrderArrowsLabel, els.readingOrderGlobalLabel]) { + if (!label) continue; + label.classList.toggle("settings-option-disabled", !readingOrderEnabled); + const input = label.querySelector("input"); + if (input) input.disabled = !readingOrderEnabled; + } + } + function paneDef(key) { + return { + file: { + key: "file", + el: els.paneFile, + canShow: () => fileCatalog.length > 0 + }, + page: { + key: "page", + el: els.panePageView, + canShow: () => Boolean(state?.hasPageView) + }, + markup: { + key: "markup", + el: els.paneMarkup, + canShow: () => Boolean(state) + }, + reading: { + key: "reading", + el: els.paneReading, + canShow: () => Boolean(state) + } + }[key]; + } + function isPaneAvailable(key) { + return paneDef(key)?.canShow() ?? false; + } + function isPaneVisible(key) { + if (!isPaneAvailable(key)) return false; + return Boolean(userPaneVisible[key]); + } + function visiblePaneKeys() { + return [...PANE_KEYS].filter((key) => isPaneVisible(key)); + } + function paneMinRatio(_unused) { + return PANE_MIN_RATIO; + } + function filePaneFitWidthPx() { + const probe = document.createElement("div"); + probe.style.cssText = "position:absolute;visibility:hidden;width:var(--file-pane-fit-width);"; + document.documentElement.appendChild(probe); + const px = probe.getBoundingClientRect().width; + probe.remove(); + return Math.ceil(px) || 108; + } + function resolvedFilePaneWidthPx() { + const fit = filePaneFitWidthPx(); + return Math.max(fit, filePaneWidthPx ?? fit); + } + function contentPaneFrWeights(keys) { + const contentKeys = keys.filter((key) => key !== "file"); + const weights = contentKeys.map((key) => paneRatios[paneRatioIndex(key)]); + const sum = weights.reduce((a, b) => a + b, 0) || contentKeys.length; + return weights.map((w) => w / sum); + } + function paneRatioIndex(key) { + return PANE_KEYS.indexOf(key); + } + function normalizePaneRatios() { + const sum = paneRatios.reduce((a, b) => a + b, 0); + if (sum <= 0) { + paneRatios = [...DEFAULT_PANE_RATIOS]; + return; + } + paneRatios = paneRatios.map((r) => r / sum); + } + function loadLayoutPrefs() { + try { + const raw = localStorage.getItem(LAYOUT_STORAGE_KEY); + if (!raw) return; + const data = JSON.parse(raw); + if (data?.visible && typeof data.visible === "object") { + for (const key of PANE_KEYS) if (typeof data.visible[key] === "boolean") userPaneVisible[key] = data.visible[key]; + if (typeof data.visible["file"] === "boolean") filePaneUserToggled = true; + } + if (Array.isArray(data?.ratios)) { + const valid = data.ratios.every((n) => typeof n === "number" && n > 0); + if (valid && data.ratios.length === 4) { + paneRatios = [...data.ratios]; + normalizePaneRatios(); + } else if (valid && data.ratios.length === 3) { + paneRatios = [1, ...data.ratios]; + normalizePaneRatios(); + } + } + if (typeof data?.filePaneWidthPx === "number" && data.filePaneWidthPx > 0) filePaneWidthPx = data.filePaneWidthPx; + } catch {} + } + function saveLayoutPrefs() { + try { + localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ + visible: userPaneVisible, + ratios: paneRatios, + filePaneWidthPx + })); + } catch {} + } + function resetPaneLayout() { + filePaneUserToggled = false; + userPaneVisible = { + file: defaultFilePaneVisible(), + page: true, + markup: true, + reading: true + }; + paneRatios = [...DEFAULT_PANE_RATIOS]; + normalizePaneRatios(); + filePaneWidthPx = null; + setReadingSettingsOpen(false); + syncPagePaneControls(); + syncToolbarPaneCheckboxes(); + saveLayoutPrefs(); + applyPaneLayout(); + } + function isLayoutStacked() { + return Boolean(layoutStackQuery?.matches); + } + function initLayoutStackListener() { + if (!els.main) return; + layoutStackQuery = window.matchMedia(`(max-width: ${LAYOUT_STACK_BREAKPOINT_PX}px)`); + const onChange = () => applyPaneLayout(); + layoutStackQuery.addEventListener("change", onChange); + onChange(); + } + function paneKeysAdjacent(leftKey, rightKey) { + const leftIdx = PANE_KEYS.indexOf(leftKey); + const rightIdx = PANE_KEYS.indexOf(rightKey); + return leftIdx >= 0 && rightIdx === leftIdx + 1; + } + function onlyHiddenPanesBetween(leftKey, rightKey) { + const leftIdx = PANE_KEYS.indexOf(leftKey); + const rightIdx = PANE_KEYS.indexOf(rightKey); + if (leftIdx < 0 || rightIdx <= leftIdx) return false; + for (let i = leftIdx + 1; i < rightIdx; i++) if (isPaneVisible(PANE_KEYS[i])) return false; + return true; + } + function shouldShowSplitterBetween(leftKey, rightKey) { + if (!isPaneVisible(leftKey) || !isPaneVisible(rightKey)) return false; + if (paneKeysAdjacent(leftKey, rightKey)) return true; + return onlyHiddenPanesBetween(leftKey, rightKey); + } + function splitterForLayoutGap(leftKey, rightKey) { + if (!shouldShowSplitterBetween(leftKey, rightKey)) return null; + const leftIdx = PANE_KEYS.indexOf(leftKey); + if (leftIdx < 0) return null; + return els.splitters[leftIdx] ?? null; + } + function visiblePaneNeighborAfter(key) { + const idx = PANE_KEYS.indexOf(key); + if (idx < 0) return null; + for (let i = idx + 1; i < PANE_KEYS.length; i++) { + const neighbor = PANE_KEYS[i]; + if (isPaneVisible(neighbor)) return neighbor; + } + return null; + } + function visiblePaneNeighborBefore(key) { + const idx = PANE_KEYS.indexOf(key); + if (idx < 0) return null; + for (let i = idx - 1; i >= 0; i--) { + const neighbor = PANE_KEYS[i]; + if (isPaneVisible(neighbor)) return neighbor; + } + return null; + } + function resolvedPhysicalSplitterKeys(physicalSplitterIndex) { + const leftPhysical = PANE_KEYS[physicalSplitterIndex]; + const rightPhysical = PANE_KEYS[physicalSplitterIndex + 1]; + if (!leftPhysical || !rightPhysical) return null; + const leftKey = isPaneVisible(leftPhysical) ? leftPhysical : visiblePaneNeighborBefore(rightPhysical); + const rightKey = isPaneVisible(rightPhysical) ? rightPhysical : visiblePaneNeighborAfter(leftPhysical); + if (!leftKey || !rightKey || leftKey === rightKey) return null; + if (!shouldShowSplitterBetween(leftKey, rightKey)) return null; + const canonical = splitterForLayoutGap(leftKey, rightKey); + if (!canonical || canonical !== els.splitters[physicalSplitterIndex]) return null; + return { + leftKey, + rightKey + }; + } + function resetPaneGridStyles() { + for (const key of PANE_KEYS) { + const el = paneDef(key)?.el; + if (el) { + el.style.gridColumn = ""; + el.style.gridRow = ""; + } + } + for (const splitter of els.splitters) { + if (!splitter) continue; + splitter.style.gridColumn = ""; + splitter.style.gridRow = ""; + splitter.hidden = true; + } + } + function setUserPaneVisible(key, visible) { + userPaneVisible[key] = visible; + if (key === "file") filePaneUserToggled = true; + if (key === "page") syncPagePaneControls(); + if (key === "reading" && !visible) setReadingSettingsOpen(false); + syncToolbarPaneCheckboxes(); + saveLayoutPrefs(); + applyPaneLayout(); + } + function applyPaneLayout() { + if (!els.main) return; + const stacked = document.body.classList.contains("viewer-loaded") && isLayoutStacked(); + els.main.classList.toggle("layout-stacked", stacked); + for (const key of PANE_KEYS) { + const def = paneDef(key); + if (!def?.el) continue; + def.el.hidden = !isPaneVisible(key); + def.el.classList.remove("pane-layout-last"); + } + if (!document.body.classList.contains("viewer-loaded")) { + resetPaneGridStyles(); + els.main.style.gridTemplateColumns = ""; + els.main.style.gridTemplateRows = ""; + return; + } + let keys = visiblePaneKeys(); + if (!keys.length) { + userPaneVisible.markup = true; + keys = visiblePaneKeys(); + } + const lastKey = keys[keys.length - 1]; + paneDef(lastKey)?.el?.classList.add("pane-layout-last"); + resetPaneGridStyles(); + if (stacked) { + els.main.style.gridTemplateRows = ""; + els.main.style.gridTemplateColumns = "1fr"; + let row = 1; + for (const key of keys) { + const def = paneDef(key); + if (!def?.el) continue; + def.el.style.gridRow = String(row++); + } + refreshPageViewLayout(); + if (els.readingSettingsToggle) els.readingSettingsToggle.hidden = !state || !isPaneVisible("reading"); + return; + } + const contentFr = contentPaneFrWeights(keys); + const cols = []; + let contentFrIndex = 0; + keys.forEach((key, index) => { + if (key === "file") cols.push(`${resolvedFilePaneWidthPx()}px`); + else cols.push(`minmax(0, ${contentFr[contentFrIndex++].toFixed(6)}fr)`); + if (index < keys.length - 1 && shouldShowSplitterBetween(keys[index], keys[index + 1])) cols.push("1px"); + }); + els.main.style.gridTemplateColumns = cols.join(" "); + els.main.style.gridTemplateRows = "minmax(0, 1fr)"; + let col = 1; + keys.forEach((key, index) => { + const def = paneDef(key); + if (!def?.el) return; + def.el.style.gridColumn = String(col); + col += 1; + if (index < keys.length - 1) { + const leftKey = keys[index]; + const rightKey = keys[index + 1]; + if (!shouldShowSplitterBetween(leftKey, rightKey)) return; + const splitter = splitterForLayoutGap(leftKey, rightKey); + if (splitter) { + splitter.hidden = false; + splitter.style.gridColumn = String(col); + col += 1; + } + } + }); + refreshPageViewLayout(); + if (els.readingSettingsToggle) els.readingSettingsToggle.hidden = !state || !isPaneVisible("reading"); + } + function setToolbarOptionsOpen(open) { + toolbarOptionsOpen = open; + if (els.toolbarOptionsPanel) els.toolbarOptionsPanel.hidden = !open; + if (els.toolbarOptionsBtn) els.toolbarOptionsBtn.setAttribute("aria-expanded", String(open)); + } + function syncToolbarPaneCheckboxes() { + if (els.toggleFilePane) { + const available = isPaneAvailable("file"); + els.toggleFilePane.checked = available && userPaneVisible.file; + els.toggleFilePane.disabled = !available; + } + if (els.toggleFilePaneLabel) els.toggleFilePaneLabel.classList.toggle("toolbar-options-item-disabled", !isPaneAvailable("file")); + if (els.togglePagePane) { + const available = isPaneAvailable("page"); + els.togglePagePane.checked = available && userPaneVisible.page; + els.togglePagePane.disabled = !available; + } + if (els.togglePagePaneLabel) els.togglePagePaneLabel.classList.toggle("toolbar-options-item-disabled", !isPaneAvailable("page")); + if (els.toggleMarkupPane) { + els.toggleMarkupPane.checked = userPaneVisible.markup; + els.toggleMarkupPane.disabled = !state; + } + if (els.toggleReadingPane) { + els.toggleReadingPane.checked = userPaneVisible.reading; + els.toggleReadingPane.disabled = !state; + } + for (const label of [ + els.toggleFilePaneLabel, + els.togglePagePaneLabel, + document.getElementById("toggle-markup-pane-label"), + document.getElementById("toggle-reading-pane-label") + ]) { + if (!label) continue; + const input = label.querySelector("input"); + label.classList.toggle("toolbar-options-item-disabled", Boolean(input?.disabled)); + } + if (els.resetPaneLayoutBtn) els.resetPaneLayoutBtn.disabled = !state; + } + function initToolbarOptions() { + if (!els.toolbarOptionsBtn || !els.toolbarOptionsPanel) return; + els.toolbarOptionsBtn.addEventListener("click", (e) => { + e.stopPropagation(); + setToolbarOptionsOpen(!toolbarOptionsOpen); + }); + document.addEventListener("click", (e) => { + if (!toolbarOptionsOpen) return; + if (e.target instanceof Node && els.toolbarOptionsPanel.contains(e.target)) return; + if (e.target instanceof Node && els.toolbarOptionsBtn.contains(e.target)) return; + setToolbarOptionsOpen(false); + }); + const onToggle = (key, input) => { + input?.addEventListener("change", () => { + if (!state) return; + if (![...PANE_KEYS].filter((k) => k === key ? input.checked : userPaneVisible[k] && isPaneAvailable(k)).length) { + input.checked = true; + return; + } + setUserPaneVisible(key, input.checked); + }); + }; + onToggle("file", els.toggleFilePane); + onToggle("page", els.togglePagePane); + onToggle("markup", els.toggleMarkupPane); + onToggle("reading", els.toggleReadingPane); + els.resetPaneLayoutBtn?.addEventListener("click", () => { + if (state) resetPaneLayout(); + }); + syncToolbarPaneCheckboxes(); + } + function contentPaneAvailableWidthPx() { + if (!els.main) return 1; + const rect = els.main.getBoundingClientRect(); + const keys = visiblePaneKeys(); + let reserved = 0; + if (keys.includes("file")) reserved += resolvedFilePaneWidthPx(); + for (let i = 0; i < keys.length - 1; i++) if (shouldShowSplitterBetween(keys[i], keys[i + 1])) reserved += 1; + return Math.max(rect.width - reserved, 1); + } + function startPaneDrag(e, physicalSplitterIndex) { + if (e.button !== 0 || isLayoutStacked() || !document.body.classList.contains("viewer-loaded")) return; + const resolved = resolvedPhysicalSplitterKeys(physicalSplitterIndex); + if (!resolved) return; + const { leftKey, rightKey } = resolved; + normalizePaneRatios(); + const leftIndex = paneRatioIndex(leftKey); + const rightIndex = paneRatioIndex(rightKey); + const dragState = { + physicalSplitterIndex, + leftKey, + rightKey, + startX: e.clientX, + leftStart: paneRatios[leftIndex], + rightStart: paneRatios[rightIndex], + pointerId: e.pointerId + }; + if (leftKey === "file") dragState.leftStartPx = resolvedFilePaneWidthPx(); + else if (rightKey === "file") dragState.rightStartPx = resolvedFilePaneWidthPx(); + paneDrag = dragState; + e.preventDefault(); + e.currentTarget.setPointerCapture(e.pointerId); + e.currentTarget.classList.add("is-dragging"); + document.body.classList.add("pane-drag-active"); + } + function onPaneDragMove(e) { + if (!paneDrag || e.pointerId !== paneDrag.pointerId || !els.main) return; + if (paneDrag.leftKey === "file" && typeof paneDrag.leftStartPx === "number") { + filePaneWidthPx = Math.max(filePaneFitWidthPx(), paneDrag.leftStartPx + (e.clientX - paneDrag.startX)); + applyPaneLayout(); + return; + } + if (paneDrag.rightKey === "file" && typeof paneDrag.rightStartPx === "number") { + filePaneWidthPx = Math.max(filePaneFitWidthPx(), paneDrag.rightStartPx - (e.clientX - paneDrag.startX)); + applyPaneLayout(); + return; + } + const keys = visiblePaneKeys(); + const contentFr = contentPaneFrWeights(keys); + const leftContentIndex = keys.filter((key) => key !== "file").indexOf(paneDrag.leftKey); + if (leftContentIndex < 0 || leftContentIndex + 1 >= contentFr.length) return; + const pairFrTotal = contentFr[leftContentIndex] + contentFr[leftContentIndex + 1]; + if (!(pairFrTotal > 0)) return; + const pairPixels = Math.max(contentPaneAvailableWidthPx() * pairFrTotal, 1); + const deltaRatio = (e.clientX - paneDrag.startX) / pairPixels; + const leftIndex = paneRatioIndex(paneDrag.leftKey); + const rightIndex = paneRatioIndex(paneDrag.rightKey); + const pairStoredTotal = paneDrag.leftStart + paneDrag.rightStart; + if (!(pairStoredTotal > 0) || leftIndex < 0 || rightIndex < 0) return; + let nextLeft = paneDrag.leftStart + deltaRatio * pairStoredTotal; + const leftMin = Math.min(paneMinRatio(paneDrag.leftKey), pairStoredTotal / 2); + const rightMin = Math.min(paneMinRatio(paneDrag.rightKey), pairStoredTotal / 2); + nextLeft = Math.min(Math.max(nextLeft, leftMin), pairStoredTotal - rightMin); + paneRatios[leftIndex] = nextLeft; + paneRatios[rightIndex] = pairStoredTotal - nextLeft; + applyPaneLayout(); + } + function endPaneDrag(e) { + if (!paneDrag || e.pointerId !== paneDrag.pointerId) return; + const splitter = els.splitters[paneDrag.physicalSplitterIndex]; + splitter?.classList.remove("is-dragging"); + if (splitter?.hasPointerCapture(e.pointerId)) splitter.releasePointerCapture(e.pointerId); + paneDrag = null; + document.body.classList.remove("pane-drag-active"); + normalizePaneRatios(); + saveLayoutPrefs(); + } + function initPaneSplitters() { + for (const [index, splitter] of els.splitters.entries()) { + if (!splitter) continue; + splitter.addEventListener("pointerdown", (e) => startPaneDrag(e, index)); + } + window.addEventListener("pointermove", (e) => onPaneDragMove(e)); + window.addEventListener("pointerup", (e) => endPaneDrag(e)); + window.addEventListener("pointercancel", (e) => endPaneDrag(e)); + } + function goToPage(n) { + if (!state) return; + setPageSettingsOpen(false); + const page = Math.min(Math.max(1, n), state.pageCount); + state.currentPage = page; + renderPage(page); + } + function resetPageNumberInput() { + if (!els.pageNumberInput) return; + els.pageNumberInput.value = state ? String(state.currentPage) : "1"; + } + function commitPageNumberInput() { + if (!state || !els.pageNumberInput) return; + const n = Number.parseInt(els.pageNumberInput.value.trim(), 10); + if (!Number.isFinite(n)) { + resetPageNumberInput(); + return; + } + goToPage(n); + } + function setPageIndicator(pageNum, pageCount) { + if (!els.pageIndicator) return; + if (els.pageCountIndicator) els.pageCountIndicator.textContent = `\u00A0of ${pageCount}`; + if (els.pageNumberInput) { + const digits = Math.max(1, String(pageCount).length); + els.pageNumberInput.style.setProperty("--page-num-digits", String(digits)); + els.pageNumberInput.disabled = !state; + if (document.activeElement !== els.pageNumberInput) els.pageNumberInput.value = String(pageNum); + } + } + function syncPagePaneControls() { + const pageVisible = isPaneVisible("page"); + if (els.settingsToggle) els.settingsToggle.hidden = !pageVisible; + if (els.pageZoomLabel) els.pageZoomLabel.hidden = !pageVisible; + if (pageVisible) updatePageZoomResetButton(); + if (els.pagePane) els.pagePane.tabIndex = pageVisible ? 0 : -1; + } + function findElementIdOnPage(el) { + if (!state?.elementIds) return null; + for (const [node, id] of state.elementIds) if (node === el) return id; + return null; + } + function navigateThreadFragment(elementId, direction) { + const el = state?.idToElement?.get(elementId); + if (!el) return; + const nav = state?.threadNavByElement?.get(el); + if (!nav) return; + const target = direction === "prev" ? nav.prev : nav.next; + if (!target) return; + const page = state.elementPageByEl.get(target); + if (!page) return; + if (page === state.currentPage) { + const id = findElementIdOnPage(target); + if (id) selectElement(id); + return; + } + state.pendingSelectElement = target; + goToPage(page); + } + function findListVirtualTextHost(list, target) { + const nodes = [...list.childNodes]; + let i = skipContainerLevelHead(nodes, 0); + while (i < nodes.length) { + const node = nodes[i]; + if (node.nodeType !== Node.ELEMENT_NODE || localName(node) !== "ldiv") { + i += 1; + continue; + } + const ldiv = node; + i += 1; + const end = skipUntilListItemBoundary(nodes, i); + if (target === ldiv || nodes.slice(i, end).some((n) => xmlContains(target, n))) return ldiv; + i = end; + } + return null; + } + function findTableVirtualTextHost(container, target) { + const nodes = [...container.childNodes]; + let i = skipContainerLevelHead(nodes, 0); + while (i < nodes.length) { + const node = nodes[i]; + if (node.nodeType !== Node.ELEMENT_NODE) { + i += 1; + continue; + } + const tag = localName(node); + if (tag === "nl" || isVirtualTextHost(node) || CELL_SPAN_TAGS.has(tag) || !isCellToken(tag)) { + i += 1; + continue; + } + const cell = node; + i += 1; + const end = skipUntilCellBoundary(nodes, i); + if (target === cell || nodes.slice(i, end).some((n) => xmlContains(target, n))) return cell; + i = end; + } + return null; + } + function findVirtualTextHost(xmlEl) { + let node = xmlEl; + while (node) { + const parent = node.parentElement; + if (!parent) return null; + const tag = localName(parent); + if (tag === "list") { + const host = findListVirtualTextHost(parent, xmlEl); + if (host) return host; + } + if (OTSL_CONTAINER_TAGS.has(tag)) { + const host = findTableVirtualTextHost(parent, xmlEl); + if (host) return host; + } + node = parent; + } + return null; + } + function resolveSelectionElement(xmlEl) { + if (!xmlEl) return null; + if (isSemanticElement(xmlEl) || isVirtualTextHost(xmlEl)) return xmlEl; + let node = xmlEl.parentElement; + while (node) { + if (localName(node) === "doclang") break; + if (isSemanticElement(node) && !isListOrOtslContainer(node)) return node; + node = node.parentElement; + } + const virtualHost = findVirtualTextHost(xmlEl); + if (virtualHost) return virtualHost; + node = xmlEl.parentElement; + while (node) { + if (localName(node) === "doclang") break; + if (isSemanticElement(node) || isVirtualTextHost(node)) return node; + node = node.parentElement; + } + return null; + } + function resolveSelectionElementId(rawElementId) { + if (!rawElementId || !state?.idToElement || !state.elementIds) return null; + const xmlEl = state.idToElement.get(rawElementId); + if (!xmlEl) return null; + const resolved = resolveSelectionElement(xmlEl); + return resolved ? state.elementIds.get(resolved) ?? null : null; + } + function selectElement(elementId) { + if (!elementId) return; + selectedElementId = elementId; + applySelection(); + } + function clearSelection() { + selectedElementId = null; + applySelection(); + } + function fragmentPeerElementIds(elementId) { + const peers = /* @__PURE__ */ new Set(); + if (!elementId || !state?.elementIds || !state.idToElement) return peers; + const el = state.idToElement.get(elementId); + const threadId = el ? elementThreadId(el) : null; + if (!threadId) return peers; + for (const [node, id] of state.elementIds) if (elementThreadId(node) === threadId) peers.add(id); + return peers; + } + function isPictureContentOverlayElement(elementId) { + return isPictureContentElement(state?.idToElement?.get(elementId) ?? null); + } + function isTableContentOverlayElement(elementId) { + return isTableContentElement(state?.idToElement?.get(elementId) ?? null); + } + function isContentsOptionHidden(elementId, clickVisible) { + if (clickVisible) return false; + if (!showPictureContents && isPictureContentOverlayElement(elementId)) return true; + if (!showTableContents && isTableContentOverlayElement(elementId)) return true; + return false; + } + function isFragmentLinkRelevant(linkEl, peerIds) { + const fromId = linkEl.getAttribute("data-fragment-from-id"); + const toId = linkEl.getAttribute("data-fragment-to-id"); + if (fromId && peerIds.has(fromId)) return true; + if (toId && peerIds.has(toId)) return true; + return false; + } + function applyBboxVisibility() { + if (!state?.hasPageView || !els.pagePane) return; + const peerIds = selectedElementId ? fragmentPeerElementIds(selectedElementId) : /* @__PURE__ */ new Set(); + for (const el of els.pagePane.querySelectorAll(".bbox")) { + el.classList.remove("related"); + const elementId = el.getAttribute("data-element-id") ?? ""; + const clickVisible = elementId === selectedElementId || peerIds.has(elementId); + if (showAllBboxes) { + if (isContentsOptionHidden(elementId, clickVisible)) el.classList.add("bbox-hidden"); + else { + el.classList.remove("bbox-hidden"); + if (peerIds.has(elementId)) el.classList.add("related"); + } + continue; + } + if (elementId === selectedElementId) el.classList.remove("bbox-hidden"); + else if (peerIds.has(elementId)) { + el.classList.remove("bbox-hidden"); + el.classList.add("related"); + } else el.classList.add("bbox-hidden"); + } + for (const el of els.pagePane.querySelectorAll(".element-badge")) { + const elementId = el.getAttribute("data-element-id") ?? ""; + const clickVisible = elementId === selectedElementId || peerIds.has(elementId); + if (!showAllBboxes || !showLayoutBadges) { + el.classList.add("bbox-hidden"); + continue; + } + if (isContentsOptionHidden(elementId, clickVisible)) el.classList.add("bbox-hidden"); + else el.classList.remove("bbox-hidden"); + } + for (const el of els.pagePane.querySelectorAll(".caption-link")) { + if (!showAllBboxes || !showCaptionLinks) { + el.classList.add("bbox-hidden"); + continue; + } + el.classList.remove("bbox-hidden"); + } + for (const el of els.pagePane.querySelectorAll(".xref-link")) { + if (!showAllBboxes || !showXrefLinks) { + el.classList.add("bbox-hidden"); + continue; + } + el.classList.remove("bbox-hidden"); + } + for (const el of els.pagePane.querySelectorAll(".fragment-link")) { + const clickVisible = Boolean(selectedElementId && isFragmentLinkRelevant(el, peerIds)); + el.classList.toggle("bbox-hidden", !(clickVisible || showAllBboxes && showFragmentLinks)); + } + for (const el of els.pagePane.querySelectorAll(".fragment-nav")) { + const elementId = el.getAttribute("data-element-id") ?? ""; + const clickVisible = elementId === selectedElementId || peerIds.has(elementId); + el.classList.toggle("bbox-hidden", !(clickVisible || showAllBboxes && showFragmentLinks)); + } + for (const el of els.pagePane.querySelectorAll(".reading-order-badge")) { + const elementId = el.getAttribute("data-element-id") ?? ""; + const clickVisible = elementId === selectedElementId || peerIds.has(elementId); + if (!showAllBboxes || !showReadingOrder) { + el.classList.add("bbox-hidden"); + continue; + } + if (isPictureContentOverlayElement(elementId) || isTableContentOverlayElement(elementId) || isContentsOptionHidden(elementId, clickVisible)) { + el.classList.add("bbox-hidden"); + continue; + } + el.classList.remove("bbox-hidden"); + } + for (const el of els.pagePane.querySelectorAll(".reading-order-step")) { + if (!showAllBboxes || !showReadingOrder || !showReadingOrderArrows) { + el.classList.add("bbox-hidden"); + continue; + } + el.classList.remove("bbox-hidden"); + } + } + function findMarkupElementForSelection(elementId) { + if (!els.markupPane) return null; + return els.markupPane.querySelector(`.markup-el-virtual-text[data-element-id="${elementId}"]`) || els.markupPane.querySelector(`[data-element-id="${elementId}"]`); + } + function findRenderedElementForSelection(elementId) { + if (!els.renderedPane) return null; + const direct = els.renderedPane.querySelector(`.rendered-el-virtual-text[data-element-id="${elementId}"]`) || els.renderedPane.querySelector(`.rendered-el[data-element-id="${elementId}"]`); + if (direct) return direct; + const xmlEl = state?.idToElement?.get(elementId); + const threadId = xmlEl ? elementThreadId(xmlEl) : null; + if (!threadId) return null; + const merged = els.renderedPane.querySelector(`.rendered-fragment-merged[data-thread-id="${threadId}"]`); + if (!merged) return null; + const primaryId = merged.getAttribute("data-element-id"); + if (!primaryId || primaryId === elementId) return merged; + return fragmentPeerElementIds(primaryId).has(elementId) ? merged : null; + } + function revealReadingLayerForSelection(renderedEl) { + const layer = renderedEl.getAttribute("data-doclang-layer"); + if (!layer || layer === "body") return; + let changed = false; + if (layer === "furniture" && !showReadingFurniture) { + showReadingFurniture = true; + changed = true; + } else if (layer === "background" && !showReadingBackground) { + showReadingBackground = true; + changed = true; + } + if (!changed) return; + syncReadingLayerCheckboxes(); + syncReadingLayerVisibility(); + } + function revealRenderedSelectionContext(renderedEl) { + const pictureContents = renderedEl.closest(".rendered-picture-contents"); + if (pictureContents && !pictureContents.open) pictureContents.open = true; + revealReadingLayerForSelection(renderedEl); + } + function applySelection() { + els.markupPane?.querySelectorAll(".markup-el.selected").forEach((el) => el.classList.remove("selected")); + els.renderedPane?.querySelectorAll(".rendered-el.selected").forEach((el) => el.classList.remove("selected")); + if (!els.pagePane) return; + els.pagePane.querySelectorAll(".bbox.selected, .overlay-badge.selected").forEach((el) => el.classList.remove("selected")); + if (!selectedElementId) { + const img = els.pagePane.querySelector(".page-view img"); + if (img) { + const svg = img.parentElement?.querySelector("svg.overlay"); + if (svg && state?.pageViewOverlay) syncOverlayBadges(img, svg, state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); + } + applyBboxVisibility(); + return; + } + const markupEl = findMarkupElementForSelection(selectedElementId); + if (markupEl) { + markupEl.classList.add("selected"); + markupEl.scrollIntoView({ + block: "nearest", + behavior: "smooth" + }); + } + const renderedEl = findRenderedElementForSelection(selectedElementId); + if (renderedEl) { + revealRenderedSelectionContext(renderedEl); + renderedEl.classList.add("selected"); + renderedEl.scrollIntoView({ + block: "nearest", + behavior: "smooth" + }); + } + if (state?.hasPageView) for (const el of els.pagePane.querySelectorAll(`[data-element-id="${selectedElementId}"]`)) el.classList.add("selected"); + const img = els.pagePane.querySelector(".page-view img"); + if (img) { + const svg = img.parentElement?.querySelector("svg.overlay"); + if (svg && state?.pageViewOverlay) syncOverlayBadges(img, svg, state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); + } + applyBboxVisibility(); + } + function renderPage(pageNum) { + if (!state) return; + const { segments, pageImages, pageCount, defaultResolution } = state; + const segment = segments[pageNum - 1] ?? []; + selectedElementId = null; + setPageIndicator(pageNum, pageCount); + if (els.btnPrev) els.btnPrev.disabled = pageNum <= 1; + if (els.btnNext) els.btnNext.disabled = pageNum >= pageCount; + if (!els.markupPane || !els.renderedPane || !els.pagePane) return; + els.markupPane.innerHTML = ""; + const elementIds = assignElementIds(segment); + state.elementIds = elementIds; + state.idToElement = invertElementIds(elementIds); + if (segmentHasMarkup(segment)) { + els.markupPane.appendChild(buildMarkupView(segment, elementIds, (id) => selectElement(resolveSelectionElementId(id) ?? id))); + els.renderedPane.innerHTML = ""; + els.renderedPane.appendChild(buildRenderedView(segment, elementIds, showReadingFurniture, showReadingBackground, (id) => selectElement(resolveSelectionElementId(id) ?? id))); + } else { + els.markupPane.innerHTML = `
${NO_MARKUP}
`; + els.renderedPane.innerHTML = `
${NO_MARKUP}
`; + } + els.pagePane.innerHTML = ""; + if (state.hasPageView) { + const imageUrl = pageImages.get(pageNum); + if (imageUrl) { + const port = document.createElement("div"); + port.className = "page-view-port"; + const wrap = document.createElement("div"); + wrap.className = "page-view"; + const img = document.createElement("img"); + img.alt = `Page ${pageNum}`; + const onImageReady = () => { + if (img.dataset.layoutGeneration === String(pageNum)) return; + img.dataset.layoutGeneration = String(pageNum); + applyPageImageSize(img, els.pagePane); + const boxes = collectBoundingBoxes(segment, defaultResolution, elementIds); + const existing = wrap.querySelector("svg.overlay"); + if (existing) existing.remove(); + const readingOrderSteps = collectReadingOrderSteps(segment, elementIds, boxes, state.readingOrder, readingOrderGlobalNumbering, state.readingOrderDisplayNumbers); + state.pageViewOverlay = { + boxes, + readingOrderSteps + }; + if (boxes.length) wrap.appendChild(buildOverlay(img, boxes, collectCaptionLinks(segment, elementIds, boxes), collectXrefLinks(segment, elementIds, boxes), readingOrderSteps, collectFragmentLinks(segment, elementIds, boxes, pageNum, state.threadPagesById), collectFragmentNavItems(segment, elementIds, boxes, state.threadNavByElement), defaultResolution, (id) => selectElement(resolveSelectionElementId(id) ?? id), navigateThreadFragment, clearSelection, () => pagePanSuppressClick, (v) => { + pagePanSuppressClick = v; + })); + syncPageViewChrome(img); + const pending = state.pendingSelectElement; + if (pending) { + state.pendingSelectElement = null; + const id = findElementIdOnPage(pending); + if (id) selectElement(id); + } + }; + img.addEventListener("load", onImageReady, { once: true }); + wrap.appendChild(img); + port.appendChild(wrap); + els.pagePane.appendChild(port); + img.src = imageUrl; + if (img.complete) onImageReady(); + if (!pagePaneResizeObserver) { + pagePaneResizeObserver = new ResizeObserver(() => refreshPageViewLayout()); + pagePaneResizeObserver.observe(els.pagePane); + } + } else els.pagePane.innerHTML = `
${NO_IMAGE}
`; + } + } + function setDocLabel(label) { + if (!els.docLabel) return; + if (label) { + els.docLabel.textContent = label; + els.docLabel.hidden = false; + } else { + els.docLabel.textContent = ""; + els.docLabel.hidden = true; + } + } + function setDocumentOpen(open, { markupOnly = false } = {}) { + document.body.classList.toggle("viewer-loaded", open); + document.body.classList.toggle("markup-only", open && markupOnly); + if (els.pageNav) els.pageNav.hidden = !open || markupOnly; + syncToolbarPaneCheckboxes(); + applyPaneLayout(); + } + function setPageViewVisible(visible) { + document.body.classList.toggle("has-page-view", visible); + syncPagePaneControls(); + syncToolbarPaneCheckboxes(); + if (!visible) setPageSettingsOpen(false); + applyPaneLayout(); + syncLayoutSubtoggles(); + } + function resetViewer() { + setDemoLoading(false); + clearFileCatalog(); + filePaneUserToggled = false; + selectedElementId = null; + pagePanDrag = null; + pagePanSuppressClick = false; + showReadingFurniture = true; + showReadingBackground = true; + syncReadingLayerCheckboxes(); + resetPageZoom(); + setDocLabel(null); + setDocumentOpen(false); + document.body.classList.remove("has-page-view"); + closeAllSettings(); + setToolbarOptionsOpen(false); + if (els.markupPane) els.markupPane.innerHTML = ""; + if (els.renderedPane) els.renderedPane.innerHTML = ""; + if (els.pagePane) els.pagePane.innerHTML = ""; + setPageIndicator(1, 1); + if (els.btnPrev) els.btnPrev.disabled = true; + if (els.btnNext) els.btnNext.disabled = true; + if (els.filePane) els.filePane.replaceChildren(); + updateFileView(); + applyPaneLayout(); + } + function pageImageMimeFromExt(ext) { + const normalized = ext.toLowerCase().replace("jpeg", "jpg"); + if (normalized === "png") return "image/png"; + if (normalized === "webp") return "image/webp"; + return "image/jpeg"; + } + function createPageImageObjectUrl(data, ext) { + return URL.createObjectURL(new Blob([data], { type: pageImageMimeFromExt(ext) })); + } + function createFirstPageImageUrlFromFiles(files) { + let bestPage = Infinity; + let bestFile = null; + for (const f of files) { + const parts = (f.webkitRelativePath || f.name).split("/"); + if (parts.length < 2 || parts[parts.length - 2] !== "pages") continue; + const m = PAGE_IMAGE_RE.exec(f.name); + if (!m) continue; + const pageNum = Number(m[1]); + if (pageNum < bestPage) { + bestPage = pageNum; + bestFile = f; + } + } + return bestFile ? URL.createObjectURL(bestFile) : null; + } + async function createFirstPageImageUrlFromZip(source) { + const entries = await unzip(source instanceof File ? await source.arrayBuffer() : source, { shouldExtract: (name) => /^pages\/\d+\.(png|jpe?g|webp)$/i.test(name) }); + let bestPage = Infinity; + let bestEntry = null; + for (const e of entries) { + const m = e.name.match(/^pages\/(\d+)\.(png|jpe?g|webp)$/i); + if (!m) continue; + const pageNum = Number(m[1]); + if (pageNum < bestPage) { + bestPage = pageNum; + bestEntry = e; + } + } + if (!bestEntry) return null; + const ext = bestEntry.name.split(".").pop() ?? "png"; + return createPageImageObjectUrl(bestEntry.data, ext); + } + async function resolveCatalogEntryThumbnail(entry) { + if (entry.thumbnailUrl) return entry.thumbnailUrl; + if (entry.kind === "markup") return null; + try { + if (entry.kind === "folder") entry.thumbnailUrl = createFirstPageImageUrlFromFiles(entry.source); + else if (entry.kind === "archive") entry.thumbnailUrl = await createFirstPageImageUrlFromZip(entry.source); + } catch { + entry.thumbnailUrl = null; + } + return entry.thumbnailUrl; + } + function enrichCatalogEntryThumbnail(entry) { + resolveCatalogEntryThumbnail(entry).then((url) => { + if (!fileCatalog.includes(entry)) { + if (url?.startsWith("blob:")) URL.revokeObjectURL(url); + return; + } + if (url) renderFileView(); + }); + } + function revokeCatalogEntry(entry) { + if (entry?.thumbnailUrl?.startsWith("blob:")) URL.revokeObjectURL(entry.thumbnailUrl); + if (entry) entry.thumbnailUrl = null; + } + function createFileViewThumbnail(entry) { + const thumb = document.createElement("span"); + thumb.className = "file-view-thumb"; + thumb.setAttribute("aria-hidden", "true"); + if (entry.thumbnailUrl) { + const img = document.createElement("img"); + img.src = entry.thumbnailUrl; + img.alt = ""; + thumb.appendChild(img); + } else { + const placeholder = document.createElement("span"); + placeholder.className = "file-view-thumb-placeholder"; + placeholder.innerHTML = FILE_THUMB_PLACEHOLDER_SVG; + thumb.appendChild(placeholder); + } + return thumb; + } + function createFileCatalogEntry(file) { + return { + id: crypto.randomUUID(), + label: file.name, + kind: isMarkupFile(file) ? "markup" : "archive", + source: file, + currentPage: 1, + pageZoom: 100, + snapshot: null, + thumbnailUrl: null + }; + } + function isArchiveFile(file) { + return /\.dclx$/i.test(file.name) || /\.zip$/i.test(file.name); + } + function isMarkupFile(file) { + return /\.(?:dclg(?:\.xml)?|xml)$/i.test(file.name); + } + function hasArchiveTransfer(dataTransfer) { + return Boolean(dataTransfer && [...dataTransfer.types].includes("Files")); + } + async function parseCatalogEntry(entry) { + try { + if (entry.kind === "markup") return buildDocumentState(entry.source instanceof File ? await entry.source.text() : new TextDecoder().decode(entry.source), /* @__PURE__ */ new Map(), entry.label, /* @__PURE__ */ new Map(), { markupOnly: true }); + if (entry.kind === "archive") { + const { markupXml, pageImages, assetUrls } = await extractArchiveFromZipBuffer(entry.source instanceof File ? await entry.source.arrayBuffer() : entry.source); + return buildDocumentState(markupXml, pageImages, entry.label, assetUrls, { markupOnly: false }); + } + if (entry.kind === "folder") { + const { markupXml, pageImages, assetUrls } = await extractArchiveFromFiles(entry.source); + return buildDocumentState(markupXml, pageImages, entry.label, assetUrls, { markupOnly: false }); + } + } catch (err) { + alert(`Failed to read ${entry.label}: ${err.message}`); + } + return null; + } + function persistActiveFileViewState() { + if (activeFileIndex < 0 || !state) return; + const entry = fileCatalog[activeFileIndex]; + if (!entry) return; + entry.currentPage = state.currentPage; + entry.pageZoom = pageZoomPercent; + } + function releaseActiveDocument() { + if (activeFileIndex >= 0) { + const entry = fileCatalog[activeFileIndex]; + if (entry?.snapshot) { + revokeDocumentState(entry.snapshot); + entry.snapshot = null; + } + } + if (state) revokeDocumentState(state); + state = null; + selectedElementId = null; + pagePanDrag = null; + pagePanSuppressClick = false; + } + function clearFileCatalog() { + releaseActiveDocument(); + for (const entry of fileCatalog) revokeCatalogEntry(entry); + fileCatalog = []; + activeFileIndex = -1; + } + async function switchToFile(index) { + if (index < 0 || index >= fileCatalog.length) return; + persistActiveFileViewState(); + releaseActiveDocument(); + activeFileIndex = index; + const entry = fileCatalog[index]; + const docState = await parseCatalogEntry(entry); + if (!docState) { + revokeCatalogEntry(entry); + fileCatalog.splice(index, 1); + activeFileIndex = -1; + if (fileCatalog.length) await switchToFile(Math.min(index, fileCatalog.length - 1)); + else resetViewer(); + return; + } + entry.snapshot = docState; + docState.currentPage = entry.currentPage ?? 1; + activateDocument(docState, entry); + } + function defaultFilePaneVisible() { + return fileCatalog.length > 1; + } + function syncFilePaneDefault() { + if (!filePaneUserToggled) { + const wasVisible = userPaneVisible.file; + const shouldBeVisible = defaultFilePaneVisible(); + userPaneVisible.file = shouldBeVisible; + if (!wasVisible && shouldBeVisible) { + paneRatios = [...DEFAULT_PANE_RATIOS]; + normalizePaneRatios(); + filePaneWidthPx = null; + } + } + } + async function closeCatalogFile(index) { + if (index < 0 || index >= fileCatalog.length) return; + const wasActive = index === activeFileIndex; + const entry = fileCatalog[index]; + if (wasActive) { + releaseActiveDocument(); + activeFileIndex = -1; + } + revokeCatalogEntry(entry); + fileCatalog.splice(index, 1); + if (!fileCatalog.length) { + resetViewer(); + return; + } + if (wasActive) { + await switchToFile(Math.min(index, fileCatalog.length - 1)); + return; + } + if (index < activeFileIndex) activeFileIndex -= 1; + updateFileView(); + } + function renderFileView() { + if (!els.filePane) return; + els.filePane.replaceChildren(); + if (!fileCatalog.length) return; + const list = document.createElement("ul"); + list.className = "file-view-list"; + list.setAttribute("role", "listbox"); + list.setAttribute("aria-label", "Open files"); + fileCatalog.forEach((entry, index) => { + const item = document.createElement("li"); + const card = document.createElement("div"); + card.className = "file-view-item"; + card.title = entry.label; + card.tabIndex = 0; + card.setAttribute("role", "option"); + if (index === activeFileIndex) { + card.classList.add("is-active"); + card.setAttribute("aria-selected", "true"); + } else card.setAttribute("aria-selected", "false"); + const thumbWrap = document.createElement("div"); + thumbWrap.className = "file-view-thumb-wrap"; + const closeBtn = document.createElement("button"); + closeBtn.type = "button"; + closeBtn.className = "file-view-close"; + closeBtn.setAttribute("aria-label", `Close ${entry.label}`); + closeBtn.textContent = "×"; + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + closeCatalogFile(index); + }); + thumbWrap.append(createFileViewThumbnail(entry), closeBtn); + const label = document.createElement("span"); + label.className = "file-view-label"; + label.textContent = entry.label; + card.append(thumbWrap, label); + card.addEventListener("click", (e) => { + if (e.target.closest(".file-view-close")) return; + switchToFile(index); + }); + card.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + switchToFile(index); + } + }); + item.appendChild(card); + list.appendChild(item); + }); + els.filePane.appendChild(list); + } + function updateFileView() { + syncFilePaneDefault(); + syncFilePaneCloseAllButton(); + renderFileView(); + syncToolbarPaneCheckboxes(); + applyPaneLayout(); + } + function syncFilePaneCloseAllButton() { + if (!els.filePaneCloseAll) return; + els.filePaneCloseAll.hidden = fileCatalog.length === 0; + } + function initFilePaneCloseAll() { + els.filePaneCloseAll?.addEventListener("click", () => { + if (!fileCatalog.length) return; + const count = fileCatalog.length; + const message = count === 1 ? `Remove "${fileCatalog[0].label}" from the viewer?` : `Remove all ${count} open files from the viewer?`; + if (confirm(message)) resetViewer(); + }); + } + function activateDocument(docState, entry) { + state = docState; + pageLayoutCache = null; + closeAllSettings(); + pageZoomPercent = entry.pageZoom ?? 100; + if (els.pageZoom) { + els.pageZoom.value = String(pageZoomPercent); + els.pageZoom.setAttribute("aria-valuenow", String(pageZoomPercent)); + } + updatePageZoomResetButton(); + const port = pageViewScrollPane(); + if (port) { + port.scrollLeft = 0; + port.scrollTop = 0; + } + setDocLabel(entry.label); + setDocumentOpen(true, { markupOnly: state.markupOnly }); + setPageViewVisible(state.hasPageView); + renderPage(state.currentPage); + updateFileView(); + } + function setDemoLoading(loading) { + document.body.classList.toggle("demo-loading", loading); + const btnDemo = document.getElementById("btn-demo"); + if (btnDemo) btnDemo.disabled = loading; + } + async function loadDemo() { + if (demoLoadInProgress) return; + demoLoadInProgress = true; + setDemoLoading(true); + try { + const res = await fetch(DEMO_ARCHIVE_URL); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const label = DEMO_ARCHIVE_URL.split("/").pop() || "demo.dclx"; + await addArchiveBufferToCatalog(await res.arrayBuffer(), label, { replace: true }); + } catch (err) { + alert(`Failed to load demo: ${err.message}\n\nServe this directory over HTTP (e.g. python3 -m http.server) and open the viewer from localhost.`); + } finally { + demoLoadInProgress = false; + if (!document.body.classList.contains("viewer-loaded")) setDemoLoading(false); + } + } + async function addFilesToCatalog(files, { replace = false } = {}) { + if (replace) { + clearFileCatalog(); + filePaneUserToggled = false; + } + const startIndex = fileCatalog.length; + for (const file of files) { + const entry = createFileCatalogEntry(file); + fileCatalog.push(entry); + enrichCatalogEntryThumbnail(entry); + } + if (!fileCatalog.length) return; + await switchToFile(replace ? 0 : startIndex); + } + async function appendFolderArchive(files) { + if (!files.some((f) => f.name === "document.xml")) { + alert("Archive must contain document.xml at its root."); + return; + } + const rootName = (files[0].webkitRelativePath || files[0].name).split("/")[0] || "archive"; + const entry = { + id: crypto.randomUUID(), + label: rootName, + kind: "folder", + source: files, + currentPage: 1, + pageZoom: 100, + snapshot: null, + thumbnailUrl: null + }; + fileCatalog.push(entry); + enrichCatalogEntryThumbnail(entry); + await switchToFile(fileCatalog.length - 1); + } + async function addArchiveBufferToCatalog(buffer, label, { replace = false } = {}) { + if (replace) { + clearFileCatalog(); + filePaneUserToggled = false; + } + const entry = { + id: crypto.randomUUID(), + label, + kind: "archive", + source: buffer, + currentPage: 1, + pageZoom: 100, + snapshot: null, + thumbnailUrl: null + }; + fileCatalog.push(entry); + enrichCatalogEntryThumbnail(entry); + await switchToFile(replace ? 0 : fileCatalog.length - 1); + } + async function loadFromDrop(dataTransfer) { + const files = [...dataTransfer.files]; + if (files.some((f) => f.name === "document.xml")) { + await appendFolderArchive(files); + return; + } + const supported = files.filter((f) => isArchiveFile(f) || isMarkupFile(f)); + if (supported.length) await addFilesToCatalog(supported, { replace: false }); + } + function initPageWheelNav() { + if (!els.pagePane) return; + let pixelAccum = 0; + let pixelGestureUntil = 0; + let lastFlipAt = 0; + function wheelDir(e) { + if (e.deltaMode === 1) return e.deltaY > 0 ? 1 : e.deltaY < 0 ? -1 : 0; + if (e.deltaMode === 2) return Math.sign(e.deltaY); + const now = performance.now(); + if (now > pixelGestureUntil) pixelAccum = 0; + pixelGestureUntil = now + 100; + pixelAccum += e.deltaY; + if (Math.abs(pixelAccum) >= 4) { + const dir = pixelAccum > 0 ? 1 : -1; + pixelAccum = 0; + return dir; + } + return 0; + } + function tryFlipPage(dir) { + if (!dir || !state) return false; + const now = performance.now(); + if (now - lastFlipAt < 200) return false; + const before = state.currentPage; + goToPage(state.currentPage + dir); + if (state.currentPage !== before) { + lastFlipAt = now; + return true; + } + return false; + } + function isScrollAtTop(pane) { + return pane.scrollTop <= 0; + } + function isScrollAtBottom(pane) { + return pane.scrollTop + pane.clientHeight >= pane.scrollHeight - 1; + } + function onScrollPaneWheel(e, pane) { + if (!state || state.markupOnly || state.pageCount <= 1) return; + const dir = wheelDir(e); + if (!dir) return; + const atTop = isScrollAtTop(pane); + const atBottom = isScrollAtBottom(pane); + if (!(dir < 0 && atTop) && !(dir > 0 && atBottom)) return; + e.preventDefault(); + if (!tryFlipPage(dir)) return; + requestAnimationFrame(() => { + pane.scrollTop = dir > 0 ? 0 : pane.scrollHeight; + }); + } + els.pagePane.addEventListener("wheel", (e) => { + if (!state?.hasPageView) return; + const pane = pageViewScrollPane(); + if (!pane) return; + if (pane.scrollHeight > pane.clientHeight || pane.scrollWidth > pane.clientWidth) { + onScrollPaneWheel(e, pane); + return; + } + e.preventDefault(); + const dir = wheelDir(e); + if (dir) tryFlipPage(dir); + }, { passive: false }); + for (const pane of [els.markupPane, els.renderedPane]) { + if (!pane) continue; + pane.addEventListener("wheel", (e) => onScrollPaneWheel(e, pane), { passive: false }); + } + els.pagePane.tabIndex = 0; + els.pagePane.setAttribute("role", "region"); + els.pagePane.setAttribute("aria-label", "Original page"); + els.pagePane.addEventListener("pointerdown", () => { + if (state?.hasPageView) els.pagePane.focus({ preventScroll: true }); + }); + els.pagePane.addEventListener("keydown", (e) => { + if (!state?.hasPageView) return; + switch (e.key) { + case "ArrowDown": + case "PageDown": + case "ArrowRight": + e.preventDefault(); + tryFlipPage(1); + break; + case "ArrowUp": + case "PageUp": + case "ArrowLeft": + e.preventDefault(); + tryFlipPage(-1); + } + }); + } + function canStartPagePan(event) { + if (!(event.target instanceof Element)) return false; + if (!event.target.closest(".page-view")) return false; + return isPagePaneScrollable(); + } + function initPageViewControls() { + if (!els.pagePane) return; + els.pageZoom?.addEventListener("input", () => { + pageZoomPercent = Math.max(100, Number(els.pageZoom.value)); + if (Number(els.pageZoom.value) < 100) els.pageZoom.value = String(pageZoomPercent); + els.pageZoom.setAttribute("aria-valuenow", String(pageZoomPercent)); + updatePageZoomResetButton(); + refreshPageViewLayout(); + }); + els.pageZoomReset?.addEventListener("click", () => { + if (pageZoomPercent !== 100) resetPageZoom(); + }); + els.pagePane.addEventListener("pointerdown", (e) => { + if (e.button !== 0 || !canStartPagePan(e)) return; + const scrollPane = pageViewScrollPane(); + if (!scrollPane) return; + pagePanDrag = { + pointerId: e.pointerId, + startX: e.clientX, + startY: e.clientY, + scrollLeft: scrollPane.scrollLeft, + scrollTop: scrollPane.scrollTop, + moved: false + }; + }); + els.pagePane.addEventListener("pointermove", (e) => { + if (!pagePanDrag || e.pointerId !== pagePanDrag.pointerId) return; + const dx = e.clientX - pagePanDrag.startX; + const dy = e.clientY - pagePanDrag.startY; + if (!pagePanDrag.moved && Math.hypot(dx, dy) >= 5) { + pagePanDrag.moved = true; + els.pagePane.classList.add("is-panning"); + els.pagePane.classList.remove("can-pan"); + els.pagePane.setPointerCapture(e.pointerId); + } + if (!pagePanDrag.moved) return; + const scrollPane = pageViewScrollPane(); + if (!scrollPane) return; + scrollPane.scrollLeft = pagePanDrag.scrollLeft + pagePanDrag.startX - e.clientX; + scrollPane.scrollTop = pagePanDrag.scrollTop + pagePanDrag.startY - e.clientY; + e.preventDefault(); + }); + function endPagePan(e) { + if (!pagePanDrag || e.pointerId !== pagePanDrag.pointerId) return; + if (pagePanDrag.moved) pagePanSuppressClick = true; + pagePanDrag = null; + els.pagePane.classList.remove("is-panning"); + if (els.pagePane.hasPointerCapture(e.pointerId)) els.pagePane.releasePointerCapture(e.pointerId); + updatePagePanePanCursor(); + } + els.pagePane.addEventListener("pointerup", (e) => endPagePan(e)); + els.pagePane.addEventListener("pointercancel", (e) => endPagePan(e)); + } + function initFileTypeHints() { + if (!els.emptyStateFileTypes) return; + const markup = SUPPORTED_FILE_EXTENSIONS.map((ext) => `${ext}`).join(", "); + els.emptyStateFileTypes.innerHTML = markup; + } + function initCursorHints() { + els.markupPane?.addEventListener("mousemove", (e) => { + if (!e.target.closest(".markup-ghost-tag-part")) { + hideCursorHint(); + return; + } + showCursorHint(VIRTUAL_TEXT_TAG_HINT, e.clientX, e.clientY); + }); + els.markupPane?.addEventListener("mouseleave", hideCursorHint); + els.openFileBtn?.addEventListener("mousemove", (e) => showCursorHint(OPEN_FILE_HINT, e.clientX, e.clientY)); + els.openFileBtn?.addEventListener("mouseleave", hideCursorHint); + } + function initBboxHints() { + if (!els.pagePane) return; + els.pagePane.addEventListener("mousemove", (e) => { + if (pagePanDrag?.moved || els.pagePane.classList.contains("is-panning")) { + hideCursorHint(); + return; + } + const navBtn = e.target.closest(".fragment-nav-btn:not(.fragment-nav-btn-disabled)"); + if (navBtn) { + showCursorHint(navBtn.getAttribute("data-nav") === "prev" ? FRAGMENT_NAV_HINT_PREV : FRAGMENT_NAV_HINT_NEXT, e.clientX, e.clientY); + return; + } + const badge = e.target.closest(".element-badge[data-element-id]"); + if (!badge || !state?.idToElement) { + hideCursorHint(); + return; + } + const elementId = badge.getAttribute("data-element-id"); + const xmlEl = elementId ? state.idToElement.get(elementId) : null; + if (!xmlEl) { + hideCursorHint(); + return; + } + showCursorHintHtml(elementHeadTooltipHtml(xmlEl, state.defaultResolution), e.clientX, e.clientY); + }); + els.pagePane.addEventListener("mouseleave", hideCursorHint); + } + function initDragDrop() { + document.body.addEventListener("dragenter", (e) => { + if (!hasArchiveTransfer(e.dataTransfer)) return; + e.preventDefault(); + document.body.classList.add("drag-over"); + }); + document.body.addEventListener("dragover", (e) => { + if (!hasArchiveTransfer(e.dataTransfer)) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; + }); + document.body.addEventListener("dragleave", (e) => { + if (!hasArchiveTransfer(e.dataTransfer)) return; + if (e.relatedTarget && document.body.contains(e.relatedTarget)) return; + document.body.classList.remove("drag-over"); + }); + document.body.addEventListener("drop", async (e) => { + if (!hasArchiveTransfer(e.dataTransfer)) return; + e.preventDefault(); + document.body.classList.remove("drag-over"); + if (e.dataTransfer) await loadFromDrop(e.dataTransfer); + }); + } + document.getElementById("btn-demo")?.addEventListener("click", loadDemo); + document.getElementById("demo-empty-link")?.addEventListener("click", (e) => { + e.preventDefault(); + loadDemo(); + }); + document.getElementById("home-link")?.addEventListener("click", (e) => { + e.preventDefault(); + resetViewer(); + }); + document.getElementById("input-archive")?.addEventListener("change", async (e) => { + const input = e.target; + const files = [...input.files ?? []].filter((f) => isArchiveFile(f) || isMarkupFile(f)); + if (!files.length) return; + await addFilesToCatalog(files, { replace: true }); + input.value = ""; + }); + els.btnPrev?.addEventListener("click", () => state && goToPage(state.currentPage - 1)); + els.btnNext?.addEventListener("click", () => state && goToPage(state.currentPage + 1)); + els.pageNumberInput?.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitPageNumberInput(); + els.pageNumberInput?.blur(); + } else if (e.key === "Escape") { + e.preventDefault(); + resetPageNumberInput(); + els.pageNumberInput?.blur(); + } + }); + els.pageNumberInput?.addEventListener("blur", resetPageNumberInput); + els.pageNumberInput?.addEventListener("focus", (e) => e.target.select()); + els.showAllBboxes?.addEventListener("change", () => { + showAllBboxes = els.showAllBboxes.checked; + syncLayoutSubtoggles(); + const img = els.pagePane?.querySelector(".page-view img"); + if (img && state?.pageViewOverlay) syncOverlayBadges(img, img.parentElement.querySelector("svg.overlay"), state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); + applyBboxVisibility(); + }); + els.showLayoutBadges?.addEventListener("change", () => { + showLayoutBadges = els.showLayoutBadges.checked; + const img = els.pagePane?.querySelector(".page-view img"); + if (img && state?.pageViewOverlay) syncOverlayBadges(img, img.parentElement.querySelector("svg.overlay"), state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); + applyBboxVisibility(); + }); + els.showCaptionLinks?.addEventListener("change", () => { + showCaptionLinks = els.showCaptionLinks.checked; + applyBboxVisibility(); + }); + els.showPictureContents?.addEventListener("change", () => { + showPictureContents = els.showPictureContents.checked; + applyBboxVisibility(); + }); + els.showTableContents?.addEventListener("change", () => { + showTableContents = els.showTableContents.checked; + applyBboxVisibility(); + }); + els.showFragmentLinks?.addEventListener("change", () => { + showFragmentLinks = els.showFragmentLinks.checked; + applyBboxVisibility(); + }); + els.showXrefLinks?.addEventListener("change", () => { + showXrefLinks = els.showXrefLinks.checked; + applyBboxVisibility(); + }); + els.showReadingOrder?.addEventListener("change", () => { + showReadingOrder = els.showReadingOrder.checked; + syncLayoutSubtoggles(); + const img = els.pagePane?.querySelector(".page-view img"); + if (img && state?.pageViewOverlay) syncOverlayBadges(img, img.parentElement.querySelector("svg.overlay"), state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); + applyBboxVisibility(); + }); + els.readingOrderArrows?.addEventListener("change", () => { + showReadingOrderArrows = els.readingOrderArrows.checked; + applyBboxVisibility(); + }); + els.readingOrderGlobal?.addEventListener("change", () => { + readingOrderGlobalNumbering = els.readingOrderGlobal.checked; + if (state) renderPage(state.currentPage); + }); + els.settingsToggle?.addEventListener("click", () => setPageSettingsOpen(!pageSettingsOpen)); + els.readingSettingsToggle?.addEventListener("click", () => setReadingSettingsOpen(!readingSettingsOpen)); + els.pageSettingsClose?.addEventListener("click", () => setPageSettingsOpen(false)); + els.pageSettingsScrim?.addEventListener("click", () => setPageSettingsOpen(false)); + els.readingSettingsClose?.addEventListener("click", () => setReadingSettingsOpen(false)); + els.readingSettingsScrim?.addEventListener("click", () => setReadingSettingsOpen(false)); + document.addEventListener("keydown", (e) => { + if (e.key !== "Escape") return; + if (toolbarOptionsOpen) setToolbarOptionsOpen(false); + else if (pageSettingsOpen) setPageSettingsOpen(false); + else if (readingSettingsOpen) setReadingSettingsOpen(false); + }); + els.showReadingFurniture?.addEventListener("change", () => { + showReadingFurniture = els.showReadingFurniture.checked; + syncReadingLayerVisibility(); + }); + els.showReadingBackground?.addEventListener("change", () => { + showReadingBackground = els.showReadingBackground.checked; + syncReadingLayerVisibility(); + }); + loadLayoutPrefs(); + normalizePaneRatios(); + initToolbarOptions(); + initPaneSplitters(); + initLayoutStackListener(); + initFileTypeHints(); + initCursorHints(); + initBboxHints(); + initDragDrop(); + initFilePaneCloseAll(); + initPageWheelNav(); + initPageViewControls(); + if (document.getElementById("btn-demo")) loadDemo(); + //#endregion +})(); From 6e9c6818bd073a215282cb3e3944dd6e2a2e574e Mon Sep 17 00:00:00 2001 From: DKL Date: Mon, 17 Aug 2026 16:15:06 +0200 Subject: [PATCH 2/7] High level split into components. Signed-off-by: DKL --- core/src/components/base/base.ts | 23 + .../components/cursor-hint/cursor-hint.css | 50 + .../components/cursor-hint/cursor-hint.html | 1 + .../src/components/cursor-hint/cursor-hint.ts | 53 + .../components/empty-state/empty-state.css | 69 + .../components/empty-state/empty-state.html | 21 + .../src/components/empty-state/empty-state.ts | 32 + core/src/components/file-pane/file-pane.css | 191 ++ core/src/components/file-pane/file-pane.html | 12 + core/src/components/file-pane/file-pane.ts | 142 ++ .../components/markup-pane/markup-pane.css | 256 +++ .../components/markup-pane/markup-pane.html | 2 + .../src/components/markup-pane/markup-pane.ts | 32 + core/src/components/page-nav/page-nav.css | 92 + core/src/components/page-nav/page-nav.html | 64 + core/src/components/page-nav/page-nav.ts | 111 ++ .../page-view-pane/page-view-pane.css | 609 ++++++ .../page-view-pane/page-view-pane.html | 104 ++ .../page-view-pane/page-view-pane.ts | 242 +++ .../components/reading-pane/reading-pane.css | 505 +++++ .../components/reading-pane/reading-pane.html | 52 + .../components/reading-pane/reading-pane.ts | 123 ++ core/src/components/toolbar/toolbar.css | 176 ++ core/src/components/toolbar/toolbar.html | 54 + core/src/components/toolbar/toolbar.ts | 170 ++ core/src/constants.ts | 9 +- core/src/document.ts | 6 +- core/src/main.ts | 1377 +++++++++----- core/src/markup.ts | 69 +- core/src/overlay.ts | 24 +- core/src/rendered.ts | 61 +- core/src/vite-env.d.ts | 1 + core/src/{xml_overlay.ts => xml-overlay.ts} | 7 +- core/src/xml.ts | 29 +- core/src/zip.ts | 5 +- core/vite.config.ts | 2 +- index.html | 281 +-- viewer.css | 1650 +---------------- viewer.js | 1268 ++++++++++--- 39 files changed, 5293 insertions(+), 2682 deletions(-) create mode 100644 core/src/components/base/base.ts create mode 100644 core/src/components/cursor-hint/cursor-hint.css create mode 100644 core/src/components/cursor-hint/cursor-hint.html create mode 100644 core/src/components/cursor-hint/cursor-hint.ts create mode 100644 core/src/components/empty-state/empty-state.css create mode 100644 core/src/components/empty-state/empty-state.html create mode 100644 core/src/components/empty-state/empty-state.ts create mode 100644 core/src/components/file-pane/file-pane.css create mode 100644 core/src/components/file-pane/file-pane.html create mode 100644 core/src/components/file-pane/file-pane.ts create mode 100644 core/src/components/markup-pane/markup-pane.css create mode 100644 core/src/components/markup-pane/markup-pane.html create mode 100644 core/src/components/markup-pane/markup-pane.ts create mode 100644 core/src/components/page-nav/page-nav.css create mode 100644 core/src/components/page-nav/page-nav.html create mode 100644 core/src/components/page-nav/page-nav.ts create mode 100644 core/src/components/page-view-pane/page-view-pane.css create mode 100644 core/src/components/page-view-pane/page-view-pane.html create mode 100644 core/src/components/page-view-pane/page-view-pane.ts create mode 100644 core/src/components/reading-pane/reading-pane.css create mode 100644 core/src/components/reading-pane/reading-pane.html create mode 100644 core/src/components/reading-pane/reading-pane.ts create mode 100644 core/src/components/toolbar/toolbar.css create mode 100644 core/src/components/toolbar/toolbar.html create mode 100644 core/src/components/toolbar/toolbar.ts create mode 100644 core/src/vite-env.d.ts rename core/src/{xml_overlay.ts => xml-overlay.ts} (94%) diff --git a/core/src/components/base/base.ts b/core/src/components/base/base.ts new file mode 100644 index 0000000..47f9756 --- /dev/null +++ b/core/src/components/base/base.ts @@ -0,0 +1,23 @@ +/** Base class for all doclang web components. */ + +export abstract class DoclangHTMLElement extends HTMLElement { + readonly shadow: ShadowRoot; + + constructor(css: string, html: string) { + super(); + this.shadow = this.attachShadow({ mode: 'open' }); + const sheet = new CSSStyleSheet(); + sheet.replaceSync(css); + this.shadow.adoptedStyleSheets = [sheet]; + this.shadow.innerHTML = html; + } + + protected q(selector: string): T { + const el = this.shadow.querySelector(selector); + if (!el) + throw new Error( + `[${this.tagName.toLowerCase()}] No element matches "${selector}"` + ); + return el; + } +} diff --git a/core/src/components/cursor-hint/cursor-hint.css b/core/src/components/cursor-hint/cursor-hint.css new file mode 100644 index 0000000..4717808 --- /dev/null +++ b/core/src/components/cursor-hint/cursor-hint.css @@ -0,0 +1,50 @@ +:host { + display: contents; +} +.cursor-hint { + position: fixed; + z-index: 200; + width: max-content; + max-width: 16rem; + padding: 0.3rem 0.5rem; + border-radius: 0.25rem; + background: var(--panel); + border: 1px solid var(--border); + color: var(--text); + font-family: var(--font-ui); + font-size: 0.75rem; + line-height: 1.35; + text-align: center; + white-space: normal; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + pointer-events: none; +} +.cursor-hint.cursor-hint-detail { + max-width: 24rem; + padding: 0.45rem 0.55rem; + text-align: left; +} +.head-tooltip { + border-collapse: collapse; + width: 100%; + font-size: 0.6875rem; + line-height: 1.4; +} +.head-tooltip th { + padding: 0.1rem 0.55rem 0.1rem 0; + color: var(--muted); + font-weight: 600; + text-align: left; + vertical-align: top; + white-space: nowrap; +} +.head-tooltip td { + padding: 0.1rem 0; + vertical-align: top; + word-break: break-word; +} +.head-tooltip .head-default { + color: var(--muted); + font-style: italic; + white-space: nowrap; +} diff --git a/core/src/components/cursor-hint/cursor-hint.html b/core/src/components/cursor-hint/cursor-hint.html new file mode 100644 index 0000000..5351090 --- /dev/null +++ b/core/src/components/cursor-hint/cursor-hint.html @@ -0,0 +1 @@ + diff --git a/core/src/components/cursor-hint/cursor-hint.ts b/core/src/components/cursor-hint/cursor-hint.ts new file mode 100644 index 0000000..8e35292 --- /dev/null +++ b/core/src/components/cursor-hint/cursor-hint.ts @@ -0,0 +1,53 @@ +/** — floating tooltip that follows the pointer */ + +import { DoclangHTMLElement } from '../base/base'; +import styles from './cursor-hint.css?inline'; +import template from './cursor-hint.html?raw'; + +const OFFSET = 10; +const MARGIN = 8; + +export class DoclangCursorHint extends DoclangHTMLElement { + private _hint: HTMLElement; + + constructor() { + super(styles, template); + this._hint = this.q('.cursor-hint'); + } + + hide(): void { + this._hint.hidden = true; + this._hint.classList.remove('cursor-hint-detail'); + this._hint.replaceChildren(); + } + + show(content: string | Node, clientX: number, clientY: number, detail = false): void { + this._hint.replaceChildren(); + if (typeof content === 'string') this._hint.textContent = content; + else this._hint.appendChild(content); + this._hint.classList.toggle('cursor-hint-detail', detail); + this._hint.hidden = false; + this._position(clientX, clientY); + } + + showHtml(html: string, clientX: number, clientY: number): void { + this._hint.innerHTML = html; + this._hint.classList.add('cursor-hint-detail'); + this._hint.hidden = false; + this._position(clientX, clientY); + } + + private _position(clientX: number, clientY: number): void { + let left = clientX + OFFSET; + let top = clientY + OFFSET; + const rect = this._hint.getBoundingClientRect(); + if (left + rect.width > window.innerWidth - MARGIN) + left = clientX - rect.width - OFFSET; + if (top + rect.height > window.innerHeight - MARGIN) + top = clientY - rect.height - OFFSET; + this._hint.style.left = `${Math.max(MARGIN, left)}px`; + this._hint.style.top = `${Math.max(MARGIN, top)}px`; + } +} + +customElements.define('doclang-cursor-hint', DoclangCursorHint); diff --git a/core/src/components/empty-state/empty-state.css b/core/src/components/empty-state/empty-state.css new file mode 100644 index 0000000..8dad389 --- /dev/null +++ b/core/src/components/empty-state/empty-state.css @@ -0,0 +1,69 @@ +:host { + display: contents; +} +.empty-state { + flex: 1 1 0; + display: flex; + align-items: center; + justify-content: center; + min-height: 0; + margin: 1rem; + padding: 2rem 1.5rem; + border: 2px dashed var(--border); + border-radius: 0.75rem; + background: var(--placeholder-bg); + text-align: center; + color: var(--muted); +} +.empty-state-inner { + max-width: 28rem; +} +.empty-state-loading { + display: none; + flex-direction: column; + align-items: center; +} +:host(.demo-loading) .empty-state-loading { + display: flex; +} +:host(.demo-loading) .empty-state-prompt { + display: none; +} +.loading-spinner { + width: 2rem; + height: 2rem; + margin: 0 auto 1rem; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: loading-spin 0.7s linear infinite; +} +@keyframes loading-spin { + to { + transform: rotate(360deg); + } +} +.empty-state-title { + margin: 0 0 0.75rem; + font-size: 1.05rem; + color: var(--text); +} +.empty-state p { + margin: 0.5rem 0; +} +.empty-state-action { + margin: 0 0 0.75rem; +} +.text-link { + color: var(--accent); + text-decoration: underline; +} +.text-link:hover { + opacity: 0.85; +} +.empty-state-meta { + font-size: 0.875rem; +} +code { + font-family: var(--font-mono); +} diff --git a/core/src/components/empty-state/empty-state.html b/core/src/components/empty-state/empty-state.html new file mode 100644 index 0000000..dc97732 --- /dev/null +++ b/core/src/components/empty-state/empty-state.html @@ -0,0 +1,21 @@ +
+
+
+ +

Loading demo…

+

Preparing the sample document

+
+
+

Drop a DocLang file here

+

+ Supported file types: +

+

or load demo

+
+
+
diff --git a/core/src/components/empty-state/empty-state.ts b/core/src/components/empty-state/empty-state.ts new file mode 100644 index 0000000..b31bd97 --- /dev/null +++ b/core/src/components/empty-state/empty-state.ts @@ -0,0 +1,32 @@ +/** — loading/prompt empty state */ + +import { DoclangHTMLElement } from '../base/base'; +import styles from './empty-state.css?inline'; +import template from './empty-state.html?raw'; + +export class DoclangEmptyState extends DoclangHTMLElement { + private _fileTypesSpan: HTMLSpanElement; + + constructor() { + super(styles, template); + this._fileTypesSpan = this.q('.file-types'); + this.q('.text-link').addEventListener('click', (e: Event) => { + e.preventDefault(); + this.dispatchEvent( + new CustomEvent('doclang-load-demo', { bubbles: true, composed: true }) + ); + }); + } + + setFileTypeHints(extensions: string[]): void { + this._fileTypesSpan.innerHTML = extensions + .map(ext => `${ext}`) + .join(', '); + } + + setDemoLoading(loading: boolean): void { + this.classList.toggle('demo-loading', loading); + } +} + +customElements.define('doclang-empty-state', DoclangEmptyState); diff --git a/core/src/components/file-pane/file-pane.css b/core/src/components/file-pane/file-pane.css new file mode 100644 index 0000000..90826e1 --- /dev/null +++ b/core/src/components/file-pane/file-pane.css @@ -0,0 +1,191 @@ +:host { + display: flex; + flex-direction: column; + border-right: 1px solid var(--border); + min-height: 0; + min-width: 0; + overflow: hidden; +} +:host([hidden]) { + display: none !important; +} +:host(.pane-layout-last) { + border-right: none; +} +.pane-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.35rem; + box-sizing: border-box; + height: 2.125rem; + padding: 0 0.35rem; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); + border-bottom: 1px solid var(--border); + background: var(--panel); +} +.pane-header-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-inline: calc(0.75rem - 0.35rem); +} +.file-pane-close-all { + appearance: none; + border: 1px solid transparent; + border-radius: 0.25rem; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 0.625rem; + font-weight: 600; + line-height: 1; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 0 0.15rem; + height: 1.25rem; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: + color 0.12s ease, + background 0.12s ease, + border-color 0.12s ease; +} +.file-pane-close-all:hover { + color: var(--text); + background: color-mix(in srgb, var(--text) 5%, transparent); +} +.file-pane-close-all:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.pane-body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 0.375rem var(--file-pane-pad-x, 0.5rem); + min-width: 0; +} +.file-view-list { + list-style: none; + margin: 0; + padding: 0; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; + width: 100%; +} +.file-view-list > li { + width: 100%; + max-width: 100%; +} +.file-view-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + width: 100%; + max-width: 100%; + box-sizing: border-box; + border: none; + border-radius: 0; + background: transparent; + color: var(--text); + font: inherit; + font-size: 0.8125rem; + line-height: 1.35; + text-align: center; + padding: 0.5rem var(--file-item-pad-x, 0.35rem); + cursor: pointer; +} +.file-view-thumb-wrap { + position: relative; + flex-shrink: 0; +} +.file-view-close { + position: absolute; + top: -0.25rem; + right: -0.25rem; + appearance: none; + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + padding: 0; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--bg); + color: var(--muted); + font: inherit; + font-size: 0.75rem; + line-height: 1; + cursor: pointer; + box-shadow: 0 1px 2px rgb(0 0 0 / 8%); + z-index: 1; +} +.file-view-close:hover { + color: var(--text); + background: var(--panel); +} +.file-view-close:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +.file-view-thumb { + flex-shrink: 0; + width: var(--file-thumb-size, 3.75rem); + height: var(--file-thumb-size, 3.75rem); + display: flex; + align-items: center; + justify-content: center; + background: var(--placeholder-bg); + border: 1px solid var(--border); + border-radius: 0.25rem; + overflow: hidden; +} +.file-view-thumb img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} +.file-view-thumb-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: var(--muted); +} +.file-view-thumb-placeholder svg { + display: block; + width: 1.875rem; + height: 1.875rem; +} +.file-view-label { + width: 100%; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.file-view-item:hover { + background: var(--markup-hover); +} +.file-view-item.is-active { + background: var(--markup-selected); + color: var(--accent); + font-weight: 500; +} +.file-view-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} diff --git a/core/src/components/file-pane/file-pane.html b/core/src/components/file-pane/file-pane.html new file mode 100644 index 0000000..5e514b5 --- /dev/null +++ b/core/src/components/file-pane/file-pane.html @@ -0,0 +1,12 @@ +
+ Files + +
+
diff --git a/core/src/components/file-pane/file-pane.ts b/core/src/components/file-pane/file-pane.ts new file mode 100644 index 0000000..713a3ee --- /dev/null +++ b/core/src/components/file-pane/file-pane.ts @@ -0,0 +1,142 @@ +/** — file list sidebar */ + +import { DoclangHTMLElement } from '../base/base'; +import { FILE_THUMB_PLACEHOLDER_SVG } from '../../constants'; +import styles from './file-pane.css?inline'; +import template from './file-pane.html?raw'; + +export interface FileEntry { + label: string; + thumbnailUrl: string | null; + isActive: boolean; +} + +export class DoclangFilePane extends DoclangHTMLElement { + private _closeAllBtn: HTMLButtonElement; + private _body: HTMLElement; + + constructor() { + super(styles, template); + this.classList.add('pane', 'pane-file'); + this._closeAllBtn = this.q('.file-pane-close-all'); + this._body = this.q('.pane-body'); + + this._closeAllBtn.addEventListener('click', () => + this.dispatchEvent( + new CustomEvent('doclang-file-pane-close-all', { + bubbles: true, + composed: true, + }) + ) + ); + } + + get section(): HTMLElement { + return this; + } + get body(): HTMLElement { + return this._body; + } + + setVisible(visible: boolean): void { + this.hidden = !visible; + } + + setLastPane(isLast: boolean): void { + this.classList.toggle('pane-layout-last', isLast); + } + + renderFiles(entries: FileEntry[]): void { + this._body.replaceChildren(); + this._closeAllBtn.hidden = entries.length === 0; + if (!entries.length) return; + + const list = document.createElement('ul'); + list.className = 'file-view-list'; + list.setAttribute('role', 'listbox'); + list.setAttribute('aria-label', 'Open files'); + + entries.forEach((entry, index) => { + const item = document.createElement('li'); + const card = document.createElement('div'); + card.className = 'file-view-item'; + if (entry.isActive) { + card.classList.add('is-active'); + card.setAttribute('aria-selected', 'true'); + } else card.setAttribute('aria-selected', 'false'); + card.title = entry.label; + card.tabIndex = 0; + card.setAttribute('role', 'option'); + + const thumbWrap = document.createElement('div'); + thumbWrap.className = 'file-view-thumb-wrap'; + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'file-view-close'; + closeBtn.setAttribute('aria-label', `Close ${entry.label}`); + closeBtn.textContent = '×'; + closeBtn.addEventListener('click', (e: Event) => { + e.stopPropagation(); + this.dispatchEvent( + new CustomEvent('doclang-file-close', { + bubbles: true, + composed: true, + detail: { index }, + }) + ); + }); + + const thumb = document.createElement('span'); + thumb.className = 'file-view-thumb'; + thumb.setAttribute('aria-hidden', 'true'); + if (entry.thumbnailUrl) { + const img = document.createElement('img'); + img.src = entry.thumbnailUrl; + img.alt = ''; + thumb.appendChild(img); + } else { + const ph = document.createElement('span'); + ph.className = 'file-view-thumb-placeholder'; + ph.innerHTML = FILE_THUMB_PLACEHOLDER_SVG; + thumb.appendChild(ph); + } + + thumbWrap.append(thumb, closeBtn); + const label = document.createElement('span'); + label.className = 'file-view-label'; + label.textContent = entry.label; + card.append(thumbWrap, label); + + card.addEventListener('click', (e: Event) => { + if ((e.target as Element).closest('.file-view-close')) return; + this.dispatchEvent( + new CustomEvent('doclang-file-select', { + bubbles: true, + composed: true, + detail: { index }, + }) + ); + }); + card.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.dispatchEvent( + new CustomEvent('doclang-file-select', { + bubbles: true, + composed: true, + detail: { index }, + }) + ); + } + }); + + item.appendChild(card); + list.appendChild(item); + }); + + this._body.appendChild(list); + } +} + +customElements.define('doclang-file-pane', DoclangFilePane); diff --git a/core/src/components/markup-pane/markup-pane.css b/core/src/components/markup-pane/markup-pane.css new file mode 100644 index 0000000..71ce6bf --- /dev/null +++ b/core/src/components/markup-pane/markup-pane.css @@ -0,0 +1,256 @@ +:host { + display: flex; + flex-direction: column; + border-right: 1px solid var(--border); + min-height: 0; + min-width: 0; +} +:host([hidden]) { + display: none !important; +} +:host(.pane-layout-last) { + border-right: none; +} +.pane-header { + display: flex; + align-items: center; + gap: 0.5rem; + box-sizing: border-box; + height: 2.125rem; + padding: 0 0.75rem; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); + border-bottom: 1px solid var(--border); + background: var(--panel); +} +.pane-body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 0; + background: var(--markup-bg); + color: var(--markup-fg); +} +.pane-body .placeholder { + margin: 1rem; +} +.placeholder { + display: flex; + align-items: center; + justify-content: center; + min-height: 12rem; + color: var(--muted); + font-style: italic; + background: var(--placeholder-bg); + border: 1px dashed var(--border); + border-radius: 0.5rem; + padding: 2rem; + text-align: center; +} +pre.markup { + margin: 0; + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} +.markup { + margin: 0; + padding: 0.75rem 1rem 1rem; + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.55; + tab-size: 2; + --markup-indent: 2ch; + --markup-gutter-width: 1rem; +} +.markup-ghost-tag-part { + opacity: 0.35; + cursor: help; +} +.head-tooltip { + border-collapse: collapse; + width: 100%; + font-size: 0.6875rem; + line-height: 1.4; +} +.head-tooltip th { + padding: 0.1rem 0.55rem 0.1rem 0; + color: var(--muted); + font-weight: 600; + text-align: left; + vertical-align: top; + white-space: nowrap; +} +.head-tooltip td { + padding: 0.1rem 0; + vertical-align: top; + word-break: break-word; +} +.head-tooltip .head-default { + color: var(--muted); + font-style: italic; + white-space: nowrap; +} +.markup-el { + cursor: pointer; + border-radius: 0.2rem; +} +.markup-el:hover { + background: var(--markup-hover); +} +.markup-el.selected { + background: var(--markup-selected); + box-shadow: inset 0 0 0 1px var(--accent); +} +.markup-line { + white-space: pre-wrap; + word-break: break-word; + cursor: pointer; + padding: 0 0.25rem; + border-radius: 0.15rem; +} +.markup-line-content { + display: flex; + align-items: baseline; + min-width: 0; + padding-left: calc(var(--markup-depth, 0) * var(--markup-indent)); +} +.markup-gutter { + flex: 0 0 var(--markup-gutter-width); + width: var(--markup-gutter-width); + display: flex; + align-items: center; + justify-content: center; +} +.markup-line-body { + flex: 1 1 auto; + min-width: 0; +} +.markup-fold-toggle { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--markup-gutter-width); + height: 1rem; + margin: 0; + padding: 0; + border: none; + border-radius: 0.15rem; + background: transparent; + color: var(--muted); + cursor: pointer; + flex-shrink: 0; +} +.markup-fold-toggle::before { + content: ''; + display: block; + width: 0; + height: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 5px solid currentColor; + transform: rotate(90deg); + transition: transform 0.12s ease; +} +.markup-el-foldable.markup-collapsed > .markup-line-open .markup-fold-toggle::before { + transform: rotate(0deg); +} +.markup-fold-toggle:hover { + background: var(--markup-hover); + color: var(--markup-fg); +} +.markup-fold-suffix { + display: none; +} +.markup-el-foldable.markup-collapsed > .markup-line-open .markup-fold-suffix { + display: inline; +} +.markup-el-foldable.markup-collapsed > .markup-fold-body { + display: none; +} +.xml-tag { + color: var(--xml-tag); +} +.xml-bracket { + color: var(--xml-bracket); +} +.xml-attr-name { + color: var(--xml-attr-name); +} +.xml-attr-value { + color: var(--xml-attr-value); +} +.xml-attr-value-truncatable { + display: inline; +} +.xml-attr-value-chip { + appearance: none; + display: inline-flex; + align-items: center; + gap: 0.3em; + margin: 0 0 0 0.35em; + padding: 0.08em 0.45em 0.08em 0.5em; + border: 1px solid var(--border); + border-radius: 0.25rem; + background: var(--placeholder-bg); + color: var(--muted); + font-family: var(--font-ui, system-ui, sans-serif); + font-size: 0.72em; + font-weight: 600; + line-height: 1.35; + cursor: pointer; + vertical-align: baseline; + white-space: nowrap; +} +.xml-attr-value-chip::after { + content: ''; + width: 0; + height: 0; + border-top: 0.28em solid currentColor; + border-left: 0.22em solid transparent; + border-right: 0.22em solid transparent; + opacity: 0.75; + transition: transform 0.12s ease; +} +.xml-attr-value-chip[aria-expanded='true']::after { + transform: rotate(180deg); +} +.xml-attr-value-chip:hover, +.xml-attr-value-chip[aria-expanded='true'] { + border-color: var(--accent); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 8%, var(--placeholder-bg)); +} +.markup-embedded-uri-panel { + cursor: text; +} +.markup-embedded-uri-panel:hover { + background: transparent; +} +.markup-embedded-uri-panel-body { + margin: 0.1rem 0 0.25rem; + padding: 0.45rem 0.6rem; + max-height: 8rem; + overflow: auto; + color: var(--xml-attr-value); + word-break: break-all; + background: var(--placeholder-bg); + border: 1px solid var(--border); + border-radius: 0.25rem; + user-select: text; +} +.xml-text { + color: var(--xml-text); +} +.xml-cdata { + color: var(--xml-cdata); +} +.xml-cdata-delimiter { + color: var(--xml-cdata-delimiter); +} diff --git a/core/src/components/markup-pane/markup-pane.html b/core/src/components/markup-pane/markup-pane.html new file mode 100644 index 0000000..2093be3 --- /dev/null +++ b/core/src/components/markup-pane/markup-pane.html @@ -0,0 +1,2 @@ +
DocLang
+
diff --git a/core/src/components/markup-pane/markup-pane.ts b/core/src/components/markup-pane/markup-pane.ts new file mode 100644 index 0000000..7d1f1fd --- /dev/null +++ b/core/src/components/markup-pane/markup-pane.ts @@ -0,0 +1,32 @@ +/** — DocLang XML markup view */ + +import { DoclangHTMLElement } from '../base/base'; +import styles from './markup-pane.css?inline'; +import template from './markup-pane.html?raw'; + +export class DoclangMarkupPane extends DoclangHTMLElement { + private _body: HTMLElement; + + constructor() { + super(styles, template); + this.classList.add('pane', 'pane-markup'); + this._body = this.q('.pane-body'); + } + + get section(): HTMLElement { + return this; + } + get body(): HTMLElement { + return this._body; + } + + setVisible(visible: boolean): void { + this.hidden = !visible; + } + + setLastPane(isLast: boolean): void { + this.classList.toggle('pane-layout-last', isLast); + } +} + +customElements.define('doclang-markup-pane', DoclangMarkupPane); diff --git a/core/src/components/page-nav/page-nav.css b/core/src/components/page-nav/page-nav.css new file mode 100644 index 0000000..6d95841 --- /dev/null +++ b/core/src/components/page-nav/page-nav.css @@ -0,0 +1,92 @@ +:host { + display: contents; +} +nav { + display: flex; + align-items: center; + gap: 0.75rem; + margin-left: 0.25rem; +} +nav::before { + content: ''; + flex-shrink: 0; + align-self: center; + width: 1px; + height: 1.125rem; + margin-right: 0.75rem; + background: var(--border); +} +nav[hidden] { + display: none; +} +.page-nav-btns { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} +.page-nav-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.875rem; + height: 1.875rem; + padding: 0; + flex-shrink: 0; + appearance: none; + border: 1px solid var(--border); + background: var(--panel); + color: var(--text); + border-radius: 0.375rem; + font: inherit; + font-size: 0.875rem; + cursor: pointer; +} +.page-nav-btn:hover { + border-color: var(--accent); +} +.page-nav-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.page-nav-chevron { + display: block; + width: 0.875rem; + height: 0.875rem; +} +.page-indicator { + display: inline-flex; + align-items: center; + font-size: 0.875rem; + color: var(--muted); + font-variant-numeric: tabular-nums; +} +.page-number-input { + appearance: none; + border: 1px solid var(--border); + border-radius: 0.375rem; + background: var(--panel); + color: var(--text); + font: inherit; + font-size: 0.875rem; + font-variant-numeric: tabular-nums; + text-align: center; + padding: 0 0.35rem; + margin: 0; + height: 1.875rem; + box-sizing: border-box; + width: calc(var(--page-num-digits, 1) * 1ch + 0.75rem); + line-height: 1; + cursor: text; +} +.page-number-input:hover:not(:disabled) { + border-color: var(--accent); +} +.page-number-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent); +} +.page-number-input:disabled { + opacity: 0.55; + cursor: not-allowed; +} diff --git a/core/src/components/page-nav/page-nav.html b/core/src/components/page-nav/page-nav.html new file mode 100644 index 0000000..00097c8 --- /dev/null +++ b/core/src/components/page-nav/page-nav.html @@ -0,0 +1,64 @@ + diff --git a/core/src/components/page-nav/page-nav.ts b/core/src/components/page-nav/page-nav.ts new file mode 100644 index 0000000..4e3e955 --- /dev/null +++ b/core/src/components/page-nav/page-nav.ts @@ -0,0 +1,111 @@ +/** — page navigation (prev/next buttons + page indicator) */ + +import { DoclangHTMLElement } from '../base/base'; +import styles from './page-nav.css?inline'; +import template from './page-nav.html?raw'; + +export class DoclangPageNav extends DoclangHTMLElement { + private _nav: HTMLElement; + private _btnPrev: HTMLButtonElement; + private _btnNext: HTMLButtonElement; + private _input: HTMLInputElement; + private _countSpan: HTMLSpanElement; + + constructor() { + super(styles, template); + this._nav = this.q('nav'); + this._btnPrev = this.q('.btn-prev'); + this._btnNext = this.q('.btn-next'); + this._input = this.q('.page-number-input'); + this._countSpan = this.q('.page-count'); + + this._btnPrev.addEventListener('click', () => + this.dispatchEvent( + new CustomEvent('doclang-prev-page', { bubbles: true, composed: true }) + ) + ); + this._btnNext.addEventListener('click', () => + this.dispatchEvent( + new CustomEvent('doclang-next-page', { bubbles: true, composed: true }) + ) + ); + this._input.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + this._commitInput(); + this._input.blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + this._resetInput(); + this._input.blur(); + } + }); + this._input.addEventListener('blur', () => this._resetInput()); + this._input.addEventListener('focus', () => this._input.select()); + } + + private _currentPage = 1; + private _pageCount = 1; + + get btnPrev(): HTMLButtonElement { + return this._btnPrev; + } + get btnNext(): HTMLButtonElement { + return this._btnNext; + } + get pageNumberInput(): HTMLInputElement { + return this._input; + } + get pageCountIndicator(): HTMLSpanElement { + return this._countSpan; + } + + setVisible(visible: boolean): void { + this._nav.hidden = !visible; + } + + setIndicator(pageNum: number, pageCount: number): void { + this._currentPage = pageNum; + this._pageCount = pageCount; + this._countSpan.textContent = `\u00A0of ${pageCount}`; + const digits = Math.max(1, String(pageCount).length); + this._input.style.setProperty('--page-num-digits', String(digits)); + this._input.disabled = false; + if (document.activeElement !== this._input) this._input.value = String(pageNum); + this._btnPrev.disabled = pageNum <= 1; + this._btnNext.disabled = pageNum >= pageCount; + } + + reset(): void { + this._currentPage = 1; + this._pageCount = 1; + this._nav.hidden = true; + this._input.value = '1'; + this._input.disabled = true; + this._btnPrev.disabled = true; + this._btnNext.disabled = true; + this._countSpan.textContent = '\u00A0of 1'; + } + + private _resetInput(): void { + this._input.value = String(this._currentPage); + } + + private _commitInput(): void { + const n = Number.parseInt(this._input.value.trim(), 10); + if (!Number.isFinite(n)) { + this._resetInput(); + return; + } + const page = Math.min(Math.max(1, n), this._pageCount); + this.dispatchEvent( + new CustomEvent('doclang-go-to-page', { + bubbles: true, + composed: true, + detail: { page }, + }) + ); + } +} + +customElements.define('doclang-page-nav', DoclangPageNav); diff --git a/core/src/components/page-view-pane/page-view-pane.css b/core/src/components/page-view-pane/page-view-pane.css new file mode 100644 index 0000000..7fae302 --- /dev/null +++ b/core/src/components/page-view-pane/page-view-pane.css @@ -0,0 +1,609 @@ +:host { + display: flex; + flex-direction: column; + border-right: 1px solid var(--border); + min-height: 0; + min-width: 0; +} +:host([hidden]) { + display: none !important; +} +:host(.pane-layout-last) { + border-right: none; +} +.pane-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + box-sizing: border-box; + height: 2.125rem; + padding: 0 0.75rem; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); + border-bottom: 1px solid var(--border); + background: var(--panel); +} +.pane-header-title { + min-width: 0; +} +.pane-page-controls { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} +.page-zoom-control { + display: flex; + align-items: center; + gap: 0.35rem; + user-select: none; +} +.page-zoom-control-label { + font-size: 0.625rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); + cursor: pointer; +} +.page-zoom-control input[type='range'] { + width: 5.5rem; + height: 1rem; + margin: 0; + cursor: pointer; +} +.page-zoom-reset { + appearance: none; + border: 1px solid var(--border); + background: var(--bg); + color: var(--muted); + border-radius: 0.375rem; + padding: 0 0.45rem; + height: 1.25rem; + font: inherit; + font-size: 0.6875rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1; + cursor: pointer; + min-width: 2.75rem; +} +.page-zoom-reset:not(:disabled):hover { + border-color: var(--accent); + color: var(--text); +} +.page-zoom-reset:disabled { + opacity: 0.55; + cursor: default; +} +.pane-settings-toggle { + appearance: none; + border: 1px solid transparent; + background: transparent; + color: var(--muted); + font-weight: 500; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + display: inline-flex; + align-items: center; + transition: + color 0.12s ease, + background 0.12s ease, + border-color 0.12s ease; + border-radius: 0.25rem; + padding: 0 0.28rem 0 0.4rem; + height: 1.25rem; + font-size: 0.625rem; + font-weight: 600; + line-height: 1; + letter-spacing: 0.04em; + text-transform: uppercase; + font: inherit; +} +.pane-settings-toggle::after { + content: ''; + flex-shrink: 0; + width: 0.26rem; + height: 0.26rem; + margin-left: 0.22rem; + margin-top: -0.1em; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(45deg); + opacity: 0.65; +} +.pane-settings-toggle:hover { + color: var(--text); + background: color-mix(in srgb, var(--text) 5%, transparent); +} +.pane-settings-toggle[aria-expanded='true'] { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--panel)); + border-color: color-mix(in srgb, var(--accent) 22%, transparent); +} +.pane-settings-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.pane-page-layout { + position: relative; + flex: 1; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; +} +.pane-body { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + padding: 0.75rem; +} +.viewer-settings-layer { + position: absolute; + inset: 0; + z-index: 5; +} +.viewer-settings-layer[hidden] { + display: none; +} +.viewer-settings-scrim { + position: absolute; + inset: 0; + z-index: 0; + appearance: none; + border: none; + margin: 0; + padding: 0; + background: color-mix(in srgb, var(--bg) 28%, transparent); + cursor: default; +} +.viewer-settings { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 1; + width: 13rem; + max-height: calc(100% - 1rem); + display: flex; + flex-direction: column; + min-height: 0; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--panel); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.14); + overflow: hidden; +} +.viewer-settings-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); +} +.viewer-settings-title { + margin: 0; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} +.viewer-settings-close { + appearance: none; + border: none; + background: transparent; + color: var(--muted); + width: 1.5rem; + height: 1.5rem; + border-radius: 0.25rem; + font-size: 1.1rem; + line-height: 1; + cursor: pointer; +} +.viewer-settings-close:hover { + color: var(--text); + background: var(--bg); +} +.viewer-settings-body { + padding: 0.75rem; + overflow: auto; +} +.settings-option { + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.875rem; + cursor: pointer; + user-select: none; +} +.settings-option-primary + .settings-subgroup { + margin-top: 0.65rem; +} +.settings-option-primary { + font-weight: 600; +} +.settings-subgroup { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-left: 0.85rem; +} +.settings-reading-order-group { + gap: 0.35rem; + padding-left: 1.6rem; +} +.settings-option-nested { + padding-left: 0; +} +.settings-option-sub { + font-size: 0.8125rem; + color: var(--muted); +} +.settings-option-disabled { + opacity: 0.45; + cursor: not-allowed; +} +.settings-option-disabled input { + cursor: not-allowed; +} +.settings-option input { + margin: 0.15rem 0 0; + flex-shrink: 0; + cursor: pointer; +} +.page-view-port { + display: flex; + overflow: auto; + flex: 1 1 auto; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + scrollbar-gutter: stable; +} +.pane-body.can-pan { + cursor: grab; +} +.pane-body.is-panning { + cursor: grabbing; + user-select: none; +} +.pane-body:focus { + outline: none; +} +.pane-body:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} +.placeholder { + display: flex; + align-items: center; + justify-content: center; + min-height: 12rem; + color: var(--muted); + font-style: italic; + background: var(--placeholder-bg); + border: 1px dashed var(--border); + border-radius: 0.5rem; + padding: 2rem; + text-align: center; +} +.page-view { + position: relative; + display: block; + width: fit-content; + flex-shrink: 0; + margin: auto; + line-height: 0; + border: 1px solid var(--border); + border-radius: 0.25rem; + overflow: hidden; +} +.page-view img { + display: block; + width: auto; + height: auto; + max-width: none; + max-height: none; + border: none; + border-radius: 0; +} +.page-view img:not([data-layout-ready]) { + opacity: 0; +} +.page-view svg.overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: auto; + overflow: hidden; +} +.overlay rect.bbox.bbox-hidden, +.overlay .overlay-badge.bbox-hidden { + display: none; +} +.overlay rect.bbox { + stroke-width: 2; + vector-effect: non-scaling-stroke; + pointer-events: none; +} +.overlay rect.bbox.selected { + fill: color-mix( + in srgb, + var(--kind-stroke, var(--accent)) 30%, + transparent + ) !important; + stroke-width: 3.5; + filter: drop-shadow( + 0 0 3px color-mix(in srgb, var(--kind-stroke, var(--accent)) 60%, transparent) + ); +} +.overlay rect.bbox.related:not(.selected) { + fill: color-mix( + in srgb, + var(--kind-stroke, var(--accent)) 30%, + transparent + ) !important; +} +.overlay rect.bbox.layer-furniture, +.overlay rect.bbox.layer-background { + fill: url(#layer-hatch) !important; + stroke: color-mix(in srgb, var(--kind-stroke, var(--muted)) 50%, transparent); +} +.overlay .layer-hatch-stripe { + fill: var(--muted); + fill-opacity: 0.06; +} +.overlay rect.bbox.layer-furniture.selected, +.overlay rect.bbox.layer-background.selected { + fill: color-mix( + in srgb, + var(--kind-stroke, var(--accent)) 22%, + transparent + ) !important; +} +.overlay rect.bbox.layer-furniture.related:not(.selected), +.overlay rect.bbox.layer-background.related:not(.selected) { + fill: color-mix( + in srgb, + var(--kind-stroke, var(--accent)) 18%, + transparent + ) !important; +} +.overlay .bbox-text { + fill: color-mix(in srgb, var(--kind-text) 14%, transparent); + stroke: var(--kind-text); +} +.overlay .bbox-heading { + fill: color-mix(in srgb, var(--kind-heading) 14%, transparent); + stroke: var(--kind-heading); +} +.overlay .bbox-list, +.overlay .bbox-ldiv { + fill: color-mix(in srgb, var(--kind-list) 14%, transparent); + stroke: var(--kind-list); +} +.overlay .bbox-table { + fill: color-mix(in srgb, var(--kind-table) 14%, transparent); + stroke: var(--kind-table); +} +.overlay .bbox-index { + fill: color-mix(in srgb, var(--kind-index) 14%, transparent); + stroke: var(--kind-index); +} +.overlay .bbox-formula { + fill: color-mix(in srgb, var(--kind-formula) 14%, transparent); + stroke: var(--kind-formula); +} +.overlay .bbox-code { + fill: color-mix(in srgb, var(--kind-code) 16%, transparent); + stroke: var(--kind-code); +} +.overlay .bbox-picture { + fill: color-mix(in srgb, var(--kind-picture) 14%, transparent); + stroke: var(--kind-picture); +} +.overlay .bbox-group { + fill: color-mix(in srgb, var(--kind-group) 14%, transparent); + stroke: var(--kind-group); +} +.overlay .bbox-footnote { + fill: color-mix(in srgb, var(--kind-footnote) 14%, transparent); + stroke: var(--kind-footnote); +} +.overlay .bbox-page_header, +.overlay .bbox-page_footer { + fill: color-mix(in srgb, var(--kind-page_header) 14%, transparent); + stroke: var(--kind-page_header); +} +.overlay .bbox-caption { + fill: color-mix(in srgb, var(--kind-caption) 14%, transparent); + stroke: var(--kind-caption); +} +.overlay line.caption-link { + color: var(--kind-caption); + stroke: currentColor; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; + pointer-events: none; + fill: none; +} +.overlay line.caption-link.bbox-hidden { + display: none; +} +.overlay line.xref-link { + color: var(--kind-footnote); + stroke: currentColor; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; + pointer-events: none; + fill: none; +} +.overlay line.xref-link.bbox-hidden { + display: none; +} +.overlay .fragment-link { + pointer-events: none; +} +.overlay .fragment-link.bbox-hidden { + display: none; +} +.overlay .fragment-link-path { + color: var(--overlay-fragment); + fill: none; + stroke: currentColor; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} +.overlay .fragment-link-path-dashed { + stroke-dasharray: 6 4; +} +.overlay .fragment-nav { + pointer-events: none; +} +.overlay .fragment-nav.bbox-hidden { + display: none; +} +.overlay .fragment-nav-btn { + pointer-events: auto; + cursor: pointer; +} +.overlay .fragment-nav-btn-disabled { + pointer-events: none; + cursor: default; + opacity: 0.35; +} +.overlay .fragment-nav-btn-bg { + fill: var(--panel); + stroke: var(--overlay-fragment); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} +.overlay .fragment-nav-btn-label { + fill: var(--overlay-fragment); + font-family: var(--font-ui); + font-weight: 700; + text-anchor: middle; + dominant-baseline: central; + pointer-events: none; +} +.overlay .fragment-nav-btn:not(.fragment-nav-btn-disabled):hover .fragment-nav-btn-bg { + fill: color-mix(in srgb, var(--overlay-fragment) 12%, var(--panel)); +} +.overlay .fragment-link-label { + fill: var(--overlay-fragment); + font-family: var(--font-ui); + font-weight: 600; + font-style: italic; + text-anchor: middle; + dominant-baseline: middle; + paint-order: stroke fill; + stroke: var(--panel); + stroke-width: 3px; +} +.overlay .reading-order-step { + color: var(--overlay-reading-order); + stroke: currentColor; + stroke-width: 1.5; + stroke-dasharray: 5 4; + vector-effect: non-scaling-stroke; + pointer-events: none; + fill: none; +} +.overlay .reading-order-step.bbox-hidden, +.overlay .reading-order-badge.bbox-hidden { + display: none; +} +.overlay .overlay-badge { + pointer-events: none; +} +.overlay .overlay-badge-bg { + fill-opacity: 1; + stroke: none; +} +.reading-order-badge .overlay-badge-bg { + fill: var(--overlay-reading-order); +} +.overlay .element-badge { + opacity: 0.8; + pointer-events: auto; + cursor: help; +} +.overlay .element-badge .overlay-badge-bg { + fill: var(--kind-stroke, var(--accent)); +} +.overlay .overlay-badge-label { + fill: #ffffff; + fill-opacity: 1; + font-family: var(--font-ui); + font-weight: 700; + text-anchor: middle; + dominant-baseline: central; + pointer-events: none; +} +.overlay .bbox-field { + fill: color-mix(in srgb, var(--kind-field) 14%, transparent); + stroke: var(--kind-field); +} +.overlay .bbox-default { + fill: color-mix(in srgb, var(--kind-default) 12%, transparent); + stroke: var(--kind-default); +} +@media (prefers-color-scheme: dark) { + .overlay .bbox-text { + fill: color-mix(in srgb, var(--kind-text) 20%, transparent); + } + .overlay .bbox-heading { + fill: color-mix(in srgb, var(--kind-heading) 20%, transparent); + } + .overlay .bbox-list, + .overlay .bbox-ldiv { + fill: color-mix(in srgb, var(--kind-list) 20%, transparent); + } + .overlay .bbox-table { + fill: color-mix(in srgb, var(--kind-table) 18%, transparent); + } + .overlay .bbox-index { + fill: color-mix(in srgb, var(--kind-index) 18%, transparent); + } + .overlay .bbox-formula { + fill: color-mix(in srgb, var(--kind-formula) 18%, transparent); + } + .overlay .bbox-code { + fill: color-mix(in srgb, var(--kind-code) 20%, transparent); + } + .overlay .bbox-picture { + fill: color-mix(in srgb, var(--kind-picture) 20%, transparent); + } + .overlay .bbox-group { + fill: color-mix(in srgb, var(--kind-group) 20%, transparent); + } + .overlay .bbox-footnote { + fill: color-mix(in srgb, var(--kind-footnote) 18%, transparent); + } + .overlay .bbox-page_header, + .overlay .bbox-page_footer { + fill: color-mix(in srgb, var(--kind-page_header) 18%, transparent); + } + .overlay .bbox-caption { + fill: color-mix(in srgb, var(--kind-caption) 18%, transparent); + } + .overlay .bbox-field { + fill: color-mix(in srgb, var(--kind-field) 18%, transparent); + } + .overlay .bbox-default { + fill: color-mix(in srgb, var(--kind-default) 18%, transparent); + } +} diff --git a/core/src/components/page-view-pane/page-view-pane.html b/core/src/components/page-view-pane/page-view-pane.html new file mode 100644 index 0000000..8b0e2ab --- /dev/null +++ b/core/src/components/page-view-pane/page-view-pane.html @@ -0,0 +1,104 @@ +
+ Original page +
+ + +
+
+
+
+ +
diff --git a/core/src/components/page-view-pane/page-view-pane.ts b/core/src/components/page-view-pane/page-view-pane.ts new file mode 100644 index 0000000..88a30a8 --- /dev/null +++ b/core/src/components/page-view-pane/page-view-pane.ts @@ -0,0 +1,242 @@ +/** — page image with zoom and overlay settings panel */ + +import { DoclangHTMLElement } from '../base/base'; +import styles from './page-view-pane.css?inline'; +import template from './page-view-pane.html?raw'; + +export class DoclangPageViewPane extends DoclangHTMLElement { + private _body: HTMLElement; + private _settingsToggle: HTMLButtonElement; + private _settingsLayer: HTMLElement; + private _settingsScrim: HTMLButtonElement; + private _settingsClose: HTMLButtonElement; + private _zoomLabel: HTMLDivElement; + private _zoomInput: HTMLInputElement; + private _zoomReset: HTMLButtonElement; + private _showAllBboxes: HTMLInputElement; + private _showAllBboxesLabel: HTMLLabelElement; + private _layoutSubtoggles: HTMLDivElement; + private _showLayoutBadges: HTMLInputElement; + private _showLayoutBadgesLabel: HTMLLabelElement; + private _showReadingOrder: HTMLInputElement; + private _showReadingOrderLabel: HTMLLabelElement; + private _readingOrderSubtoggles: HTMLDivElement; + private _readingOrderArrows: HTMLInputElement; + private _readingOrderArrowsLabel: HTMLLabelElement; + private _readingOrderGlobal: HTMLInputElement; + private _readingOrderGlobalLabel: HTMLLabelElement; + private _showPictureContents: HTMLInputElement; + private _showPictureContentsLabel: HTMLLabelElement; + private _showTableContents: HTMLInputElement; + private _showTableContentsLabel: HTMLLabelElement; + private _showFragmentLinks: HTMLInputElement; + private _showFragmentLinksLabel: HTMLLabelElement; + private _showXrefLinks: HTMLInputElement; + private _showXrefLinksLabel: HTMLLabelElement; + private _showCaptionLinks: HTMLInputElement; + private _showCaptionLinksLabel: HTMLLabelElement; + + constructor() { + super(styles, template); + this.classList.add('pane', 'pane-page-view'); + this._body = this.q('.pane-body'); + this._zoomLabel = this.q('.page-zoom-control'); + this._zoomInput = this.q('.zoom-input'); + this._zoomReset = this.q('.page-zoom-reset'); + this._settingsToggle = this.q('.pane-settings-toggle'); + this._settingsLayer = this.q('.viewer-settings-layer'); + this._settingsScrim = this.q('.viewer-settings-scrim'); + this._settingsClose = this.q('.viewer-settings-close'); + this._showAllBboxesLabel = this.q('label:has(.cb-all-bboxes)'); + this._showAllBboxes = this.q('.cb-all-bboxes'); + this._layoutSubtoggles = this.q('.settings-subgroup'); + this._showReadingOrderLabel = this.q('label:has(.cb-reading-order)'); + this._showReadingOrder = this.q('.cb-reading-order'); + this._readingOrderSubtoggles = this.q('.settings-reading-order-group'); + this._readingOrderArrowsLabel = this.q('label:has(.cb-reading-order-arrows)'); + this._readingOrderArrows = this.q('.cb-reading-order-arrows'); + this._readingOrderGlobalLabel = this.q('label:has(.cb-reading-order-global)'); + this._readingOrderGlobal = this.q('.cb-reading-order-global'); + this._showPictureContentsLabel = this.q('label:has(.cb-picture-contents)'); + this._showPictureContents = this.q('.cb-picture-contents'); + this._showTableContentsLabel = this.q('label:has(.cb-table-contents)'); + this._showTableContents = this.q('.cb-table-contents'); + this._showFragmentLinksLabel = this.q('label:has(.cb-fragment-links)'); + this._showFragmentLinks = this.q('.cb-fragment-links'); + this._showXrefLinksLabel = this.q('label:has(.cb-xref-links)'); + this._showXrefLinks = this.q('.cb-xref-links'); + this._showCaptionLinksLabel = this.q('label:has(.cb-caption-links)'); + this._showCaptionLinks = this.q('.cb-caption-links'); + this._showLayoutBadgesLabel = this.q('label:has(.cb-layout-badges)'); + this._showLayoutBadges = this.q('.cb-layout-badges'); + + this._wireEvents(); + } + + // Accessors for main.ts + get section(): HTMLElement { + return this; + } + get body(): HTMLElement { + return this._body; + } + get settingsToggle(): HTMLButtonElement { + return this._settingsToggle; + } + get settingsLayer(): HTMLElement { + return this._settingsLayer; + } + get zoomLabel(): HTMLDivElement { + return this._zoomLabel; + } + get zoomInput(): HTMLInputElement { + return this._zoomInput; + } + get zoomReset(): HTMLButtonElement { + return this._zoomReset; + } + get showAllBboxes(): HTMLInputElement { + return this._showAllBboxes; + } + get showAllBboxesLabel(): HTMLLabelElement { + return this._showAllBboxesLabel; + } + get showLayoutBadges(): HTMLInputElement { + return this._showLayoutBadges; + } + get showLayoutBadgesLabel(): HTMLLabelElement { + return this._showLayoutBadgesLabel; + } + get showReadingOrder(): HTMLInputElement { + return this._showReadingOrder; + } + get showReadingOrderLabel(): HTMLLabelElement { + return this._showReadingOrderLabel; + } + get readingOrderArrows(): HTMLInputElement { + return this._readingOrderArrows; + } + get readingOrderArrowsLabel(): HTMLLabelElement { + return this._readingOrderArrowsLabel; + } + get readingOrderGlobal(): HTMLInputElement { + return this._readingOrderGlobal; + } + get readingOrderGlobalLabel(): HTMLLabelElement { + return this._readingOrderGlobalLabel; + } + get showPictureContents(): HTMLInputElement { + return this._showPictureContents; + } + get showPictureContentsLabel(): HTMLLabelElement { + return this._showPictureContentsLabel; + } + get showTableContents(): HTMLInputElement { + return this._showTableContents; + } + get showTableContentsLabel(): HTMLLabelElement { + return this._showTableContentsLabel; + } + get showFragmentLinks(): HTMLInputElement { + return this._showFragmentLinks; + } + get showFragmentLinksLabel(): HTMLLabelElement { + return this._showFragmentLinksLabel; + } + get showXrefLinks(): HTMLInputElement { + return this._showXrefLinks; + } + get showXrefLinksLabel(): HTMLLabelElement { + return this._showXrefLinksLabel; + } + get showCaptionLinks(): HTMLInputElement { + return this._showCaptionLinks; + } + get showCaptionLinksLabel(): HTMLLabelElement { + return this._showCaptionLinksLabel; + } + get layoutSubtoggles(): HTMLDivElement { + return this._layoutSubtoggles; + } + get readingOrderSubtoggles(): HTMLDivElement { + return this._readingOrderSubtoggles; + } + + setVisible(visible: boolean): void { + this.hidden = !visible; + } + + setLastPane(isLast: boolean): void { + this.classList.toggle('pane-layout-last', isLast); + } + + setSettingsOpen(open: boolean): void { + this._settingsLayer.hidden = !open; + this._settingsToggle.setAttribute('aria-expanded', String(open)); + } + + private _wireEvents(): void { + this._settingsToggle.addEventListener('click', () => + this.dispatchEvent( + new CustomEvent('doclang-page-settings-toggle', { + bubbles: true, + composed: true, + }) + ) + ); + this._settingsClose.addEventListener('click', () => + this.dispatchEvent( + new CustomEvent('doclang-page-settings-close', { + bubbles: true, + composed: true, + }) + ) + ); + this._settingsScrim.addEventListener('click', () => + this.dispatchEvent( + new CustomEvent('doclang-page-settings-close', { + bubbles: true, + composed: true, + }) + ) + ); + this._zoomInput.addEventListener('input', () => + this.dispatchEvent( + new CustomEvent('doclang-zoom-change', { + bubbles: true, + composed: true, + detail: { value: Number(this._zoomInput.value) }, + }) + ) + ); + this._zoomReset.addEventListener('click', () => + this.dispatchEvent( + new CustomEvent('doclang-zoom-reset', { bubbles: true, composed: true }) + ) + ); + for (const [el, name] of [ + [this._showAllBboxes, 'doclang-show-all-bboxes'], + [this._showLayoutBadges, 'doclang-show-layout-badges'], + [this._showReadingOrder, 'doclang-show-reading-order'], + [this._readingOrderArrows, 'doclang-reading-order-arrows'], + [this._readingOrderGlobal, 'doclang-reading-order-global'], + [this._showPictureContents, 'doclang-show-picture-contents'], + [this._showTableContents, 'doclang-show-table-contents'], + [this._showFragmentLinks, 'doclang-show-fragment-links'], + [this._showXrefLinks, 'doclang-show-xref-links'], + [this._showCaptionLinks, 'doclang-show-caption-links'], + ] as [HTMLInputElement, string][]) { + el.addEventListener('change', () => + this.dispatchEvent( + new CustomEvent(name, { + bubbles: true, + composed: true, + detail: { checked: el.checked }, + }) + ) + ); + } + } +} + +customElements.define('doclang-page-view-pane', DoclangPageViewPane); diff --git a/core/src/components/reading-pane/reading-pane.css b/core/src/components/reading-pane/reading-pane.css new file mode 100644 index 0000000..2df2620 --- /dev/null +++ b/core/src/components/reading-pane/reading-pane.css @@ -0,0 +1,505 @@ +:host { + display: flex; + flex-direction: column; + border-right: 1px solid var(--border); + min-height: 0; + min-width: 0; +} +:host([hidden]) { + display: none !important; +} +:host(.pane-layout-last) { + border-right: none; +} +.pane-header { + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + height: 2.125rem; + padding: 0 0.75rem; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); + border-bottom: 1px solid var(--border); + background: var(--panel); +} +.pane-header-title { + min-width: 0; +} +.pane-settings-toggle { + appearance: none; + border: 1px solid transparent; + background: transparent; + color: var(--muted); + font-weight: 500; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + display: inline-flex; + align-items: center; + transition: + color 0.12s ease, + background 0.12s ease, + border-color 0.12s ease; + border-radius: 0.25rem; + padding: 0 0.28rem 0 0.4rem; + height: 1.25rem; + font-size: 0.625rem; + font-weight: 600; + line-height: 1; + letter-spacing: 0.04em; + text-transform: uppercase; + font: inherit; +} +.pane-settings-toggle::after { + content: ''; + flex-shrink: 0; + width: 0.26rem; + height: 0.26rem; + margin-left: 0.22rem; + margin-top: -0.1em; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(45deg); + opacity: 0.65; +} +.pane-settings-toggle:hover { + color: var(--text); + background: color-mix(in srgb, var(--text) 5%, transparent); +} +.pane-settings-toggle[aria-expanded='true'] { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--panel)); + border-color: color-mix(in srgb, var(--accent) 22%, transparent); +} +.pane-settings-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.pane-reading-layout { + position: relative; + flex: 1; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; +} +.pane-body { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: auto; + padding: 1rem; +} +.viewer-settings-layer { + position: absolute; + inset: 0; + z-index: 5; +} +.viewer-settings-layer[hidden] { + display: none; +} +.viewer-settings-scrim { + position: absolute; + inset: 0; + z-index: 0; + appearance: none; + border: none; + margin: 0; + padding: 0; + background: color-mix(in srgb, var(--bg) 28%, transparent); + cursor: default; +} +.viewer-settings { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 1; + width: 13rem; + max-height: calc(100% - 1rem); + display: flex; + flex-direction: column; + min-height: 0; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--panel); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.14); + overflow: hidden; +} +.viewer-settings-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); +} +.viewer-settings-title { + margin: 0; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} +.viewer-settings-close { + appearance: none; + border: none; + background: transparent; + color: var(--muted); + width: 1.5rem; + height: 1.5rem; + border-radius: 0.25rem; + font-size: 1.1rem; + line-height: 1; + cursor: pointer; +} +.viewer-settings-close:hover { + color: var(--text); + background: var(--bg); +} +.viewer-settings-body { + padding: 0.75rem; + overflow: auto; +} +.settings-option { + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.875rem; + cursor: pointer; + user-select: none; +} +.settings-subgroup { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-left: 0.85rem; +} +.settings-option-sub { + font-size: 0.8125rem; + color: var(--muted); +} +.settings-option input { + margin: 0.15rem 0 0; + flex-shrink: 0; + cursor: pointer; +} +.rendered-doc { + max-width: 42rem; + margin: 0 auto; + line-height: 1.6; +} +.rendered-el-virtual-text > p, +.rendered-el-virtual-text > div { + margin: 0; + display: inline; +} +.rendered-doc li > .rendered-el-virtual-text { + display: inline; +} +.rendered-doc li > .rendered-el-virtual-text > p, +.rendered-doc li > .rendered-el-virtual-text > div { + display: inline; + margin: 0; +} +.rendered-table .rendered-el-virtual-text { + display: inline; + margin: 0; +} +.rendered-table .rendered-el-virtual-text > p, +.rendered-table .rendered-el-virtual-text > div { + display: inline; + margin: 0; +} +.rendered-el-virtual-text > div > .rendered-el { + display: block; + margin: 0.25rem 0; +} +.rendered-el { + cursor: pointer; + border-radius: 0.25rem; +} +.rendered-el:hover { + outline: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); +} +.rendered-el.selected { + outline: 2px solid var(--accent); + outline-offset: 2px; + background: color-mix(in srgb, var(--accent) 8%, transparent); +} +.rendered-doc p { + margin: 0 0 0.75rem; +} +.rendered-doc h1, +.rendered-doc h2, +.rendered-doc h3, +.rendered-doc h4, +.rendered-doc h5, +.rendered-doc h6 { + margin: 1.25rem 0 0.5rem; + line-height: 1.25; +} +.rendered-doc h1:first-child, +.rendered-doc h2:first-child, +.rendered-doc h3:first-child { + margin-top: 0; +} +.rendered-doc ul, +.rendered-doc ol { + margin: 0 0 0.75rem; + padding-left: 1.5rem; +} +.rendered-doc li { + margin: 0.25rem 0; +} +.rendered-doc li > .rendered-el > p:first-child, +.rendered-doc li > .rendered-el > h1:first-child, +.rendered-doc li > .rendered-el > h2:first-child, +.rendered-doc li > .rendered-el > h3:first-child, +.rendered-doc li > .rendered-el > h4:first-child, +.rendered-doc li > .rendered-el > h5:first-child, +.rendered-doc li > .rendered-el > h6:first-child { + margin-top: 0; +} +.rendered-marker { + margin-right: 0.35rem; +} +.rendered-handwriting { + font-family: 'Segoe Print', 'Bradley Hand', cursive; +} +.rendered-doc pre { + margin: 0 0 0.75rem; + padding: 0.75rem 1rem; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.5; + background: var(--placeholder-bg); + border: 1px solid var(--border); + border-radius: 0.375rem; +} +.rendered-doc code { + font-family: var(--font-mono); + font-size: 0.875em; +} +.rendered-doc pre code { + font-size: inherit; + background: none; + border: none; + padding: 0; +} +.rendered-code-label { + display: block; + margin-bottom: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.rendered-formula { + display: block; + margin: 0 0 0.75rem; + padding: 0.5rem 0.75rem; + font-family: var(--font-mono); + font-size: 0.875rem; + background: var(--placeholder-bg); + border-left: 3px solid var(--border); + overflow-x: auto; +} +.rendered-formula-inline { + display: inline; + font-family: var(--font-mono); + font-size: 0.875em; +} +.rendered-doc figure { + margin: 0 0 0.75rem; +} +.rendered-doc figure img { + max-width: 100%; + height: auto; + border: 1px solid var(--border); + border-radius: 0.25rem; +} +.rendered-doc figure img.rendered-picture-unavailable { + display: inline-block; + min-width: 3rem; + min-height: 2.5rem; + object-fit: contain; + vertical-align: middle; +} +.rendered-doc figcaption { + margin-top: 0.35rem; + font-size: 0.875rem; + color: var(--muted); +} +.rendered-picture-contents { + margin-top: 0.5rem; + font-size: 0.875rem; + border: 1px solid var(--border); + border-radius: 0.375rem; + background: var(--placeholder-bg); +} +.rendered-picture-contents summary { + padding: 0.4rem 0.65rem; + cursor: pointer; + font-weight: 600; + color: var(--muted); + user-select: none; +} +.rendered-picture-contents summary:hover { + color: var(--text); +} +.rendered-picture-contents-body { + padding: 0.5rem 0.75rem 0.75rem; + border-top: 1px solid var(--border); +} +.rendered-picture-contents-body .rendered-el > p { + margin-bottom: 0.35rem; + font-size: 0.8125rem; +} +.rendered-table { + width: 100%; + margin: 0 0 0.75rem; + border-collapse: collapse; + font-size: 0.875rem; +} +.rendered-table caption { + caption-side: top; + margin-bottom: 0.35rem; + font-size: 0.875rem; + color: var(--muted); + text-align: left; +} +.rendered-table th, +.rendered-table td { + border: 1px solid var(--border); + padding: 0.35rem 0.5rem; + vertical-align: top; + text-align: left; +} +.rendered-table th { + background: color-mix(in srgb, var(--border) 35%, var(--panel)); + font-weight: 600; +} +.rendered-table .rendered-table-cell-text { + display: inline; +} +.rendered-table .rendered-el { + margin: 0; +} +.rendered-table .rendered-el-virtual-text > p, +.rendered-table .rendered-el-virtual-text > div { + margin: 0; +} +.rendered-table .rendered-table { + margin: 0.25rem 0 0; +} +.rendered-page-header, +.rendered-page-footer { + font-size: 0.875rem; + color: var(--muted); +} +.rendered-page-header { + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); +} +.rendered-page-footer { + margin-top: 1rem; + padding-top: 0.5rem; + border-top: 1px solid var(--border); +} +.rendered-doc:not(.show-reading-furniture) + .rendered-el[data-doclang-layer='furniture'] { + display: none; +} +.rendered-doc:not(.show-reading-background) + .rendered-el[data-doclang-layer='background'] { + display: none; +} +.rendered-el[data-doclang-layer='furniture'], +.rendered-el[data-doclang-layer='background'] { + position: relative; +} +.rendered-el[data-doclang-layer='furniture']::before, +.rendered-el[data-doclang-layer='background']::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background: repeating-linear-gradient( + -45deg, + transparent 0 10px, + color-mix(in srgb, var(--muted) 6%, transparent) 10px 16px + ); +} +.rendered-el.rendered-footnote > aside { + font-size: 0.875rem; + color: var(--muted); + border-left: 2px solid var(--border); + padding-left: 0.75rem; + margin: 0 0 0.75rem; +} +.rendered-unsupported { + margin: 0 0 0.75rem; + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; + font-style: italic; + color: var(--muted); + background: var(--placeholder-bg); + border: 1px dashed var(--border); + border-radius: 0.375rem; +} +.rendered-field-region { + margin: 0 0 1rem; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: color-mix(in srgb, var(--kind-field) 6%, transparent); +} +.rendered-field-region > .rendered-field-heading:first-child, +.rendered-field-region + > .rendered-el.rendered-field_heading + > .rendered-field-heading:first-child { + margin-top: 0; +} +.rendered-field-item { + margin: 0.5rem 0; +} +.rendered-field-item > .rendered-el.rendered-field-key, +.rendered-field-key { + font-weight: 600; +} +.rendered-field-item > .rendered-el.rendered-field-key + .rendered-el, +.rendered-field-item > .rendered-el.rendered-field-key + .rendered-el-virtual-text { + margin-top: 0.15rem; +} +.rendered-field-value-fillable .rendered-field-fillable-slot { + display: inline-block; + min-width: 6rem; + min-height: 1.25em; + border-bottom: 1px solid color-mix(in srgb, var(--text) 55%, transparent); + vertical-align: bottom; +} +.rendered-field-hint { + font-size: 0.85em; + color: var(--muted); + font-style: italic; +} +.rendered-field-heading { + color: var(--text); +} +.rendered-checkbox { + margin: 0 0.25rem 0 0; + vertical-align: middle; + accent-color: var(--kind-field); +} +.rendered-checkbox-wrap { + display: inline; +} diff --git a/core/src/components/reading-pane/reading-pane.html b/core/src/components/reading-pane/reading-pane.html new file mode 100644 index 0000000..d5de6f4 --- /dev/null +++ b/core/src/components/reading-pane/reading-pane.html @@ -0,0 +1,52 @@ +
+ Reading view + +
+
+
+ +
diff --git a/core/src/components/reading-pane/reading-pane.ts b/core/src/components/reading-pane/reading-pane.ts new file mode 100644 index 0000000..ca6a9c9 --- /dev/null +++ b/core/src/components/reading-pane/reading-pane.ts @@ -0,0 +1,123 @@ +/** — reading/rendered view with layers settings panel */ + +import { DoclangHTMLElement } from '../base/base'; +import styles from './reading-pane.css?inline'; +import template from './reading-pane.html?raw'; + +export class DoclangReadingPane extends DoclangHTMLElement { + private _body: HTMLElement; + private _settingsToggle: HTMLButtonElement; + private _settingsLayer: HTMLElement; + private _settingsScrim: HTMLButtonElement; + private _settingsClose: HTMLButtonElement; + private _showFurniture: HTMLInputElement; + private _showFurnitureLabel: HTMLLabelElement; + private _showBackground: HTMLInputElement; + private _showBackgroundLabel: HTMLLabelElement; + + constructor() { + super(styles, template); + this.classList.add('pane', 'pane-reading'); + this._body = this.q('.pane-body'); + this._settingsToggle = this.q('.pane-settings-toggle'); + this._settingsLayer = this.q('.viewer-settings-layer'); + this._settingsScrim = this.q('.viewer-settings-scrim'); + this._settingsClose = this.q('.viewer-settings-close'); + this._showFurnitureLabel = this.q('label:has(.cb-furniture)'); + this._showFurniture = this.q('.cb-furniture'); + this._showBackgroundLabel = this.q('label:has(.cb-background)'); + this._showBackground = this.q('.cb-background'); + + this._wireEvents(); + } + + get section(): HTMLElement { + return this; + } + get body(): HTMLElement { + return this._body; + } + get settingsToggle(): HTMLButtonElement { + return this._settingsToggle; + } + get settingsLayer(): HTMLElement { + return this._settingsLayer; + } + get showFurniture(): HTMLInputElement { + return this._showFurniture; + } + get showFurnitureLabel(): HTMLLabelElement { + return this._showFurnitureLabel; + } + get showBackground(): HTMLInputElement { + return this._showBackground; + } + get showBackgroundLabel(): HTMLLabelElement { + return this._showBackgroundLabel; + } + + setVisible(visible: boolean): void { + this.hidden = !visible; + } + + setLastPane(isLast: boolean): void { + this.classList.toggle('pane-layout-last', isLast); + } + + setSettingsOpen(open: boolean): void { + this._settingsLayer.hidden = !open; + this._settingsToggle.setAttribute('aria-expanded', String(open)); + } + + syncCheckboxes(furniture: boolean, background: boolean): void { + this._showFurniture.checked = furniture; + this._showBackground.checked = background; + } + + private _wireEvents(): void { + this._settingsToggle.addEventListener('click', () => + this.dispatchEvent( + new CustomEvent('doclang-reading-settings-toggle', { + bubbles: true, + composed: true, + }) + ) + ); + this._settingsClose.addEventListener('click', () => + this.dispatchEvent( + new CustomEvent('doclang-reading-settings-close', { + bubbles: true, + composed: true, + }) + ) + ); + this._settingsScrim.addEventListener('click', () => + this.dispatchEvent( + new CustomEvent('doclang-reading-settings-close', { + bubbles: true, + composed: true, + }) + ) + ); + this._showFurniture.addEventListener('change', () => + this.dispatchEvent( + new CustomEvent('doclang-show-reading-furniture', { + bubbles: true, + composed: true, + detail: { checked: this._showFurniture.checked }, + }) + ) + ); + this._showBackground.addEventListener('change', () => + this.dispatchEvent( + new CustomEvent('doclang-show-reading-background', { + bubbles: true, + composed: true, + detail: { checked: this._showBackground.checked }, + }) + ) + ); + } +} + +customElements.define('doclang-reading-pane', DoclangReadingPane); diff --git a/core/src/components/toolbar/toolbar.css b/core/src/components/toolbar/toolbar.css new file mode 100644 index 0000000..b334762 --- /dev/null +++ b/core/src/components/toolbar/toolbar.css @@ -0,0 +1,176 @@ +:host { + display: contents; +} +.toolbar { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + grid-column: 3; + justify-self: end; +} +.header-divider { + flex-shrink: 0; + align-self: center; + width: 1px; + height: 1.125rem; + background: var(--border); +} +.toolbar-options-wrap { + position: relative; +} +/* Menu/control triggers */ +.toolbar-options-btn { + appearance: none; + border: 1px solid transparent; + background: transparent; + color: var(--muted); + font-weight: 500; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + display: inline-flex; + align-items: center; + transition: + color 0.12s ease, + background 0.12s ease, + border-color 0.12s ease; + border-radius: 0.375rem; + padding: 0.35rem 0.45rem 0.35rem 0.55rem; + font-size: 0.8125rem; + line-height: 1.2; + font: inherit; +} +.toolbar-options-btn::after { + content: ''; + flex-shrink: 0; + width: 0.32rem; + height: 0.32rem; + margin-left: 0.3rem; + margin-top: -0.14em; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(45deg); + opacity: 0.65; +} +.toolbar-options-btn:hover { + color: var(--text); + background: color-mix(in srgb, var(--text) 5%, transparent); +} +.toolbar-options-btn[aria-expanded='true'] { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--panel)); + border-color: color-mix(in srgb, var(--accent) 22%, transparent); +} +.toolbar-options-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.toolbar-options-panel { + position: absolute; + top: calc(100% + 0.35rem); + right: 0; + z-index: 30; + min-width: 11rem; + padding: 0.5rem 0; + border: 1px solid var(--border); + border-radius: 0.375rem; + background: var(--panel); + box-shadow: 0 0.5rem 1.25rem color-mix(in srgb, var(--text) 12%, transparent); +} +.toolbar-options-panel[hidden] { + display: none; +} +.toolbar-options-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.75rem; + font-size: 0.875rem; + cursor: pointer; + font-family: var(--font-ui); +} +.toolbar-options-item:hover { + background: color-mix(in srgb, var(--accent) 6%, var(--panel)); +} +.toolbar-options-item input { + margin: 0; + flex-shrink: 0; + cursor: pointer; +} +.toolbar-options-item-disabled { + opacity: 0.45; + cursor: not-allowed; +} +.toolbar-options-item-disabled input { + cursor: not-allowed; +} +.toolbar-options-divider { + margin: 0.35rem 0; + border-top: 1px solid var(--border); +} +.toolbar-options-reset { + display: block; + width: 100%; + box-sizing: border-box; + margin: 0; + padding: 0.35rem 0.75rem; + border: 0; + border-radius: 0; + background: transparent; + color: var(--text); + font: inherit; + font-size: 0.875rem; + text-align: left; + cursor: pointer; +} +.toolbar-options-reset:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 6%, var(--panel)); + color: var(--accent); +} +.toolbar-options-reset:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.toolbar-file-group { + display: inline-flex; + align-items: center; +} +button, +label.file-btn { + appearance: none; + border: 1px solid var(--border); + background: var(--panel); + color: var(--text); + border-radius: 0.375rem; + padding: 0.4rem 0.75rem; + font: inherit; + font-size: 0.875rem; + cursor: pointer; +} +button:hover, +label.file-btn:hover { + border-color: var(--accent); +} +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} +label.file-btn input { + display: none; +} +.header-site-link { + color: var(--muted); + font-size: 0.875rem; + text-decoration: none; + white-space: nowrap; +} +.header-site-link:hover { + color: var(--accent); + text-decoration: underline; +} +.header-site-link:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 0.125rem; +} diff --git a/core/src/components/toolbar/toolbar.html b/core/src/components/toolbar/toolbar.html new file mode 100644 index 0000000..6103923 --- /dev/null +++ b/core/src/components/toolbar/toolbar.html @@ -0,0 +1,54 @@ +
+
+ + +
+ + + + + + + doclang.ai +
diff --git a/core/src/components/toolbar/toolbar.ts b/core/src/components/toolbar/toolbar.ts new file mode 100644 index 0000000..3c52a6d --- /dev/null +++ b/core/src/components/toolbar/toolbar.ts @@ -0,0 +1,170 @@ +/** — header toolbar (Views menu, file open, demo, site link) */ + +import { DoclangHTMLElement } from '../base/base'; +import styles from './toolbar.css?inline'; +import template from './toolbar.html?raw'; + +export class DoclangToolbar extends DoclangHTMLElement { + private _toolbarOptionsBtn: HTMLButtonElement; + private _toolbarOptionsPanel: HTMLDivElement; + private _toggleFilePane: HTMLInputElement; + private _toggleFilePaneLabel: HTMLLabelElement; + private _togglePagePane: HTMLInputElement; + private _togglePagePaneLabel: HTMLLabelElement; + private _toggleMarkupPane: HTMLInputElement; + private _toggleMarkupPaneLabel: HTMLLabelElement; + private _toggleReadingPane: HTMLInputElement; + private _toggleReadingPaneLabel: HTMLLabelElement; + private _resetPaneLayoutBtn: HTMLButtonElement; + private _btnDemo: HTMLButtonElement; + private _openFileBtn: HTMLLabelElement; + private _inputArchive: HTMLInputElement; + private _panelOpen = false; + + constructor() { + super(styles, template); + this._toolbarOptionsBtn = this.q('.toolbar-options-btn'); + this._toolbarOptionsPanel = this.q('.toolbar-options-panel'); + this._toggleFilePaneLabel = this.q('label:has(.cb-file-pane)'); + this._toggleFilePane = this.q('.cb-file-pane'); + this._togglePagePaneLabel = this.q('label:has(.cb-page-pane)'); + this._togglePagePane = this.q('.cb-page-pane'); + this._toggleMarkupPaneLabel = this.q('label:has(.cb-markup-pane)'); + this._toggleMarkupPane = this.q('.cb-markup-pane'); + this._toggleReadingPaneLabel = this.q('label:has(.cb-reading-pane)'); + this._toggleReadingPane = this.q('.cb-reading-pane'); + this._resetPaneLayoutBtn = this.q('.toolbar-options-reset'); + this._btnDemo = this.q('.btn-demo'); + this._openFileBtn = this.q('.file-btn'); + this._inputArchive = this.q('.input-archive'); + + this._wireEvents(); + } + + get toggleFilePane(): HTMLInputElement { + return this._toggleFilePane; + } + get toggleFilePaneLabel(): HTMLLabelElement { + return this._toggleFilePaneLabel; + } + get togglePagePane(): HTMLInputElement { + return this._togglePagePane; + } + get togglePagePaneLabel(): HTMLLabelElement { + return this._togglePagePaneLabel; + } + get toggleMarkupPane(): HTMLInputElement { + return this._toggleMarkupPane; + } + get toggleMarkupPaneLabel(): HTMLLabelElement { + return this._toggleMarkupPaneLabel; + } + get toggleReadingPane(): HTMLInputElement { + return this._toggleReadingPane; + } + get toggleReadingPaneLabel(): HTMLLabelElement { + return this._toggleReadingPaneLabel; + } + get resetPaneLayoutBtn(): HTMLButtonElement { + return this._resetPaneLayoutBtn; + } + get toolbarOptionsBtn(): HTMLButtonElement { + return this._toolbarOptionsBtn; + } + get btnDemo(): HTMLButtonElement { + return this._btnDemo; + } + get openFileBtn(): HTMLLabelElement { + return this._openFileBtn; + } + get inputArchive(): HTMLInputElement { + return this._inputArchive; + } + + setOptionsOpen(open: boolean): void { + this._panelOpen = open; + this._toolbarOptionsPanel.hidden = !open; + this._toolbarOptionsBtn.setAttribute('aria-expanded', String(open)); + } + + closeOptionsIfOpen(): void { + if (this._panelOpen) this.setOptionsOpen(false); + } + + private _wireEvents(): void { + this._toolbarOptionsBtn.addEventListener('click', (e: Event) => { + e.stopPropagation(); + this.setOptionsOpen(!this._panelOpen); + }); + // Close when clicking outside + document.addEventListener('click', (e: MouseEvent) => { + if (!this._panelOpen) return; + const path = e.composedPath(); + if ( + path.includes(this._toolbarOptionsPanel) || + path.includes(this._toolbarOptionsBtn) + ) + return; + this.setOptionsOpen(false); + }); + this._btnDemo.addEventListener('click', () => + this.dispatchEvent( + new CustomEvent('doclang-load-demo', { bubbles: true, composed: true }) + ) + ); + this._inputArchive.addEventListener('change', (e: Event) => { + const input = e.target as HTMLInputElement; + this.dispatchEvent( + new CustomEvent('doclang-open-files', { + bubbles: true, + composed: true, + detail: { files: [...(input.files ?? [])] }, + }) + ); + input.value = ''; + }); + this._toggleFilePane.addEventListener('change', () => + this.dispatchEvent( + new CustomEvent('doclang-toggle-pane', { + bubbles: true, + composed: true, + detail: { pane: 'file', checked: this._toggleFilePane.checked }, + }) + ) + ); + this._togglePagePane.addEventListener('change', () => + this.dispatchEvent( + new CustomEvent('doclang-toggle-pane', { + bubbles: true, + composed: true, + detail: { pane: 'page', checked: this._togglePagePane.checked }, + }) + ) + ); + this._toggleMarkupPane.addEventListener('change', () => + this.dispatchEvent( + new CustomEvent('doclang-toggle-pane', { + bubbles: true, + composed: true, + detail: { pane: 'markup', checked: this._toggleMarkupPane.checked }, + }) + ) + ); + this._toggleReadingPane.addEventListener('change', () => + this.dispatchEvent( + new CustomEvent('doclang-toggle-pane', { + bubbles: true, + composed: true, + detail: { pane: 'reading', checked: this._toggleReadingPane.checked }, + }) + ) + ); + this._resetPaneLayoutBtn.addEventListener('click', () => + this.dispatchEvent( + new CustomEvent('doclang-reset-pane-layout', { bubbles: true, composed: true }) + ) + ); + } +} + +customElements.define('doclang-toolbar', DoclangToolbar); diff --git a/core/src/constants.ts b/core/src/constants.ts index aa1fda2..9df6fe3 100644 --- a/core/src/constants.ts +++ b/core/src/constants.ts @@ -71,7 +71,14 @@ export const CELL_TOKENS = new Set([ 'xcel', 'nl', ]); -export const CELL_CONTENT_TAGS = new Set(['fcel', 'ecel', 'ched', 'rhed', 'corn', 'srow']); +export const CELL_CONTENT_TAGS = new Set([ + 'fcel', + 'ecel', + 'ched', + 'rhed', + 'corn', + 'srow', +]); export const CELL_SPAN_TAGS = new Set(['lcel', 'ucel', 'xcel']); export const OTSL_CONTAINER_TAGS = new Set(['table', 'index', 'tabular']); export const RENDER_BLOCK_TAGS = new Set([ diff --git a/core/src/document.ts b/core/src/document.ts index 1555112..76a513d 100644 --- a/core/src/document.ts +++ b/core/src/document.ts @@ -39,7 +39,7 @@ import type { ThreadNav, } from './types'; import { extractArchiveFromZipBuffer } from './zip'; -import { isPictureContentElement, isTableContentElement } from './xml_overlay'; +import { isPictureContentElement, isTableContentElement } from './xml-overlay'; // --------------------------------------------------------------------------- // Document XML parsing helpers @@ -84,7 +84,9 @@ export function assignElementIds(segment: Element[]): Map { return ids; } -export function invertElementIds(elementIds: Map): Map { +export function invertElementIds( + elementIds: Map +): Map { const idToElement = new Map(); for (const [el, id] of elementIds) idToElement.set(id, el); return idToElement; diff --git a/core/src/main.ts b/core/src/main.ts index d23b307..f9bb44d 100644 --- a/core/src/main.ts +++ b/core/src/main.ts @@ -1,5 +1,22 @@ /* DocLang Archive Viewer — archive format: github.com/doclang-project/doclang spec.md#doclang-archive-format */ +import './components/cursor-hint/cursor-hint'; +import './components/page-nav/page-nav'; +import './components/toolbar/toolbar'; +import './components/file-pane/file-pane'; +import './components/markup-pane/markup-pane'; +import './components/page-view-pane/page-view-pane'; +import './components/reading-pane/reading-pane'; +import './components/empty-state/empty-state'; +import type { DoclangCursorHint } from './components/cursor-hint/cursor-hint'; +import type { DoclangPageNav } from './components/page-nav/page-nav'; +import type { DoclangToolbar } from './components/toolbar/toolbar'; +import type { DoclangFilePane } from './components/file-pane/file-pane'; +import type { DoclangMarkupPane } from './components/markup-pane/markup-pane'; +import type { DoclangPageViewPane } from './components/page-view-pane/page-view-pane'; +import type { DoclangReadingPane } from './components/reading-pane/reading-pane'; +import type { DoclangEmptyState } from './components/empty-state/empty-state'; + import { SUPPORTED_FILE_EXTENSIONS, OPEN_FILE_HINT, @@ -8,7 +25,6 @@ import { FRAGMENT_NAV_HINT_NEXT, NO_MARKUP, NO_IMAGE, - FILE_THUMB_PLACEHOLDER_SVG, PAGE_IMAGE_RE, PAGE_ZOOM_DEFAULT, PAGE_PAN_DRAG_THRESHOLD, @@ -78,7 +94,7 @@ import { applyPageImageSize, setOverlayAccessors, } from './overlay'; -import { isPictureContentElement, isTableContentElement } from './xml_overlay'; +import { isPictureContentElement, isTableContentElement } from './xml-overlay'; import { unzip } from './zip'; // --------------------------------------------------------------------------- @@ -124,100 +140,116 @@ setOverlayAccessors({ getPageZoomPercent: () => pageZoomPercent, getPagePane: () => els.pagePane, getPageLayoutCache: () => pageLayoutCache, - setPageLayoutCache: c => { pageLayoutCache = c; }, + setPageLayoutCache: c => { + pageLayoutCache = c; + }, getSelectedElementId: () => selectedElementId, }); // --------------------------------------------------------------------------- -// DOM element references +// Web component instances +// --------------------------------------------------------------------------- + +const doclangCursorHint = document.querySelector( + 'doclang-cursor-hint' +) as DoclangCursorHint | null; +const doclangPageNav = document.querySelector( + 'doclang-page-nav' +) as DoclangPageNav | null; +const doclangToolbar = document.querySelector( + 'doclang-toolbar' +) as DoclangToolbar | null; +const doclangFilePane = document.querySelector( + 'doclang-file-pane' +) as DoclangFilePane | null; +const doclangMarkupPane = document.querySelector( + 'doclang-markup-pane' +) as DoclangMarkupPane | null; +const doclangPageViewPane = document.querySelector( + 'doclang-page-view-pane' +) as DoclangPageViewPane | null; +const doclangReadingPane = document.querySelector( + 'doclang-reading-pane' +) as DoclangReadingPane | null; +const doclangEmptyState = document.querySelector( + 'doclang-empty-state' +) as DoclangEmptyState | null; + +// --------------------------------------------------------------------------- +// DOM element references (flattened from component accessors) // --------------------------------------------------------------------------- const els = { - openFileBtn: document.getElementById('open-file-btn'), - emptyStateFileTypes: document.getElementById('empty-state-file-types'), + openFileBtn: doclangToolbar?.openFileBtn ?? null, docLabel: document.getElementById('doc-label'), - filePane: document.getElementById('file-pane'), - filePaneCloseAll: document.getElementById('btn-file-pane-close-all'), - pageNav: document.getElementById('page-nav'), - pageIndicator: document.getElementById('page-indicator'), - pageNumberInput: document.getElementById('page-number-input') as HTMLInputElement | null, - pageCountIndicator: document.getElementById('page-count-indicator'), - btnPrev: document.getElementById('btn-prev') as HTMLButtonElement | null, - btnNext: document.getElementById('btn-next') as HTMLButtonElement | null, - showAllBboxes: document.getElementById('show-all-bboxes') as HTMLInputElement | null, - showLayoutBadges: document.getElementById('show-layout-badges') as HTMLInputElement | null, - showLayoutBadgesLabel: document.getElementById('show-layout-badges-label'), - settingsToggle: document.getElementById('btn-settings-toggle'), - readingSettingsToggle: document.getElementById('btn-reading-settings-toggle'), - pageSettingsLayer: document.getElementById('page-settings-layer'), - pageSettingsScrim: document.getElementById('page-settings-scrim'), - pageSettingsClose: document.getElementById('btn-page-settings-close'), - readingSettingsLayer: document.getElementById('reading-settings-layer'), - readingSettingsScrim: document.getElementById('reading-settings-scrim'), - readingSettingsClose: document.getElementById('btn-reading-settings-close'), - showReadingFurniture: document.getElementById('show-reading-furniture') as HTMLInputElement | null, - showReadingFurnitureLabel: document.getElementById('show-reading-furniture-label'), - showReadingBackground: document.getElementById('show-reading-background') as HTMLInputElement | null, - showReadingBackgroundLabel: document.getElementById('show-reading-background-label'), - showCaptionLinks: document.getElementById('show-caption-links') as HTMLInputElement | null, - showCaptionLinksLabel: document.getElementById('show-caption-links-label'), - showPictureContents: document.getElementById('show-picture-contents') as HTMLInputElement | null, - showPictureContentsLabel: document.getElementById('show-picture-contents-label'), - showTableContents: document.getElementById('show-table-contents') as HTMLInputElement | null, - showTableContentsLabel: document.getElementById('show-table-contents-label'), - showFragmentLinks: document.getElementById('show-fragment-links') as HTMLInputElement | null, - showFragmentLinksLabel: document.getElementById('show-fragment-links-label'), - showXrefLinks: document.getElementById('show-xref-links') as HTMLInputElement | null, - showXrefLinksLabel: document.getElementById('show-xref-links-label'), - showReadingOrder: document.getElementById('show-reading-order') as HTMLInputElement | null, - showReadingOrderLabel: document.getElementById('show-reading-order-label'), - readingOrderArrows: document.getElementById('reading-order-arrows') as HTMLInputElement | null, - readingOrderArrowsLabel: document.getElementById('reading-order-arrows-label'), - readingOrderGlobal: document.getElementById('reading-order-global') as HTMLInputElement | null, - readingOrderGlobalLabel: document.getElementById('reading-order-global-label'), - pageZoom: document.getElementById('page-zoom') as HTMLInputElement | null, - pageZoomLabel: document.getElementById('page-zoom-label'), - pageZoomReset: document.getElementById('page-zoom-reset') as HTMLButtonElement | null, + filePane: doclangFilePane?.body ?? null, + filePaneCloseAll: null as HTMLButtonElement | null, // handled by doclang-file-pane internally + pageNav: doclangPageNav ?? null, + pageNumberInput: doclangPageNav?.pageNumberInput ?? null, + pageCountIndicator: doclangPageNav?.pageCountIndicator ?? null, + btnPrev: doclangPageNav?.btnPrev ?? null, + btnNext: doclangPageNav?.btnNext ?? null, + showAllBboxes: doclangPageViewPane?.showAllBboxes ?? null, + showLayoutBadges: doclangPageViewPane?.showLayoutBadges ?? null, + showLayoutBadgesLabel: doclangPageViewPane?.showLayoutBadgesLabel ?? null, + settingsToggle: doclangPageViewPane?.settingsToggle ?? null, + readingSettingsToggle: doclangReadingPane?.settingsToggle ?? null, + pageSettingsLayer: doclangPageViewPane?.settingsLayer ?? null, + pageSettingsScrim: null as HTMLElement | null, // handled by doclang-page-view-pane internally + pageSettingsClose: null as HTMLElement | null, // handled by doclang-page-view-pane internally + readingSettingsLayer: doclangReadingPane?.settingsLayer ?? null, + readingSettingsScrim: null as HTMLElement | null, // handled by doclang-reading-pane internally + readingSettingsClose: null as HTMLElement | null, // handled by doclang-reading-pane internally + showReadingFurniture: doclangReadingPane?.showFurniture ?? null, + showReadingFurnitureLabel: doclangReadingPane?.showFurnitureLabel ?? null, + showReadingBackground: doclangReadingPane?.showBackground ?? null, + showReadingBackgroundLabel: doclangReadingPane?.showBackgroundLabel ?? null, + showCaptionLinks: doclangPageViewPane?.showCaptionLinks ?? null, + showCaptionLinksLabel: doclangPageViewPane?.showCaptionLinksLabel ?? null, + showPictureContents: doclangPageViewPane?.showPictureContents ?? null, + showPictureContentsLabel: doclangPageViewPane?.showPictureContentsLabel ?? null, + showTableContents: doclangPageViewPane?.showTableContents ?? null, + showTableContentsLabel: doclangPageViewPane?.showTableContentsLabel ?? null, + showFragmentLinks: doclangPageViewPane?.showFragmentLinks ?? null, + showFragmentLinksLabel: doclangPageViewPane?.showFragmentLinksLabel ?? null, + showXrefLinks: doclangPageViewPane?.showXrefLinks ?? null, + showXrefLinksLabel: doclangPageViewPane?.showXrefLinksLabel ?? null, + showReadingOrder: doclangPageViewPane?.showReadingOrder ?? null, + showReadingOrderLabel: doclangPageViewPane?.showReadingOrderLabel ?? null, + readingOrderArrows: doclangPageViewPane?.readingOrderArrows ?? null, + readingOrderArrowsLabel: doclangPageViewPane?.readingOrderArrowsLabel ?? null, + readingOrderGlobal: doclangPageViewPane?.readingOrderGlobal ?? null, + readingOrderGlobalLabel: doclangPageViewPane?.readingOrderGlobalLabel ?? null, + pageZoom: doclangPageViewPane?.zoomInput ?? null, + pageZoomLabel: doclangPageViewPane?.zoomLabel ?? null, + pageZoomReset: doclangPageViewPane?.zoomReset ?? null, main: document.getElementById('main'), - emptyState: document.getElementById('empty-state'), - emptyStateLoading: document.getElementById('empty-state-loading'), - emptyStatePrompt: document.getElementById('empty-state-prompt'), - markupPane: document.getElementById('markup-pane'), - renderedPane: document.getElementById('rendered-pane'), - pagePane: document.getElementById('page-pane'), - paneFile: document.querySelector('.pane-file') as HTMLElement | null, - panePageView: document.querySelector('.pane-page-view') as HTMLElement | null, - paneMarkup: document.querySelector('.pane-markup') as HTMLElement | null, - paneReading: document.querySelector('.pane-reading') as HTMLElement | null, + emptyState: doclangEmptyState ?? null, + markupPane: doclangMarkupPane?.body ?? null, + renderedPane: doclangReadingPane?.body ?? null, + pagePane: doclangPageViewPane?.body ?? null, + paneFile: doclangFilePane?.section ?? null, + panePageView: doclangPageViewPane?.section ?? null, + paneMarkup: doclangMarkupPane?.section ?? null, + paneReading: doclangReadingPane?.section ?? null, splitters: [ document.getElementById('splitter-0'), document.getElementById('splitter-1'), document.getElementById('splitter-2'), ] as (HTMLElement | null)[], - toolbarOptionsBtn: document.getElementById('btn-toolbar-options'), - toolbarOptionsPanel: document.getElementById('toolbar-options-panel'), - toggleFilePane: document.getElementById('toggle-file-pane') as HTMLInputElement | null, - toggleFilePaneLabel: document.getElementById('toggle-file-pane-label'), - togglePagePane: document.getElementById('toggle-page-pane') as HTMLInputElement | null, - toggleMarkupPane: document.getElementById('toggle-markup-pane') as HTMLInputElement | null, - toggleReadingPane: document.getElementById('toggle-reading-pane') as HTMLInputElement | null, - togglePagePaneLabel: document.getElementById('toggle-page-pane-label'), - resetPaneLayoutBtn: document.getElementById('btn-reset-pane-layout') as HTMLButtonElement | null, + toolbarOptionsBtn: doclangToolbar?.toolbarOptionsBtn ?? null, + toolbarOptionsPanel: null as HTMLElement | null, // handled by doclang-toolbar internally + toggleFilePane: doclangToolbar?.toggleFilePane ?? null, + toggleFilePaneLabel: doclangToolbar?.toggleFilePaneLabel ?? null, + togglePagePane: doclangToolbar?.togglePagePane ?? null, + toggleMarkupPane: doclangToolbar?.toggleMarkupPane ?? null, + toggleReadingPane: doclangToolbar?.toggleReadingPane ?? null, + togglePagePaneLabel: doclangToolbar?.togglePagePaneLabel ?? null, + resetPaneLayoutBtn: doclangToolbar?.resetPaneLayoutBtn ?? null, }; -// --------------------------------------------------------------------------- -// Cursor hint -// --------------------------------------------------------------------------- - -const cursorHintEl = document.getElementById('cursor-hint') as HTMLElement | null; -const CURSOR_HINT_OFFSET = 10; -const CURSOR_HINT_MARGIN = 8; - function hideCursorHint(): void { - if (!cursorHintEl) return; - cursorHintEl.hidden = true; - cursorHintEl.classList.remove('cursor-hint-detail'); - cursorHintEl.replaceChildren(); + doclangCursorHint?.hide(); } function showCursorHint( @@ -226,46 +258,11 @@ function showCursorHint( clientY: number, { detail = false } = {} ): void { - if (!cursorHintEl) return; - cursorHintEl.replaceChildren(); - if (typeof content === 'string') { - cursorHintEl.textContent = content; - } else { - cursorHintEl.appendChild(content); - } - cursorHintEl.classList.toggle('cursor-hint-detail', detail); - cursorHintEl.hidden = false; - - let left = clientX + CURSOR_HINT_OFFSET; - let top = clientY + CURSOR_HINT_OFFSET; - const rect = cursorHintEl.getBoundingClientRect(); - if (left + rect.width > window.innerWidth - CURSOR_HINT_MARGIN) { - left = clientX - rect.width - CURSOR_HINT_OFFSET; - } - if (top + rect.height > window.innerHeight - CURSOR_HINT_MARGIN) { - top = clientY - rect.height - CURSOR_HINT_OFFSET; - } - cursorHintEl.style.left = `${Math.max(CURSOR_HINT_MARGIN, left)}px`; - cursorHintEl.style.top = `${Math.max(CURSOR_HINT_MARGIN, top)}px`; + doclangCursorHint?.show(content, clientX, clientY, detail); } function showCursorHintHtml(html: string, clientX: number, clientY: number): void { - if (!cursorHintEl) return; - cursorHintEl.innerHTML = html; - cursorHintEl.classList.add('cursor-hint-detail'); - cursorHintEl.hidden = false; - - let left = clientX + CURSOR_HINT_OFFSET; - let top = clientY + CURSOR_HINT_OFFSET; - const rect = cursorHintEl.getBoundingClientRect(); - if (left + rect.width > window.innerWidth - CURSOR_HINT_MARGIN) { - left = clientX - rect.width - CURSOR_HINT_OFFSET; - } - if (top + rect.height > window.innerHeight - CURSOR_HINT_MARGIN) { - top = clientY - rect.height - CURSOR_HINT_OFFSET; - } - cursorHintEl.style.left = `${Math.max(CURSOR_HINT_MARGIN, left)}px`; - cursorHintEl.style.top = `${Math.max(CURSOR_HINT_MARGIN, top)}px`; + doclangCursorHint?.showHtml(html, clientX, clientY); } // --------------------------------------------------------------------------- @@ -278,7 +275,10 @@ function headTextPreview(el: Element, maxLen = 72): string { return text.length > maxLen ? `${text.slice(0, maxLen)}…` : text; } -function collectElementHeadInfo(el: Element, defaultResolution: { width: number; height: number }): HeadInfo[] { +function collectElementHeadInfo( + el: Element, + defaultResolution: { width: number; height: number } +): HeadInfo[] { const labelEl = firstHeadChild(el, 'label'); const threadEl = firstHeadChild(el, 'thread'); const xrefEl = firstHeadChild(el, 'xref'); @@ -289,7 +289,9 @@ function collectElementHeadInfo(el: Element, defaultResolution: { width: number; const summaryEl = firstHeadChild(el, 'summary'); const customEl = firstHeadChild(el, 'custom'); const locs = elementHeadLocations(el); - const rows: HeadInfo[] = [{ key: 'element', value: elementLabel(el), isDefault: false }]; + const rows: HeadInfo[] = [ + { key: 'element', value: elementLabel(el), isDefault: false }, + ]; rows.push({ key: 'label', @@ -298,19 +300,31 @@ function collectElementHeadInfo(el: Element, defaultResolution: { width: number; }); if (threadEl) { - rows.push({ key: 'thread_id', value: threadEl.getAttribute('thread_id') ?? '—', isDefault: false }); + rows.push({ + key: 'thread_id', + value: threadEl.getAttribute('thread_id') ?? '—', + isDefault: false, + }); } else { rows.push({ key: 'thread', value: '—', isDefault: true }); } if (xrefEl) { - rows.push({ key: 'xref', value: `thread_id ${xrefEl.getAttribute('thread_id') ?? '—'}`, isDefault: false }); + rows.push({ + key: 'xref', + value: `thread_id ${xrefEl.getAttribute('thread_id') ?? '—'}`, + isDefault: false, + }); } else { rows.push({ key: 'xref', value: '—', isDefault: true }); } if (hrefEl) { - rows.push({ key: 'href', value: hrefEl.getAttribute('uri') ?? '—', isDefault: false }); + rows.push({ + key: 'href', + value: hrefEl.getAttribute('uri') ?? '—', + isDefault: false, + }); } else { rows.push({ key: 'href', value: '—', isDefault: true }); } @@ -325,10 +339,15 @@ function collectElementHeadInfo(el: Element, defaultResolution: { width: number; if (locs.length === 4) { for (let idx = 0; idx < 4; idx += 1) { const loc = locs[idx]!; - const axisDefault = idx % 2 === 0 ? defaultResolution.width : defaultResolution.height; + const axisDefault = + idx % 2 === 0 ? defaultResolution.width : defaultResolution.height; const resolution = locationResolution(loc, axisDefault); const value = loc.getAttribute('value') ?? '0'; - rows.push({ key: cornerLabels[idx]!, value: `${value} @ ${resolution}`, isDefault: false }); + rows.push({ + key: cornerLabels[idx]!, + value: `${value} @ ${resolution}`, + isDefault: false, + }); } } else { for (const key of cornerLabels) { @@ -336,15 +355,34 @@ function collectElementHeadInfo(el: Element, defaultResolution: { width: number; } } - rows.push({ key: 'caption', value: captionEl ? headTextPreview(captionEl) : '—', isDefault: !captionEl }); - rows.push({ key: 'description', value: descriptionEl ? headTextPreview(descriptionEl) : '—', isDefault: !descriptionEl }); - rows.push({ key: 'summary', value: summaryEl ? headTextPreview(summaryEl) : '—', isDefault: !summaryEl }); - rows.push({ key: 'custom', value: customEl ? headTextPreview(customEl) : '—', isDefault: !customEl }); + rows.push({ + key: 'caption', + value: captionEl ? headTextPreview(captionEl) : '—', + isDefault: !captionEl, + }); + rows.push({ + key: 'description', + value: descriptionEl ? headTextPreview(descriptionEl) : '—', + isDefault: !descriptionEl, + }); + rows.push({ + key: 'summary', + value: summaryEl ? headTextPreview(summaryEl) : '—', + isDefault: !summaryEl, + }); + rows.push({ + key: 'custom', + value: customEl ? headTextPreview(customEl) : '—', + isDefault: !customEl, + }); return rows; } -function elementHeadTooltipHtml(el: Element, defaultResolution: { width: number; height: number }): string { +function elementHeadTooltipHtml( + el: Element, + defaultResolution: { width: number; height: number } +): string { const rows = collectElementHeadInfo(el, defaultResolution); const body = rows .map(({ key, value, isDefault }) => { @@ -362,7 +400,8 @@ function elementHeadTooltipHtml(el: Element, defaultResolution: { width: number; function syncReadingLayerCheckboxes(): void { if (els.showReadingFurniture) els.showReadingFurniture.checked = showReadingFurniture; - if (els.showReadingBackground) els.showReadingBackground.checked = showReadingBackground; + if (els.showReadingBackground) + els.showReadingBackground.checked = showReadingBackground; } function syncReadingLayerVisibility(): void { @@ -382,7 +421,10 @@ function updatePageZoomResetButton(): void { function pageViewScrollPane(): HTMLElement | null { if (!els.pagePane) return null; - return (els.pagePane.querySelector('.page-view-port') as HTMLElement | null) ?? els.pagePane; + return ( + (els.pagePane.querySelector('.page-view-port') as HTMLElement | null) ?? + els.pagePane + ); } function isPagePaneScrollable(): boolean { @@ -399,7 +441,10 @@ function resetPageZoom(): void { } updatePageZoomResetButton(); const port = pageViewScrollPane(); - if (port) { port.scrollLeft = 0; port.scrollTop = 0; } + if (port) { + port.scrollLeft = 0; + port.scrollTop = 0; + } refreshPageViewLayout(); } @@ -408,7 +453,9 @@ function refreshPageViewLayout(): void { cancelAnimationFrame(pageLayoutFrame); pageLayoutFrame = requestAnimationFrame(() => { pageLayoutFrame = 0; - const img = els.pagePane?.querySelector('.page-view img') as HTMLImageElement | null; + const img = els.pagePane?.querySelector( + '.page-view img' + ) as HTMLImageElement | null; if (img?.naturalWidth) applyPageLayout(img); }); } @@ -422,10 +469,13 @@ function syncPageViewChrome(img: HTMLImageElement): void { const svg = img.parentElement?.querySelector('svg.overlay') as SVGSVGElement | null; if (svg && state?.pageViewOverlay) { syncOverlayBadges( - img, svg, + img, + svg, state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, - showAllBboxes, showLayoutBadges, showReadingOrder + showAllBboxes, + showLayoutBadges, + showReadingOrder ); } updatePagePanePanCursor(); @@ -443,14 +493,12 @@ function updatePagePanePanCursor(): void { function setPageSettingsOpen(open: boolean): void { pageSettingsOpen = open; - if (els.pageSettingsLayer) els.pageSettingsLayer.hidden = !open; - if (els.settingsToggle) els.settingsToggle.setAttribute('aria-expanded', String(open)); + doclangPageViewPane?.setSettingsOpen(open); } function setReadingSettingsOpen(open: boolean): void { readingSettingsOpen = open; - if (els.readingSettingsLayer) els.readingSettingsLayer.hidden = !open; - if (els.readingSettingsToggle) els.readingSettingsToggle.setAttribute('aria-expanded', String(open)); + doclangReadingPane?.setSettingsOpen(open); } function closeAllSettings(): void { @@ -465,8 +513,13 @@ function closeAllSettings(): void { function syncLayoutSubtoggles(): void { const layoutEnabled = Boolean(state?.hasPageView && showAllBboxes); for (const label of [ - els.showLayoutBadgesLabel, els.showPictureContentsLabel, els.showTableContentsLabel, - els.showFragmentLinksLabel, els.showCaptionLinksLabel, els.showXrefLinksLabel, els.showReadingOrderLabel, + els.showLayoutBadgesLabel, + els.showPictureContentsLabel, + els.showTableContentsLabel, + els.showFragmentLinksLabel, + els.showCaptionLinksLabel, + els.showXrefLinksLabel, + els.showReadingOrderLabel, ]) { if (!label) continue; label.classList.toggle('settings-option-disabled', !layoutEnabled); @@ -486,12 +539,32 @@ function syncLayoutSubtoggles(): void { // Pane layout // --------------------------------------------------------------------------- -function paneDef(key: PaneKey): { key: PaneKey; el: HTMLElement | null; canShow: () => boolean } { +function paneDef(key: PaneKey): { + key: PaneKey; + el: HTMLElement | null; + canShow: () => boolean; +} { const defs = { - file: { key: 'file' as PaneKey, el: els.paneFile, canShow: () => fileCatalog.length > 0 }, - page: { key: 'page' as PaneKey, el: els.panePageView, canShow: () => Boolean(state?.hasPageView) }, - markup: { key: 'markup' as PaneKey, el: els.paneMarkup, canShow: () => Boolean(state) }, - reading: { key: 'reading' as PaneKey, el: els.paneReading, canShow: () => Boolean(state) }, + file: { + key: 'file' as PaneKey, + el: els.paneFile, + canShow: () => fileCatalog.length > 0, + }, + page: { + key: 'page' as PaneKey, + el: els.panePageView, + canShow: () => Boolean(state?.hasPageView), + }, + markup: { + key: 'markup' as PaneKey, + el: els.paneMarkup, + canShow: () => Boolean(state), + }, + reading: { + key: 'reading' as PaneKey, + el: els.paneReading, + canShow: () => Boolean(state), + }, }; return defs[key]; } @@ -516,7 +589,8 @@ function paneMinRatio(_unused: PaneKey): number { function filePaneFitWidthPx(): number { const probe = document.createElement('div'); - probe.style.cssText = 'position:absolute;visibility:hidden;width:var(--file-pane-fit-width);'; + probe.style.cssText = + 'position:absolute;visibility:hidden;width:var(--file-pane-fit-width);'; document.documentElement.appendChild(probe); const px = probe.getBoundingClientRect().width; probe.remove(); @@ -541,7 +615,10 @@ function paneRatioIndex(key: PaneKey): number { function normalizePaneRatios(): void { const sum = paneRatios.reduce((a, b) => a + b, 0); - if (sum <= 0) { paneRatios = [...DEFAULT_PANE_RATIOS]; return; } + if (sum <= 0) { + paneRatios = [...DEFAULT_PANE_RATIOS]; + return; + } paneRatios = paneRatios.map(r => r / sum); } @@ -549,10 +626,15 @@ function loadLayoutPrefs(): void { try { const raw = localStorage.getItem(LAYOUT_STORAGE_KEY); if (!raw) return; - const data = JSON.parse(raw) as { visible?: Record; ratios?: unknown[]; filePaneWidthPx?: unknown }; + const data = JSON.parse(raw) as { + visible?: Record; + ratios?: unknown[]; + filePaneWidthPx?: unknown; + }; if (data?.visible && typeof data.visible === 'object') { for (const key of PANE_KEYS) { - if (typeof data.visible[key] === 'boolean') userPaneVisible[key] = data.visible[key] as boolean; + if (typeof data.visible[key] === 'boolean') + userPaneVisible[key] = data.visible[key] as boolean; } if (typeof data.visible['file'] === 'boolean') filePaneUserToggled = true; } @@ -569,7 +651,9 @@ function loadLayoutPrefs(): void { if (typeof data?.filePaneWidthPx === 'number' && data.filePaneWidthPx > 0) { filePaneWidthPx = data.filePaneWidthPx; } - } catch { /* ignore invalid stored layout */ } + } catch { + /* ignore invalid stored layout */ + } } function saveLayoutPrefs(): void { @@ -578,12 +662,19 @@ function saveLayoutPrefs(): void { LAYOUT_STORAGE_KEY, JSON.stringify({ visible: userPaneVisible, ratios: paneRatios, filePaneWidthPx }) ); - } catch { /* ignore quota / private mode */ } + } catch { + /* ignore quota / private mode */ + } } function resetPaneLayout(): void { filePaneUserToggled = false; - userPaneVisible = { file: defaultFilePaneVisible(), page: true, markup: true, reading: true }; + userPaneVisible = { + file: defaultFilePaneVisible(), + page: true, + markup: true, + reading: true, + }; paneRatios = [...DEFAULT_PANE_RATIOS]; normalizePaneRatios(); filePaneWidthPx = null; @@ -655,12 +746,18 @@ function visiblePaneNeighborBefore(key: PaneKey): PaneKey | null { return null; } -function resolvedPhysicalSplitterKeys(physicalSplitterIndex: number): { leftKey: PaneKey; rightKey: PaneKey } | null { +function resolvedPhysicalSplitterKeys( + physicalSplitterIndex: number +): { leftKey: PaneKey; rightKey: PaneKey } | null { const leftPhysical = PANE_KEYS[physicalSplitterIndex]; const rightPhysical = PANE_KEYS[physicalSplitterIndex + 1]; if (!leftPhysical || !rightPhysical) return null; - const leftKey = isPaneVisible(leftPhysical) ? leftPhysical : visiblePaneNeighborBefore(rightPhysical); - const rightKey = isPaneVisible(rightPhysical) ? rightPhysical : visiblePaneNeighborAfter(leftPhysical); + const leftKey = isPaneVisible(leftPhysical) + ? leftPhysical + : visiblePaneNeighborBefore(rightPhysical); + const rightKey = isPaneVisible(rightPhysical) + ? rightPhysical + : visiblePaneNeighborAfter(leftPhysical); if (!leftKey || !rightKey || leftKey === rightKey) return null; if (!shouldShowSplitterBetween(leftKey, rightKey)) return null; const canonical = splitterForLayoutGap(leftKey, rightKey); @@ -671,7 +768,10 @@ function resolvedPhysicalSplitterKeys(physicalSplitterIndex: number): { leftKey: function resetPaneGridStyles(): void { for (const key of PANE_KEYS) { const el = paneDef(key)?.el; - if (el) { el.style.gridColumn = ''; el.style.gridRow = ''; } + if (el) { + el.style.gridColumn = ''; + el.style.gridRow = ''; + } } for (const splitter of els.splitters) { if (!splitter) continue; @@ -693,7 +793,8 @@ function setUserPaneVisible(key: PaneKey, visible: boolean): void { function applyPaneLayout(): void { if (!els.main) return; - const stacked = document.body.classList.contains('viewer-loaded') && isLayoutStacked(); + const stacked = + document.body.classList.contains('viewer-loaded') && isLayoutStacked(); els.main.classList.toggle('layout-stacked', stacked); for (const key of PANE_KEYS) { @@ -711,7 +812,10 @@ function applyPaneLayout(): void { } let keys = visiblePaneKeys(); - if (!keys.length) { userPaneVisible.markup = true; keys = visiblePaneKeys(); } + if (!keys.length) { + userPaneVisible.markup = true; + keys = visiblePaneKeys(); + } const lastKey = keys[keys.length - 1]!; paneDef(lastKey)?.el?.classList.add('pane-layout-last'); @@ -727,7 +831,8 @@ function applyPaneLayout(): void { def.el.style.gridRow = String(row++); } refreshPageViewLayout(); - if (els.readingSettingsToggle) els.readingSettingsToggle.hidden = !state || !isPaneVisible('reading'); + if (els.readingSettingsToggle) + els.readingSettingsToggle.hidden = !state || !isPaneVisible('reading'); return; } @@ -740,7 +845,10 @@ function applyPaneLayout(): void { } else { cols.push(`minmax(0, ${contentFr[contentFrIndex++]!.toFixed(6)}fr)`); } - if (index < keys.length - 1 && shouldShowSplitterBetween(keys[index]!, keys[index + 1]!)) { + if ( + index < keys.length - 1 && + shouldShowSplitterBetween(keys[index]!, keys[index + 1]!) + ) { cols.push('1px'); } }); @@ -758,12 +866,17 @@ function applyPaneLayout(): void { const rightKey = keys[index + 1]!; if (!shouldShowSplitterBetween(leftKey, rightKey)) return; const splitter = splitterForLayoutGap(leftKey, rightKey); - if (splitter) { splitter.hidden = false; splitter.style.gridColumn = String(col); col += 1; } + if (splitter) { + splitter.hidden = false; + splitter.style.gridColumn = String(col); + col += 1; + } } }); refreshPageViewLayout(); - if (els.readingSettingsToggle) els.readingSettingsToggle.hidden = !state || !isPaneVisible('reading'); + if (els.readingSettingsToggle) + els.readingSettingsToggle.hidden = !state || !isPaneVisible('reading'); } // --------------------------------------------------------------------------- @@ -772,8 +885,7 @@ function applyPaneLayout(): void { function setToolbarOptionsOpen(open: boolean): void { toolbarOptionsOpen = open; - if (els.toolbarOptionsPanel) els.toolbarOptionsPanel.hidden = !open; - if (els.toolbarOptionsBtn) els.toolbarOptionsBtn.setAttribute('aria-expanded', String(open)); + doclangToolbar?.setOptionsOpen(open); } function syncToolbarPaneCheckboxes(): void { @@ -782,17 +894,32 @@ function syncToolbarPaneCheckboxes(): void { els.toggleFilePane.checked = available && userPaneVisible.file; els.toggleFilePane.disabled = !available; } - if (els.toggleFilePaneLabel) els.toggleFilePaneLabel.classList.toggle('toolbar-options-item-disabled', !isPaneAvailable('file')); + if (els.toggleFilePaneLabel) + els.toggleFilePaneLabel.classList.toggle( + 'toolbar-options-item-disabled', + !isPaneAvailable('file') + ); if (els.togglePagePane) { const available = isPaneAvailable('page'); els.togglePagePane.checked = available && userPaneVisible.page; els.togglePagePane.disabled = !available; } - if (els.togglePagePaneLabel) els.togglePagePaneLabel.classList.toggle('toolbar-options-item-disabled', !isPaneAvailable('page')); - if (els.toggleMarkupPane) { els.toggleMarkupPane.checked = userPaneVisible.markup; els.toggleMarkupPane.disabled = !state; } - if (els.toggleReadingPane) { els.toggleReadingPane.checked = userPaneVisible.reading; els.toggleReadingPane.disabled = !state; } + if (els.togglePagePaneLabel) + els.togglePagePaneLabel.classList.toggle( + 'toolbar-options-item-disabled', + !isPaneAvailable('page') + ); + if (els.toggleMarkupPane) { + els.toggleMarkupPane.checked = userPaneVisible.markup; + els.toggleMarkupPane.disabled = !state; + } + if (els.toggleReadingPane) { + els.toggleReadingPane.checked = userPaneVisible.reading; + els.toggleReadingPane.disabled = !state; + } for (const label of [ - els.toggleFilePaneLabel, els.togglePagePaneLabel, + els.toggleFilePaneLabel, + els.togglePagePaneLabel, document.getElementById('toggle-markup-pane-label'), document.getElementById('toggle-reading-pane-label'), ]) { @@ -804,30 +931,28 @@ function syncToolbarPaneCheckboxes(): void { } function initToolbarOptions(): void { - if (!els.toolbarOptionsBtn || !els.toolbarOptionsPanel) return; - els.toolbarOptionsBtn.addEventListener('click', e => { - e.stopPropagation(); - setToolbarOptionsOpen(!toolbarOptionsOpen); + if (!doclangToolbar) return; + // doclang-toolbar handles its own open/close toggle and click-outside. + // We listen to the composed events it emits. + doclangToolbar.addEventListener('doclang-toggle-pane', (e: Event) => { + const { pane, checked } = (e as CustomEvent<{ pane: PaneKey; checked: boolean }>) + .detail; + if (!state) { + syncToolbarPaneCheckboxes(); + return; + } + const nextKeys = [...PANE_KEYS].filter(k => + k === pane ? checked : userPaneVisible[k] && isPaneAvailable(k) + ); + if (!nextKeys.length) { + syncToolbarPaneCheckboxes(); + return; + } + setUserPaneVisible(pane, checked); }); - document.addEventListener('click', e => { - if (!toolbarOptionsOpen) return; - if (e.target instanceof Node && els.toolbarOptionsPanel!.contains(e.target)) return; - if (e.target instanceof Node && els.toolbarOptionsBtn!.contains(e.target)) return; - setToolbarOptionsOpen(false); + doclangToolbar.addEventListener('doclang-reset-pane-layout', () => { + if (state) resetPaneLayout(); }); - const onToggle = (key: PaneKey, input: HTMLInputElement | null): void => { - input?.addEventListener('change', () => { - if (!state) return; - const nextKeys = [...PANE_KEYS].filter(k => (k === key ? input.checked : userPaneVisible[k] && isPaneAvailable(k))); - if (!nextKeys.length) { input.checked = true; return; } - setUserPaneVisible(key, input.checked); - }); - }; - onToggle('file', els.toggleFilePane); - onToggle('page', els.togglePagePane); - onToggle('markup', els.toggleMarkupPane); - onToggle('reading', els.toggleReadingPane); - els.resetPaneLayoutBtn?.addEventListener('click', () => { if (state) resetPaneLayout(); }); syncToolbarPaneCheckboxes(); } @@ -848,7 +973,12 @@ function contentPaneAvailableWidthPx(): number { } function startPaneDrag(e: PointerEvent, physicalSplitterIndex: number): void { - if (e.button !== 0 || isLayoutStacked() || !document.body.classList.contains('viewer-loaded')) return; + if ( + e.button !== 0 || + isLayoutStacked() || + !document.body.classList.contains('viewer-loaded') + ) + return; const resolved = resolvedPhysicalSplitterKeys(physicalSplitterIndex); if (!resolved) return; const { leftKey, rightKey } = resolved; @@ -856,7 +986,9 @@ function startPaneDrag(e: PointerEvent, physicalSplitterIndex: number): void { const leftIndex = paneRatioIndex(leftKey); const rightIndex = paneRatioIndex(rightKey); const dragState: PaneDragState = { - physicalSplitterIndex, leftKey, rightKey, + physicalSplitterIndex, + leftKey, + rightKey, startX: e.clientX, leftStart: paneRatios[leftIndex]!, rightStart: paneRatios[rightIndex]!, @@ -874,17 +1006,29 @@ function startPaneDrag(e: PointerEvent, physicalSplitterIndex: number): void { function onPaneDragMove(e: PointerEvent): void { if (!paneDrag || e.pointerId !== paneDrag.pointerId || !els.main) return; if (paneDrag.leftKey === 'file' && typeof paneDrag.leftStartPx === 'number') { - filePaneWidthPx = Math.max(filePaneFitWidthPx(), paneDrag.leftStartPx + (e.clientX - paneDrag.startX)); - applyPaneLayout(); return; + filePaneWidthPx = Math.max( + filePaneFitWidthPx(), + paneDrag.leftStartPx + (e.clientX - paneDrag.startX) + ); + applyPaneLayout(); + return; } if (paneDrag.rightKey === 'file' && typeof paneDrag.rightStartPx === 'number') { - filePaneWidthPx = Math.max(filePaneFitWidthPx(), paneDrag.rightStartPx - (e.clientX - paneDrag.startX)); - applyPaneLayout(); return; + filePaneWidthPx = Math.max( + filePaneFitWidthPx(), + paneDrag.rightStartPx - (e.clientX - paneDrag.startX) + ); + applyPaneLayout(); + return; } const keys = visiblePaneKeys(); const contentFr = contentPaneFrWeights(keys); - const contentKeys = keys.filter((key): key is Exclude => key !== 'file'); - const leftContentIndex = contentKeys.indexOf(paneDrag.leftKey as Exclude); + const contentKeys = keys.filter( + (key): key is Exclude => key !== 'file' + ); + const leftContentIndex = contentKeys.indexOf( + paneDrag.leftKey as Exclude + ); if (leftContentIndex < 0 || leftContentIndex + 1 >= contentFr.length) return; const pairFrTotal = contentFr[leftContentIndex]! + contentFr[leftContentIndex + 1]!; if (!(pairFrTotal > 0)) return; @@ -907,7 +1051,8 @@ function endPaneDrag(e: PointerEvent): void { if (!paneDrag || e.pointerId !== paneDrag.pointerId) return; const splitter = els.splitters[paneDrag.physicalSplitterIndex]; splitter?.classList.remove('is-dragging'); - if (splitter?.hasPointerCapture(e.pointerId)) splitter.releasePointerCapture(e.pointerId); + if (splitter?.hasPointerCapture(e.pointerId)) + splitter.releasePointerCapture(e.pointerId); paneDrag = null; document.body.classList.remove('pane-drag-active'); normalizePaneRatios(); @@ -917,7 +1062,9 @@ function endPaneDrag(e: PointerEvent): void { function initPaneSplitters(): void { for (const [index, splitter] of els.splitters.entries()) { if (!splitter) continue; - splitter.addEventListener('pointerdown', e => startPaneDrag(e as PointerEvent, index)); + splitter.addEventListener('pointerdown', e => + startPaneDrag(e as PointerEvent, index) + ); } window.addEventListener('pointermove', e => onPaneDragMove(e as PointerEvent)); window.addEventListener('pointerup', e => endPaneDrag(e as PointerEvent)); @@ -936,27 +1083,8 @@ function goToPage(n: number): void { renderPage(page); } -function resetPageNumberInput(): void { - if (!els.pageNumberInput) return; - els.pageNumberInput.value = state ? String(state.currentPage) : '1'; -} - -function commitPageNumberInput(): void { - if (!state || !els.pageNumberInput) return; - const n = Number.parseInt(els.pageNumberInput.value.trim(), 10); - if (!Number.isFinite(n)) { resetPageNumberInput(); return; } - goToPage(n); -} - function setPageIndicator(pageNum: number, pageCount: number): void { - if (!els.pageIndicator) return; - if (els.pageCountIndicator) els.pageCountIndicator.textContent = `\u00A0of ${pageCount}`; - if (els.pageNumberInput) { - const digits = Math.max(1, String(pageCount).length); - els.pageNumberInput.style.setProperty('--page-num-digits', String(digits)); - els.pageNumberInput.disabled = !state; - if (document.activeElement !== els.pageNumberInput) els.pageNumberInput.value = String(pageNum); - } + doclangPageNav?.setIndicator(pageNum, pageCount); } function syncPagePaneControls(): void { @@ -1006,10 +1134,15 @@ function findListVirtualTextHost(list: Element, target: Element): Element | null let i = skipContainerLevelHead(nodes, 0); while (i < nodes.length) { const node = nodes[i]!; - if (node.nodeType !== Node.ELEMENT_NODE || localName(node as Element) !== 'ldiv') { i += 1; continue; } - const ldiv = node as Element; i += 1; + if (node.nodeType !== Node.ELEMENT_NODE || localName(node as Element) !== 'ldiv') { + i += 1; + continue; + } + const ldiv = node as Element; + i += 1; const end = skipUntilListItemBoundary(nodes, i); - if (target === ldiv || nodes.slice(i, end).some(n => xmlContains(target, n))) return ldiv; + if (target === ldiv || nodes.slice(i, end).some(n => xmlContains(target, n))) + return ldiv; i = end; } return null; @@ -1020,12 +1153,25 @@ function findTableVirtualTextHost(container: Element, target: Element): Element let i = skipContainerLevelHead(nodes, 0); while (i < nodes.length) { const node = nodes[i]!; - if (node.nodeType !== Node.ELEMENT_NODE) { i += 1; continue; } + if (node.nodeType !== Node.ELEMENT_NODE) { + i += 1; + continue; + } const tag = localName(node as Element); - if (tag === 'nl' || isVirtualTextHost(node as Element) || CELL_SPAN_TAGS.has(tag) || !isCellToken(tag)) { i += 1; continue; } - const cell = node as Element; i += 1; + if ( + tag === 'nl' || + isVirtualTextHost(node as Element) || + CELL_SPAN_TAGS.has(tag) || + !isCellToken(tag) + ) { + i += 1; + continue; + } + const cell = node as Element; + i += 1; const end = skipUntilCellBoundary(nodes, i); - if (target === cell || nodes.slice(i, end).some(n => xmlContains(target, n))) return cell; + if (target === cell || nodes.slice(i, end).some(n => xmlContains(target, n))) + return cell; i = end; } return null; @@ -1037,8 +1183,14 @@ function findVirtualTextHost(xmlEl: Element): Element | null { const parent: Element | null = node.parentElement; if (!parent) return null; const tag = localName(parent); - if (tag === 'list') { const host = findListVirtualTextHost(parent, xmlEl); if (host) return host; } - if (OTSL_CONTAINER_TAGS.has(tag)) { const host = findTableVirtualTextHost(parent, xmlEl); if (host) return host; } + if (tag === 'list') { + const host = findListVirtualTextHost(parent, xmlEl); + if (host) return host; + } + if (OTSL_CONTAINER_TAGS.has(tag)) { + const host = findTableVirtualTextHost(parent, xmlEl); + if (host) return host; + } node = parent; } return null; @@ -1118,58 +1270,98 @@ function isFragmentLinkRelevant(linkEl: Element, peerIds: Set): boolean function applyBboxVisibility(): void { if (!state?.hasPageView || !els.pagePane) return; - const peerIds = selectedElementId ? fragmentPeerElementIds(selectedElementId) : new Set(); + const peerIds = selectedElementId + ? fragmentPeerElementIds(selectedElementId) + : new Set(); for (const el of els.pagePane.querySelectorAll('.bbox')) { el.classList.remove('related'); const elementId = el.getAttribute('data-element-id') ?? ''; const clickVisible = elementId === selectedElementId || peerIds.has(elementId); if (showAllBboxes) { - if (isContentsOptionHidden(elementId, clickVisible)) { el.classList.add('bbox-hidden'); } - else { el.classList.remove('bbox-hidden'); if (peerIds.has(elementId)) el.classList.add('related'); } + if (isContentsOptionHidden(elementId, clickVisible)) { + el.classList.add('bbox-hidden'); + } else { + el.classList.remove('bbox-hidden'); + if (peerIds.has(elementId)) el.classList.add('related'); + } continue; } - if (elementId === selectedElementId) { el.classList.remove('bbox-hidden'); } - else if (peerIds.has(elementId)) { el.classList.remove('bbox-hidden'); el.classList.add('related'); } - else { el.classList.add('bbox-hidden'); } + if (elementId === selectedElementId) { + el.classList.remove('bbox-hidden'); + } else if (peerIds.has(elementId)) { + el.classList.remove('bbox-hidden'); + el.classList.add('related'); + } else { + el.classList.add('bbox-hidden'); + } } for (const el of els.pagePane.querySelectorAll('.element-badge')) { const elementId = el.getAttribute('data-element-id') ?? ''; const clickVisible = elementId === selectedElementId || peerIds.has(elementId); - if (!showAllBboxes || !showLayoutBadges) { el.classList.add('bbox-hidden'); continue; } - if (isContentsOptionHidden(elementId, clickVisible)) el.classList.add('bbox-hidden'); + if (!showAllBboxes || !showLayoutBadges) { + el.classList.add('bbox-hidden'); + continue; + } + if (isContentsOptionHidden(elementId, clickVisible)) + el.classList.add('bbox-hidden'); else el.classList.remove('bbox-hidden'); } for (const el of els.pagePane.querySelectorAll('.caption-link')) { - if (!showAllBboxes || !showCaptionLinks) { el.classList.add('bbox-hidden'); continue; } + if (!showAllBboxes || !showCaptionLinks) { + el.classList.add('bbox-hidden'); + continue; + } el.classList.remove('bbox-hidden'); } for (const el of els.pagePane.querySelectorAll('.xref-link')) { - if (!showAllBboxes || !showXrefLinks) { el.classList.add('bbox-hidden'); continue; } + if (!showAllBboxes || !showXrefLinks) { + el.classList.add('bbox-hidden'); + continue; + } el.classList.remove('bbox-hidden'); } for (const el of els.pagePane.querySelectorAll('.fragment-link')) { - const clickVisible = Boolean(selectedElementId && isFragmentLinkRelevant(el, peerIds)); - el.classList.toggle('bbox-hidden', !(clickVisible || (showAllBboxes && showFragmentLinks))); + const clickVisible = Boolean( + selectedElementId && isFragmentLinkRelevant(el, peerIds) + ); + el.classList.toggle( + 'bbox-hidden', + !(clickVisible || (showAllBboxes && showFragmentLinks)) + ); } for (const el of els.pagePane.querySelectorAll('.fragment-nav')) { const elementId = el.getAttribute('data-element-id') ?? ''; const clickVisible = elementId === selectedElementId || peerIds.has(elementId); - el.classList.toggle('bbox-hidden', !(clickVisible || (showAllBboxes && showFragmentLinks))); + el.classList.toggle( + 'bbox-hidden', + !(clickVisible || (showAllBboxes && showFragmentLinks)) + ); } for (const el of els.pagePane.querySelectorAll('.reading-order-badge')) { const elementId = el.getAttribute('data-element-id') ?? ''; const clickVisible = elementId === selectedElementId || peerIds.has(elementId); - if (!showAllBboxes || !showReadingOrder) { el.classList.add('bbox-hidden'); continue; } - if (isPictureContentOverlayElement(elementId) || isTableContentOverlayElement(elementId) || isContentsOptionHidden(elementId, clickVisible)) { - el.classList.add('bbox-hidden'); continue; + if (!showAllBboxes || !showReadingOrder) { + el.classList.add('bbox-hidden'); + continue; + } + if ( + isPictureContentOverlayElement(elementId) || + isTableContentOverlayElement(elementId) || + isContentsOptionHidden(elementId, clickVisible) + ) { + el.classList.add('bbox-hidden'); + continue; } el.classList.remove('bbox-hidden'); } for (const el of els.pagePane.querySelectorAll('.reading-order-step')) { - if (!showAllBboxes || !showReadingOrder || !showReadingOrderArrows) { el.classList.add('bbox-hidden'); continue; } + if (!showAllBboxes || !showReadingOrder || !showReadingOrderArrows) { + el.classList.add('bbox-hidden'); + continue; + } el.classList.remove('bbox-hidden'); } } @@ -1177,21 +1369,25 @@ function applyBboxVisibility(): void { function findMarkupElementForSelection(elementId: string): Element | null { if (!els.markupPane) return null; return ( - els.markupPane.querySelector(`.markup-el-virtual-text[data-element-id="${elementId}"]`) || - els.markupPane.querySelector(`[data-element-id="${elementId}"]`) + els.markupPane.querySelector( + `.markup-el-virtual-text[data-element-id="${elementId}"]` + ) || els.markupPane.querySelector(`[data-element-id="${elementId}"]`) ); } function findRenderedElementForSelection(elementId: string): Element | null { if (!els.renderedPane) return null; const direct = - els.renderedPane.querySelector(`.rendered-el-virtual-text[data-element-id="${elementId}"]`) || - els.renderedPane.querySelector(`.rendered-el[data-element-id="${elementId}"]`); + els.renderedPane.querySelector( + `.rendered-el-virtual-text[data-element-id="${elementId}"]` + ) || els.renderedPane.querySelector(`.rendered-el[data-element-id="${elementId}"]`); if (direct) return direct; const xmlEl = state?.idToElement?.get(elementId); const threadId = xmlEl ? elementThreadId(xmlEl) : null; if (!threadId) return null; - const merged = els.renderedPane.querySelector(`.rendered-fragment-merged[data-thread-id="${threadId}"]`); + const merged = els.renderedPane.querySelector( + `.rendered-fragment-merged[data-thread-id="${threadId}"]` + ); if (!merged) return null; const primaryId = merged.getAttribute('data-element-id'); if (!primaryId || primaryId === elementId) return merged; @@ -1202,38 +1398,65 @@ function revealReadingLayerForSelection(renderedEl: Element): void { const layer = renderedEl.getAttribute('data-doclang-layer'); if (!layer || layer === 'body') return; let changed = false; - if (layer === 'furniture' && !showReadingFurniture) { showReadingFurniture = true; changed = true; } - else if (layer === 'background' && !showReadingBackground) { showReadingBackground = true; changed = true; } + if (layer === 'furniture' && !showReadingFurniture) { + showReadingFurniture = true; + changed = true; + } else if (layer === 'background' && !showReadingBackground) { + showReadingBackground = true; + changed = true; + } if (!changed) return; syncReadingLayerCheckboxes(); syncReadingLayerVisibility(); } function revealRenderedSelectionContext(renderedEl: Element): void { - const pictureContents = renderedEl.closest('.rendered-picture-contents') as HTMLDetailsElement | null; + const pictureContents = renderedEl.closest( + '.rendered-picture-contents' + ) as HTMLDetailsElement | null; if (pictureContents && !pictureContents.open) pictureContents.open = true; revealReadingLayerForSelection(renderedEl); } function applySelection(): void { - els.markupPane?.querySelectorAll('.markup-el.selected').forEach(el => el.classList.remove('selected')); - els.renderedPane?.querySelectorAll('.rendered-el.selected').forEach(el => el.classList.remove('selected')); + els.markupPane + ?.querySelectorAll('.markup-el.selected') + .forEach(el => el.classList.remove('selected')); + els.renderedPane + ?.querySelectorAll('.rendered-el.selected') + .forEach(el => el.classList.remove('selected')); if (!els.pagePane) return; - els.pagePane.querySelectorAll('.bbox.selected, .overlay-badge.selected').forEach(el => el.classList.remove('selected')); + els.pagePane + .querySelectorAll('.bbox.selected, .overlay-badge.selected') + .forEach(el => el.classList.remove('selected')); if (!selectedElementId) { const img = els.pagePane.querySelector('.page-view img') as HTMLImageElement | null; if (img) { - const svg = img.parentElement?.querySelector('svg.overlay') as SVGSVGElement | null; + const svg = img.parentElement?.querySelector( + 'svg.overlay' + ) as SVGSVGElement | null; if (svg && state?.pageViewOverlay) { - syncOverlayBadges(img, svg, state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); + syncOverlayBadges( + img, + svg, + state.pageViewOverlay.boxes, + state.pageViewOverlay.readingOrderSteps, + showAllBboxes, + showLayoutBadges, + showReadingOrder + ); } } - applyBboxVisibility(); return; + applyBboxVisibility(); + return; } const markupEl = findMarkupElementForSelection(selectedElementId); - if (markupEl) { markupEl.classList.add('selected'); markupEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } + if (markupEl) { + markupEl.classList.add('selected'); + markupEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } const renderedEl = findRenderedElementForSelection(selectedElementId); if (renderedEl) { @@ -1243,7 +1466,9 @@ function applySelection(): void { } if (state?.hasPageView) { - for (const el of els.pagePane.querySelectorAll(`[data-element-id="${selectedElementId}"]`)) { + for (const el of els.pagePane.querySelectorAll( + `[data-element-id="${selectedElementId}"]` + )) { el.classList.add('selected'); } } @@ -1252,7 +1477,15 @@ function applySelection(): void { if (img) { const svg = img.parentElement?.querySelector('svg.overlay') as SVGSVGElement | null; if (svg && state?.pageViewOverlay) { - syncOverlayBadges(img, svg, state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); + syncOverlayBadges( + img, + svg, + state.pageViewOverlay.boxes, + state.pageViewOverlay.readingOrderSteps, + showAllBboxes, + showLayoutBadges, + showReadingOrder + ); } } applyBboxVisibility(); @@ -1281,9 +1514,21 @@ function renderPage(pageNum: number): void { state.idToElement = invertElementIds(elementIds); if (segmentHasMarkup(segment)) { - els.markupPane.appendChild(buildMarkupView(segment, elementIds, id => selectElement(resolveSelectionElementId(id) ?? id))); + els.markupPane.appendChild( + buildMarkupView(segment, elementIds, id => + selectElement(resolveSelectionElementId(id) ?? id) + ) + ); els.renderedPane.innerHTML = ''; - els.renderedPane.appendChild(buildRenderedView(segment, elementIds, showReadingFurniture, showReadingBackground, id => selectElement(resolveSelectionElementId(id) ?? id))); + els.renderedPane.appendChild( + buildRenderedView( + segment, + elementIds, + showReadingFurniture, + showReadingBackground, + id => selectElement(resolveSelectionElementId(id) ?? id) + ) + ); } else { els.markupPane.innerHTML = `
${NO_MARKUP}
`; els.renderedPane.innerHTML = `
${NO_MARKUP}
`; @@ -1308,25 +1553,43 @@ function renderPage(pageNum: number): void { const existing = wrap.querySelector('svg.overlay'); if (existing) existing.remove(); const readingOrderSteps = collectReadingOrderSteps( - segment, elementIds, boxes, - state!.readingOrder, readingOrderGlobalNumbering, state!.readingOrderDisplayNumbers + segment, + elementIds, + boxes, + state!.readingOrder, + readingOrderGlobalNumbering, + state!.readingOrderDisplayNumbers ); state!.pageViewOverlay = { boxes, readingOrderSteps }; if (boxes.length) { wrap.appendChild( buildOverlay( - img, boxes, + img, + boxes, collectCaptionLinks(segment, elementIds, boxes), collectXrefLinks(segment, elementIds, boxes), readingOrderSteps, - collectFragmentLinks(segment, elementIds, boxes, pageNum, state!.threadPagesById), - collectFragmentNavItems(segment, elementIds, boxes, state!.threadNavByElement), + collectFragmentLinks( + segment, + elementIds, + boxes, + pageNum, + state!.threadPagesById + ), + collectFragmentNavItems( + segment, + elementIds, + boxes, + state!.threadNavByElement + ), defaultResolution, id => selectElement(resolveSelectionElementId(id) ?? id), navigateThreadFragment, clearSelection, () => pagePanSuppressClick, - v => { pagePanSuppressClick = v; } + v => { + pagePanSuppressClick = v; + } ) ); } @@ -1362,14 +1625,19 @@ function renderPage(pageNum: number): void { function setDocLabel(label: string | null): void { if (!els.docLabel) return; - if (label) { els.docLabel.textContent = label; els.docLabel.hidden = false; } - else { els.docLabel.textContent = ''; els.docLabel.hidden = true; } + if (label) { + els.docLabel.textContent = label; + els.docLabel.hidden = false; + } else { + els.docLabel.textContent = ''; + els.docLabel.hidden = true; + } } function setDocumentOpen(open: boolean, { markupOnly = false } = {}): void { document.body.classList.toggle('viewer-loaded', open); document.body.classList.toggle('markup-only', open && markupOnly); - if (els.pageNav) els.pageNav.hidden = !open || markupOnly; + doclangPageNav?.setVisible(open && !markupOnly); syncToolbarPaneCheckboxes(); applyPaneLayout(); } @@ -1422,7 +1690,9 @@ function pageImageMimeFromExt(ext: string): string { } function createPageImageObjectUrl(data: Uint8Array, ext: string): string { - return URL.createObjectURL(new Blob([data as BlobPart], { type: pageImageMimeFromExt(ext) })); + return URL.createObjectURL( + new Blob([data as BlobPart], { type: pageImageMimeFromExt(ext) }) + ); } function createFirstPageImageUrlFromFiles(files: File[]): string | null { @@ -1435,12 +1705,17 @@ function createFirstPageImageUrlFromFiles(files: File[]): string | null { const m = PAGE_IMAGE_RE.exec(f.name); if (!m) continue; const pageNum = Number(m[1]); - if (pageNum < bestPage) { bestPage = pageNum; bestFile = f; } + if (pageNum < bestPage) { + bestPage = pageNum; + bestFile = f; + } } return bestFile ? URL.createObjectURL(bestFile) : null; } -async function createFirstPageImageUrlFromZip(source: File | ArrayBuffer): Promise { +async function createFirstPageImageUrlFromZip( + source: File | ArrayBuffer +): Promise { const buffer = source instanceof File ? await source.arrayBuffer() : source; const entries = await unzip(buffer, { shouldExtract: name => /^pages\/\d+\.(png|jpe?g|webp)$/i.test(name), @@ -1451,23 +1726,32 @@ async function createFirstPageImageUrlFromZip(source: File | ArrayBuffer): Promi const m = e.name.match(/^pages\/(\d+)\.(png|jpe?g|webp)$/i); if (!m) continue; const pageNum = Number(m[1]); - if (pageNum < bestPage) { bestPage = pageNum; bestEntry = e; } + if (pageNum < bestPage) { + bestPage = pageNum; + bestEntry = e; + } } if (!bestEntry) return null; const ext = bestEntry.name.split('.').pop() ?? 'png'; return createPageImageObjectUrl(bestEntry.data, ext); } -async function resolveCatalogEntryThumbnail(entry: FileCatalogEntry): Promise { +async function resolveCatalogEntryThumbnail( + entry: FileCatalogEntry +): Promise { if (entry.thumbnailUrl) return entry.thumbnailUrl; if (entry.kind === 'markup') return null; try { if (entry.kind === 'folder') { entry.thumbnailUrl = createFirstPageImageUrlFromFiles(entry.source as File[]); } else if (entry.kind === 'archive') { - entry.thumbnailUrl = await createFirstPageImageUrlFromZip(entry.source as File | ArrayBuffer); + entry.thumbnailUrl = await createFirstPageImageUrlFromZip( + entry.source as File | ArrayBuffer + ); } - } catch { entry.thumbnailUrl = null; } + } catch { + entry.thumbnailUrl = null; + } return entry.thumbnailUrl; } @@ -1486,24 +1770,6 @@ function revokeCatalogEntry(entry: FileCatalogEntry | null): void { if (entry) entry.thumbnailUrl = null; } -function createFileViewThumbnail(entry: FileCatalogEntry): HTMLElement { - const thumb = document.createElement('span'); - thumb.className = 'file-view-thumb'; - thumb.setAttribute('aria-hidden', 'true'); - if (entry.thumbnailUrl) { - const img = document.createElement('img'); - img.src = entry.thumbnailUrl; - img.alt = ''; - thumb.appendChild(img); - } else { - const placeholder = document.createElement('span'); - placeholder.className = 'file-view-thumb-placeholder'; - placeholder.innerHTML = FILE_THUMB_PLACEHOLDER_SVG; - thumb.appendChild(placeholder); - } - return thumb; -} - function createFileCatalogEntry(file: File): FileCatalogEntry { return { id: crypto.randomUUID(), @@ -1529,24 +1795,37 @@ function hasArchiveTransfer(dataTransfer: DataTransfer | null): boolean { return Boolean(dataTransfer && [...dataTransfer.types].includes('Files')); } -async function parseCatalogEntry(entry: FileCatalogEntry): Promise { +async function parseCatalogEntry( + entry: FileCatalogEntry +): Promise { try { if (entry.kind === 'markup') { - const text = entry.source instanceof File - ? await (entry.source as File).text() - : new TextDecoder().decode(entry.source as ArrayBuffer); - return buildDocumentState(text, new Map(), entry.label, new Map(), { markupOnly: true }); + const text = + entry.source instanceof File + ? await (entry.source as File).text() + : new TextDecoder().decode(entry.source as ArrayBuffer); + return buildDocumentState(text, new Map(), entry.label, new Map(), { + markupOnly: true, + }); } if (entry.kind === 'archive') { - const buffer = entry.source instanceof File - ? await (entry.source as File).arrayBuffer() - : entry.source as ArrayBuffer; - const { markupXml, pageImages, assetUrls } = await extractArchiveFromZipBuffer(buffer); - return buildDocumentState(markupXml, pageImages, entry.label, assetUrls, { markupOnly: false }); + const buffer = + entry.source instanceof File + ? await (entry.source as File).arrayBuffer() + : (entry.source as ArrayBuffer); + const { markupXml, pageImages, assetUrls } = + await extractArchiveFromZipBuffer(buffer); + return buildDocumentState(markupXml, pageImages, entry.label, assetUrls, { + markupOnly: false, + }); } if (entry.kind === 'folder') { - const { markupXml, pageImages, assetUrls } = await extractArchiveFromFiles(entry.source as File[]); - return buildDocumentState(markupXml, pageImages, entry.label, assetUrls, { markupOnly: false }); + const { markupXml, pageImages, assetUrls } = await extractArchiveFromFiles( + entry.source as File[] + ); + return buildDocumentState(markupXml, pageImages, entry.label, assetUrls, { + markupOnly: false, + }); } } catch (err) { alert(`Failed to read ${entry.label}: ${(err as Error).message}`); @@ -1565,7 +1844,10 @@ function persistActiveFileViewState(): void { function releaseActiveDocument(): void { if (activeFileIndex >= 0) { const entry = fileCatalog[activeFileIndex]; - if (entry?.snapshot) { revokeDocumentState(entry.snapshot); entry.snapshot = null; } + if (entry?.snapshot) { + revokeDocumentState(entry.snapshot); + entry.snapshot = null; + } } if (state) revokeDocumentState(state); state = null; @@ -1592,8 +1874,9 @@ async function switchToFile(index: number): Promise { revokeCatalogEntry(entry); fileCatalog.splice(index, 1); activeFileIndex = -1; - if (fileCatalog.length) { await switchToFile(Math.min(index, fileCatalog.length - 1)); } - else resetViewer(); + if (fileCatalog.length) { + await switchToFile(Math.min(index, fileCatalog.length - 1)); + } else resetViewer(); return; } entry.snapshot = docState; @@ -1622,51 +1905,33 @@ async function closeCatalogFile(index: number): Promise { if (index < 0 || index >= fileCatalog.length) return; const wasActive = index === activeFileIndex; const entry = fileCatalog[index]!; - if (wasActive) { releaseActiveDocument(); activeFileIndex = -1; } + if (wasActive) { + releaseActiveDocument(); + activeFileIndex = -1; + } revokeCatalogEntry(entry); fileCatalog.splice(index, 1); - if (!fileCatalog.length) { resetViewer(); return; } - if (wasActive) { await switchToFile(Math.min(index, fileCatalog.length - 1)); return; } + if (!fileCatalog.length) { + resetViewer(); + return; + } + if (wasActive) { + await switchToFile(Math.min(index, fileCatalog.length - 1)); + return; + } if (index < activeFileIndex) activeFileIndex -= 1; updateFileView(); } function renderFileView(): void { - if (!els.filePane) return; - els.filePane.replaceChildren(); - if (!fileCatalog.length) return; - const list = document.createElement('ul'); - list.className = 'file-view-list'; - list.setAttribute('role', 'listbox'); - list.setAttribute('aria-label', 'Open files'); - fileCatalog.forEach((entry, index) => { - const item = document.createElement('li'); - const card = document.createElement('div'); - card.className = 'file-view-item'; - card.title = entry.label; - card.tabIndex = 0; - card.setAttribute('role', 'option'); - if (index === activeFileIndex) { card.classList.add('is-active'); card.setAttribute('aria-selected', 'true'); } - else card.setAttribute('aria-selected', 'false'); - const thumbWrap = document.createElement('div'); - thumbWrap.className = 'file-view-thumb-wrap'; - const closeBtn = document.createElement('button'); - closeBtn.type = 'button'; - closeBtn.className = 'file-view-close'; - closeBtn.setAttribute('aria-label', `Close ${entry.label}`); - closeBtn.textContent = '×'; - closeBtn.addEventListener('click', e => { e.stopPropagation(); closeCatalogFile(index); }); - thumbWrap.append(createFileViewThumbnail(entry), closeBtn); - const label = document.createElement('span'); - label.className = 'file-view-label'; - label.textContent = entry.label; - card.append(thumbWrap, label); - card.addEventListener('click', e => { if ((e.target as Element).closest('.file-view-close')) return; switchToFile(index); }); - card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchToFile(index); } }); - item.appendChild(card); - list.appendChild(item); - }); - els.filePane.appendChild(list); + if (!doclangFilePane) return; + doclangFilePane.renderFiles( + fileCatalog.map((entry, index) => ({ + label: entry.label, + thumbnailUrl: entry.thumbnailUrl, + isActive: index === activeFileIndex, + })) + ); } function updateFileView(): void { @@ -1678,19 +1943,25 @@ function updateFileView(): void { } function syncFilePaneCloseAllButton(): void { - if (!els.filePaneCloseAll) return; - els.filePaneCloseAll.hidden = fileCatalog.length === 0; + // Close-all button visibility is managed inside doclang-file-pane via renderFiles } function initFilePaneCloseAll(): void { - els.filePaneCloseAll?.addEventListener('click', () => { + doclangFilePane?.addEventListener('doclang-file-pane-close-all', () => { if (!fileCatalog.length) return; const count = fileCatalog.length; - const message = count === 1 - ? `Remove "${fileCatalog[0]!.label}" from the viewer?` - : `Remove all ${count} open files from the viewer?`; + const message = + count === 1 + ? `Remove "${fileCatalog[0]!.label}" from the viewer?` + : `Remove all ${count} open files from the viewer?`; if (confirm(message)) resetViewer(); }); + doclangFilePane?.addEventListener('doclang-file-select', (e: Event) => { + switchToFile((e as CustomEvent<{ index: number }>).detail.index); + }); + doclangFilePane?.addEventListener('doclang-file-close', (e: Event) => { + closeCatalogFile((e as CustomEvent<{ index: number }>).detail.index); + }); } function activateDocument(docState: DocumentState, entry: FileCatalogEntry): void { @@ -1704,7 +1975,10 @@ function activateDocument(docState: DocumentState, entry: FileCatalogEntry): voi } updatePageZoomResetButton(); const port = pageViewScrollPane(); - if (port) { port.scrollLeft = 0; port.scrollTop = 0; } + if (port) { + port.scrollLeft = 0; + port.scrollTop = 0; + } setDocLabel(entry.label); setDocumentOpen(true, { markupOnly: state.markupOnly }); setPageViewVisible(state.hasPageView); @@ -1720,8 +1994,8 @@ declare const DEMO_ARCHIVE_URL: string; function setDemoLoading(loading: boolean): void { document.body.classList.toggle('demo-loading', loading); - const btnDemo = document.getElementById('btn-demo') as HTMLButtonElement | null; - if (btnDemo) btnDemo.disabled = loading; + doclangEmptyState?.setDemoLoading(loading); + if (doclangToolbar) doclangToolbar.btnDemo.disabled = loading; } async function loadDemo(): Promise { @@ -1734,15 +2008,23 @@ async function loadDemo(): Promise { const label = DEMO_ARCHIVE_URL.split('/').pop() || 'demo.dclx'; await addArchiveBufferToCatalog(await res.arrayBuffer(), label, { replace: true }); } catch (err) { - alert(`Failed to load demo: ${(err as Error).message}\n\nServe this directory over HTTP (e.g. python3 -m http.server) and open the viewer from localhost.`); + alert( + `Failed to load demo: ${(err as Error).message}\n\nServe this directory over HTTP (e.g. python3 -m http.server) and open the viewer from localhost.` + ); } finally { demoLoadInProgress = false; - if (!document.body.classList.contains('viewer-loaded')) setDemoLoading(false); + setDemoLoading(false); } } -async function addFilesToCatalog(files: File[], { replace = false } = {}): Promise { - if (replace) { clearFileCatalog(); filePaneUserToggled = false; } +async function addFilesToCatalog( + files: File[], + { replace = false } = {} +): Promise { + if (replace) { + clearFileCatalog(); + filePaneUserToggled = false; + } const startIndex = fileCatalog.length; for (const file of files) { const entry = createFileCatalogEntry(file); @@ -1754,22 +2036,45 @@ async function addFilesToCatalog(files: File[], { replace = false } = {}): Promi } async function appendFolderArchive(files: File[]): Promise { - if (!files.some(f => f.name === 'document.xml')) { alert('Archive must contain document.xml at its root.'); return; } - const rootName = (files[0]!.webkitRelativePath || files[0]!.name).split('/')[0] || 'archive'; + if (!files.some(f => f.name === 'document.xml')) { + alert('Archive must contain document.xml at its root.'); + return; + } + const rootName = + (files[0]!.webkitRelativePath || files[0]!.name).split('/')[0] || 'archive'; const entry: FileCatalogEntry = { - id: crypto.randomUUID(), label: rootName, kind: 'folder', source: files, - currentPage: 1, pageZoom: PAGE_ZOOM_DEFAULT, snapshot: null, thumbnailUrl: null, + id: crypto.randomUUID(), + label: rootName, + kind: 'folder', + source: files, + currentPage: 1, + pageZoom: PAGE_ZOOM_DEFAULT, + snapshot: null, + thumbnailUrl: null, }; fileCatalog.push(entry); enrichCatalogEntryThumbnail(entry); await switchToFile(fileCatalog.length - 1); } -async function addArchiveBufferToCatalog(buffer: ArrayBuffer, label: string, { replace = false } = {}): Promise { - if (replace) { clearFileCatalog(); filePaneUserToggled = false; } +async function addArchiveBufferToCatalog( + buffer: ArrayBuffer, + label: string, + { replace = false } = {} +): Promise { + if (replace) { + clearFileCatalog(); + filePaneUserToggled = false; + } const entry: FileCatalogEntry = { - id: crypto.randomUUID(), label, kind: 'archive', source: buffer, - currentPage: 1, pageZoom: PAGE_ZOOM_DEFAULT, snapshot: null, thumbnailUrl: null, + id: crypto.randomUUID(), + label, + kind: 'archive', + source: buffer, + currentPage: 1, + pageZoom: PAGE_ZOOM_DEFAULT, + snapshot: null, + thumbnailUrl: null, }; fileCatalog.push(entry); enrichCatalogEntryThumbnail(entry); @@ -1778,7 +2083,10 @@ async function addArchiveBufferToCatalog(buffer: ArrayBuffer, label: string, { r async function loadFromDrop(dataTransfer: DataTransfer): Promise { const files = [...dataTransfer.files]; - if (files.some(f => f.name === 'document.xml')) { await appendFolderArchive(files); return; } + if (files.some(f => f.name === 'document.xml')) { + await appendFolderArchive(files); + return; + } const supported = files.filter(f => isArchiveFile(f) || isMarkupFile(f)); if (supported.length) await addFilesToCatalog(supported, { replace: false }); } @@ -1801,7 +2109,9 @@ function initPageWheelNav(): void { pixelGestureUntil = now + PAGE_WHEEL_GESTURE_MS; pixelAccum += e.deltaY; if (Math.abs(pixelAccum) >= PAGE_WHEEL_PIXEL_THRESHOLD) { - const dir = pixelAccum > 0 ? 1 : -1; pixelAccum = 0; return dir; + const dir = pixelAccum > 0 ? 1 : -1; + pixelAccum = 0; + return dir; } return 0; } @@ -1812,12 +2122,19 @@ function initPageWheelNav(): void { if (now - lastFlipAt < PAGE_WHEEL_COOLDOWN_MS) return false; const before = state.currentPage; goToPage(state.currentPage + dir); - if (state.currentPage !== before) { lastFlipAt = now; return true; } + if (state.currentPage !== before) { + lastFlipAt = now; + return true; + } return false; } - function isScrollAtTop(pane: HTMLElement): boolean { return pane.scrollTop <= 0; } - function isScrollAtBottom(pane: HTMLElement): boolean { return pane.scrollTop + pane.clientHeight >= pane.scrollHeight - 1; } + function isScrollAtTop(pane: HTMLElement): boolean { + return pane.scrollTop <= 0; + } + function isScrollAtBottom(pane: HTMLElement): boolean { + return pane.scrollTop + pane.clientHeight >= pane.scrollHeight - 1; + } function onScrollPaneWheel(e: WheelEvent, pane: HTMLElement): void { if (!state || state.markupOnly || state.pageCount <= 1) return; @@ -1830,34 +2147,58 @@ function initPageWheelNav(): void { if (!wantPrev && !wantNext) return; e.preventDefault(); if (!tryFlipPage(dir)) return; - requestAnimationFrame(() => { pane.scrollTop = dir > 0 ? 0 : pane.scrollHeight; }); + requestAnimationFrame(() => { + pane.scrollTop = dir > 0 ? 0 : pane.scrollHeight; + }); } - els.pagePane.addEventListener('wheel', e => { - if (!state?.hasPageView) return; - const pane = pageViewScrollPane(); - if (!pane) return; - const scrollable = pane.scrollHeight > pane.clientHeight || pane.scrollWidth > pane.clientWidth; - if (scrollable) { onScrollPaneWheel(e, pane); return; } - e.preventDefault(); - const dir = wheelDir(e); - if (dir) tryFlipPage(dir); - }, { passive: false }); + els.pagePane.addEventListener( + 'wheel', + e => { + if (!state?.hasPageView) return; + const pane = pageViewScrollPane(); + if (!pane) return; + const scrollable = + pane.scrollHeight > pane.clientHeight || pane.scrollWidth > pane.clientWidth; + if (scrollable) { + onScrollPaneWheel(e, pane); + return; + } + e.preventDefault(); + const dir = wheelDir(e); + if (dir) tryFlipPage(dir); + }, + { passive: false } + ); for (const pane of [els.markupPane, els.renderedPane]) { if (!pane) continue; - pane.addEventListener('wheel', e => onScrollPaneWheel(e as WheelEvent, pane), { passive: false }); + pane.addEventListener('wheel', e => onScrollPaneWheel(e as WheelEvent, pane), { + passive: false, + }); } els.pagePane.tabIndex = 0; els.pagePane.setAttribute('role', 'region'); els.pagePane.setAttribute('aria-label', 'Original page'); - els.pagePane.addEventListener('pointerdown', () => { if (state?.hasPageView) els.pagePane!.focus({ preventScroll: true }); }); + els.pagePane.addEventListener('pointerdown', () => { + if (state?.hasPageView) els.pagePane!.focus({ preventScroll: true }); + }); els.pagePane.addEventListener('keydown', e => { if (!state?.hasPageView) return; switch (e.key) { - case 'ArrowDown': case 'PageDown': case 'ArrowRight': e.preventDefault(); tryFlipPage(1); break; - case 'ArrowUp': case 'PageUp': case 'ArrowLeft': e.preventDefault(); tryFlipPage(-1); break; + case 'ArrowDown': + case 'PageDown': + case 'ArrowRight': + e.preventDefault(); + tryFlipPage(1); + break; + case 'ArrowUp': + case 'PageUp': + case 'ArrowLeft': + e.preventDefault(); + tryFlipPage(-1); + break; } }); } @@ -1874,20 +2215,20 @@ function canStartPagePan(event: PointerEvent): boolean { function initPageViewControls(): void { if (!els.pagePane) return; - els.pageZoom?.addEventListener('input', () => { - pageZoomPercent = Math.max(PAGE_ZOOM_DEFAULT, Number(els.pageZoom!.value)); - if (Number(els.pageZoom!.value) < PAGE_ZOOM_DEFAULT) els.pageZoom!.value = String(pageZoomPercent); - els.pageZoom!.setAttribute('aria-valuenow', String(pageZoomPercent)); - updatePageZoomResetButton(); - refreshPageViewLayout(); - }); - els.pageZoomReset?.addEventListener('click', () => { if (pageZoomPercent !== PAGE_ZOOM_DEFAULT) resetPageZoom(); }); + // Zoom input and reset are wired via doclang-page-view-pane component events above. els.pagePane.addEventListener('pointerdown', e => { if (e.button !== 0 || !canStartPagePan(e)) return; const scrollPane = pageViewScrollPane(); if (!scrollPane) return; - pagePanDrag = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, scrollLeft: scrollPane.scrollLeft, scrollTop: scrollPane.scrollTop, moved: false }; + pagePanDrag = { + pointerId: e.pointerId, + startX: e.clientX, + startY: e.clientY, + scrollLeft: scrollPane.scrollLeft, + scrollTop: scrollPane.scrollTop, + moved: false, + }; }); els.pagePane.addEventListener('pointermove', e => { @@ -1913,7 +2254,8 @@ function initPageViewControls(): void { if (pagePanDrag.moved) pagePanSuppressClick = true; pagePanDrag = null; els.pagePane!.classList.remove('is-panning'); - if (els.pagePane!.hasPointerCapture(e.pointerId)) els.pagePane!.releasePointerCapture(e.pointerId); + if (els.pagePane!.hasPointerCapture(e.pointerId)) + els.pagePane!.releasePointerCapture(e.pointerId); updatePagePanePanCursor(); } @@ -1926,36 +2268,58 @@ function initPageViewControls(): void { // --------------------------------------------------------------------------- function initFileTypeHints(): void { - if (!els.emptyStateFileTypes) return; - const markup = SUPPORTED_FILE_EXTENSIONS.map(ext => `${ext}`).join(', '); - els.emptyStateFileTypes.innerHTML = markup; + doclangEmptyState?.setFileTypeHints(SUPPORTED_FILE_EXTENSIONS); } function initCursorHints(): void { els.markupPane?.addEventListener('mousemove', e => { - if (!(e.target as Element).closest('.markup-ghost-tag-part')) { hideCursorHint(); return; } + if (!(e.target as Element).closest('.markup-ghost-tag-part')) { + hideCursorHint(); + return; + } showCursorHint(VIRTUAL_TEXT_TAG_HINT, e.clientX, e.clientY); }); els.markupPane?.addEventListener('mouseleave', hideCursorHint); - els.openFileBtn?.addEventListener('mousemove', e => showCursorHint(OPEN_FILE_HINT, e.clientX, e.clientY)); + els.openFileBtn?.addEventListener('mousemove', e => + showCursorHint(OPEN_FILE_HINT, e.clientX, e.clientY) + ); els.openFileBtn?.addEventListener('mouseleave', hideCursorHint); } function initBboxHints(): void { if (!els.pagePane) return; els.pagePane.addEventListener('mousemove', e => { - if (pagePanDrag?.moved || els.pagePane!.classList.contains('is-panning')) { hideCursorHint(); return; } - const navBtn = (e.target as Element).closest('.fragment-nav-btn:not(.fragment-nav-btn-disabled)'); + if (pagePanDrag?.moved || els.pagePane!.classList.contains('is-panning')) { + hideCursorHint(); + return; + } + const navBtn = (e.target as Element).closest( + '.fragment-nav-btn:not(.fragment-nav-btn-disabled)' + ); if (navBtn) { - const hint = navBtn.getAttribute('data-nav') === 'prev' ? FRAGMENT_NAV_HINT_PREV : FRAGMENT_NAV_HINT_NEXT; - showCursorHint(hint, e.clientX, e.clientY); return; + const hint = + navBtn.getAttribute('data-nav') === 'prev' + ? FRAGMENT_NAV_HINT_PREV + : FRAGMENT_NAV_HINT_NEXT; + showCursorHint(hint, e.clientX, e.clientY); + return; } const badge = (e.target as Element).closest('.element-badge[data-element-id]'); - if (!badge || !state?.idToElement) { hideCursorHint(); return; } + if (!badge || !state?.idToElement) { + hideCursorHint(); + return; + } const elementId = badge.getAttribute('data-element-id'); const xmlEl = elementId ? state.idToElement.get(elementId) : null; - if (!xmlEl) { hideCursorHint(); return; } - showCursorHintHtml(elementHeadTooltipHtml(xmlEl, state.defaultResolution), e.clientX, e.clientY); + if (!xmlEl) { + hideCursorHint(); + return; + } + showCursorHintHtml( + elementHeadTooltipHtml(xmlEl, state.defaultResolution), + e.clientX, + e.clientY + ); }); els.pagePane.addEventListener('mouseleave', hideCursorHint); } @@ -1985,71 +2349,166 @@ function initDragDrop(): void { } // --------------------------------------------------------------------------- -// Event wiring +// Event wiring — component custom events // --------------------------------------------------------------------------- -document.getElementById('btn-demo')?.addEventListener('click', loadDemo); -document.getElementById('demo-empty-link')?.addEventListener('click', e => { e.preventDefault(); loadDemo(); }); -document.getElementById('home-link')?.addEventListener('click', e => { e.preventDefault(); resetViewer(); }); -document.getElementById('input-archive')?.addEventListener('change', async e => { - const input = e.target as HTMLInputElement; - const files = [...(input.files ?? [])].filter(f => isArchiveFile(f) || isMarkupFile(f)); +// Toolbar +doclangToolbar?.addEventListener('doclang-load-demo', loadDemo); +doclangToolbar?.addEventListener('doclang-open-files', async (e: Event) => { + const files = (e as CustomEvent<{ files: File[] }>).detail.files.filter( + f => isArchiveFile(f) || isMarkupFile(f) + ); if (!files.length) return; await addFilesToCatalog(files, { replace: true }); - input.value = ''; }); -els.btnPrev?.addEventListener('click', () => state && goToPage(state.currentPage - 1)); -els.btnNext?.addEventListener('click', () => state && goToPage(state.currentPage + 1)); +// Empty state +doclangEmptyState?.addEventListener('doclang-load-demo', loadDemo); -els.pageNumberInput?.addEventListener('keydown', e => { - if (e.key === 'Enter') { e.preventDefault(); commitPageNumberInput(); els.pageNumberInput?.blur(); } - else if (e.key === 'Escape') { e.preventDefault(); resetPageNumberInput(); els.pageNumberInput?.blur(); } +// Home link +document.getElementById('home-link')?.addEventListener('click', e => { + e.preventDefault(); + resetViewer(); +}); + +// Page nav +doclangPageNav?.addEventListener( + 'doclang-prev-page', + () => state && goToPage(state.currentPage - 1) +); +doclangPageNav?.addEventListener( + 'doclang-next-page', + () => state && goToPage(state.currentPage + 1) +); +doclangPageNav?.addEventListener('doclang-go-to-page', (e: Event) => { + goToPage((e as CustomEvent<{ page: number }>).detail.page); +}); + +// Page view pane overlay settings +doclangPageViewPane?.addEventListener('doclang-page-settings-toggle', () => + setPageSettingsOpen(!pageSettingsOpen) +); +doclangPageViewPane?.addEventListener('doclang-page-settings-close', () => + setPageSettingsOpen(false) +); +doclangPageViewPane?.addEventListener('doclang-zoom-change', (e: Event) => { + const val = Math.max( + PAGE_ZOOM_DEFAULT, + (e as CustomEvent<{ value: number }>).detail.value + ); + pageZoomPercent = val; + if (doclangPageViewPane) { + doclangPageViewPane.zoomInput.value = String(pageZoomPercent); + doclangPageViewPane.zoomInput.setAttribute( + 'aria-valuenow', + String(pageZoomPercent) + ); + } + updatePageZoomResetButton(); + refreshPageViewLayout(); +}); +doclangPageViewPane?.addEventListener('doclang-zoom-reset', () => { + if (pageZoomPercent !== PAGE_ZOOM_DEFAULT) resetPageZoom(); }); -els.pageNumberInput?.addEventListener('blur', resetPageNumberInput); -els.pageNumberInput?.addEventListener('focus', e => (e.target as HTMLInputElement).select()); -els.showAllBboxes?.addEventListener('change', () => { - showAllBboxes = els.showAllBboxes!.checked; +doclangPageViewPane?.addEventListener('doclang-show-all-bboxes', (e: Event) => { + showAllBboxes = (e as CustomEvent<{ checked: boolean }>).detail.checked; syncLayoutSubtoggles(); const img = els.pagePane?.querySelector('.page-view img') as HTMLImageElement | null; - if (img && state?.pageViewOverlay) syncOverlayBadges(img, img.parentElement!.querySelector('svg.overlay') as SVGSVGElement, state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); + if (img && state?.pageViewOverlay) + syncOverlayBadges( + img, + img.parentElement!.querySelector('svg.overlay') as SVGSVGElement, + state.pageViewOverlay.boxes, + state.pageViewOverlay.readingOrderSteps, + showAllBboxes, + showLayoutBadges, + showReadingOrder + ); applyBboxVisibility(); }); -els.showLayoutBadges?.addEventListener('change', () => { - showLayoutBadges = els.showLayoutBadges!.checked; +doclangPageViewPane?.addEventListener('doclang-show-layout-badges', (e: Event) => { + showLayoutBadges = (e as CustomEvent<{ checked: boolean }>).detail.checked; const img = els.pagePane?.querySelector('.page-view img') as HTMLImageElement | null; - if (img && state?.pageViewOverlay) syncOverlayBadges(img, img.parentElement!.querySelector('svg.overlay') as SVGSVGElement, state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); + if (img && state?.pageViewOverlay) + syncOverlayBadges( + img, + img.parentElement!.querySelector('svg.overlay') as SVGSVGElement, + state.pageViewOverlay.boxes, + state.pageViewOverlay.readingOrderSteps, + showAllBboxes, + showLayoutBadges, + showReadingOrder + ); applyBboxVisibility(); }); -els.showCaptionLinks?.addEventListener('change', () => { showCaptionLinks = els.showCaptionLinks!.checked; applyBboxVisibility(); }); -els.showPictureContents?.addEventListener('change', () => { showPictureContents = els.showPictureContents!.checked; applyBboxVisibility(); }); -els.showTableContents?.addEventListener('change', () => { showTableContents = els.showTableContents!.checked; applyBboxVisibility(); }); -els.showFragmentLinks?.addEventListener('change', () => { showFragmentLinks = els.showFragmentLinks!.checked; applyBboxVisibility(); }); -els.showXrefLinks?.addEventListener('change', () => { showXrefLinks = els.showXrefLinks!.checked; applyBboxVisibility(); }); -els.showReadingOrder?.addEventListener('change', () => { - showReadingOrder = els.showReadingOrder!.checked; +doclangPageViewPane?.addEventListener('doclang-show-reading-order', (e: Event) => { + showReadingOrder = (e as CustomEvent<{ checked: boolean }>).detail.checked; syncLayoutSubtoggles(); const img = els.pagePane?.querySelector('.page-view img') as HTMLImageElement | null; - if (img && state?.pageViewOverlay) syncOverlayBadges(img, img.parentElement!.querySelector('svg.overlay') as SVGSVGElement, state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); + if (img && state?.pageViewOverlay) + syncOverlayBadges( + img, + img.parentElement!.querySelector('svg.overlay') as SVGSVGElement, + state.pageViewOverlay.boxes, + state.pageViewOverlay.readingOrderSteps, + showAllBboxes, + showLayoutBadges, + showReadingOrder + ); + applyBboxVisibility(); +}); +doclangPageViewPane?.addEventListener('doclang-reading-order-arrows', (e: Event) => { + showReadingOrderArrows = (e as CustomEvent<{ checked: boolean }>).detail.checked; + applyBboxVisibility(); +}); +doclangPageViewPane?.addEventListener('doclang-reading-order-global', (e: Event) => { + readingOrderGlobalNumbering = (e as CustomEvent<{ checked: boolean }>).detail.checked; + if (state) renderPage(state.currentPage); +}); +doclangPageViewPane?.addEventListener('doclang-show-picture-contents', (e: Event) => { + showPictureContents = (e as CustomEvent<{ checked: boolean }>).detail.checked; + applyBboxVisibility(); +}); +doclangPageViewPane?.addEventListener('doclang-show-table-contents', (e: Event) => { + showTableContents = (e as CustomEvent<{ checked: boolean }>).detail.checked; + applyBboxVisibility(); +}); +doclangPageViewPane?.addEventListener('doclang-show-fragment-links', (e: Event) => { + showFragmentLinks = (e as CustomEvent<{ checked: boolean }>).detail.checked; applyBboxVisibility(); }); -els.readingOrderArrows?.addEventListener('change', () => { showReadingOrderArrows = els.readingOrderArrows!.checked; applyBboxVisibility(); }); -els.readingOrderGlobal?.addEventListener('change', () => { readingOrderGlobalNumbering = els.readingOrderGlobal!.checked; if (state) renderPage(state.currentPage); }); -els.settingsToggle?.addEventListener('click', () => setPageSettingsOpen(!pageSettingsOpen)); -els.readingSettingsToggle?.addEventListener('click', () => setReadingSettingsOpen(!readingSettingsOpen)); -els.pageSettingsClose?.addEventListener('click', () => setPageSettingsOpen(false)); -els.pageSettingsScrim?.addEventListener('click', () => setPageSettingsOpen(false)); -els.readingSettingsClose?.addEventListener('click', () => setReadingSettingsOpen(false)); -els.readingSettingsScrim?.addEventListener('click', () => setReadingSettingsOpen(false)); +doclangPageViewPane?.addEventListener('doclang-show-xref-links', (e: Event) => { + showXrefLinks = (e as CustomEvent<{ checked: boolean }>).detail.checked; + applyBboxVisibility(); +}); +doclangPageViewPane?.addEventListener('doclang-show-caption-links', (e: Event) => { + showCaptionLinks = (e as CustomEvent<{ checked: boolean }>).detail.checked; + applyBboxVisibility(); +}); + +// Reading pane layer settings +doclangReadingPane?.addEventListener('doclang-reading-settings-toggle', () => + setReadingSettingsOpen(!readingSettingsOpen) +); +doclangReadingPane?.addEventListener('doclang-reading-settings-close', () => + setReadingSettingsOpen(false) +); +doclangReadingPane?.addEventListener('doclang-show-reading-furniture', (e: Event) => { + showReadingFurniture = (e as CustomEvent<{ checked: boolean }>).detail.checked; + syncReadingLayerVisibility(); +}); +doclangReadingPane?.addEventListener('doclang-show-reading-background', (e: Event) => { + showReadingBackground = (e as CustomEvent<{ checked: boolean }>).detail.checked; + syncReadingLayerVisibility(); +}); + document.addEventListener('keydown', e => { if (e.key !== 'Escape') return; if (toolbarOptionsOpen) setToolbarOptionsOpen(false); else if (pageSettingsOpen) setPageSettingsOpen(false); else if (readingSettingsOpen) setReadingSettingsOpen(false); }); -els.showReadingFurniture?.addEventListener('change', () => { showReadingFurniture = els.showReadingFurniture!.checked; syncReadingLayerVisibility(); }); -els.showReadingBackground?.addEventListener('change', () => { showReadingBackground = els.showReadingBackground!.checked; syncReadingLayerVisibility(); }); // --------------------------------------------------------------------------- // Bootstrap @@ -2068,4 +2527,10 @@ initFilePaneCloseAll(); initPageWheelNav(); initPageViewControls(); -if (document.getElementById('btn-demo')) loadDemo(); +// Sync initial demo-loading state from body onto the component host +// ( is set in HTML before JS runs) +if (document.body.classList.contains('demo-loading')) { + doclangEmptyState?.setDemoLoading(true); +} + +if (doclangToolbar?.btnDemo) loadDemo(); diff --git a/core/src/markup.ts b/core/src/markup.ts index d7116d6..6851832 100644 --- a/core/src/markup.ts +++ b/core/src/markup.ts @@ -95,7 +95,8 @@ export function toggleTruncatableMarkupAttrValue(toggle: Element): void { if (!wrapper) return; const markupLine = wrapper.closest('.markup-line') as HTMLElement | null; - const fullValue = (wrapper as HTMLElement & { dataset: DOMStringMap }).dataset.fullValue ?? ''; + const fullValue = + (wrapper as HTMLElement & { dataset: DOMStringMap }).dataset.fullValue ?? ''; const label = toggle.querySelector('.xml-attr-value-chip-label'); const collapsedLabel = (toggle as HTMLElement & { dataset: DOMStringMap }).dataset.collapsedLabel ?? @@ -387,8 +388,12 @@ function buildMarkupList( let i = 0; while (i < nodes.length) { const node = nodes[i]; - if (!node) { i += 1; continue; } - if (node.nodeType === Node.ELEMENT_NODE && localName(node as Element) === 'ldiv') break; + if (!node) { + i += 1; + continue; + } + if (node.nodeType === Node.ELEMENT_NODE && localName(node as Element) === 'ldiv') + break; if (isTextLikeNode(node) && isWhitespaceOnlyText(node)) { i += 1; continue; @@ -397,7 +402,9 @@ function buildMarkupList( if (node.nodeType === Node.ELEMENT_NODE) { const tag = localName(node as Element); if (HEAD_TAGS.has(tag) || tag === 'location') { - children.appendChild(buildMarkupElement(node as Element, childDepth, elementIds)); + children.appendChild( + buildMarkupElement(node as Element, childDepth, elementIds) + ); i += 1; continue; } @@ -406,8 +413,14 @@ function buildMarkupList( } while (i < nodes.length) { const node = nodes[i]; - if (!node) { i += 1; continue; } - if (node.nodeType !== Node.ELEMENT_NODE || localName(node as Element) !== 'ldiv') { + if (!node) { + i += 1; + continue; + } + if ( + node.nodeType !== Node.ELEMENT_NODE || + localName(node as Element) !== 'ldiv' + ) { appendMarkupNodesFromSlice(children, childDepth, [node], elementIds); i += 1; continue; @@ -416,7 +429,13 @@ function buildMarkupList( children.appendChild(buildMarkupElement(ldiv, childDepth, elementIds)); i += 1; const end = skipUntilListItemBoundary(nodes, i); - appendMarkupVirtualText(children, childDepth, ldiv, nodes.slice(i, end), elementIds); + appendMarkupVirtualText( + children, + childDepth, + ldiv, + nodes.slice(i, end), + elementIds + ); i = end; } }); @@ -432,8 +451,15 @@ function buildMarkupOtslContainer( let i = 0; while (i < nodes.length) { const node = nodes[i]; - if (!node) { i += 1; continue; } - if (node.nodeType === Node.ELEMENT_NODE && isCellToken(localName(node as Element))) break; + if (!node) { + i += 1; + continue; + } + if ( + node.nodeType === Node.ELEMENT_NODE && + isCellToken(localName(node as Element)) + ) + break; if (isTextLikeNode(node) && isWhitespaceOnlyText(node)) { i += 1; continue; @@ -442,7 +468,9 @@ function buildMarkupOtslContainer( if (node.nodeType === Node.ELEMENT_NODE) { const tag = localName(node as Element); if (HEAD_TAGS.has(tag) || tag === 'location' || tag === 'h_thread') { - children.appendChild(buildMarkupElement(node as Element, childDepth, elementIds)); + children.appendChild( + buildMarkupElement(node as Element, childDepth, elementIds) + ); i += 1; continue; } @@ -451,7 +479,10 @@ function buildMarkupOtslContainer( } while (i < nodes.length) { const node = nodes[i]; - if (!node) { i += 1; continue; } + if (!node) { + i += 1; + continue; + } if (node.nodeType !== Node.ELEMENT_NODE) { appendMarkupNodesFromSlice(children, childDepth, [node], elementIds); i += 1; @@ -473,7 +504,13 @@ function buildMarkupOtslContainer( i += 1; if (CELL_SPAN_TAGS.has(tag)) continue; const end = skipUntilCellBoundary(nodes, i); - appendMarkupVirtualText(children, childDepth, cell, nodes.slice(i, end), elementIds); + appendMarkupVirtualText( + children, + childDepth, + cell, + nodes.slice(i, end), + elementIds + ); i = end; } }); @@ -486,7 +523,8 @@ export function buildMarkupElement( ): HTMLElement { const tag = localName(el); if (tag === 'list') return buildMarkupList(el, depth, elementIds); - if (OTSL_CONTAINER_TAGS.has(tag)) return buildMarkupOtslContainer(el, depth, elementIds); + if (OTSL_CONTAINER_TAGS.has(tag)) + return buildMarkupOtslContainer(el, depth, elementIds); const block = document.createElement('div'); block.className = 'markup-el'; @@ -602,8 +640,9 @@ export function buildMarkupView( const ghostText = target.closest('.markup-el-virtual-text'); const elementId = ghostText?.hasAttribute('data-element-id') ? ghostText.getAttribute('data-element-id')! - : target.closest('.markup-el[data-element-id]')?.getAttribute('data-element-id') ?? - null; + : (target + .closest('.markup-el[data-element-id]') + ?.getAttribute('data-element-id') ?? null); if (elementId) onSelectElement(elementId); }); return root; diff --git a/core/src/overlay.ts b/core/src/overlay.ts index 70f15ab..1f429fc 100644 --- a/core/src/overlay.ts +++ b/core/src/overlay.ts @@ -12,11 +12,7 @@ import { FRAGMENT_NAV_HINT_PREV, FRAGMENT_NAV_HINT_NEXT, } from './constants'; -import { - layerClassForValue, - kindClassForTag, - bboxClassForKind, -} from './xml_overlay'; +import { layerClassForValue, kindClassForTag, bboxClassForKind } from './xml-overlay'; import type { BoundingBox, Resolution, @@ -30,7 +26,7 @@ import type { } from './types'; // We re-export the overlay box paint utilities needed by main.ts -export { elementKindKey, kindClassForTag, bboxClassForKind } from './xml_overlay'; +export { elementKindKey, kindClassForTag, bboxClassForKind } from './xml-overlay'; // --------------------------------------------------------------------------- // State accessors — injected by main.ts @@ -38,7 +34,9 @@ export { elementKindKey, kindClassForTag, bboxClassForKind } from './xml_overlay let getPageZoomPercent: () => number = () => PAGE_ZOOM_DEFAULT; let getPagePane: () => HTMLElement | null = () => null; let getPageLayoutCache: () => PageLayoutCache | null = () => null; -let setPageLayoutCache: (c: PageLayoutCache) => void = () => { /* noop */ }; +let setPageLayoutCache: (c: PageLayoutCache) => void = () => { + /* noop */ +}; let getSelectedElementId: () => string | null = () => null; export function setOverlayAccessors(opts: { @@ -121,10 +119,7 @@ export function overlayUserLength( return (baseUserPx * refFit) / (fitScale * zoom); } -export function applyPageImageSize( - img: HTMLImageElement, - pane: HTMLElement -): boolean { +export function applyPageImageSize(img: HTMLImageElement, pane: HTMLElement): boolean { if (!img?.naturalWidth || !img.naturalHeight) return false; const zoomPct = Math.max(PAGE_ZOOM_DEFAULT, getPageZoomPercent()); const fitScale = getCachedFitScale(img, pane); @@ -297,9 +292,7 @@ function overlayBadgeLayout( width = overlayUserLength( label.length * OVERLAY_BADGE_FONT_SIZE * 0.55 + OVERLAY_BADGE_PAD_X * 2 ); - height = overlayUserLength( - OVERLAY_BADGE_FONT_SIZE * 1.1 + OVERLAY_BADGE_PAD_Y * 2 - ); + height = overlayUserLength(OVERLAY_BADGE_FONT_SIZE * 1.1 + OVERLAY_BADGE_PAD_Y * 2); } probe.remove(); return { width, height }; @@ -427,8 +420,7 @@ function appendOverlayLinks( line.setAttribute('marker-end', `url(#${markerId})`); const fromId = (link as CaptionLink).captionElementId ?? (link as XrefLink).fromElementId; - const toId = - (link as CaptionLink).hostElementId ?? (link as XrefLink).toElementId; + const toId = (link as CaptionLink).hostElementId ?? (link as XrefLink).toElementId; line.setAttribute(fromIdAttr, fromId); line.setAttribute(toIdAttr, toId); svg.appendChild(line); diff --git a/core/src/rendered.ts b/core/src/rendered.ts index 14f86ad..0d8caad 100644 --- a/core/src/rendered.ts +++ b/core/src/rendered.ts @@ -144,8 +144,13 @@ function appendRenderedBodyBlocks( let i = skipElementHeadNodes(nodes, 0); while (i < nodes.length) { const node = nodes[i]!; - if (node.nodeType === Node.ELEMENT_NODE && RENDER_BLOCK_TAGS.has(localName(node as Element))) { - const rendered = renderBlockElement(node as Element, elementIds, { inline: false }); + if ( + node.nodeType === Node.ELEMENT_NODE && + RENDER_BLOCK_TAGS.has(localName(node as Element)) + ) { + const rendered = renderBlockElement(node as Element, elementIds, { + inline: false, + }); if (rendered) parent.appendChild(rendered); } else { appendRenderedNode(parent, node, elementIds, { inline: false }); @@ -312,10 +317,7 @@ function renderFormula( return wrapRendered(el, span, elementIds.get(el)); } -function renderPicture( - el: Element, - elementIds: Map -): HTMLElement { +function renderPicture(el: Element, elementIds: Map): HTMLElement { const figure = document.createElement('figure'); const captionEl = readCaptionElement(el); const srcEl = childElements(el).find(c => localName(c) === 'src') ?? null; @@ -370,8 +372,13 @@ function appendPictureBodyContent( let i = startIdx; while (i < nodes.length) { const node = nodes[i]!; - if (node.nodeType === Node.ELEMENT_NODE && RENDER_BLOCK_TAGS.has(localName(node as Element))) { - const rendered = renderBlockElement(node as Element, elementIds, { inline: false }); + if ( + node.nodeType === Node.ELEMENT_NODE && + RENDER_BLOCK_TAGS.has(localName(node as Element)) + ) { + const rendered = renderBlockElement(node as Element, elementIds, { + inline: false, + }); if (rendered) container.appendChild(rendered); } else { appendRenderedNode(container, node, elementIds, { inline: false }); @@ -387,8 +394,7 @@ function renderVirtualTextBlock( ): HTMLElement { const hasBlock = contentNodes.some( n => - n.nodeType === Node.ELEMENT_NODE && - RENDER_BLOCK_TAGS.has(localName(n as Element)) + n.nodeType === Node.ELEMENT_NODE && RENDER_BLOCK_TAGS.has(localName(n as Element)) ); const inner = document.createElement(hasBlock ? 'div' : 'p'); for (const node of contentNodes) { @@ -396,7 +402,9 @@ function renderVirtualTextBlock( node.nodeType === Node.ELEMENT_NODE && RENDER_BLOCK_TAGS.has(localName(node as Element)) ) { - const rendered = renderBlockElement(node as Element, elementIds, { inline: false }); + const rendered = renderBlockElement(node as Element, elementIds, { + inline: false, + }); if (rendered) inner.appendChild(rendered); } else { appendRenderedNode(inner, node, elementIds, { inline: true }); @@ -433,7 +441,8 @@ function shouldWrapVirtualText(contentNodes: ChildNode[]): boolean { for (const node of contentNodes) { if (isVirtualTextSkippableNode(node)) continue; if (isTextLikeNode(node)) return true; - if (node.nodeType === Node.ELEMENT_NODE && !isSemanticElement(node as Element)) return true; + if (node.nodeType === Node.ELEMENT_NODE && !isSemanticElement(node as Element)) + return true; } return false; } @@ -455,7 +464,9 @@ function appendRenderedSliceContent( node.nodeType === Node.ELEMENT_NODE && RENDER_BLOCK_TAGS.has(localName(node as Element)) ) { - const rendered = renderBlockElement(node as Element, elementIds, { inline: false }); + const rendered = renderBlockElement(node as Element, elementIds, { + inline: false, + }); if (rendered) container.appendChild(rendered); } else { appendRenderedNode(container, node, elementIds, { inline: true }); @@ -595,7 +606,11 @@ function findHorizontalCellOrigin( return null; } -function nextFreeColumn(grid: (OtslCell | undefined)[][], row: number, col: number): number { +function nextFreeColumn( + grid: (OtslCell | undefined)[][], + row: number, + col: number +): number { let c = col; while (grid[row]?.[c]?.covered) c += 1; return c; @@ -701,7 +716,12 @@ function renderOtslContainer( const td = document.createElement(cellTag); if (cell.colspan > 1) td.colSpan = cell.colspan; if (cell.rowspan > 1) td.rowSpan = cell.rowspan; - appendRenderedSliceContent(td, cell.token, cell.contentNodes as ChildNode[], elementIds); + appendRenderedSliceContent( + td, + cell.token, + cell.contentNodes as ChildNode[], + elementIds + ); tr.appendChild(td); } if (tr.childNodes.length) tbody.appendChild(tr); @@ -958,7 +978,11 @@ function collectIntraPageThreads(segment: Element[]): Map { return byThread; } -export function applyReadingLayerClasses(root: HTMLElement, showFurniture: boolean, showBackground: boolean): void { +export function applyReadingLayerClasses( + root: HTMLElement, + showFurniture: boolean, + showBackground: boolean +): void { root.classList.toggle('show-reading-furniture', showFurniture); root.classList.toggle('show-reading-background', showBackground); } @@ -999,8 +1023,9 @@ export function buildRenderedView( const ghostText = target.closest('.rendered-el-virtual-text'); const elementId = ghostText?.hasAttribute('data-element-id') ? ghostText.getAttribute('data-element-id')! - : target.closest('.rendered-el[data-element-id]')?.getAttribute('data-element-id') ?? - null; + : (target + .closest('.rendered-el[data-element-id]') + ?.getAttribute('data-element-id') ?? null); if (elementId) onSelectElement(elementId); }); applyReadingLayerClasses(root, showFurniture, showBackground); diff --git a/core/src/vite-env.d.ts b/core/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/core/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/core/src/xml_overlay.ts b/core/src/xml-overlay.ts similarity index 94% rename from core/src/xml_overlay.ts rename to core/src/xml-overlay.ts index 92cc794..bb3aaa9 100644 --- a/core/src/xml_overlay.ts +++ b/core/src/xml-overlay.ts @@ -45,7 +45,12 @@ export function isTableContentElement(el: Element | null): boolean { } export function elementKindKey(kind: string): string { - if (kind.startsWith('field_') || kind === 'key' || kind === 'value' || kind === 'hint') + if ( + kind.startsWith('field_') || + kind === 'key' || + kind === 'value' || + kind === 'hint' + ) return 'field'; if (kind === 'tabular') return 'table'; const known = new Set([ diff --git a/core/src/xml.ts b/core/src/xml.ts index 2466012..a2f63a8 100644 --- a/core/src/xml.ts +++ b/core/src/xml.ts @@ -10,9 +10,7 @@ import { import type { ParsedElementHead } from './types'; export function isTextLikeNode(node: Node): boolean { - return ( - node.nodeType === Node.TEXT_NODE || node.nodeType === Node.CDATA_SECTION_NODE - ); + return node.nodeType === Node.TEXT_NODE || node.nodeType === Node.CDATA_SECTION_NODE; } export function isWhitespaceOnlyText(node: Node): boolean { @@ -114,7 +112,10 @@ export function skipContainerLevelHead(nodes: ChildNode[], startIdx: number): nu return i; } -export function skipUntilListItemBoundary(nodes: ChildNode[], startIdx: number): number { +export function skipUntilListItemBoundary( + nodes: ChildNode[], + startIdx: number +): number { let i = startIdx; while (i < nodes.length) { const node = nodes[i]; @@ -150,10 +151,7 @@ export function locationResolution(el: Element, axisDefault: number): number { } export function headingLevel(el: Element): number { - return Math.min( - Math.max(parseInt(el.getAttribute('level') ?? '1', 10) || 1, 1), - 6 - ); + return Math.min(Math.max(parseInt(el.getAttribute('level') ?? '1', 10) || 1, 1), 6); } export function firstHeadChild(el: Element, tag: string): Element | null { @@ -295,7 +293,10 @@ export function layerFromHeadNodes(nodes: ChildNode[], startIdx: number): string let i = startIdx; while (i < nodes.length) { const node = nodes[i]; - if (!node) { i += 1; continue; } + if (!node) { + i += 1; + continue; + } if (node.nodeType !== Node.ELEMENT_NODE) { i += 1; continue; @@ -328,7 +329,8 @@ export function headingLevelFromEl(el: Element): number { export function elementLabel(el: Element): string { if (isVirtualTextOverlayUnit(el)) return 'text'; const tag = localName(el); - if (tag === 'heading' || tag === 'field_heading') return `${tag}[${headingLevel(el)}]`; + if (tag === 'heading' || tag === 'field_heading') + return `${tag}[${headingLevel(el)}]`; const level = el.getAttribute('level'); if (level) return `${tag}[${level}]`; const cls = el.getAttribute('class'); @@ -340,9 +342,12 @@ export function isVirtualTextOverlayUnit(el: Element): boolean { if (headLocations(el).length === 4) return false; if (!hasVirtualTextLocations(el)) return false; const tag = localName(el); - if (tag === 'ldiv' && el.parentElement && localName(el.parentElement) === 'list') return true; + if (tag === 'ldiv' && el.parentElement && localName(el.parentElement) === 'list') + return true; if (isCellToken(tag) && tag !== 'nl' && !CELL_SPAN_TAGS.has(tag)) { - return el.parentElement ? OTSL_CONTAINER_TAGS.has(localName(el.parentElement)) : false; + return el.parentElement + ? OTSL_CONTAINER_TAGS.has(localName(el.parentElement)) + : false; } return false; } diff --git a/core/src/zip.ts b/core/src/zip.ts index f4e4fb9..83d375c 100644 --- a/core/src/zip.ts +++ b/core/src/zip.ts @@ -213,7 +213,10 @@ export async function extractArchiveFromZipBuffer(buffer: ArrayBuffer): Promise< const ext = m[2]!.toLowerCase().replace('jpeg', 'jpg'); const mime = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg'; - pageImages.set(Number(m[1]), URL.createObjectURL(new Blob([e.data as BlobPart], { type: mime }))); + pageImages.set( + Number(m[1]), + URL.createObjectURL(new Blob([e.data as BlobPart], { type: mime })) + ); continue; } if (e.name.startsWith('assets/') && !e.name.endsWith('/')) { diff --git a/core/vite.config.ts b/core/vite.config.ts index 69061e4..e032e8d 100644 --- a/core/vite.config.ts +++ b/core/vite.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ output: { // Unwrap the IIFE wrapper — the original viewer.js runs top-level code // so the bundle is already self-contained without an export surface. - extend: true + extend: true, }, }, }, diff --git a/index.html b/index.html index 576e023..6e32fd5 100644 --- a/index.html +++ b/index.html @@ -11,7 +11,7 @@ - +
@@ -19,116 +19,20 @@

DocLang Viewer

- +
-
-
- - -
- - - - - - - doclang.ai -
+

Drop to open another file

-
-
-
- -

Loading demo…

-

Preparing the sample document

-
-
-

Drop a DocLang file here

-

Supported file types:

-

or load demo

-
-
-
+
-
-
- Files - -
-
-
+
DocLang Viewer tabindex="0" hidden >
-
-
- Original page -
- - -
-
-
-
- -
-
+
Overlays tabindex="0" hidden >
-
-
DocLang
-
-
+
Overlays tabindex="0" hidden >
-
-
- Reading view - -
-
-
- -
-
+
diff --git a/viewer.css b/viewer.css index ba12ead..69ad974 100644 --- a/viewer.css +++ b/viewer.css @@ -173,196 +173,6 @@ header h1 { white-space: nowrap; } -.pane-file { - min-width: 0; - overflow: hidden; -} - -.pane-file .pane-header, -.pane-file .pane-body { - min-width: 0; -} - -.pane-file-header { - justify-content: space-between; - padding-inline: 0.35rem; - gap: 0.35rem; -} - -.pane-file-header .pane-header-title { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.file-pane-close-all { - appearance: none; - border: 1px solid transparent; - border-radius: 0.25rem; - background: transparent; - color: var(--muted); - font: inherit; - font-size: 0.625rem; - font-weight: 600; - line-height: 1; - letter-spacing: 0.04em; - text-transform: uppercase; - padding: 0 0.15rem; - height: 1.25rem; - cursor: pointer; - white-space: nowrap; - flex-shrink: 0; - transition: color 0.12s ease, background 0.12s ease, border-color 0.12s ease; -} - -.file-pane-close-all:hover { - color: var(--text); - background: color-mix(in srgb, var(--text) 5%, transparent); -} - -.file-pane-close-all:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -#file-pane { - padding: 0.375rem var(--file-pane-pad-x); - overflow: auto; - min-width: 0; -} - -.file-view-list { - list-style: none; - margin: 0; - padding: 0; - min-width: 0; - display: flex; - flex-direction: column; - gap: 0.25rem; - width: 100%; -} - -.file-view-list > li { - width: 100%; - max-width: 100%; -} - -.file-view-item { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.35rem; - width: 100%; - max-width: 100%; - box-sizing: border-box; - border: none; - border-radius: 0; - background: transparent; - color: var(--text); - font: inherit; - font-size: 0.8125rem; - line-height: 1.35; - text-align: center; - padding: 0.5rem var(--file-item-pad-x); - cursor: pointer; -} - -.file-view-thumb-wrap { - position: relative; - flex-shrink: 0; -} - -.file-view-close { - position: absolute; - top: -0.25rem; - right: -0.25rem; - appearance: none; - display: flex; - align-items: center; - justify-content: center; - width: 1rem; - height: 1rem; - padding: 0; - border: 1px solid var(--border); - border-radius: 50%; - background: var(--bg); - color: var(--muted); - font: inherit; - font-size: 0.75rem; - line-height: 1; - cursor: pointer; - box-shadow: 0 1px 2px rgb(0 0 0 / 8%); - z-index: 1; -} - -.file-view-close:hover { - color: var(--text); - background: var(--panel); -} - -.file-view-close:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 1px; -} - -.file-view-thumb { - flex-shrink: 0; - width: var(--file-thumb-size); - height: var(--file-thumb-size); - display: flex; - align-items: center; - justify-content: center; - background: var(--placeholder-bg); - border: 1px solid var(--border); - border-radius: 0.25rem; - overflow: hidden; -} - -.file-view-thumb img { - display: block; - width: 100%; - height: 100%; - object-fit: contain; -} - -.file-view-thumb-placeholder { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - color: var(--muted); -} - -.file-view-thumb-placeholder svg { - display: block; - width: 1.875rem; - height: 1.875rem; -} - -.file-view-label { - width: 100%; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.file-view-item:hover { - background: var(--markup-hover); -} - -.file-view-item.is-active { - background: var(--markup-selected); - color: var(--accent); - font-weight: 500; -} - -.file-view-item:focus-visible { - outline: 2px solid var(--accent); - outline-offset: -2px; -} - .header-logo-link { display: block; flex-shrink: 0; @@ -708,1448 +518,110 @@ label.file-btn input { cursor: not-allowed; } -.pane-page-header { - justify-content: space-between; -} - -.pane-reading-header { - justify-content: space-between; +main.split { + display: none; + flex: 1 1 0; + min-height: 0; } -.pane-page-controls { - display: flex; - align-items: center; - gap: 0.5rem; - flex-shrink: 0; +body.viewer-loaded #main { + display: grid; } -.page-zoom-control { - display: flex; - align-items: center; - gap: 0.35rem; - user-select: none; +body.viewer-loaded #main.layout-stacked { + grid-template-columns: 1fr !important; } -.page-zoom-control-label { - font-size: 0.625rem; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--muted); - cursor: pointer; +body.viewer-loaded #main.layout-stacked .pane-splitter { + display: none !important; } -.page-zoom-control input[type="range"] { - width: 5.5rem; - height: 1rem; - margin: 0; - cursor: pointer; +body.viewer-loaded #main.layout-stacked .pane { + border-right: none; + border-bottom: 1px solid var(--border); } -.page-zoom-reset { - appearance: none; - border: 1px solid var(--border); - background: var(--bg); - color: var(--muted); - border-radius: 0.375rem; - padding: 0 0.45rem; - height: 1.25rem; - font: inherit; - font-size: 0.6875rem; - font-weight: 600; - font-variant-numeric: tabular-nums; - line-height: 1; - cursor: pointer; - min-width: 2.75rem; +body.viewer-loaded #main.layout-stacked .pane-layout-last { + border-bottom: none; } -.page-zoom-reset:not(:disabled):hover { - border-color: var(--accent); - color: var(--text); +body:not(.viewer-loaded) #page-nav, +body.markup-only #page-nav { + display: none !important; } -.page-zoom-reset:disabled { - opacity: 0.55; - cursor: default; +body.viewer-loaded #empty-state, +body.viewer-loaded doclang-empty-state { + display: none; } -.pane-header-title { - min-width: 0; +body.viewer-loaded #main:not(.layout-stacked) { + grid-template-rows: minmax(0, 1fr); } -.pane-settings-toggle { - border-radius: 0.25rem; - padding: 0 0.28rem 0 0.4rem; - height: 1.25rem; - font-size: 0.625rem; - font-weight: 600; - line-height: 1; - letter-spacing: 0.04em; - text-transform: uppercase; +body.viewer-loaded #main:not(.layout-stacked) .pane { + border-right: none; } -.pane-page-layout, -.pane-reading-layout { - position: relative; - flex: 1; - min-height: 0; - min-width: 0; +.pane { display: flex; flex-direction: column; + border-right: 1px solid var(--border); + min-height: 0; + min-width: 0; } -.pane-page-layout > .pane-body, -.pane-reading-layout > .pane-body { - flex: 1 1 auto; - min-width: 0; - min-height: 0; +.pane[hidden] { + display: none !important; } -.viewer-settings-layer { - position: absolute; - inset: 0; - z-index: 5; +.pane-layout-last { + border-right: none; } -.viewer-settings-layer[hidden] { - display: none; +.pane-splitter { + position: relative; + width: 1px; + margin: 0 -4px; + padding: 0 4px; + box-sizing: content-box; + cursor: col-resize; + touch-action: none; + z-index: 2; + background: transparent; } -.viewer-settings-scrim { +.pane-splitter::after { + content: ""; position: absolute; - inset: 0; - z-index: 0; - appearance: none; - border: none; - margin: 0; - padding: 0; - background: color-mix(in srgb, var(--bg) 28%, transparent); - cursor: default; + top: 0; + bottom: 0; + left: 4px; + width: 1px; + background: var(--border); + transition: background 0.15s ease, width 0.15s ease; } -.viewer-settings { - position: absolute; - top: 0.5rem; - right: 0.5rem; - z-index: 1; - width: 13rem; - max-height: calc(100% - 1rem); - display: flex; - flex-direction: column; - min-height: 0; - border: 1px solid var(--border); - border-radius: 0.5rem; - background: var(--panel); - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.14); - overflow: hidden; +.pane-splitter:hover::after, +.pane-splitter:focus-visible::after, +.pane-splitter.is-dragging::after { + width: 3px; + background: var(--accent); } -.viewer-settings-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--border); +.pane-splitter[hidden] { + display: none !important; } -.viewer-settings-title { - margin: 0; - font-size: 0.6875rem; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--muted); -} - -.viewer-settings-close { - appearance: none; - border: none; - background: transparent; - color: var(--muted); - width: 1.5rem; - height: 1.5rem; - border-radius: 0.25rem; - font-size: 1.1rem; - line-height: 1; - cursor: pointer; -} - -.viewer-settings-close:hover { - color: var(--text); - background: var(--bg); -} - -.viewer-settings-body { - padding: 0.75rem; - overflow: auto; -} - -.settings-option { - display: flex; - align-items: flex-start; - gap: 0.5rem; - font-size: 0.875rem; - cursor: pointer; - user-select: none; -} - -.settings-option-primary + .settings-subgroup { - margin-top: 0.65rem; -} - -.settings-option-primary { - font-weight: 600; -} - -.settings-option-heading { - cursor: default; -} - -.settings-subgroup { - display: flex; - flex-direction: column; - gap: 0.5rem; - padding-left: 0.85rem; -} - -.settings-reading-order-group { - gap: 0.35rem; - padding-left: 1.6rem; -} - -.settings-option-nested { - padding-left: 0; -} - -.settings-option-sub { - font-size: 0.8125rem; - color: var(--muted); -} - -.settings-option-disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.settings-option-disabled input { - cursor: not-allowed; -} - -.settings-option input { - margin: 0.15rem 0 0; - flex-shrink: 0; - cursor: pointer; -} - -main.split { - display: none; - flex: 1 1 0; - min-height: 0; -} - -body.viewer-loaded #main { - display: grid; -} - -body.viewer-loaded #main.layout-stacked { - grid-template-columns: 1fr !important; -} - -body.viewer-loaded #main.layout-stacked .pane-splitter { - display: none !important; -} - -body.viewer-loaded #main.layout-stacked .pane { - border-right: none; - border-bottom: 1px solid var(--border); -} - -body.viewer-loaded #main.layout-stacked .pane-layout-last { - border-bottom: none; -} - -body:not(.viewer-loaded) #page-nav, -body.markup-only #page-nav { - display: none !important; -} - -body.viewer-loaded #empty-state { - display: none; -} - -body.viewer-loaded #main:not(.layout-stacked) { - grid-template-rows: minmax(0, 1fr); -} - -body.viewer-loaded #main:not(.layout-stacked) .pane { - border-right: none; -} - -.pane { - display: flex; - flex-direction: column; - border-right: 1px solid var(--border); - min-height: 0; - min-width: 0; -} - -.pane[hidden] { - display: none !important; -} - -.pane-layout-last { - border-right: none; -} - -.pane-splitter { - position: relative; - width: 1px; - margin: 0 -4px; - padding: 0 4px; - box-sizing: content-box; - cursor: col-resize; - touch-action: none; - z-index: 2; - background: transparent; -} - -.pane-splitter::after { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 4px; - width: 1px; - background: var(--border); - transition: background 0.15s ease, width 0.15s ease; -} - -.pane-splitter:hover::after, -.pane-splitter:focus-visible::after, -.pane-splitter.is-dragging::after { - width: 3px; - background: var(--accent); -} - -.pane-splitter[hidden] { - display: none !important; -} - -body.pane-drag-active { - cursor: col-resize; - user-select: none; +body.pane-drag-active { + cursor: col-resize; + user-select: none; } body.pane-drag-active .pane-splitter { cursor: col-resize; } -.pane-file, -.pane-page-view { - min-width: 0; -} - -.pane-markup, -.pane-reading { - min-width: 0; -} - -.pane-header { - display: flex; - align-items: center; - gap: 0.5rem; - box-sizing: border-box; - height: 2.125rem; - padding: 0 0.75rem; - font-size: 0.75rem; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--muted); - border-bottom: 1px solid var(--border); - background: var(--panel); -} - -.pane-body { - flex: 1; - min-height: 0; - overflow: auto; - padding: 1rem; -} - -#markup-pane { - padding: 0; - background: var(--markup-bg); - color: var(--markup-fg); -} - -#markup-pane .placeholder { - margin: 1rem; -} - -#page-pane { - overflow: hidden; - display: flex; - flex-direction: column; - padding: 0.75rem; - min-height: 0; - min-width: 0; -} - -.page-view-port { - display: flex; - overflow: auto; - flex: 1 1 auto; - width: 100%; - height: 100%; - min-width: 0; - min-height: 0; - scrollbar-gutter: stable; -} - -#page-pane.can-pan { - cursor: grab; -} - -#page-pane.is-panning { - cursor: grabbing; - user-select: none; -} - -#page-pane:focus { - outline: none; -} - -#page-pane:focus-visible { - outline: 2px solid var(--accent); - outline-offset: -2px; -} - -.placeholder { - display: flex; - align-items: center; - justify-content: center; - min-height: 12rem; - color: var(--muted); - font-style: italic; - background: var(--placeholder-bg); - border: 1px dashed var(--border); - border-radius: 0.5rem; - padding: 2rem; - text-align: center; -} - -pre.markup { - margin: 0; - font-family: var(--font-mono); - font-size: 0.8125rem; - line-height: 1.5; - white-space: pre-wrap; - word-break: break-word; -} - -.markup { - margin: 0; - padding: 0.75rem 1rem 1rem; - font-family: var(--font-mono); - font-size: 0.8125rem; - line-height: 1.55; - tab-size: 2; - --markup-indent: 2ch; - --markup-gutter-width: 1rem; -} - -.markup-ghost-tag-part { - opacity: 0.35; - cursor: help; -} - -.cursor-hint { - position: fixed; - z-index: 200; - width: max-content; - max-width: 16rem; - padding: 0.3rem 0.5rem; - border-radius: 0.25rem; - background: var(--panel); - border: 1px solid var(--border); - color: var(--text); - font-family: var(--font-ui); - font-size: 0.75rem; - line-height: 1.35; - text-align: center; - white-space: normal; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); - pointer-events: none; -} - -.cursor-hint.cursor-hint-detail { - max-width: 24rem; - padding: 0.45rem 0.55rem; - text-align: left; -} - -.head-tooltip { - border-collapse: collapse; - width: 100%; - font-size: 0.6875rem; - line-height: 1.4; -} - -.head-tooltip th { - padding: 0.1rem 0.55rem 0.1rem 0; - color: var(--muted); - font-weight: 600; - text-align: left; - vertical-align: top; - white-space: nowrap; -} - -.head-tooltip td { - padding: 0.1rem 0; - vertical-align: top; - word-break: break-word; -} - -.head-tooltip .head-default { - color: var(--muted); - font-style: italic; - white-space: nowrap; -} - -.markup-el { - cursor: pointer; - border-radius: 0.2rem; -} - -.markup-el:hover { - background: var(--markup-hover); -} - -.markup-el.selected { - background: var(--markup-selected); - box-shadow: inset 0 0 0 1px var(--accent); -} - -.markup-line { - white-space: pre-wrap; - word-break: break-word; - cursor: pointer; - padding: 0 0.25rem; - border-radius: 0.15rem; -} - -.markup-line-content { - display: flex; - align-items: baseline; - min-width: 0; - padding-left: calc(var(--markup-depth, 0) * var(--markup-indent)); -} - -.markup-gutter { - flex: 0 0 var(--markup-gutter-width); - width: var(--markup-gutter-width); - display: flex; - align-items: center; - justify-content: center; -} - -.markup-line-body { - flex: 1 1 auto; - min-width: 0; -} - -.markup-fold-toggle { - appearance: none; - display: inline-flex; - align-items: center; - justify-content: center; - width: var(--markup-gutter-width); - height: 1rem; - margin: 0; - padding: 0; - border: none; - border-radius: 0.15rem; - background: transparent; - color: var(--muted); - cursor: pointer; - flex-shrink: 0; -} - -.markup-fold-toggle::before { - content: ""; - display: block; - width: 0; - height: 0; - border-top: 4px solid transparent; - border-bottom: 4px solid transparent; - border-left: 5px solid currentColor; - transform: rotate(90deg); - transition: transform 0.12s ease; -} - -.markup-el-foldable.markup-collapsed > .markup-line-open .markup-fold-toggle::before { - transform: rotate(0deg); -} - -.markup-fold-toggle:hover { - background: var(--markup-hover); - color: var(--markup-fg); -} - -.markup-fold-suffix { - display: none; -} - -.markup-el-foldable.markup-collapsed > .markup-line-open .markup-fold-suffix { - display: inline; -} - -.markup-el-foldable.markup-collapsed > .markup-fold-body { - display: none; -} - -.xml-tag { - color: var(--xml-tag); -} - -.xml-bracket { - color: var(--xml-bracket); -} - -.xml-attr-name { - color: var(--xml-attr-name); -} - -.xml-attr-value { - color: var(--xml-attr-value); -} - -.xml-attr-value-truncatable { - display: inline; -} - -.xml-attr-value-chip { - appearance: none; - display: inline-flex; - align-items: center; - gap: 0.3em; - margin: 0 0 0 0.35em; - padding: 0.08em 0.45em 0.08em 0.5em; - border: 1px solid var(--border); - border-radius: 0.25rem; - background: var(--placeholder-bg); - color: var(--muted); - font-family: var(--font-ui, system-ui, sans-serif); - font-size: 0.72em; - font-weight: 600; - line-height: 1.35; - cursor: pointer; - vertical-align: baseline; - white-space: nowrap; -} - -.xml-attr-value-chip::after { - content: ""; - width: 0; - height: 0; - border-top: 0.28em solid currentColor; - border-left: 0.22em solid transparent; - border-right: 0.22em solid transparent; - opacity: 0.75; - transition: transform 0.12s ease; -} - -.xml-attr-value-chip[aria-expanded="true"]::after { - transform: rotate(180deg); -} - -.xml-attr-value-chip:hover, -.xml-attr-value-chip[aria-expanded="true"] { - border-color: var(--accent); - color: var(--accent); - background: color-mix(in srgb, var(--accent) 8%, var(--placeholder-bg)); -} - -.markup-embedded-uri-panel { - cursor: text; -} - -.markup-embedded-uri-panel:hover { - background: transparent; -} - -.markup-embedded-uri-panel-body { - margin: 0.1rem 0 0.25rem; - padding: 0.45rem 0.6rem; - max-height: 8rem; - overflow: auto; - color: var(--xml-attr-value); - word-break: break-all; - background: var(--placeholder-bg); - border: 1px solid var(--border); - border-radius: 0.25rem; - user-select: text; -} - -.xml-text { - color: var(--xml-text); -} - -.xml-cdata { - color: var(--xml-cdata); -} - -.xml-cdata-delimiter { - color: var(--xml-cdata-delimiter); -} - -.page-view { - position: relative; - display: block; - width: fit-content; - flex-shrink: 0; - margin: auto; - line-height: 0; - border: 1px solid var(--border); - border-radius: 0.25rem; - overflow: hidden; -} - -.page-view img { - display: block; - width: auto; - height: auto; - max-width: none; - max-height: none; - border: none; - border-radius: 0; -} - -.page-view img:not([data-layout-ready]) { - opacity: 0; -} - -.page-view svg.overlay { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - pointer-events: auto; - overflow: hidden; -} - -.overlay rect.bbox.bbox-hidden, -.overlay .overlay-badge.bbox-hidden { - display: none; -} - -.overlay rect.bbox { - stroke-width: 2; - vector-effect: non-scaling-stroke; - pointer-events: none; -} - -.overlay rect.bbox.selected { - fill: color-mix(in srgb, var(--kind-stroke, var(--accent)) 30%, transparent) !important; - stroke-width: 3.5; - filter: drop-shadow(0 0 3px color-mix(in srgb, var(--kind-stroke, var(--accent)) 60%, transparent)); -} - -.overlay rect.bbox.related:not(.selected) { - fill: color-mix(in srgb, var(--kind-stroke, var(--accent)) 30%, transparent) !important; -} - -.overlay rect.bbox.layer-furniture, -.overlay rect.bbox.layer-background { - fill: url(#layer-hatch) !important; - stroke: color-mix(in srgb, var(--kind-stroke, var(--muted)) 50%, transparent); -} - -.overlay .layer-hatch-stripe { - fill: var(--muted); - fill-opacity: 0.06; -} - -.overlay rect.bbox.layer-furniture.selected, -.overlay rect.bbox.layer-background.selected { - fill: color-mix(in srgb, var(--kind-stroke, var(--accent)) 22%, transparent) !important; -} - -.overlay rect.bbox.layer-furniture.related:not(.selected), -.overlay rect.bbox.layer-background.related:not(.selected) { - fill: color-mix(in srgb, var(--kind-stroke, var(--accent)) 18%, transparent) !important; -} - -/* text */ -.overlay .bbox-text { fill: color-mix(in srgb, var(--kind-text) 14%, transparent); stroke: var(--kind-text); } - -/* heading */ -.overlay .bbox-heading { fill: color-mix(in srgb, var(--kind-heading) 14%, transparent); stroke: var(--kind-heading); } - -/* list / ldiv */ -.overlay .bbox-list, -.overlay .bbox-ldiv { fill: color-mix(in srgb, var(--kind-list) 14%, transparent); stroke: var(--kind-list); } - -/* table */ -.overlay .bbox-table { fill: color-mix(in srgb, var(--kind-table) 14%, transparent); stroke: var(--kind-table); } - -/* index */ -.overlay .bbox-index { fill: color-mix(in srgb, var(--kind-index) 14%, transparent); stroke: var(--kind-index); } - -/* formula */ -.overlay .bbox-formula { fill: color-mix(in srgb, var(--kind-formula) 14%, transparent); stroke: var(--kind-formula); } - -/* code */ -.overlay .bbox-code { fill: color-mix(in srgb, var(--kind-code) 16%, transparent); stroke: var(--kind-code); } - -/* picture */ -.overlay .bbox-picture { fill: color-mix(in srgb, var(--kind-picture) 14%, transparent); stroke: var(--kind-picture); } - -/* group */ -.overlay .bbox-group { fill: color-mix(in srgb, var(--kind-group) 14%, transparent); stroke: var(--kind-group); } - -/* footnote */ -.overlay .bbox-footnote { fill: color-mix(in srgb, var(--kind-footnote) 14%, transparent); stroke: var(--kind-footnote); } - -/* page chrome */ -.overlay .bbox-page_header, -.overlay .bbox-page_footer { fill: color-mix(in srgb, var(--kind-page_header) 14%, transparent); stroke: var(--kind-page_header); } - -/* caption */ -.overlay .bbox-caption { fill: color-mix(in srgb, var(--kind-caption) 14%, transparent); stroke: var(--kind-caption); } - -.overlay line.caption-link { - color: var(--kind-caption); - stroke: currentColor; - stroke-width: 1.5; - vector-effect: non-scaling-stroke; - pointer-events: none; - fill: none; -} - -.overlay line.caption-link.bbox-hidden { - display: none; -} - -.overlay line.xref-link { - color: var(--kind-footnote); - stroke: currentColor; - stroke-width: 1.5; - vector-effect: non-scaling-stroke; - pointer-events: none; - fill: none; -} - -.overlay line.xref-link.bbox-hidden { - display: none; -} - -.overlay .fragment-link { - pointer-events: none; -} - -.overlay .fragment-link.bbox-hidden { - display: none; -} - -.overlay .fragment-link-path { - color: var(--overlay-fragment); - fill: none; - stroke: currentColor; - stroke-width: 1.5; - vector-effect: non-scaling-stroke; -} - -.overlay .fragment-link-path-dashed { - stroke-dasharray: 6 4; -} - -.overlay .fragment-nav { - pointer-events: none; -} - -.overlay .fragment-nav.bbox-hidden { - display: none; -} - -.overlay .fragment-nav-btn { - pointer-events: auto; - cursor: pointer; -} - -.overlay .fragment-nav-btn-disabled { - pointer-events: none; - cursor: default; - opacity: 0.35; -} - -.overlay .fragment-nav-btn-bg { - fill: var(--panel); - stroke: var(--overlay-fragment); - stroke-width: 1; - vector-effect: non-scaling-stroke; -} - -.overlay .fragment-nav-btn-label { - fill: var(--overlay-fragment); - font-family: var(--font-ui); - font-weight: 700; - text-anchor: middle; - dominant-baseline: central; - pointer-events: none; -} - -.overlay .fragment-nav-btn:not(.fragment-nav-btn-disabled):hover .fragment-nav-btn-bg { - fill: color-mix(in srgb, var(--overlay-fragment) 12%, var(--panel)); -} - -.overlay .fragment-link-label { - fill: var(--overlay-fragment); - font-family: var(--font-ui); - font-weight: 600; - font-style: italic; - text-anchor: middle; - dominant-baseline: middle; - paint-order: stroke fill; - stroke: var(--panel); - stroke-width: 3px; -} - -.overlay .reading-order-step { - color: var(--overlay-reading-order); - stroke: currentColor; - stroke-width: 1.5; - stroke-dasharray: 5 4; - vector-effect: non-scaling-stroke; - pointer-events: none; - fill: none; -} - -.overlay .reading-order-step.bbox-hidden, -.overlay .reading-order-badge.bbox-hidden { - display: none; -} - -.overlay .overlay-badge { - pointer-events: none; -} - -.overlay .overlay-badge-bg { - fill-opacity: 1; - stroke: none; -} - -.reading-order-badge .overlay-badge-bg { - fill: var(--overlay-reading-order); -} - -.overlay .element-badge { - opacity: 0.8; - pointer-events: auto; - cursor: help; -} - -.overlay .element-badge .overlay-badge-bg { - fill: var(--kind-stroke, var(--accent)); -} - -.overlay .overlay-badge-label { - fill: #ffffff; - fill-opacity: 1; - font-family: var(--font-ui); - font-weight: 700; - text-anchor: middle; - dominant-baseline: central; - pointer-events: none; -} - -/* form fields */ -.overlay .bbox-field { fill: color-mix(in srgb, var(--kind-field) 14%, transparent); stroke: var(--kind-field); } - -/* fallback */ -.overlay .bbox-default { fill: color-mix(in srgb, var(--kind-default) 12%, transparent); stroke: var(--kind-default); } - -@media (prefers-color-scheme: dark) { - .overlay .bbox-text { fill: color-mix(in srgb, var(--kind-text) 20%, transparent); } - - .overlay .bbox-heading { fill: color-mix(in srgb, var(--kind-heading) 20%, transparent); } - - .overlay .bbox-list, - .overlay .bbox-ldiv { fill: color-mix(in srgb, var(--kind-list) 20%, transparent); } - - .overlay .bbox-table { fill: color-mix(in srgb, var(--kind-table) 18%, transparent); } - - .overlay .bbox-index { fill: color-mix(in srgb, var(--kind-index) 18%, transparent); } - - .overlay .bbox-formula { fill: color-mix(in srgb, var(--kind-formula) 18%, transparent); } - - .overlay .bbox-code { fill: color-mix(in srgb, var(--kind-code) 20%, transparent); } - - .overlay .bbox-picture { fill: color-mix(in srgb, var(--kind-picture) 20%, transparent); } - - .overlay .bbox-group { fill: color-mix(in srgb, var(--kind-group) 20%, transparent); } - - .overlay .bbox-footnote { fill: color-mix(in srgb, var(--kind-footnote) 18%, transparent); } - - .overlay .bbox-page_header, - .overlay .bbox-page_footer { fill: color-mix(in srgb, var(--kind-page_header) 18%, transparent); } - - .overlay .bbox-caption { fill: color-mix(in srgb, var(--kind-caption) 18%, transparent); } - - .overlay .bbox-field { fill: color-mix(in srgb, var(--kind-field) 18%, transparent); } - - .overlay .bbox-default { fill: color-mix(in srgb, var(--kind-default) 18%, transparent); } -} - -.empty-state { - flex: 1 1 0; - display: flex; - align-items: center; - justify-content: center; - min-height: 0; - margin: 1rem; - padding: 2rem 1.5rem; - border: 2px dashed var(--border); - border-radius: 0.75rem; - background: var(--placeholder-bg); - text-align: center; - color: var(--muted); -} - -.empty-state-inner { - max-width: 28rem; -} - -.empty-state-loading { - display: none; - flex-direction: column; - align-items: center; -} - -body.demo-loading .empty-state-loading { - display: flex; -} - -body.demo-loading .empty-state-prompt { - display: none; -} - -.loading-spinner { - width: 2rem; - height: 2rem; - margin: 0 auto 1rem; - border: 2px solid var(--border); - border-top-color: var(--accent); - border-radius: 50%; - animation: loading-spin 0.7s linear infinite; -} - -@keyframes loading-spin { - to { - transform: rotate(360deg); - } -} - -.empty-state-title { - margin: 0 0 0.75rem; - font-size: 1.05rem; - color: var(--text); -} - -.empty-state p { - margin: 0.5rem 0; -} - -.empty-state-action { - margin: 0 0 0.75rem; -} - -.text-link { - color: var(--accent); - text-decoration: underline; -} - -.text-link:hover { - opacity: 0.85; -} - -.empty-state-meta { - font-size: 0.875rem; -} - -.rendered-doc { - max-width: 42rem; - margin: 0 auto; - line-height: 1.6; -} - -.rendered-el-virtual-text > p, -.rendered-el-virtual-text > div { - margin: 0; - display: inline; -} - -.rendered-doc li > .rendered-el-virtual-text { - display: inline; -} - -.rendered-doc li > .rendered-el-virtual-text > p, -.rendered-doc li > .rendered-el-virtual-text > div { - display: inline; - margin: 0; -} - -.rendered-table .rendered-el-virtual-text { - display: inline; - margin: 0; -} - -.rendered-table .rendered-el-virtual-text > p, -.rendered-table .rendered-el-virtual-text > div { - display: inline; - margin: 0; -} - -.rendered-el-virtual-text > div > .rendered-el { - display: block; - margin: 0.25rem 0; -} - -.rendered-el { - cursor: pointer; - border-radius: 0.25rem; -} - -.rendered-el:hover { - outline: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); -} - -.rendered-el.selected { - outline: 2px solid var(--accent); - outline-offset: 2px; - background: color-mix(in srgb, var(--accent) 8%, transparent); -} - -.rendered-doc p { - margin: 0 0 0.75rem; -} - -.rendered-doc h1, -.rendered-doc h2, -.rendered-doc h3, -.rendered-doc h4, -.rendered-doc h5, -.rendered-doc h6 { - margin: 1.25rem 0 0.5rem; - line-height: 1.25; -} - -.rendered-doc h1:first-child, -.rendered-doc h2:first-child, -.rendered-doc h3:first-child { - margin-top: 0; -} - -.rendered-doc ul, -.rendered-doc ol { - margin: 0 0 0.75rem; - padding-left: 1.5rem; -} - -.rendered-doc li { - margin: 0.25rem 0; -} - -.rendered-doc li > .rendered-el > p:first-child, -.rendered-doc li > .rendered-el > h1:first-child, -.rendered-doc li > .rendered-el > h2:first-child, -.rendered-doc li > .rendered-el > h3:first-child, -.rendered-doc li > .rendered-el > h4:first-child, -.rendered-doc li > .rendered-el > h5:first-child, -.rendered-doc li > .rendered-el > h6:first-child { - margin-top: 0; -} - -.rendered-marker { - margin-right: 0.35rem; -} - -.rendered-handwriting { - font-family: "Segoe Print", "Bradley Hand", cursive; -} - -.rendered-doc pre { - margin: 0 0 0.75rem; - padding: 0.75rem 1rem; - overflow-x: auto; - font-family: var(--font-mono); - font-size: 0.8125rem; - line-height: 1.5; - background: var(--placeholder-bg); - border: 1px solid var(--border); - border-radius: 0.375rem; -} - -.rendered-doc code { - font-family: var(--font-mono); - font-size: 0.875em; -} - -.rendered-doc pre code { - font-size: inherit; - background: none; - border: none; - padding: 0; -} - -.rendered-code-label { - display: block; - margin-bottom: 0.25rem; - font-size: 0.75rem; - font-weight: 600; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.rendered-formula { - display: block; - margin: 0 0 0.75rem; - padding: 0.5rem 0.75rem; - font-family: var(--font-mono); - font-size: 0.875rem; - background: var(--placeholder-bg); - border-left: 3px solid var(--border); - overflow-x: auto; -} - -.rendered-formula-inline { - display: inline; - font-family: var(--font-mono); - font-size: 0.875em; -} - -.rendered-doc figure { - margin: 0 0 0.75rem; -} - -.rendered-doc figure img { - max-width: 100%; - height: auto; - border: 1px solid var(--border); - border-radius: 0.25rem; -} - -.rendered-doc figure img.rendered-picture-unavailable { - display: inline-block; - min-width: 3rem; - min-height: 2.5rem; - object-fit: contain; - vertical-align: middle; -} - -.rendered-doc figcaption { - margin-top: 0.35rem; - font-size: 0.875rem; - color: var(--muted); -} - -.rendered-picture-contents { - margin-top: 0.5rem; - font-size: 0.875rem; - border: 1px solid var(--border); - border-radius: 0.375rem; - background: var(--placeholder-bg); -} - -.rendered-picture-contents summary { - padding: 0.4rem 0.65rem; - cursor: pointer; - font-weight: 600; - color: var(--muted); - user-select: none; -} - -.rendered-picture-contents summary:hover { - color: var(--text); -} - -.rendered-picture-contents-body { - padding: 0.5rem 0.75rem 0.75rem; - border-top: 1px solid var(--border); -} - -.rendered-picture-contents-body .rendered-el > p { - margin-bottom: 0.35rem; - font-size: 0.8125rem; -} - -.rendered-table { - width: 100%; - margin: 0 0 0.75rem; - border-collapse: collapse; - font-size: 0.875rem; -} - -.rendered-table caption { - caption-side: top; - margin-bottom: 0.35rem; - font-size: 0.875rem; - color: var(--muted); - text-align: left; -} - -.rendered-table th, -.rendered-table td { - border: 1px solid var(--border); - padding: 0.35rem 0.5rem; - vertical-align: top; - text-align: left; -} - -.rendered-table th { - background: color-mix(in srgb, var(--border) 35%, var(--panel)); - font-weight: 600; -} - -.rendered-table .rendered-table-cell-text { - display: inline; -} - -.rendered-table .rendered-el { - margin: 0; -} - -.rendered-table .rendered-el-virtual-text > p, -.rendered-table .rendered-el-virtual-text > div { - margin: 0; -} - -.rendered-table .rendered-table { - margin: 0.25rem 0 0; -} - -.rendered-page-header, -.rendered-page-footer { - font-size: 0.875rem; - color: var(--muted); -} - -.rendered-page-header { - margin-bottom: 1rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--border); -} - -.rendered-page-footer { - margin-top: 1rem; - padding-top: 0.5rem; - border-top: 1px solid var(--border); -} - -.rendered-doc:not(.show-reading-furniture) .rendered-el[data-doclang-layer="furniture"] { - display: none; -} - -.rendered-doc:not(.show-reading-background) .rendered-el[data-doclang-layer="background"] { - display: none; -} - -.rendered-el[data-doclang-layer="furniture"], -.rendered-el[data-doclang-layer="background"] { - position: relative; -} - -.rendered-el[data-doclang-layer="furniture"]::before, -.rendered-el[data-doclang-layer="background"]::before { - content: ""; - position: absolute; - inset: 0; - border-radius: inherit; - pointer-events: none; - background: repeating-linear-gradient( - -45deg, - transparent 0 10px, - color-mix(in srgb, var(--muted) 6%, transparent) 10px 16px - ); -} - -.rendered-el.rendered-footnote > aside { - font-size: 0.875rem; - color: var(--muted); - border-left: 2px solid var(--border); - padding-left: 0.75rem; - margin: 0 0 0.75rem; -} - -.rendered-unsupported { - margin: 0 0 0.75rem; - padding: 0.5rem 0.75rem; - font-size: 0.8125rem; - font-style: italic; - color: var(--muted); - background: var(--placeholder-bg); - border: 1px dashed var(--border); - border-radius: 0.375rem; -} - -.rendered-field-region { - margin: 0 0 1rem; - padding: 0.75rem 1rem; - border: 1px solid var(--border); - border-radius: 0.5rem; - background: color-mix(in srgb, var(--kind-field) 6%, transparent); -} - -.rendered-field-region > .rendered-field-heading:first-child, -.rendered-field-region > .rendered-el.rendered-field_heading > .rendered-field-heading:first-child { - margin-top: 0; -} - -.rendered-field-item { - margin: 0.5rem 0; -} - -.rendered-field-item > .rendered-el.rendered-field-key, -.rendered-field-key { - font-weight: 600; -} - -.rendered-field-item > .rendered-el.rendered-field-key + .rendered-el, -.rendered-field-item > .rendered-el.rendered-field-key + .rendered-el-virtual-text { - margin-top: 0.15rem; -} - -.rendered-field-value-fillable .rendered-field-fillable-slot { - display: inline-block; - min-width: 6rem; - min-height: 1.25em; - border-bottom: 1px solid color-mix(in srgb, var(--text) 55%, transparent); - vertical-align: bottom; -} - -.rendered-field-hint { - font-size: 0.85em; - color: var(--muted); - font-style: italic; -} - -.rendered-field-heading { - color: var(--text); -} - -.rendered-checkbox { - margin: 0 0.25rem 0 0; - vertical-align: middle; - accent-color: var(--kind-field); -} - -.rendered-checkbox-wrap { - display: inline; -} @media (max-width: 1200px) { body.viewer-loaded #main.layout-stacked .pane { diff --git a/viewer.js b/viewer.js index e5811ef..93e644a 100644 --- a/viewer.js +++ b/viewer.js @@ -1,4 +1,329 @@ (function() { + //#region src/components/base/base.ts + /** Base class for all doclang web components. */ + var DoclangHTMLElement = class extends HTMLElement { + /** The attached shadow root — available after the constructor runs. */ + shadow; + /** + * @param css Shadow DOM stylesheet — pass the `?inline` import directly. + * @param html Shadow DOM template — pass the `?raw` import directly. + */ + constructor(css, html) { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + const sheet = new CSSStyleSheet(); + sheet.replaceSync(css); + this.shadow.adoptedStyleSheets = [sheet]; + this.shadow.innerHTML = html; + } + /** Typed shorthand for `this.shadow.querySelector`. */ + q(selector) { + const el = this.shadow.querySelector(selector); + if (!el) throw new Error(`[${this.tagName.toLowerCase()}] No element matches "${selector}"`); + return el; + } + }; + //#endregion + //#region src/components/cursor-hint/cursor-hint.css?inline + var cursor_hint_default$1 = ":host {\n display: contents;\n}\n.cursor-hint {\n position: fixed;\n z-index: 200;\n width: max-content;\n max-width: 16rem;\n padding: 0.3rem 0.5rem;\n border-radius: 0.25rem;\n background: var(--panel);\n border: 1px solid var(--border);\n color: var(--text);\n font-family: var(--font-ui);\n font-size: 0.75rem;\n line-height: 1.35;\n text-align: center;\n white-space: normal;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);\n pointer-events: none;\n}\n.cursor-hint.cursor-hint-detail {\n max-width: 24rem;\n padding: 0.45rem 0.55rem;\n text-align: left;\n}\n.head-tooltip {\n border-collapse: collapse;\n width: 100%;\n font-size: 0.6875rem;\n line-height: 1.4;\n}\n.head-tooltip th {\n padding: 0.1rem 0.55rem 0.1rem 0;\n color: var(--muted);\n font-weight: 600;\n text-align: left;\n vertical-align: top;\n white-space: nowrap;\n}\n.head-tooltip td {\n padding: 0.1rem 0;\n vertical-align: top;\n word-break: break-word;\n}\n.head-tooltip .head-default {\n color: var(--muted);\n font-style: italic;\n white-space: nowrap;\n}\n"; + //#endregion + //#region src/components/cursor-hint/cursor-hint.html?raw + var cursor_hint_default = "\n"; + //#endregion + //#region src/components/cursor-hint/cursor-hint.ts + /** — floating tooltip that follows the pointer */ + var OFFSET = 10; + var MARGIN = 8; + var DoclangCursorHint = class extends DoclangHTMLElement { + _hint; + constructor() { + super(cursor_hint_default$1, cursor_hint_default); + this._hint = this.q(".cursor-hint"); + } + hide() { + this._hint.hidden = true; + this._hint.classList.remove("cursor-hint-detail"); + this._hint.replaceChildren(); + } + show(content, clientX, clientY, detail = false) { + this._hint.replaceChildren(); + if (typeof content === "string") this._hint.textContent = content; + else this._hint.appendChild(content); + this._hint.classList.toggle("cursor-hint-detail", detail); + this._hint.hidden = false; + this._position(clientX, clientY); + } + showHtml(html, clientX, clientY) { + this._hint.innerHTML = html; + this._hint.classList.add("cursor-hint-detail"); + this._hint.hidden = false; + this._position(clientX, clientY); + } + _position(clientX, clientY) { + let left = clientX + OFFSET; + let top = clientY + OFFSET; + const rect = this._hint.getBoundingClientRect(); + if (left + rect.width > window.innerWidth - MARGIN) left = clientX - rect.width - OFFSET; + if (top + rect.height > window.innerHeight - MARGIN) top = clientY - rect.height - OFFSET; + this._hint.style.left = `${Math.max(MARGIN, left)}px`; + this._hint.style.top = `${Math.max(MARGIN, top)}px`; + } + }; + customElements.define("doclang-cursor-hint", DoclangCursorHint); + //#endregion + //#region src/components/page-nav/page-nav.css?inline + var page_nav_default$1 = ":host {\n display: contents;\n}\nnav {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n margin-left: 0.25rem;\n}\nnav::before {\n content: '';\n flex-shrink: 0;\n align-self: center;\n width: 1px;\n height: 1.125rem;\n margin-right: 0.75rem;\n background: var(--border);\n}\nnav[hidden] {\n display: none;\n}\n.page-nav-btns {\n display: inline-flex;\n align-items: center;\n gap: 0.25rem;\n}\n.page-nav-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 1.875rem;\n height: 1.875rem;\n padding: 0;\n flex-shrink: 0;\n appearance: none;\n border: 1px solid var(--border);\n background: var(--panel);\n color: var(--text);\n border-radius: 0.375rem;\n font: inherit;\n font-size: 0.875rem;\n cursor: pointer;\n}\n.page-nav-btn:hover {\n border-color: var(--accent);\n}\n.page-nav-btn:disabled {\n opacity: 0.45;\n cursor: not-allowed;\n}\n.page-nav-chevron {\n display: block;\n width: 0.875rem;\n height: 0.875rem;\n}\n.page-indicator {\n display: inline-flex;\n align-items: center;\n font-size: 0.875rem;\n color: var(--muted);\n font-variant-numeric: tabular-nums;\n}\n.page-number-input {\n appearance: none;\n border: 1px solid var(--border);\n border-radius: 0.375rem;\n background: var(--panel);\n color: var(--text);\n font: inherit;\n font-size: 0.875rem;\n font-variant-numeric: tabular-nums;\n text-align: center;\n padding: 0 0.35rem;\n margin: 0;\n height: 1.875rem;\n box-sizing: border-box;\n width: calc(var(--page-num-digits, 1) * 1ch + 0.75rem);\n line-height: 1;\n cursor: text;\n}\n.page-number-input:hover:not(:disabled) {\n border-color: var(--accent);\n}\n.page-number-input:focus {\n outline: none;\n border-color: var(--accent);\n box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent);\n}\n.page-number-input:disabled {\n opacity: 0.55;\n cursor: not-allowed;\n}\n"; + //#endregion + //#region src/components/page-nav/page-nav.html?raw + var page_nav_default = "\n"; + //#endregion + //#region src/components/page-nav/page-nav.ts + /** — page navigation (prev/next buttons + page indicator) */ + var DoclangPageNav = class extends DoclangHTMLElement { + _nav; + _btnPrev; + _btnNext; + _input; + _countSpan; + constructor() { + super(page_nav_default$1, page_nav_default); + this._nav = this.q("nav"); + this._btnPrev = this.q(".btn-prev"); + this._btnNext = this.q(".btn-next"); + this._input = this.q(".page-number-input"); + this._countSpan = this.q(".page-count"); + this._btnPrev.addEventListener("click", () => this.dispatchEvent(new CustomEvent("doclang-prev-page", { + bubbles: true, + composed: true + }))); + this._btnNext.addEventListener("click", () => this.dispatchEvent(new CustomEvent("doclang-next-page", { + bubbles: true, + composed: true + }))); + this._input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + this._commitInput(); + this._input.blur(); + } else if (e.key === "Escape") { + e.preventDefault(); + this._resetInput(); + this._input.blur(); + } + }); + this._input.addEventListener("blur", () => this._resetInput()); + this._input.addEventListener("focus", () => this._input.select()); + } + _currentPage = 1; + _pageCount = 1; + get btnPrev() { + return this._btnPrev; + } + get btnNext() { + return this._btnNext; + } + get pageNumberInput() { + return this._input; + } + get pageCountIndicator() { + return this._countSpan; + } + setVisible(visible) { + this._nav.hidden = !visible; + } + setIndicator(pageNum, pageCount) { + this._currentPage = pageNum; + this._pageCount = pageCount; + this._countSpan.textContent = `\u00A0of ${pageCount}`; + const digits = Math.max(1, String(pageCount).length); + this._input.style.setProperty("--page-num-digits", String(digits)); + this._input.disabled = false; + if (document.activeElement !== this._input) this._input.value = String(pageNum); + this._btnPrev.disabled = pageNum <= 1; + this._btnNext.disabled = pageNum >= pageCount; + } + reset() { + this._currentPage = 1; + this._pageCount = 1; + this._nav.hidden = true; + this._input.value = "1"; + this._input.disabled = true; + this._btnPrev.disabled = true; + this._btnNext.disabled = true; + this._countSpan.textContent = "\xA0of 1"; + } + _resetInput() { + this._input.value = String(this._currentPage); + } + _commitInput() { + const n = Number.parseInt(this._input.value.trim(), 10); + if (!Number.isFinite(n)) { + this._resetInput(); + return; + } + const page = Math.min(Math.max(1, n), this._pageCount); + this.dispatchEvent(new CustomEvent("doclang-go-to-page", { + bubbles: true, + composed: true, + detail: { page } + })); + } + }; + customElements.define("doclang-page-nav", DoclangPageNav); + //#endregion + //#region src/components/toolbar/toolbar.css?inline + var toolbar_default$1 = ":host {\n display: contents;\n}\n.toolbar {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n flex-wrap: wrap;\n grid-column: 3;\n justify-self: end;\n}\n.header-divider {\n flex-shrink: 0;\n align-self: center;\n width: 1px;\n height: 1.125rem;\n background: var(--border);\n}\n.toolbar-options-wrap {\n position: relative;\n}\n/* Menu/control triggers */\n.toolbar-options-btn {\n appearance: none;\n border: 1px solid transparent;\n background: transparent;\n color: var(--muted);\n font-weight: 500;\n cursor: pointer;\n white-space: nowrap;\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n transition:\n color 0.12s ease,\n background 0.12s ease,\n border-color 0.12s ease;\n border-radius: 0.375rem;\n padding: 0.35rem 0.45rem 0.35rem 0.55rem;\n font-size: 0.8125rem;\n line-height: 1.2;\n font: inherit;\n}\n.toolbar-options-btn::after {\n content: '';\n flex-shrink: 0;\n width: 0.32rem;\n height: 0.32rem;\n margin-left: 0.3rem;\n margin-top: -0.14em;\n border-right: 1.5px solid currentColor;\n border-bottom: 1.5px solid currentColor;\n transform: rotate(45deg);\n opacity: 0.65;\n}\n.toolbar-options-btn:hover {\n color: var(--text);\n background: color-mix(in srgb, var(--text) 5%, transparent);\n}\n.toolbar-options-btn[aria-expanded='true'] {\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 10%, var(--panel));\n border-color: color-mix(in srgb, var(--accent) 22%, transparent);\n}\n.toolbar-options-btn:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n}\n.toolbar-options-panel {\n position: absolute;\n top: calc(100% + 0.35rem);\n right: 0;\n z-index: 30;\n min-width: 11rem;\n padding: 0.5rem 0;\n border: 1px solid var(--border);\n border-radius: 0.375rem;\n background: var(--panel);\n box-shadow: 0 0.5rem 1.25rem color-mix(in srgb, var(--text) 12%, transparent);\n}\n.toolbar-options-panel[hidden] {\n display: none;\n}\n.toolbar-options-item {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n padding: 0.35rem 0.75rem;\n font-size: 0.875rem;\n cursor: pointer;\n font-family: var(--font-ui);\n}\n.toolbar-options-item:hover {\n background: color-mix(in srgb, var(--accent) 6%, var(--panel));\n}\n.toolbar-options-item input {\n margin: 0;\n flex-shrink: 0;\n cursor: pointer;\n}\n.toolbar-options-item-disabled {\n opacity: 0.45;\n cursor: not-allowed;\n}\n.toolbar-options-item-disabled input {\n cursor: not-allowed;\n}\n.toolbar-options-divider {\n margin: 0.35rem 0;\n border-top: 1px solid var(--border);\n}\n.toolbar-options-reset {\n display: block;\n width: 100%;\n box-sizing: border-box;\n margin: 0;\n padding: 0.35rem 0.75rem;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: var(--text);\n font: inherit;\n font-size: 0.875rem;\n text-align: left;\n cursor: pointer;\n}\n.toolbar-options-reset:hover:not(:disabled) {\n background: color-mix(in srgb, var(--accent) 6%, var(--panel));\n color: var(--accent);\n}\n.toolbar-options-reset:disabled {\n opacity: 0.45;\n cursor: not-allowed;\n}\n.toolbar-file-group {\n display: inline-flex;\n align-items: center;\n}\nbutton,\nlabel.file-btn {\n appearance: none;\n border: 1px solid var(--border);\n background: var(--panel);\n color: var(--text);\n border-radius: 0.375rem;\n padding: 0.4rem 0.75rem;\n font: inherit;\n font-size: 0.875rem;\n cursor: pointer;\n}\nbutton:hover,\nlabel.file-btn:hover {\n border-color: var(--accent);\n}\nbutton:disabled {\n opacity: 0.45;\n cursor: not-allowed;\n}\nlabel.file-btn input {\n display: none;\n}\n.header-site-link {\n color: var(--muted);\n font-size: 0.875rem;\n text-decoration: none;\n white-space: nowrap;\n}\n.header-site-link:hover {\n color: var(--accent);\n text-decoration: underline;\n}\n.header-site-link:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n border-radius: 0.125rem;\n}\n"; + //#endregion + //#region src/components/toolbar/toolbar.html?raw + var toolbar_default = "
\n
\n \n Views\n \n \n
\n \n \n \n \n \n \n doclang.ai\n
\n"; + //#endregion + //#region src/components/toolbar/toolbar.ts + /** — header toolbar (Views menu, file open, demo, site link) */ + var DoclangToolbar = class extends DoclangHTMLElement { + _toolbarOptionsBtn; + _toolbarOptionsPanel; + _toggleFilePane; + _toggleFilePaneLabel; + _togglePagePane; + _togglePagePaneLabel; + _toggleMarkupPane; + _toggleMarkupPaneLabel; + _toggleReadingPane; + _toggleReadingPaneLabel; + _resetPaneLayoutBtn; + _btnDemo; + _openFileBtn; + _inputArchive; + _panelOpen = false; + constructor() { + super(toolbar_default$1, toolbar_default); + this._toolbarOptionsBtn = this.q(".toolbar-options-btn"); + this._toolbarOptionsPanel = this.q(".toolbar-options-panel"); + this._toggleFilePaneLabel = this.q("label:has(.cb-file-pane)"); + this._toggleFilePane = this.q(".cb-file-pane"); + this._togglePagePaneLabel = this.q("label:has(.cb-page-pane)"); + this._togglePagePane = this.q(".cb-page-pane"); + this._toggleMarkupPaneLabel = this.q("label:has(.cb-markup-pane)"); + this._toggleMarkupPane = this.q(".cb-markup-pane"); + this._toggleReadingPaneLabel = this.q("label:has(.cb-reading-pane)"); + this._toggleReadingPane = this.q(".cb-reading-pane"); + this._resetPaneLayoutBtn = this.q(".toolbar-options-reset"); + this._btnDemo = this.q(".btn-demo"); + this._openFileBtn = this.q(".file-btn"); + this._inputArchive = this.q(".input-archive"); + this._wireEvents(); + } + get toggleFilePane() { + return this._toggleFilePane; + } + get toggleFilePaneLabel() { + return this._toggleFilePaneLabel; + } + get togglePagePane() { + return this._togglePagePane; + } + get togglePagePaneLabel() { + return this._togglePagePaneLabel; + } + get toggleMarkupPane() { + return this._toggleMarkupPane; + } + get toggleMarkupPaneLabel() { + return this._toggleMarkupPaneLabel; + } + get toggleReadingPane() { + return this._toggleReadingPane; + } + get toggleReadingPaneLabel() { + return this._toggleReadingPaneLabel; + } + get resetPaneLayoutBtn() { + return this._resetPaneLayoutBtn; + } + get toolbarOptionsBtn() { + return this._toolbarOptionsBtn; + } + get btnDemo() { + return this._btnDemo; + } + get openFileBtn() { + return this._openFileBtn; + } + get inputArchive() { + return this._inputArchive; + } + setOptionsOpen(open) { + this._panelOpen = open; + this._toolbarOptionsPanel.hidden = !open; + this._toolbarOptionsBtn.setAttribute("aria-expanded", String(open)); + } + closeOptionsIfOpen() { + if (this._panelOpen) this.setOptionsOpen(false); + } + _wireEvents() { + this._toolbarOptionsBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.setOptionsOpen(!this._panelOpen); + }); + document.addEventListener("click", (e) => { + if (!this._panelOpen) return; + const path = e.composedPath(); + if (path.includes(this._toolbarOptionsPanel) || path.includes(this._toolbarOptionsBtn)) return; + this.setOptionsOpen(false); + }); + this._btnDemo.addEventListener("click", () => this.dispatchEvent(new CustomEvent("doclang-load-demo", { + bubbles: true, + composed: true + }))); + this._inputArchive.addEventListener("change", (e) => { + const input = e.target; + this.dispatchEvent(new CustomEvent("doclang-open-files", { + bubbles: true, + composed: true, + detail: { files: [...input.files ?? []] } + })); + input.value = ""; + }); + this._toggleFilePane.addEventListener("change", () => this.dispatchEvent(new CustomEvent("doclang-toggle-pane", { + bubbles: true, + composed: true, + detail: { + pane: "file", + checked: this._toggleFilePane.checked + } + }))); + this._togglePagePane.addEventListener("change", () => this.dispatchEvent(new CustomEvent("doclang-toggle-pane", { + bubbles: true, + composed: true, + detail: { + pane: "page", + checked: this._togglePagePane.checked + } + }))); + this._toggleMarkupPane.addEventListener("change", () => this.dispatchEvent(new CustomEvent("doclang-toggle-pane", { + bubbles: true, + composed: true, + detail: { + pane: "markup", + checked: this._toggleMarkupPane.checked + } + }))); + this._toggleReadingPane.addEventListener("change", () => this.dispatchEvent(new CustomEvent("doclang-toggle-pane", { + bubbles: true, + composed: true, + detail: { + pane: "reading", + checked: this._toggleReadingPane.checked + } + }))); + this._resetPaneLayoutBtn.addEventListener("click", () => this.dispatchEvent(new CustomEvent("doclang-reset-pane-layout", { + bubbles: true, + composed: true + }))); + } + }; + customElements.define("doclang-toolbar", DoclangToolbar); + //#endregion //#region src/constants.ts var SUPPORTED_FILE_EXTENSIONS = [".dclx", ".dclg"]; var OPEN_FILE_HINT = `Open a DocLang file (${SUPPORTED_FILE_EXTENSIONS.join(", ")})`; @@ -143,6 +468,488 @@ }; var LAYOUT_STACK_BREAKPOINT_PX = 1200; //#endregion + //#region src/components/file-pane/file-pane.css?inline + var file_pane_default$1 = ":host {\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--border);\n min-height: 0;\n min-width: 0;\n overflow: hidden;\n}\n:host([hidden]) {\n display: none !important;\n}\n:host(.pane-layout-last) {\n border-right: none;\n}\n.pane-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.35rem;\n box-sizing: border-box;\n height: 2.125rem;\n padding: 0 0.35rem;\n font-size: 0.75rem;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n color: var(--muted);\n border-bottom: 1px solid var(--border);\n background: var(--panel);\n}\n.pane-header-title {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n padding-inline: calc(0.75rem - 0.35rem);\n}\n.file-pane-close-all {\n appearance: none;\n border: 1px solid transparent;\n border-radius: 0.25rem;\n background: transparent;\n color: var(--muted);\n font: inherit;\n font-size: 0.625rem;\n font-weight: 600;\n line-height: 1;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n padding: 0 0.15rem;\n height: 1.25rem;\n cursor: pointer;\n white-space: nowrap;\n flex-shrink: 0;\n transition:\n color 0.12s ease,\n background 0.12s ease,\n border-color 0.12s ease;\n}\n.file-pane-close-all:hover {\n color: var(--text);\n background: color-mix(in srgb, var(--text) 5%, transparent);\n}\n.file-pane-close-all:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n}\n.pane-body {\n flex: 1;\n min-height: 0;\n overflow: auto;\n padding: 0.375rem var(--file-pane-pad-x, 0.5rem);\n min-width: 0;\n}\n.file-view-list {\n list-style: none;\n margin: 0;\n padding: 0;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n width: 100%;\n}\n.file-view-list > li {\n width: 100%;\n max-width: 100%;\n}\n.file-view-item {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 0.35rem;\n width: 100%;\n max-width: 100%;\n box-sizing: border-box;\n border: none;\n border-radius: 0;\n background: transparent;\n color: var(--text);\n font: inherit;\n font-size: 0.8125rem;\n line-height: 1.35;\n text-align: center;\n padding: 0.5rem var(--file-item-pad-x, 0.35rem);\n cursor: pointer;\n}\n.file-view-thumb-wrap {\n position: relative;\n flex-shrink: 0;\n}\n.file-view-close {\n position: absolute;\n top: -0.25rem;\n right: -0.25rem;\n appearance: none;\n display: flex;\n align-items: center;\n justify-content: center;\n width: 1rem;\n height: 1rem;\n padding: 0;\n border: 1px solid var(--border);\n border-radius: 50%;\n background: var(--bg);\n color: var(--muted);\n font: inherit;\n font-size: 0.75rem;\n line-height: 1;\n cursor: pointer;\n box-shadow: 0 1px 2px rgb(0 0 0 / 8%);\n z-index: 1;\n}\n.file-view-close:hover {\n color: var(--text);\n background: var(--panel);\n}\n.file-view-close:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n.file-view-thumb {\n flex-shrink: 0;\n width: var(--file-thumb-size, 3.75rem);\n height: var(--file-thumb-size, 3.75rem);\n display: flex;\n align-items: center;\n justify-content: center;\n background: var(--placeholder-bg);\n border: 1px solid var(--border);\n border-radius: 0.25rem;\n overflow: hidden;\n}\n.file-view-thumb img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.file-view-thumb-placeholder {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 100%;\n height: 100%;\n color: var(--muted);\n}\n.file-view-thumb-placeholder svg {\n display: block;\n width: 1.875rem;\n height: 1.875rem;\n}\n.file-view-label {\n width: 100%;\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.file-view-item:hover {\n background: var(--markup-hover);\n}\n.file-view-item.is-active {\n background: var(--markup-selected);\n color: var(--accent);\n font-weight: 500;\n}\n.file-view-item:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: -2px;\n}\n"; + //#endregion + //#region src/components/file-pane/file-pane.html?raw + var file_pane_default = "
\n Files\n \n Clear\n \n
\n
\n"; + //#endregion + //#region src/components/file-pane/file-pane.ts + /** — file list sidebar */ + var DoclangFilePane = class extends DoclangHTMLElement { + _closeAllBtn; + _body; + constructor() { + super(file_pane_default$1, file_pane_default); + this.classList.add("pane", "pane-file"); + this._closeAllBtn = this.q(".file-pane-close-all"); + this._body = this.q(".pane-body"); + this._closeAllBtn.addEventListener("click", () => this.dispatchEvent(new CustomEvent("doclang-file-pane-close-all", { + bubbles: true, + composed: true + }))); + } + get section() { + return this; + } + get body() { + return this._body; + } + setVisible(visible) { + this.hidden = !visible; + } + setLastPane(isLast) { + this.classList.toggle("pane-layout-last", isLast); + } + renderFiles(entries) { + this._body.replaceChildren(); + this._closeAllBtn.hidden = entries.length === 0; + if (!entries.length) return; + const list = document.createElement("ul"); + list.className = "file-view-list"; + list.setAttribute("role", "listbox"); + list.setAttribute("aria-label", "Open files"); + entries.forEach((entry, index) => { + const item = document.createElement("li"); + const card = document.createElement("div"); + card.className = "file-view-item"; + if (entry.isActive) { + card.classList.add("is-active"); + card.setAttribute("aria-selected", "true"); + } else card.setAttribute("aria-selected", "false"); + card.title = entry.label; + card.tabIndex = 0; + card.setAttribute("role", "option"); + const thumbWrap = document.createElement("div"); + thumbWrap.className = "file-view-thumb-wrap"; + const closeBtn = document.createElement("button"); + closeBtn.type = "button"; + closeBtn.className = "file-view-close"; + closeBtn.setAttribute("aria-label", `Close ${entry.label}`); + closeBtn.textContent = "×"; + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("doclang-file-close", { + bubbles: true, + composed: true, + detail: { index } + })); + }); + const thumb = document.createElement("span"); + thumb.className = "file-view-thumb"; + thumb.setAttribute("aria-hidden", "true"); + if (entry.thumbnailUrl) { + const img = document.createElement("img"); + img.src = entry.thumbnailUrl; + img.alt = ""; + thumb.appendChild(img); + } else { + const ph = document.createElement("span"); + ph.className = "file-view-thumb-placeholder"; + ph.innerHTML = FILE_THUMB_PLACEHOLDER_SVG; + thumb.appendChild(ph); + } + thumbWrap.append(thumb, closeBtn); + const label = document.createElement("span"); + label.className = "file-view-label"; + label.textContent = entry.label; + card.append(thumbWrap, label); + card.addEventListener("click", (e) => { + if (e.target.closest(".file-view-close")) return; + this.dispatchEvent(new CustomEvent("doclang-file-select", { + bubbles: true, + composed: true, + detail: { index } + })); + }); + card.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + this.dispatchEvent(new CustomEvent("doclang-file-select", { + bubbles: true, + composed: true, + detail: { index } + })); + } + }); + item.appendChild(card); + list.appendChild(item); + }); + this._body.appendChild(list); + } + }; + customElements.define("doclang-file-pane", DoclangFilePane); + //#endregion + //#region src/components/markup-pane/markup-pane.css?inline + var markup_pane_default$1 = ":host {\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--border);\n min-height: 0;\n min-width: 0;\n}\n:host([hidden]) {\n display: none !important;\n}\n:host(.pane-layout-last) {\n border-right: none;\n}\n.pane-header {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n box-sizing: border-box;\n height: 2.125rem;\n padding: 0 0.75rem;\n font-size: 0.75rem;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n color: var(--muted);\n border-bottom: 1px solid var(--border);\n background: var(--panel);\n}\n.pane-body {\n flex: 1;\n min-height: 0;\n overflow: auto;\n padding: 0;\n background: var(--markup-bg);\n color: var(--markup-fg);\n}\n.pane-body .placeholder {\n margin: 1rem;\n}\n.placeholder {\n display: flex;\n align-items: center;\n justify-content: center;\n min-height: 12rem;\n color: var(--muted);\n font-style: italic;\n background: var(--placeholder-bg);\n border: 1px dashed var(--border);\n border-radius: 0.5rem;\n padding: 2rem;\n text-align: center;\n}\npre.markup {\n margin: 0;\n font-family: var(--font-mono);\n font-size: 0.8125rem;\n line-height: 1.5;\n white-space: pre-wrap;\n word-break: break-word;\n}\n.markup {\n margin: 0;\n padding: 0.75rem 1rem 1rem;\n font-family: var(--font-mono);\n font-size: 0.8125rem;\n line-height: 1.55;\n tab-size: 2;\n --markup-indent: 2ch;\n --markup-gutter-width: 1rem;\n}\n.markup-ghost-tag-part {\n opacity: 0.35;\n cursor: help;\n}\n.head-tooltip {\n border-collapse: collapse;\n width: 100%;\n font-size: 0.6875rem;\n line-height: 1.4;\n}\n.head-tooltip th {\n padding: 0.1rem 0.55rem 0.1rem 0;\n color: var(--muted);\n font-weight: 600;\n text-align: left;\n vertical-align: top;\n white-space: nowrap;\n}\n.head-tooltip td {\n padding: 0.1rem 0;\n vertical-align: top;\n word-break: break-word;\n}\n.head-tooltip .head-default {\n color: var(--muted);\n font-style: italic;\n white-space: nowrap;\n}\n.markup-el {\n cursor: pointer;\n border-radius: 0.2rem;\n}\n.markup-el:hover {\n background: var(--markup-hover);\n}\n.markup-el.selected {\n background: var(--markup-selected);\n box-shadow: inset 0 0 0 1px var(--accent);\n}\n.markup-line {\n white-space: pre-wrap;\n word-break: break-word;\n cursor: pointer;\n padding: 0 0.25rem;\n border-radius: 0.15rem;\n}\n.markup-line-content {\n display: flex;\n align-items: baseline;\n min-width: 0;\n padding-left: calc(var(--markup-depth, 0) * var(--markup-indent));\n}\n.markup-gutter {\n flex: 0 0 var(--markup-gutter-width);\n width: var(--markup-gutter-width);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.markup-line-body {\n flex: 1 1 auto;\n min-width: 0;\n}\n.markup-fold-toggle {\n appearance: none;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: var(--markup-gutter-width);\n height: 1rem;\n margin: 0;\n padding: 0;\n border: none;\n border-radius: 0.15rem;\n background: transparent;\n color: var(--muted);\n cursor: pointer;\n flex-shrink: 0;\n}\n.markup-fold-toggle::before {\n content: '';\n display: block;\n width: 0;\n height: 0;\n border-top: 4px solid transparent;\n border-bottom: 4px solid transparent;\n border-left: 5px solid currentColor;\n transform: rotate(90deg);\n transition: transform 0.12s ease;\n}\n.markup-el-foldable.markup-collapsed > .markup-line-open .markup-fold-toggle::before {\n transform: rotate(0deg);\n}\n.markup-fold-toggle:hover {\n background: var(--markup-hover);\n color: var(--markup-fg);\n}\n.markup-fold-suffix {\n display: none;\n}\n.markup-el-foldable.markup-collapsed > .markup-line-open .markup-fold-suffix {\n display: inline;\n}\n.markup-el-foldable.markup-collapsed > .markup-fold-body {\n display: none;\n}\n.xml-tag {\n color: var(--xml-tag);\n}\n.xml-bracket {\n color: var(--xml-bracket);\n}\n.xml-attr-name {\n color: var(--xml-attr-name);\n}\n.xml-attr-value {\n color: var(--xml-attr-value);\n}\n.xml-attr-value-truncatable {\n display: inline;\n}\n.xml-attr-value-chip {\n appearance: none;\n display: inline-flex;\n align-items: center;\n gap: 0.3em;\n margin: 0 0 0 0.35em;\n padding: 0.08em 0.45em 0.08em 0.5em;\n border: 1px solid var(--border);\n border-radius: 0.25rem;\n background: var(--placeholder-bg);\n color: var(--muted);\n font-family: var(--font-ui, system-ui, sans-serif);\n font-size: 0.72em;\n font-weight: 600;\n line-height: 1.35;\n cursor: pointer;\n vertical-align: baseline;\n white-space: nowrap;\n}\n.xml-attr-value-chip::after {\n content: '';\n width: 0;\n height: 0;\n border-top: 0.28em solid currentColor;\n border-left: 0.22em solid transparent;\n border-right: 0.22em solid transparent;\n opacity: 0.75;\n transition: transform 0.12s ease;\n}\n.xml-attr-value-chip[aria-expanded='true']::after {\n transform: rotate(180deg);\n}\n.xml-attr-value-chip:hover,\n.xml-attr-value-chip[aria-expanded='true'] {\n border-color: var(--accent);\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 8%, var(--placeholder-bg));\n}\n.markup-embedded-uri-panel {\n cursor: text;\n}\n.markup-embedded-uri-panel:hover {\n background: transparent;\n}\n.markup-embedded-uri-panel-body {\n margin: 0.1rem 0 0.25rem;\n padding: 0.45rem 0.6rem;\n max-height: 8rem;\n overflow: auto;\n color: var(--xml-attr-value);\n word-break: break-all;\n background: var(--placeholder-bg);\n border: 1px solid var(--border);\n border-radius: 0.25rem;\n user-select: text;\n}\n.xml-text {\n color: var(--xml-text);\n}\n.xml-cdata {\n color: var(--xml-cdata);\n}\n.xml-cdata-delimiter {\n color: var(--xml-cdata-delimiter);\n}\n"; + //#endregion + //#region src/components/markup-pane/markup-pane.html?raw + var markup_pane_default = "
DocLang
\n
\n"; + //#endregion + //#region src/components/markup-pane/markup-pane.ts + /** — DocLang XML markup view */ + var DoclangMarkupPane = class extends DoclangHTMLElement { + _body; + constructor() { + super(markup_pane_default$1, markup_pane_default); + this.classList.add("pane", "pane-markup"); + this._body = this.q(".pane-body"); + } + get section() { + return this; + } + get body() { + return this._body; + } + setVisible(visible) { + this.hidden = !visible; + } + setLastPane(isLast) { + this.classList.toggle("pane-layout-last", isLast); + } + }; + customElements.define("doclang-markup-pane", DoclangMarkupPane); + //#endregion + //#region src/components/page-view-pane/page-view-pane.css?inline + var page_view_pane_default$1 = ":host {\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--border);\n min-height: 0;\n min-width: 0;\n}\n:host([hidden]) {\n display: none !important;\n}\n:host(.pane-layout-last) {\n border-right: none;\n}\n.pane-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.5rem;\n box-sizing: border-box;\n height: 2.125rem;\n padding: 0 0.75rem;\n font-size: 0.75rem;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n color: var(--muted);\n border-bottom: 1px solid var(--border);\n background: var(--panel);\n}\n.pane-header-title {\n min-width: 0;\n}\n.pane-page-controls {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n flex-shrink: 0;\n}\n.page-zoom-control {\n display: flex;\n align-items: center;\n gap: 0.35rem;\n user-select: none;\n}\n.page-zoom-control-label {\n font-size: 0.625rem;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n color: var(--muted);\n cursor: pointer;\n}\n.page-zoom-control input[type='range'] {\n width: 5.5rem;\n height: 1rem;\n margin: 0;\n cursor: pointer;\n}\n.page-zoom-reset {\n appearance: none;\n border: 1px solid var(--border);\n background: var(--bg);\n color: var(--muted);\n border-radius: 0.375rem;\n padding: 0 0.45rem;\n height: 1.25rem;\n font: inherit;\n font-size: 0.6875rem;\n font-weight: 600;\n font-variant-numeric: tabular-nums;\n line-height: 1;\n cursor: pointer;\n min-width: 2.75rem;\n}\n.page-zoom-reset:not(:disabled):hover {\n border-color: var(--accent);\n color: var(--text);\n}\n.page-zoom-reset:disabled {\n opacity: 0.55;\n cursor: default;\n}\n.pane-settings-toggle {\n appearance: none;\n border: 1px solid transparent;\n background: transparent;\n color: var(--muted);\n font-weight: 500;\n cursor: pointer;\n white-space: nowrap;\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n transition:\n color 0.12s ease,\n background 0.12s ease,\n border-color 0.12s ease;\n border-radius: 0.25rem;\n padding: 0 0.28rem 0 0.4rem;\n height: 1.25rem;\n font-size: 0.625rem;\n font-weight: 600;\n line-height: 1;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n font: inherit;\n}\n.pane-settings-toggle::after {\n content: '';\n flex-shrink: 0;\n width: 0.26rem;\n height: 0.26rem;\n margin-left: 0.22rem;\n margin-top: -0.1em;\n border-right: 1.5px solid currentColor;\n border-bottom: 1.5px solid currentColor;\n transform: rotate(45deg);\n opacity: 0.65;\n}\n.pane-settings-toggle:hover {\n color: var(--text);\n background: color-mix(in srgb, var(--text) 5%, transparent);\n}\n.pane-settings-toggle[aria-expanded='true'] {\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 10%, var(--panel));\n border-color: color-mix(in srgb, var(--accent) 22%, transparent);\n}\n.pane-settings-toggle:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n}\n.pane-page-layout {\n position: relative;\n flex: 1;\n min-height: 0;\n min-width: 0;\n display: flex;\n flex-direction: column;\n}\n.pane-body {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 0;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n padding: 0.75rem;\n}\n.viewer-settings-layer {\n position: absolute;\n inset: 0;\n z-index: 5;\n}\n.viewer-settings-layer[hidden] {\n display: none;\n}\n.viewer-settings-scrim {\n position: absolute;\n inset: 0;\n z-index: 0;\n appearance: none;\n border: none;\n margin: 0;\n padding: 0;\n background: color-mix(in srgb, var(--bg) 28%, transparent);\n cursor: default;\n}\n.viewer-settings {\n position: absolute;\n top: 0.5rem;\n right: 0.5rem;\n z-index: 1;\n width: 13rem;\n max-height: calc(100% - 1rem);\n display: flex;\n flex-direction: column;\n min-height: 0;\n border: 1px solid var(--border);\n border-radius: 0.5rem;\n background: var(--panel);\n box-shadow: 0 4px 20px rgba(0, 0, 0, 0.14);\n overflow: hidden;\n}\n.viewer-settings-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.5rem;\n padding: 0.5rem 0.75rem;\n border-bottom: 1px solid var(--border);\n}\n.viewer-settings-title {\n margin: 0;\n font-size: 0.6875rem;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n color: var(--muted);\n}\n.viewer-settings-close {\n appearance: none;\n border: none;\n background: transparent;\n color: var(--muted);\n width: 1.5rem;\n height: 1.5rem;\n border-radius: 0.25rem;\n font-size: 1.1rem;\n line-height: 1;\n cursor: pointer;\n}\n.viewer-settings-close:hover {\n color: var(--text);\n background: var(--bg);\n}\n.viewer-settings-body {\n padding: 0.75rem;\n overflow: auto;\n}\n.settings-option {\n display: flex;\n align-items: flex-start;\n gap: 0.5rem;\n font-size: 0.875rem;\n cursor: pointer;\n user-select: none;\n}\n.settings-option-primary + .settings-subgroup {\n margin-top: 0.65rem;\n}\n.settings-option-primary {\n font-weight: 600;\n}\n.settings-subgroup {\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n padding-left: 0.85rem;\n}\n.settings-reading-order-group {\n gap: 0.35rem;\n padding-left: 1.6rem;\n}\n.settings-option-nested {\n padding-left: 0;\n}\n.settings-option-sub {\n font-size: 0.8125rem;\n color: var(--muted);\n}\n.settings-option-disabled {\n opacity: 0.45;\n cursor: not-allowed;\n}\n.settings-option-disabled input {\n cursor: not-allowed;\n}\n.settings-option input {\n margin: 0.15rem 0 0;\n flex-shrink: 0;\n cursor: pointer;\n}\n.page-view-port {\n display: flex;\n overflow: auto;\n flex: 1 1 auto;\n width: 100%;\n height: 100%;\n min-width: 0;\n min-height: 0;\n scrollbar-gutter: stable;\n}\n.pane-body.can-pan {\n cursor: grab;\n}\n.pane-body.is-panning {\n cursor: grabbing;\n user-select: none;\n}\n.pane-body:focus {\n outline: none;\n}\n.pane-body:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: -2px;\n}\n.placeholder {\n display: flex;\n align-items: center;\n justify-content: center;\n min-height: 12rem;\n color: var(--muted);\n font-style: italic;\n background: var(--placeholder-bg);\n border: 1px dashed var(--border);\n border-radius: 0.5rem;\n padding: 2rem;\n text-align: center;\n}\n.page-view {\n position: relative;\n display: block;\n width: fit-content;\n flex-shrink: 0;\n margin: auto;\n line-height: 0;\n border: 1px solid var(--border);\n border-radius: 0.25rem;\n overflow: hidden;\n}\n.page-view img {\n display: block;\n width: auto;\n height: auto;\n max-width: none;\n max-height: none;\n border: none;\n border-radius: 0;\n}\n.page-view img:not([data-layout-ready]) {\n opacity: 0;\n}\n.page-view svg.overlay {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n pointer-events: auto;\n overflow: hidden;\n}\n.overlay rect.bbox.bbox-hidden,\n.overlay .overlay-badge.bbox-hidden {\n display: none;\n}\n.overlay rect.bbox {\n stroke-width: 2;\n vector-effect: non-scaling-stroke;\n pointer-events: none;\n}\n.overlay rect.bbox.selected {\n fill: color-mix(\n in srgb,\n var(--kind-stroke, var(--accent)) 30%,\n transparent\n ) !important;\n stroke-width: 3.5;\n filter: drop-shadow(\n 0 0 3px color-mix(in srgb, var(--kind-stroke, var(--accent)) 60%, transparent)\n );\n}\n.overlay rect.bbox.related:not(.selected) {\n fill: color-mix(\n in srgb,\n var(--kind-stroke, var(--accent)) 30%,\n transparent\n ) !important;\n}\n.overlay rect.bbox.layer-furniture,\n.overlay rect.bbox.layer-background {\n fill: url(#layer-hatch) !important;\n stroke: color-mix(in srgb, var(--kind-stroke, var(--muted)) 50%, transparent);\n}\n.overlay .layer-hatch-stripe {\n fill: var(--muted);\n fill-opacity: 0.06;\n}\n.overlay rect.bbox.layer-furniture.selected,\n.overlay rect.bbox.layer-background.selected {\n fill: color-mix(\n in srgb,\n var(--kind-stroke, var(--accent)) 22%,\n transparent\n ) !important;\n}\n.overlay rect.bbox.layer-furniture.related:not(.selected),\n.overlay rect.bbox.layer-background.related:not(.selected) {\n fill: color-mix(\n in srgb,\n var(--kind-stroke, var(--accent)) 18%,\n transparent\n ) !important;\n}\n.overlay .bbox-text {\n fill: color-mix(in srgb, var(--kind-text) 14%, transparent);\n stroke: var(--kind-text);\n}\n.overlay .bbox-heading {\n fill: color-mix(in srgb, var(--kind-heading) 14%, transparent);\n stroke: var(--kind-heading);\n}\n.overlay .bbox-list,\n.overlay .bbox-ldiv {\n fill: color-mix(in srgb, var(--kind-list) 14%, transparent);\n stroke: var(--kind-list);\n}\n.overlay .bbox-table {\n fill: color-mix(in srgb, var(--kind-table) 14%, transparent);\n stroke: var(--kind-table);\n}\n.overlay .bbox-index {\n fill: color-mix(in srgb, var(--kind-index) 14%, transparent);\n stroke: var(--kind-index);\n}\n.overlay .bbox-formula {\n fill: color-mix(in srgb, var(--kind-formula) 14%, transparent);\n stroke: var(--kind-formula);\n}\n.overlay .bbox-code {\n fill: color-mix(in srgb, var(--kind-code) 16%, transparent);\n stroke: var(--kind-code);\n}\n.overlay .bbox-picture {\n fill: color-mix(in srgb, var(--kind-picture) 14%, transparent);\n stroke: var(--kind-picture);\n}\n.overlay .bbox-group {\n fill: color-mix(in srgb, var(--kind-group) 14%, transparent);\n stroke: var(--kind-group);\n}\n.overlay .bbox-footnote {\n fill: color-mix(in srgb, var(--kind-footnote) 14%, transparent);\n stroke: var(--kind-footnote);\n}\n.overlay .bbox-page_header,\n.overlay .bbox-page_footer {\n fill: color-mix(in srgb, var(--kind-page_header) 14%, transparent);\n stroke: var(--kind-page_header);\n}\n.overlay .bbox-caption {\n fill: color-mix(in srgb, var(--kind-caption) 14%, transparent);\n stroke: var(--kind-caption);\n}\n.overlay line.caption-link {\n color: var(--kind-caption);\n stroke: currentColor;\n stroke-width: 1.5;\n vector-effect: non-scaling-stroke;\n pointer-events: none;\n fill: none;\n}\n.overlay line.caption-link.bbox-hidden {\n display: none;\n}\n.overlay line.xref-link {\n color: var(--kind-footnote);\n stroke: currentColor;\n stroke-width: 1.5;\n vector-effect: non-scaling-stroke;\n pointer-events: none;\n fill: none;\n}\n.overlay line.xref-link.bbox-hidden {\n display: none;\n}\n.overlay .fragment-link {\n pointer-events: none;\n}\n.overlay .fragment-link.bbox-hidden {\n display: none;\n}\n.overlay .fragment-link-path {\n color: var(--overlay-fragment);\n fill: none;\n stroke: currentColor;\n stroke-width: 1.5;\n vector-effect: non-scaling-stroke;\n}\n.overlay .fragment-link-path-dashed {\n stroke-dasharray: 6 4;\n}\n.overlay .fragment-nav {\n pointer-events: none;\n}\n.overlay .fragment-nav.bbox-hidden {\n display: none;\n}\n.overlay .fragment-nav-btn {\n pointer-events: auto;\n cursor: pointer;\n}\n.overlay .fragment-nav-btn-disabled {\n pointer-events: none;\n cursor: default;\n opacity: 0.35;\n}\n.overlay .fragment-nav-btn-bg {\n fill: var(--panel);\n stroke: var(--overlay-fragment);\n stroke-width: 1;\n vector-effect: non-scaling-stroke;\n}\n.overlay .fragment-nav-btn-label {\n fill: var(--overlay-fragment);\n font-family: var(--font-ui);\n font-weight: 700;\n text-anchor: middle;\n dominant-baseline: central;\n pointer-events: none;\n}\n.overlay .fragment-nav-btn:not(.fragment-nav-btn-disabled):hover .fragment-nav-btn-bg {\n fill: color-mix(in srgb, var(--overlay-fragment) 12%, var(--panel));\n}\n.overlay .fragment-link-label {\n fill: var(--overlay-fragment);\n font-family: var(--font-ui);\n font-weight: 600;\n font-style: italic;\n text-anchor: middle;\n dominant-baseline: middle;\n paint-order: stroke fill;\n stroke: var(--panel);\n stroke-width: 3px;\n}\n.overlay .reading-order-step {\n color: var(--overlay-reading-order);\n stroke: currentColor;\n stroke-width: 1.5;\n stroke-dasharray: 5 4;\n vector-effect: non-scaling-stroke;\n pointer-events: none;\n fill: none;\n}\n.overlay .reading-order-step.bbox-hidden,\n.overlay .reading-order-badge.bbox-hidden {\n display: none;\n}\n.overlay .overlay-badge {\n pointer-events: none;\n}\n.overlay .overlay-badge-bg {\n fill-opacity: 1;\n stroke: none;\n}\n.reading-order-badge .overlay-badge-bg {\n fill: var(--overlay-reading-order);\n}\n.overlay .element-badge {\n opacity: 0.8;\n pointer-events: auto;\n cursor: help;\n}\n.overlay .element-badge .overlay-badge-bg {\n fill: var(--kind-stroke, var(--accent));\n}\n.overlay .overlay-badge-label {\n fill: #ffffff;\n fill-opacity: 1;\n font-family: var(--font-ui);\n font-weight: 700;\n text-anchor: middle;\n dominant-baseline: central;\n pointer-events: none;\n}\n.overlay .bbox-field {\n fill: color-mix(in srgb, var(--kind-field) 14%, transparent);\n stroke: var(--kind-field);\n}\n.overlay .bbox-default {\n fill: color-mix(in srgb, var(--kind-default) 12%, transparent);\n stroke: var(--kind-default);\n}\n@media (prefers-color-scheme: dark) {\n .overlay .bbox-text {\n fill: color-mix(in srgb, var(--kind-text) 20%, transparent);\n }\n .overlay .bbox-heading {\n fill: color-mix(in srgb, var(--kind-heading) 20%, transparent);\n }\n .overlay .bbox-list,\n .overlay .bbox-ldiv {\n fill: color-mix(in srgb, var(--kind-list) 20%, transparent);\n }\n .overlay .bbox-table {\n fill: color-mix(in srgb, var(--kind-table) 18%, transparent);\n }\n .overlay .bbox-index {\n fill: color-mix(in srgb, var(--kind-index) 18%, transparent);\n }\n .overlay .bbox-formula {\n fill: color-mix(in srgb, var(--kind-formula) 18%, transparent);\n }\n .overlay .bbox-code {\n fill: color-mix(in srgb, var(--kind-code) 20%, transparent);\n }\n .overlay .bbox-picture {\n fill: color-mix(in srgb, var(--kind-picture) 20%, transparent);\n }\n .overlay .bbox-group {\n fill: color-mix(in srgb, var(--kind-group) 20%, transparent);\n }\n .overlay .bbox-footnote {\n fill: color-mix(in srgb, var(--kind-footnote) 18%, transparent);\n }\n .overlay .bbox-page_header,\n .overlay .bbox-page_footer {\n fill: color-mix(in srgb, var(--kind-page_header) 18%, transparent);\n }\n .overlay .bbox-caption {\n fill: color-mix(in srgb, var(--kind-caption) 18%, transparent);\n }\n .overlay .bbox-field {\n fill: color-mix(in srgb, var(--kind-field) 18%, transparent);\n }\n .overlay .bbox-default {\n fill: color-mix(in srgb, var(--kind-default) 18%, transparent);\n }\n}\n"; + //#endregion + //#region src/components/page-view-pane/page-view-pane.html?raw + var page_view_pane_default = "
\n Original page\n
\n \n \n
\n
\n
\n
\n \n
\n"; + //#endregion + //#region src/components/page-view-pane/page-view-pane.ts + /** — page image with zoom and overlay settings panel */ + var DoclangPageViewPane = class extends DoclangHTMLElement { + _body; + _settingsToggle; + _settingsLayer; + _settingsScrim; + _settingsClose; + _zoomLabel; + _zoomInput; + _zoomReset; + _showAllBboxes; + _showAllBboxesLabel; + _layoutSubtoggles; + _showLayoutBadges; + _showLayoutBadgesLabel; + _showReadingOrder; + _showReadingOrderLabel; + _readingOrderSubtoggles; + _readingOrderArrows; + _readingOrderArrowsLabel; + _readingOrderGlobal; + _readingOrderGlobalLabel; + _showPictureContents; + _showPictureContentsLabel; + _showTableContents; + _showTableContentsLabel; + _showFragmentLinks; + _showFragmentLinksLabel; + _showXrefLinks; + _showXrefLinksLabel; + _showCaptionLinks; + _showCaptionLinksLabel; + constructor() { + super(page_view_pane_default$1, page_view_pane_default); + this.classList.add("pane", "pane-page-view"); + this._body = this.q(".pane-body"); + this._zoomLabel = this.q(".page-zoom-control"); + this._zoomInput = this.q(".zoom-input"); + this._zoomReset = this.q(".page-zoom-reset"); + this._settingsToggle = this.q(".pane-settings-toggle"); + this._settingsLayer = this.q(".viewer-settings-layer"); + this._settingsScrim = this.q(".viewer-settings-scrim"); + this._settingsClose = this.q(".viewer-settings-close"); + this._showAllBboxesLabel = this.q("label:has(.cb-all-bboxes)"); + this._showAllBboxes = this.q(".cb-all-bboxes"); + this._layoutSubtoggles = this.q(".settings-subgroup"); + this._showReadingOrderLabel = this.q("label:has(.cb-reading-order)"); + this._showReadingOrder = this.q(".cb-reading-order"); + this._readingOrderSubtoggles = this.q(".settings-reading-order-group"); + this._readingOrderArrowsLabel = this.q("label:has(.cb-reading-order-arrows)"); + this._readingOrderArrows = this.q(".cb-reading-order-arrows"); + this._readingOrderGlobalLabel = this.q("label:has(.cb-reading-order-global)"); + this._readingOrderGlobal = this.q(".cb-reading-order-global"); + this._showPictureContentsLabel = this.q("label:has(.cb-picture-contents)"); + this._showPictureContents = this.q(".cb-picture-contents"); + this._showTableContentsLabel = this.q("label:has(.cb-table-contents)"); + this._showTableContents = this.q(".cb-table-contents"); + this._showFragmentLinksLabel = this.q("label:has(.cb-fragment-links)"); + this._showFragmentLinks = this.q(".cb-fragment-links"); + this._showXrefLinksLabel = this.q("label:has(.cb-xref-links)"); + this._showXrefLinks = this.q(".cb-xref-links"); + this._showCaptionLinksLabel = this.q("label:has(.cb-caption-links)"); + this._showCaptionLinks = this.q(".cb-caption-links"); + this._showLayoutBadgesLabel = this.q("label:has(.cb-layout-badges)"); + this._showLayoutBadges = this.q(".cb-layout-badges"); + this._wireEvents(); + } + get section() { + return this; + } + get body() { + return this._body; + } + get settingsToggle() { + return this._settingsToggle; + } + get settingsLayer() { + return this._settingsLayer; + } + get zoomLabel() { + return this._zoomLabel; + } + get zoomInput() { + return this._zoomInput; + } + get zoomReset() { + return this._zoomReset; + } + get showAllBboxes() { + return this._showAllBboxes; + } + get showAllBboxesLabel() { + return this._showAllBboxesLabel; + } + get showLayoutBadges() { + return this._showLayoutBadges; + } + get showLayoutBadgesLabel() { + return this._showLayoutBadgesLabel; + } + get showReadingOrder() { + return this._showReadingOrder; + } + get showReadingOrderLabel() { + return this._showReadingOrderLabel; + } + get readingOrderArrows() { + return this._readingOrderArrows; + } + get readingOrderArrowsLabel() { + return this._readingOrderArrowsLabel; + } + get readingOrderGlobal() { + return this._readingOrderGlobal; + } + get readingOrderGlobalLabel() { + return this._readingOrderGlobalLabel; + } + get showPictureContents() { + return this._showPictureContents; + } + get showPictureContentsLabel() { + return this._showPictureContentsLabel; + } + get showTableContents() { + return this._showTableContents; + } + get showTableContentsLabel() { + return this._showTableContentsLabel; + } + get showFragmentLinks() { + return this._showFragmentLinks; + } + get showFragmentLinksLabel() { + return this._showFragmentLinksLabel; + } + get showXrefLinks() { + return this._showXrefLinks; + } + get showXrefLinksLabel() { + return this._showXrefLinksLabel; + } + get showCaptionLinks() { + return this._showCaptionLinks; + } + get showCaptionLinksLabel() { + return this._showCaptionLinksLabel; + } + get layoutSubtoggles() { + return this._layoutSubtoggles; + } + get readingOrderSubtoggles() { + return this._readingOrderSubtoggles; + } + setVisible(visible) { + this.hidden = !visible; + } + setLastPane(isLast) { + this.classList.toggle("pane-layout-last", isLast); + } + setSettingsOpen(open) { + this._settingsLayer.hidden = !open; + this._settingsToggle.setAttribute("aria-expanded", String(open)); + } + _wireEvents() { + this._settingsToggle.addEventListener("click", () => this.dispatchEvent(new CustomEvent("doclang-page-settings-toggle", { + bubbles: true, + composed: true + }))); + this._settingsClose.addEventListener("click", () => this.dispatchEvent(new CustomEvent("doclang-page-settings-close", { + bubbles: true, + composed: true + }))); + this._settingsScrim.addEventListener("click", () => this.dispatchEvent(new CustomEvent("doclang-page-settings-close", { + bubbles: true, + composed: true + }))); + this._zoomInput.addEventListener("input", () => this.dispatchEvent(new CustomEvent("doclang-zoom-change", { + bubbles: true, + composed: true, + detail: { value: Number(this._zoomInput.value) } + }))); + this._zoomReset.addEventListener("click", () => this.dispatchEvent(new CustomEvent("doclang-zoom-reset", { + bubbles: true, + composed: true + }))); + for (const [el, name] of [ + [this._showAllBboxes, "doclang-show-all-bboxes"], + [this._showLayoutBadges, "doclang-show-layout-badges"], + [this._showReadingOrder, "doclang-show-reading-order"], + [this._readingOrderArrows, "doclang-reading-order-arrows"], + [this._readingOrderGlobal, "doclang-reading-order-global"], + [this._showPictureContents, "doclang-show-picture-contents"], + [this._showTableContents, "doclang-show-table-contents"], + [this._showFragmentLinks, "doclang-show-fragment-links"], + [this._showXrefLinks, "doclang-show-xref-links"], + [this._showCaptionLinks, "doclang-show-caption-links"] + ]) el.addEventListener("change", () => this.dispatchEvent(new CustomEvent(name, { + bubbles: true, + composed: true, + detail: { checked: el.checked } + }))); + } + }; + customElements.define("doclang-page-view-pane", DoclangPageViewPane); + //#endregion + //#region src/components/reading-pane/reading-pane.css?inline + var reading_pane_default$1 = ":host {\n display: flex;\n flex-direction: column;\n border-right: 1px solid var(--border);\n min-height: 0;\n min-width: 0;\n}\n:host([hidden]) {\n display: none !important;\n}\n:host(.pane-layout-last) {\n border-right: none;\n}\n.pane-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n box-sizing: border-box;\n height: 2.125rem;\n padding: 0 0.75rem;\n font-size: 0.75rem;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n color: var(--muted);\n border-bottom: 1px solid var(--border);\n background: var(--panel);\n}\n.pane-header-title {\n min-width: 0;\n}\n.pane-settings-toggle {\n appearance: none;\n border: 1px solid transparent;\n background: transparent;\n color: var(--muted);\n font-weight: 500;\n cursor: pointer;\n white-space: nowrap;\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n transition:\n color 0.12s ease,\n background 0.12s ease,\n border-color 0.12s ease;\n border-radius: 0.25rem;\n padding: 0 0.28rem 0 0.4rem;\n height: 1.25rem;\n font-size: 0.625rem;\n font-weight: 600;\n line-height: 1;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n font: inherit;\n}\n.pane-settings-toggle::after {\n content: '';\n flex-shrink: 0;\n width: 0.26rem;\n height: 0.26rem;\n margin-left: 0.22rem;\n margin-top: -0.1em;\n border-right: 1.5px solid currentColor;\n border-bottom: 1.5px solid currentColor;\n transform: rotate(45deg);\n opacity: 0.65;\n}\n.pane-settings-toggle:hover {\n color: var(--text);\n background: color-mix(in srgb, var(--text) 5%, transparent);\n}\n.pane-settings-toggle[aria-expanded='true'] {\n color: var(--accent);\n background: color-mix(in srgb, var(--accent) 10%, var(--panel));\n border-color: color-mix(in srgb, var(--accent) 22%, transparent);\n}\n.pane-settings-toggle:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n}\n.pane-reading-layout {\n position: relative;\n flex: 1;\n min-height: 0;\n min-width: 0;\n display: flex;\n flex-direction: column;\n}\n.pane-body {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 0;\n overflow: auto;\n padding: 1rem;\n}\n.viewer-settings-layer {\n position: absolute;\n inset: 0;\n z-index: 5;\n}\n.viewer-settings-layer[hidden] {\n display: none;\n}\n.viewer-settings-scrim {\n position: absolute;\n inset: 0;\n z-index: 0;\n appearance: none;\n border: none;\n margin: 0;\n padding: 0;\n background: color-mix(in srgb, var(--bg) 28%, transparent);\n cursor: default;\n}\n.viewer-settings {\n position: absolute;\n top: 0.5rem;\n right: 0.5rem;\n z-index: 1;\n width: 13rem;\n max-height: calc(100% - 1rem);\n display: flex;\n flex-direction: column;\n min-height: 0;\n border: 1px solid var(--border);\n border-radius: 0.5rem;\n background: var(--panel);\n box-shadow: 0 4px 20px rgba(0, 0, 0, 0.14);\n overflow: hidden;\n}\n.viewer-settings-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.5rem;\n padding: 0.5rem 0.75rem;\n border-bottom: 1px solid var(--border);\n}\n.viewer-settings-title {\n margin: 0;\n font-size: 0.6875rem;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n color: var(--muted);\n}\n.viewer-settings-close {\n appearance: none;\n border: none;\n background: transparent;\n color: var(--muted);\n width: 1.5rem;\n height: 1.5rem;\n border-radius: 0.25rem;\n font-size: 1.1rem;\n line-height: 1;\n cursor: pointer;\n}\n.viewer-settings-close:hover {\n color: var(--text);\n background: var(--bg);\n}\n.viewer-settings-body {\n padding: 0.75rem;\n overflow: auto;\n}\n.settings-option {\n display: flex;\n align-items: flex-start;\n gap: 0.5rem;\n font-size: 0.875rem;\n cursor: pointer;\n user-select: none;\n}\n.settings-subgroup {\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n padding-left: 0.85rem;\n}\n.settings-option-sub {\n font-size: 0.8125rem;\n color: var(--muted);\n}\n.settings-option input {\n margin: 0.15rem 0 0;\n flex-shrink: 0;\n cursor: pointer;\n}\n.rendered-doc {\n max-width: 42rem;\n margin: 0 auto;\n line-height: 1.6;\n}\n.rendered-el-virtual-text > p,\n.rendered-el-virtual-text > div {\n margin: 0;\n display: inline;\n}\n.rendered-doc li > .rendered-el-virtual-text {\n display: inline;\n}\n.rendered-doc li > .rendered-el-virtual-text > p,\n.rendered-doc li > .rendered-el-virtual-text > div {\n display: inline;\n margin: 0;\n}\n.rendered-table .rendered-el-virtual-text {\n display: inline;\n margin: 0;\n}\n.rendered-table .rendered-el-virtual-text > p,\n.rendered-table .rendered-el-virtual-text > div {\n display: inline;\n margin: 0;\n}\n.rendered-el-virtual-text > div > .rendered-el {\n display: block;\n margin: 0.25rem 0;\n}\n.rendered-el {\n cursor: pointer;\n border-radius: 0.25rem;\n}\n.rendered-el:hover {\n outline: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);\n}\n.rendered-el.selected {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n background: color-mix(in srgb, var(--accent) 8%, transparent);\n}\n.rendered-doc p {\n margin: 0 0 0.75rem;\n}\n.rendered-doc h1,\n.rendered-doc h2,\n.rendered-doc h3,\n.rendered-doc h4,\n.rendered-doc h5,\n.rendered-doc h6 {\n margin: 1.25rem 0 0.5rem;\n line-height: 1.25;\n}\n.rendered-doc h1:first-child,\n.rendered-doc h2:first-child,\n.rendered-doc h3:first-child {\n margin-top: 0;\n}\n.rendered-doc ul,\n.rendered-doc ol {\n margin: 0 0 0.75rem;\n padding-left: 1.5rem;\n}\n.rendered-doc li {\n margin: 0.25rem 0;\n}\n.rendered-doc li > .rendered-el > p:first-child,\n.rendered-doc li > .rendered-el > h1:first-child,\n.rendered-doc li > .rendered-el > h2:first-child,\n.rendered-doc li > .rendered-el > h3:first-child,\n.rendered-doc li > .rendered-el > h4:first-child,\n.rendered-doc li > .rendered-el > h5:first-child,\n.rendered-doc li > .rendered-el > h6:first-child {\n margin-top: 0;\n}\n.rendered-marker {\n margin-right: 0.35rem;\n}\n.rendered-handwriting {\n font-family: 'Segoe Print', 'Bradley Hand', cursive;\n}\n.rendered-doc pre {\n margin: 0 0 0.75rem;\n padding: 0.75rem 1rem;\n overflow-x: auto;\n font-family: var(--font-mono);\n font-size: 0.8125rem;\n line-height: 1.5;\n background: var(--placeholder-bg);\n border: 1px solid var(--border);\n border-radius: 0.375rem;\n}\n.rendered-doc code {\n font-family: var(--font-mono);\n font-size: 0.875em;\n}\n.rendered-doc pre code {\n font-size: inherit;\n background: none;\n border: none;\n padding: 0;\n}\n.rendered-code-label {\n display: block;\n margin-bottom: 0.25rem;\n font-size: 0.75rem;\n font-weight: 600;\n color: var(--muted);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n}\n.rendered-formula {\n display: block;\n margin: 0 0 0.75rem;\n padding: 0.5rem 0.75rem;\n font-family: var(--font-mono);\n font-size: 0.875rem;\n background: var(--placeholder-bg);\n border-left: 3px solid var(--border);\n overflow-x: auto;\n}\n.rendered-formula-inline {\n display: inline;\n font-family: var(--font-mono);\n font-size: 0.875em;\n}\n.rendered-doc figure {\n margin: 0 0 0.75rem;\n}\n.rendered-doc figure img {\n max-width: 100%;\n height: auto;\n border: 1px solid var(--border);\n border-radius: 0.25rem;\n}\n.rendered-doc figure img.rendered-picture-unavailable {\n display: inline-block;\n min-width: 3rem;\n min-height: 2.5rem;\n object-fit: contain;\n vertical-align: middle;\n}\n.rendered-doc figcaption {\n margin-top: 0.35rem;\n font-size: 0.875rem;\n color: var(--muted);\n}\n.rendered-picture-contents {\n margin-top: 0.5rem;\n font-size: 0.875rem;\n border: 1px solid var(--border);\n border-radius: 0.375rem;\n background: var(--placeholder-bg);\n}\n.rendered-picture-contents summary {\n padding: 0.4rem 0.65rem;\n cursor: pointer;\n font-weight: 600;\n color: var(--muted);\n user-select: none;\n}\n.rendered-picture-contents summary:hover {\n color: var(--text);\n}\n.rendered-picture-contents-body {\n padding: 0.5rem 0.75rem 0.75rem;\n border-top: 1px solid var(--border);\n}\n.rendered-picture-contents-body .rendered-el > p {\n margin-bottom: 0.35rem;\n font-size: 0.8125rem;\n}\n.rendered-table {\n width: 100%;\n margin: 0 0 0.75rem;\n border-collapse: collapse;\n font-size: 0.875rem;\n}\n.rendered-table caption {\n caption-side: top;\n margin-bottom: 0.35rem;\n font-size: 0.875rem;\n color: var(--muted);\n text-align: left;\n}\n.rendered-table th,\n.rendered-table td {\n border: 1px solid var(--border);\n padding: 0.35rem 0.5rem;\n vertical-align: top;\n text-align: left;\n}\n.rendered-table th {\n background: color-mix(in srgb, var(--border) 35%, var(--panel));\n font-weight: 600;\n}\n.rendered-table .rendered-table-cell-text {\n display: inline;\n}\n.rendered-table .rendered-el {\n margin: 0;\n}\n.rendered-table .rendered-el-virtual-text > p,\n.rendered-table .rendered-el-virtual-text > div {\n margin: 0;\n}\n.rendered-table .rendered-table {\n margin: 0.25rem 0 0;\n}\n.rendered-page-header,\n.rendered-page-footer {\n font-size: 0.875rem;\n color: var(--muted);\n}\n.rendered-page-header {\n margin-bottom: 1rem;\n padding-bottom: 0.5rem;\n border-bottom: 1px solid var(--border);\n}\n.rendered-page-footer {\n margin-top: 1rem;\n padding-top: 0.5rem;\n border-top: 1px solid var(--border);\n}\n.rendered-doc:not(.show-reading-furniture)\n .rendered-el[data-doclang-layer='furniture'] {\n display: none;\n}\n.rendered-doc:not(.show-reading-background)\n .rendered-el[data-doclang-layer='background'] {\n display: none;\n}\n.rendered-el[data-doclang-layer='furniture'],\n.rendered-el[data-doclang-layer='background'] {\n position: relative;\n}\n.rendered-el[data-doclang-layer='furniture']::before,\n.rendered-el[data-doclang-layer='background']::before {\n content: '';\n position: absolute;\n inset: 0;\n border-radius: inherit;\n pointer-events: none;\n background: repeating-linear-gradient(\n -45deg,\n transparent 0 10px,\n color-mix(in srgb, var(--muted) 6%, transparent) 10px 16px\n );\n}\n.rendered-el.rendered-footnote > aside {\n font-size: 0.875rem;\n color: var(--muted);\n border-left: 2px solid var(--border);\n padding-left: 0.75rem;\n margin: 0 0 0.75rem;\n}\n.rendered-unsupported {\n margin: 0 0 0.75rem;\n padding: 0.5rem 0.75rem;\n font-size: 0.8125rem;\n font-style: italic;\n color: var(--muted);\n background: var(--placeholder-bg);\n border: 1px dashed var(--border);\n border-radius: 0.375rem;\n}\n.rendered-field-region {\n margin: 0 0 1rem;\n padding: 0.75rem 1rem;\n border: 1px solid var(--border);\n border-radius: 0.5rem;\n background: color-mix(in srgb, var(--kind-field) 6%, transparent);\n}\n.rendered-field-region > .rendered-field-heading:first-child,\n.rendered-field-region\n > .rendered-el.rendered-field_heading\n > .rendered-field-heading:first-child {\n margin-top: 0;\n}\n.rendered-field-item {\n margin: 0.5rem 0;\n}\n.rendered-field-item > .rendered-el.rendered-field-key,\n.rendered-field-key {\n font-weight: 600;\n}\n.rendered-field-item > .rendered-el.rendered-field-key + .rendered-el,\n.rendered-field-item > .rendered-el.rendered-field-key + .rendered-el-virtual-text {\n margin-top: 0.15rem;\n}\n.rendered-field-value-fillable .rendered-field-fillable-slot {\n display: inline-block;\n min-width: 6rem;\n min-height: 1.25em;\n border-bottom: 1px solid color-mix(in srgb, var(--text) 55%, transparent);\n vertical-align: bottom;\n}\n.rendered-field-hint {\n font-size: 0.85em;\n color: var(--muted);\n font-style: italic;\n}\n.rendered-field-heading {\n color: var(--text);\n}\n.rendered-checkbox {\n margin: 0 0.25rem 0 0;\n vertical-align: middle;\n accent-color: var(--kind-field);\n}\n.rendered-checkbox-wrap {\n display: inline;\n}\n"; + //#endregion + //#region src/components/reading-pane/reading-pane.html?raw + var reading_pane_default = "
\n Reading view\n \n Layers\n \n
\n
\n
\n \n \n
\n\n"; + //#endregion + //#region src/components/reading-pane/reading-pane.ts + /** — reading/rendered view with layers settings panel */ + var DoclangReadingPane = class extends DoclangHTMLElement { + _body; + _settingsToggle; + _settingsLayer; + _settingsScrim; + _settingsClose; + _showFurniture; + _showFurnitureLabel; + _showBackground; + _showBackgroundLabel; + constructor() { + super(reading_pane_default$1, reading_pane_default); + this.classList.add("pane", "pane-reading"); + this._body = this.q(".pane-body"); + this._settingsToggle = this.q(".pane-settings-toggle"); + this._settingsLayer = this.q(".viewer-settings-layer"); + this._settingsScrim = this.q(".viewer-settings-scrim"); + this._settingsClose = this.q(".viewer-settings-close"); + this._showFurnitureLabel = this.q("label:has(.cb-furniture)"); + this._showFurniture = this.q(".cb-furniture"); + this._showBackgroundLabel = this.q("label:has(.cb-background)"); + this._showBackground = this.q(".cb-background"); + this._wireEvents(); + } + get section() { + return this; + } + get body() { + return this._body; + } + get settingsToggle() { + return this._settingsToggle; + } + get settingsLayer() { + return this._settingsLayer; + } + get showFurniture() { + return this._showFurniture; + } + get showFurnitureLabel() { + return this._showFurnitureLabel; + } + get showBackground() { + return this._showBackground; + } + get showBackgroundLabel() { + return this._showBackgroundLabel; + } + setVisible(visible) { + this.hidden = !visible; + } + setLastPane(isLast) { + this.classList.toggle("pane-layout-last", isLast); + } + setSettingsOpen(open) { + this._settingsLayer.hidden = !open; + this._settingsToggle.setAttribute("aria-expanded", String(open)); + } + syncCheckboxes(furniture, background) { + this._showFurniture.checked = furniture; + this._showBackground.checked = background; + } + _wireEvents() { + this._settingsToggle.addEventListener("click", () => this.dispatchEvent(new CustomEvent("doclang-reading-settings-toggle", { + bubbles: true, + composed: true + }))); + this._settingsClose.addEventListener("click", () => this.dispatchEvent(new CustomEvent("doclang-reading-settings-close", { + bubbles: true, + composed: true + }))); + this._settingsScrim.addEventListener("click", () => this.dispatchEvent(new CustomEvent("doclang-reading-settings-close", { + bubbles: true, + composed: true + }))); + this._showFurniture.addEventListener("change", () => this.dispatchEvent(new CustomEvent("doclang-show-reading-furniture", { + bubbles: true, + composed: true, + detail: { checked: this._showFurniture.checked } + }))); + this._showBackground.addEventListener("change", () => this.dispatchEvent(new CustomEvent("doclang-show-reading-background", { + bubbles: true, + composed: true, + detail: { checked: this._showBackground.checked } + }))); + } + }; + customElements.define("doclang-reading-pane", DoclangReadingPane); + //#endregion + //#region src/components/empty-state/empty-state.css?inline + var empty_state_default$1 = ":host {\n display: contents;\n}\n.empty-state {\n flex: 1 1 0;\n display: flex;\n align-items: center;\n justify-content: center;\n min-height: 0;\n margin: 1rem;\n padding: 2rem 1.5rem;\n border: 2px dashed var(--border);\n border-radius: 0.75rem;\n background: var(--placeholder-bg);\n text-align: center;\n color: var(--muted);\n}\n.empty-state-inner {\n max-width: 28rem;\n}\n.empty-state-loading {\n display: none;\n flex-direction: column;\n align-items: center;\n}\n:host(.demo-loading) .empty-state-loading {\n display: flex;\n}\n:host(.demo-loading) .empty-state-prompt {\n display: none;\n}\n.loading-spinner {\n width: 2rem;\n height: 2rem;\n margin: 0 auto 1rem;\n border: 2px solid var(--border);\n border-top-color: var(--accent);\n border-radius: 50%;\n animation: loading-spin 0.7s linear infinite;\n}\n@keyframes loading-spin {\n to {\n transform: rotate(360deg);\n }\n}\n.empty-state-title {\n margin: 0 0 0.75rem;\n font-size: 1.05rem;\n color: var(--text);\n}\n.empty-state p {\n margin: 0.5rem 0;\n}\n.empty-state-action {\n margin: 0 0 0.75rem;\n}\n.text-link {\n color: var(--accent);\n text-decoration: underline;\n}\n.text-link:hover {\n opacity: 0.85;\n}\n.empty-state-meta {\n font-size: 0.875rem;\n}\ncode {\n font-family: var(--font-mono);\n}\n"; + //#endregion + //#region src/components/empty-state/empty-state.html?raw + var empty_state_default = "
\n
\n \n
\n

Loading demo…

\n

Preparing the sample document

\n
\n
\n

Drop a DocLang file here

\n

\n Supported file types: \n

\n

or load demo

\n
\n
\n\n"; + //#endregion + //#region src/components/empty-state/empty-state.ts + /** — loading/prompt empty state */ + var DoclangEmptyState = class extends DoclangHTMLElement { + _fileTypesSpan; + constructor() { + super(empty_state_default$1, empty_state_default); + this._fileTypesSpan = this.q(".file-types"); + this.q(".text-link").addEventListener("click", (e) => { + e.preventDefault(); + this.dispatchEvent(new CustomEvent("doclang-load-demo", { + bubbles: true, + composed: true + })); + }); + } + setFileTypeHints(extensions) { + this._fileTypesSpan.innerHTML = extensions.map((ext) => `${ext}`).join(", "); + } + setDemoLoading(loading) { + this.classList.toggle("demo-loading", loading); + } + }; + customElements.define("doclang-empty-state", DoclangEmptyState); + //#endregion //#region src/xml.ts function isTextLikeNode(node) { return node.nodeType === Node.TEXT_NODE || node.nodeType === Node.CDATA_SECTION_NODE; @@ -561,7 +1368,7 @@ } } //#endregion - //#region src/xml_overlay.ts + //#region src/xml-overlay.ts function isPictureContentElement(el) { if (!el) return false; const tag = localName(el); @@ -2783,114 +3590,90 @@ }, getSelectedElementId: () => selectedElementId }); + var doclangCursorHint = document.querySelector("doclang-cursor-hint"); + var doclangPageNav = document.querySelector("doclang-page-nav"); + var doclangToolbar = document.querySelector("doclang-toolbar"); + var doclangFilePane = document.querySelector("doclang-file-pane"); + var doclangMarkupPane = document.querySelector("doclang-markup-pane"); + var doclangPageViewPane = document.querySelector("doclang-page-view-pane"); + var doclangReadingPane = document.querySelector("doclang-reading-pane"); + var doclangEmptyState = document.querySelector("doclang-empty-state"); var els = { - openFileBtn: document.getElementById("open-file-btn"), - emptyStateFileTypes: document.getElementById("empty-state-file-types"), + openFileBtn: doclangToolbar?.openFileBtn ?? null, docLabel: document.getElementById("doc-label"), - filePane: document.getElementById("file-pane"), - filePaneCloseAll: document.getElementById("btn-file-pane-close-all"), - pageNav: document.getElementById("page-nav"), - pageIndicator: document.getElementById("page-indicator"), - pageNumberInput: document.getElementById("page-number-input"), - pageCountIndicator: document.getElementById("page-count-indicator"), - btnPrev: document.getElementById("btn-prev"), - btnNext: document.getElementById("btn-next"), - showAllBboxes: document.getElementById("show-all-bboxes"), - showLayoutBadges: document.getElementById("show-layout-badges"), - showLayoutBadgesLabel: document.getElementById("show-layout-badges-label"), - settingsToggle: document.getElementById("btn-settings-toggle"), - readingSettingsToggle: document.getElementById("btn-reading-settings-toggle"), - pageSettingsLayer: document.getElementById("page-settings-layer"), - pageSettingsScrim: document.getElementById("page-settings-scrim"), - pageSettingsClose: document.getElementById("btn-page-settings-close"), - readingSettingsLayer: document.getElementById("reading-settings-layer"), - readingSettingsScrim: document.getElementById("reading-settings-scrim"), - readingSettingsClose: document.getElementById("btn-reading-settings-close"), - showReadingFurniture: document.getElementById("show-reading-furniture"), - showReadingFurnitureLabel: document.getElementById("show-reading-furniture-label"), - showReadingBackground: document.getElementById("show-reading-background"), - showReadingBackgroundLabel: document.getElementById("show-reading-background-label"), - showCaptionLinks: document.getElementById("show-caption-links"), - showCaptionLinksLabel: document.getElementById("show-caption-links-label"), - showPictureContents: document.getElementById("show-picture-contents"), - showPictureContentsLabel: document.getElementById("show-picture-contents-label"), - showTableContents: document.getElementById("show-table-contents"), - showTableContentsLabel: document.getElementById("show-table-contents-label"), - showFragmentLinks: document.getElementById("show-fragment-links"), - showFragmentLinksLabel: document.getElementById("show-fragment-links-label"), - showXrefLinks: document.getElementById("show-xref-links"), - showXrefLinksLabel: document.getElementById("show-xref-links-label"), - showReadingOrder: document.getElementById("show-reading-order"), - showReadingOrderLabel: document.getElementById("show-reading-order-label"), - readingOrderArrows: document.getElementById("reading-order-arrows"), - readingOrderArrowsLabel: document.getElementById("reading-order-arrows-label"), - readingOrderGlobal: document.getElementById("reading-order-global"), - readingOrderGlobalLabel: document.getElementById("reading-order-global-label"), - pageZoom: document.getElementById("page-zoom"), - pageZoomLabel: document.getElementById("page-zoom-label"), - pageZoomReset: document.getElementById("page-zoom-reset"), + filePane: doclangFilePane?.body ?? null, + filePaneCloseAll: null, + pageNav: doclangPageNav ?? null, + pageNumberInput: doclangPageNav?.pageNumberInput ?? null, + pageCountIndicator: doclangPageNav?.pageCountIndicator ?? null, + btnPrev: doclangPageNav?.btnPrev ?? null, + btnNext: doclangPageNav?.btnNext ?? null, + showAllBboxes: doclangPageViewPane?.showAllBboxes ?? null, + showLayoutBadges: doclangPageViewPane?.showLayoutBadges ?? null, + showLayoutBadgesLabel: doclangPageViewPane?.showLayoutBadgesLabel ?? null, + settingsToggle: doclangPageViewPane?.settingsToggle ?? null, + readingSettingsToggle: doclangReadingPane?.settingsToggle ?? null, + pageSettingsLayer: doclangPageViewPane?.settingsLayer ?? null, + pageSettingsScrim: null, + pageSettingsClose: null, + readingSettingsLayer: doclangReadingPane?.settingsLayer ?? null, + readingSettingsScrim: null, + readingSettingsClose: null, + showReadingFurniture: doclangReadingPane?.showFurniture ?? null, + showReadingFurnitureLabel: doclangReadingPane?.showFurnitureLabel ?? null, + showReadingBackground: doclangReadingPane?.showBackground ?? null, + showReadingBackgroundLabel: doclangReadingPane?.showBackgroundLabel ?? null, + showCaptionLinks: doclangPageViewPane?.showCaptionLinks ?? null, + showCaptionLinksLabel: doclangPageViewPane?.showCaptionLinksLabel ?? null, + showPictureContents: doclangPageViewPane?.showPictureContents ?? null, + showPictureContentsLabel: doclangPageViewPane?.showPictureContentsLabel ?? null, + showTableContents: doclangPageViewPane?.showTableContents ?? null, + showTableContentsLabel: doclangPageViewPane?.showTableContentsLabel ?? null, + showFragmentLinks: doclangPageViewPane?.showFragmentLinks ?? null, + showFragmentLinksLabel: doclangPageViewPane?.showFragmentLinksLabel ?? null, + showXrefLinks: doclangPageViewPane?.showXrefLinks ?? null, + showXrefLinksLabel: doclangPageViewPane?.showXrefLinksLabel ?? null, + showReadingOrder: doclangPageViewPane?.showReadingOrder ?? null, + showReadingOrderLabel: doclangPageViewPane?.showReadingOrderLabel ?? null, + readingOrderArrows: doclangPageViewPane?.readingOrderArrows ?? null, + readingOrderArrowsLabel: doclangPageViewPane?.readingOrderArrowsLabel ?? null, + readingOrderGlobal: doclangPageViewPane?.readingOrderGlobal ?? null, + readingOrderGlobalLabel: doclangPageViewPane?.readingOrderGlobalLabel ?? null, + pageZoom: doclangPageViewPane?.zoomInput ?? null, + pageZoomLabel: doclangPageViewPane?.zoomLabel ?? null, + pageZoomReset: doclangPageViewPane?.zoomReset ?? null, main: document.getElementById("main"), - emptyState: document.getElementById("empty-state"), - emptyStateLoading: document.getElementById("empty-state-loading"), - emptyStatePrompt: document.getElementById("empty-state-prompt"), - markupPane: document.getElementById("markup-pane"), - renderedPane: document.getElementById("rendered-pane"), - pagePane: document.getElementById("page-pane"), - paneFile: document.querySelector(".pane-file"), - panePageView: document.querySelector(".pane-page-view"), - paneMarkup: document.querySelector(".pane-markup"), - paneReading: document.querySelector(".pane-reading"), + emptyState: doclangEmptyState ?? null, + markupPane: doclangMarkupPane?.body ?? null, + renderedPane: doclangReadingPane?.body ?? null, + pagePane: doclangPageViewPane?.body ?? null, + paneFile: doclangFilePane?.section ?? null, + panePageView: doclangPageViewPane?.section ?? null, + paneMarkup: doclangMarkupPane?.section ?? null, + paneReading: doclangReadingPane?.section ?? null, splitters: [ document.getElementById("splitter-0"), document.getElementById("splitter-1"), document.getElementById("splitter-2") ], - toolbarOptionsBtn: document.getElementById("btn-toolbar-options"), - toolbarOptionsPanel: document.getElementById("toolbar-options-panel"), - toggleFilePane: document.getElementById("toggle-file-pane"), - toggleFilePaneLabel: document.getElementById("toggle-file-pane-label"), - togglePagePane: document.getElementById("toggle-page-pane"), - toggleMarkupPane: document.getElementById("toggle-markup-pane"), - toggleReadingPane: document.getElementById("toggle-reading-pane"), - togglePagePaneLabel: document.getElementById("toggle-page-pane-label"), - resetPaneLayoutBtn: document.getElementById("btn-reset-pane-layout") + toolbarOptionsBtn: doclangToolbar?.toolbarOptionsBtn ?? null, + toolbarOptionsPanel: null, + toggleFilePane: doclangToolbar?.toggleFilePane ?? null, + toggleFilePaneLabel: doclangToolbar?.toggleFilePaneLabel ?? null, + togglePagePane: doclangToolbar?.togglePagePane ?? null, + toggleMarkupPane: doclangToolbar?.toggleMarkupPane ?? null, + toggleReadingPane: doclangToolbar?.toggleReadingPane ?? null, + togglePagePaneLabel: doclangToolbar?.togglePagePaneLabel ?? null, + resetPaneLayoutBtn: doclangToolbar?.resetPaneLayoutBtn ?? null }; - var cursorHintEl = document.getElementById("cursor-hint"); - var CURSOR_HINT_OFFSET = 10; - var CURSOR_HINT_MARGIN = 8; function hideCursorHint() { - if (!cursorHintEl) return; - cursorHintEl.hidden = true; - cursorHintEl.classList.remove("cursor-hint-detail"); - cursorHintEl.replaceChildren(); + doclangCursorHint?.hide(); } function showCursorHint(content, clientX, clientY, { detail = false } = {}) { - if (!cursorHintEl) return; - cursorHintEl.replaceChildren(); - if (typeof content === "string") cursorHintEl.textContent = content; - else cursorHintEl.appendChild(content); - cursorHintEl.classList.toggle("cursor-hint-detail", detail); - cursorHintEl.hidden = false; - let left = clientX + CURSOR_HINT_OFFSET; - let top = clientY + CURSOR_HINT_OFFSET; - const rect = cursorHintEl.getBoundingClientRect(); - if (left + rect.width > window.innerWidth - CURSOR_HINT_MARGIN) left = clientX - rect.width - CURSOR_HINT_OFFSET; - if (top + rect.height > window.innerHeight - CURSOR_HINT_MARGIN) top = clientY - rect.height - CURSOR_HINT_OFFSET; - cursorHintEl.style.left = `${Math.max(CURSOR_HINT_MARGIN, left)}px`; - cursorHintEl.style.top = `${Math.max(CURSOR_HINT_MARGIN, top)}px`; + doclangCursorHint?.show(content, clientX, clientY, detail); } function showCursorHintHtml(html, clientX, clientY) { - if (!cursorHintEl) return; - cursorHintEl.innerHTML = html; - cursorHintEl.classList.add("cursor-hint-detail"); - cursorHintEl.hidden = false; - let left = clientX + CURSOR_HINT_OFFSET; - let top = clientY + CURSOR_HINT_OFFSET; - const rect = cursorHintEl.getBoundingClientRect(); - if (left + rect.width > window.innerWidth - CURSOR_HINT_MARGIN) left = clientX - rect.width - CURSOR_HINT_OFFSET; - if (top + rect.height > window.innerHeight - CURSOR_HINT_MARGIN) top = clientY - rect.height - CURSOR_HINT_OFFSET; - cursorHintEl.style.left = `${Math.max(CURSOR_HINT_MARGIN, left)}px`; - cursorHintEl.style.top = `${Math.max(CURSOR_HINT_MARGIN, top)}px`; + doclangCursorHint?.showHtml(html, clientX, clientY); } function headTextPreview(el, maxLen = 72) { const text = el.textContent?.replace(/\s+/g, " ").trim() ?? ""; @@ -3064,13 +3847,11 @@ } function setPageSettingsOpen(open) { pageSettingsOpen = open; - if (els.pageSettingsLayer) els.pageSettingsLayer.hidden = !open; - if (els.settingsToggle) els.settingsToggle.setAttribute("aria-expanded", String(open)); + doclangPageViewPane?.setSettingsOpen(open); } function setReadingSettingsOpen(open) { readingSettingsOpen = open; - if (els.readingSettingsLayer) els.readingSettingsLayer.hidden = !open; - if (els.readingSettingsToggle) els.readingSettingsToggle.setAttribute("aria-expanded", String(open)); + doclangReadingPane?.setSettingsOpen(open); } function closeAllSettings() { setPageSettingsOpen(false); @@ -3374,8 +4155,7 @@ } function setToolbarOptionsOpen(open) { toolbarOptionsOpen = open; - if (els.toolbarOptionsPanel) els.toolbarOptionsPanel.hidden = !open; - if (els.toolbarOptionsBtn) els.toolbarOptionsBtn.setAttribute("aria-expanded", String(open)); + doclangToolbar?.setOptionsOpen(open); } function syncToolbarPaneCheckboxes() { if (els.toggleFilePane) { @@ -3411,32 +4191,20 @@ if (els.resetPaneLayoutBtn) els.resetPaneLayoutBtn.disabled = !state; } function initToolbarOptions() { - if (!els.toolbarOptionsBtn || !els.toolbarOptionsPanel) return; - els.toolbarOptionsBtn.addEventListener("click", (e) => { - e.stopPropagation(); - setToolbarOptionsOpen(!toolbarOptionsOpen); - }); - document.addEventListener("click", (e) => { - if (!toolbarOptionsOpen) return; - if (e.target instanceof Node && els.toolbarOptionsPanel.contains(e.target)) return; - if (e.target instanceof Node && els.toolbarOptionsBtn.contains(e.target)) return; - setToolbarOptionsOpen(false); + if (!doclangToolbar) return; + doclangToolbar.addEventListener("doclang-toggle-pane", (e) => { + const { pane, checked } = e.detail; + if (!state) { + syncToolbarPaneCheckboxes(); + return; + } + if (![...PANE_KEYS].filter((k) => k === pane ? checked : userPaneVisible[k] && isPaneAvailable(k)).length) { + syncToolbarPaneCheckboxes(); + return; + } + setUserPaneVisible(pane, checked); }); - const onToggle = (key, input) => { - input?.addEventListener("change", () => { - if (!state) return; - if (![...PANE_KEYS].filter((k) => k === key ? input.checked : userPaneVisible[k] && isPaneAvailable(k)).length) { - input.checked = true; - return; - } - setUserPaneVisible(key, input.checked); - }); - }; - onToggle("file", els.toggleFilePane); - onToggle("page", els.togglePagePane); - onToggle("markup", els.toggleMarkupPane); - onToggle("reading", els.toggleReadingPane); - els.resetPaneLayoutBtn?.addEventListener("click", () => { + doclangToolbar.addEventListener("doclang-reset-pane-layout", () => { if (state) resetPaneLayout(); }); syncToolbarPaneCheckboxes(); @@ -3533,28 +4301,8 @@ state.currentPage = page; renderPage(page); } - function resetPageNumberInput() { - if (!els.pageNumberInput) return; - els.pageNumberInput.value = state ? String(state.currentPage) : "1"; - } - function commitPageNumberInput() { - if (!state || !els.pageNumberInput) return; - const n = Number.parseInt(els.pageNumberInput.value.trim(), 10); - if (!Number.isFinite(n)) { - resetPageNumberInput(); - return; - } - goToPage(n); - } function setPageIndicator(pageNum, pageCount) { - if (!els.pageIndicator) return; - if (els.pageCountIndicator) els.pageCountIndicator.textContent = `\u00A0of ${pageCount}`; - if (els.pageNumberInput) { - const digits = Math.max(1, String(pageCount).length); - els.pageNumberInput.style.setProperty("--page-num-digits", String(digits)); - els.pageNumberInput.disabled = !state; - if (document.activeElement !== els.pageNumberInput) els.pageNumberInput.value = String(pageNum); - } + doclangPageNav?.setIndicator(pageNum, pageCount); } function syncPagePaneControls() { const pageVisible = isPaneVisible("page"); @@ -3936,7 +4684,7 @@ function setDocumentOpen(open, { markupOnly = false } = {}) { document.body.classList.toggle("viewer-loaded", open); document.body.classList.toggle("markup-only", open && markupOnly); - if (els.pageNav) els.pageNav.hidden = !open || markupOnly; + doclangPageNav?.setVisible(open && !markupOnly); syncToolbarPaneCheckboxes(); applyPaneLayout(); } @@ -4040,23 +4788,6 @@ if (entry?.thumbnailUrl?.startsWith("blob:")) URL.revokeObjectURL(entry.thumbnailUrl); if (entry) entry.thumbnailUrl = null; } - function createFileViewThumbnail(entry) { - const thumb = document.createElement("span"); - thumb.className = "file-view-thumb"; - thumb.setAttribute("aria-hidden", "true"); - if (entry.thumbnailUrl) { - const img = document.createElement("img"); - img.src = entry.thumbnailUrl; - img.alt = ""; - thumb.appendChild(img); - } else { - const placeholder = document.createElement("span"); - placeholder.className = "file-view-thumb-placeholder"; - placeholder.innerHTML = FILE_THUMB_PLACEHOLDER_SVG; - thumb.appendChild(placeholder); - } - return thumb; - } function createFileCatalogEntry(file) { return { id: crypto.randomUUID(), @@ -4177,73 +4908,32 @@ updateFileView(); } function renderFileView() { - if (!els.filePane) return; - els.filePane.replaceChildren(); - if (!fileCatalog.length) return; - const list = document.createElement("ul"); - list.className = "file-view-list"; - list.setAttribute("role", "listbox"); - list.setAttribute("aria-label", "Open files"); - fileCatalog.forEach((entry, index) => { - const item = document.createElement("li"); - const card = document.createElement("div"); - card.className = "file-view-item"; - card.title = entry.label; - card.tabIndex = 0; - card.setAttribute("role", "option"); - if (index === activeFileIndex) { - card.classList.add("is-active"); - card.setAttribute("aria-selected", "true"); - } else card.setAttribute("aria-selected", "false"); - const thumbWrap = document.createElement("div"); - thumbWrap.className = "file-view-thumb-wrap"; - const closeBtn = document.createElement("button"); - closeBtn.type = "button"; - closeBtn.className = "file-view-close"; - closeBtn.setAttribute("aria-label", `Close ${entry.label}`); - closeBtn.textContent = "×"; - closeBtn.addEventListener("click", (e) => { - e.stopPropagation(); - closeCatalogFile(index); - }); - thumbWrap.append(createFileViewThumbnail(entry), closeBtn); - const label = document.createElement("span"); - label.className = "file-view-label"; - label.textContent = entry.label; - card.append(thumbWrap, label); - card.addEventListener("click", (e) => { - if (e.target.closest(".file-view-close")) return; - switchToFile(index); - }); - card.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - switchToFile(index); - } - }); - item.appendChild(card); - list.appendChild(item); - }); - els.filePane.appendChild(list); + if (!doclangFilePane) return; + doclangFilePane.renderFiles(fileCatalog.map((entry, index) => ({ + label: entry.label, + thumbnailUrl: entry.thumbnailUrl, + isActive: index === activeFileIndex + }))); } function updateFileView() { syncFilePaneDefault(); - syncFilePaneCloseAllButton(); renderFileView(); syncToolbarPaneCheckboxes(); applyPaneLayout(); } - function syncFilePaneCloseAllButton() { - if (!els.filePaneCloseAll) return; - els.filePaneCloseAll.hidden = fileCatalog.length === 0; - } function initFilePaneCloseAll() { - els.filePaneCloseAll?.addEventListener("click", () => { + doclangFilePane?.addEventListener("doclang-file-pane-close-all", () => { if (!fileCatalog.length) return; const count = fileCatalog.length; const message = count === 1 ? `Remove "${fileCatalog[0].label}" from the viewer?` : `Remove all ${count} open files from the viewer?`; if (confirm(message)) resetViewer(); }); + doclangFilePane?.addEventListener("doclang-file-select", (e) => { + switchToFile(e.detail.index); + }); + doclangFilePane?.addEventListener("doclang-file-close", (e) => { + closeCatalogFile(e.detail.index); + }); } function activateDocument(docState, entry) { state = docState; @@ -4268,8 +4958,8 @@ } function setDemoLoading(loading) { document.body.classList.toggle("demo-loading", loading); - const btnDemo = document.getElementById("btn-demo"); - if (btnDemo) btnDemo.disabled = loading; + doclangEmptyState?.setDemoLoading(loading); + if (doclangToolbar) doclangToolbar.btnDemo.disabled = loading; } async function loadDemo() { if (demoLoadInProgress) return; @@ -4284,7 +4974,7 @@ alert(`Failed to load demo: ${err.message}\n\nServe this directory over HTTP (e.g. python3 -m http.server) and open the viewer from localhost.`); } finally { demoLoadInProgress = false; - if (!document.body.classList.contains("viewer-loaded")) setDemoLoading(false); + setDemoLoading(false); } } async function addFilesToCatalog(files, { replace = false } = {}) { @@ -4445,16 +5135,6 @@ } function initPageViewControls() { if (!els.pagePane) return; - els.pageZoom?.addEventListener("input", () => { - pageZoomPercent = Math.max(100, Number(els.pageZoom.value)); - if (Number(els.pageZoom.value) < 100) els.pageZoom.value = String(pageZoomPercent); - els.pageZoom.setAttribute("aria-valuenow", String(pageZoomPercent)); - updatePageZoomResetButton(); - refreshPageViewLayout(); - }); - els.pageZoomReset?.addEventListener("click", () => { - if (pageZoomPercent !== 100) resetPageZoom(); - }); els.pagePane.addEventListener("pointerdown", (e) => { if (e.button !== 0 || !canStartPagePan(e)) return; const scrollPane = pageViewScrollPane(); @@ -4497,9 +5177,7 @@ els.pagePane.addEventListener("pointercancel", (e) => endPagePan(e)); } function initFileTypeHints() { - if (!els.emptyStateFileTypes) return; - const markup = SUPPORTED_FILE_EXTENSIONS.map((ext) => `${ext}`).join(", "); - els.emptyStateFileTypes.innerHTML = markup; + doclangEmptyState?.setFileTypeHints(SUPPORTED_FILE_EXTENSIONS); } function initCursorHints() { els.markupPane?.addEventListener("mousemove", (e) => { @@ -4563,105 +5241,100 @@ if (e.dataTransfer) await loadFromDrop(e.dataTransfer); }); } - document.getElementById("btn-demo")?.addEventListener("click", loadDemo); - document.getElementById("demo-empty-link")?.addEventListener("click", (e) => { - e.preventDefault(); - loadDemo(); + doclangToolbar?.addEventListener("doclang-load-demo", loadDemo); + doclangToolbar?.addEventListener("doclang-open-files", async (e) => { + const files = e.detail.files.filter((f) => isArchiveFile(f) || isMarkupFile(f)); + if (!files.length) return; + await addFilesToCatalog(files, { replace: true }); }); + doclangEmptyState?.addEventListener("doclang-load-demo", loadDemo); document.getElementById("home-link")?.addEventListener("click", (e) => { e.preventDefault(); resetViewer(); }); - document.getElementById("input-archive")?.addEventListener("change", async (e) => { - const input = e.target; - const files = [...input.files ?? []].filter((f) => isArchiveFile(f) || isMarkupFile(f)); - if (!files.length) return; - await addFilesToCatalog(files, { replace: true }); - input.value = ""; + doclangPageNav?.addEventListener("doclang-prev-page", () => state && goToPage(state.currentPage - 1)); + doclangPageNav?.addEventListener("doclang-next-page", () => state && goToPage(state.currentPage + 1)); + doclangPageNav?.addEventListener("doclang-go-to-page", (e) => { + goToPage(e.detail.page); }); - els.btnPrev?.addEventListener("click", () => state && goToPage(state.currentPage - 1)); - els.btnNext?.addEventListener("click", () => state && goToPage(state.currentPage + 1)); - els.pageNumberInput?.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - commitPageNumberInput(); - els.pageNumberInput?.blur(); - } else if (e.key === "Escape") { - e.preventDefault(); - resetPageNumberInput(); - els.pageNumberInput?.blur(); + doclangPageViewPane?.addEventListener("doclang-page-settings-toggle", () => setPageSettingsOpen(!pageSettingsOpen)); + doclangPageViewPane?.addEventListener("doclang-page-settings-close", () => setPageSettingsOpen(false)); + doclangPageViewPane?.addEventListener("doclang-zoom-change", (e) => { + pageZoomPercent = Math.max(100, e.detail.value); + if (doclangPageViewPane) { + doclangPageViewPane.zoomInput.value = String(pageZoomPercent); + doclangPageViewPane.zoomInput.setAttribute("aria-valuenow", String(pageZoomPercent)); } + updatePageZoomResetButton(); + refreshPageViewLayout(); }); - els.pageNumberInput?.addEventListener("blur", resetPageNumberInput); - els.pageNumberInput?.addEventListener("focus", (e) => e.target.select()); - els.showAllBboxes?.addEventListener("change", () => { - showAllBboxes = els.showAllBboxes.checked; + doclangPageViewPane?.addEventListener("doclang-zoom-reset", () => { + if (pageZoomPercent !== 100) resetPageZoom(); + }); + doclangPageViewPane?.addEventListener("doclang-show-all-bboxes", (e) => { + showAllBboxes = e.detail.checked; syncLayoutSubtoggles(); const img = els.pagePane?.querySelector(".page-view img"); if (img && state?.pageViewOverlay) syncOverlayBadges(img, img.parentElement.querySelector("svg.overlay"), state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); applyBboxVisibility(); }); - els.showLayoutBadges?.addEventListener("change", () => { - showLayoutBadges = els.showLayoutBadges.checked; + doclangPageViewPane?.addEventListener("doclang-show-layout-badges", (e) => { + showLayoutBadges = e.detail.checked; const img = els.pagePane?.querySelector(".page-view img"); if (img && state?.pageViewOverlay) syncOverlayBadges(img, img.parentElement.querySelector("svg.overlay"), state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); applyBboxVisibility(); }); - els.showCaptionLinks?.addEventListener("change", () => { - showCaptionLinks = els.showCaptionLinks.checked; + doclangPageViewPane?.addEventListener("doclang-show-reading-order", (e) => { + showReadingOrder = e.detail.checked; + syncLayoutSubtoggles(); + const img = els.pagePane?.querySelector(".page-view img"); + if (img && state?.pageViewOverlay) syncOverlayBadges(img, img.parentElement.querySelector("svg.overlay"), state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); applyBboxVisibility(); }); - els.showPictureContents?.addEventListener("change", () => { - showPictureContents = els.showPictureContents.checked; + doclangPageViewPane?.addEventListener("doclang-reading-order-arrows", (e) => { + showReadingOrderArrows = e.detail.checked; applyBboxVisibility(); }); - els.showTableContents?.addEventListener("change", () => { - showTableContents = els.showTableContents.checked; + doclangPageViewPane?.addEventListener("doclang-reading-order-global", (e) => { + readingOrderGlobalNumbering = e.detail.checked; + if (state) renderPage(state.currentPage); + }); + doclangPageViewPane?.addEventListener("doclang-show-picture-contents", (e) => { + showPictureContents = e.detail.checked; applyBboxVisibility(); }); - els.showFragmentLinks?.addEventListener("change", () => { - showFragmentLinks = els.showFragmentLinks.checked; + doclangPageViewPane?.addEventListener("doclang-show-table-contents", (e) => { + showTableContents = e.detail.checked; applyBboxVisibility(); }); - els.showXrefLinks?.addEventListener("change", () => { - showXrefLinks = els.showXrefLinks.checked; + doclangPageViewPane?.addEventListener("doclang-show-fragment-links", (e) => { + showFragmentLinks = e.detail.checked; applyBboxVisibility(); }); - els.showReadingOrder?.addEventListener("change", () => { - showReadingOrder = els.showReadingOrder.checked; - syncLayoutSubtoggles(); - const img = els.pagePane?.querySelector(".page-view img"); - if (img && state?.pageViewOverlay) syncOverlayBadges(img, img.parentElement.querySelector("svg.overlay"), state.pageViewOverlay.boxes, state.pageViewOverlay.readingOrderSteps, showAllBboxes, showLayoutBadges, showReadingOrder); + doclangPageViewPane?.addEventListener("doclang-show-xref-links", (e) => { + showXrefLinks = e.detail.checked; applyBboxVisibility(); }); - els.readingOrderArrows?.addEventListener("change", () => { - showReadingOrderArrows = els.readingOrderArrows.checked; + doclangPageViewPane?.addEventListener("doclang-show-caption-links", (e) => { + showCaptionLinks = e.detail.checked; applyBboxVisibility(); }); - els.readingOrderGlobal?.addEventListener("change", () => { - readingOrderGlobalNumbering = els.readingOrderGlobal.checked; - if (state) renderPage(state.currentPage); + doclangReadingPane?.addEventListener("doclang-reading-settings-toggle", () => setReadingSettingsOpen(!readingSettingsOpen)); + doclangReadingPane?.addEventListener("doclang-reading-settings-close", () => setReadingSettingsOpen(false)); + doclangReadingPane?.addEventListener("doclang-show-reading-furniture", (e) => { + showReadingFurniture = e.detail.checked; + syncReadingLayerVisibility(); + }); + doclangReadingPane?.addEventListener("doclang-show-reading-background", (e) => { + showReadingBackground = e.detail.checked; + syncReadingLayerVisibility(); }); - els.settingsToggle?.addEventListener("click", () => setPageSettingsOpen(!pageSettingsOpen)); - els.readingSettingsToggle?.addEventListener("click", () => setReadingSettingsOpen(!readingSettingsOpen)); - els.pageSettingsClose?.addEventListener("click", () => setPageSettingsOpen(false)); - els.pageSettingsScrim?.addEventListener("click", () => setPageSettingsOpen(false)); - els.readingSettingsClose?.addEventListener("click", () => setReadingSettingsOpen(false)); - els.readingSettingsScrim?.addEventListener("click", () => setReadingSettingsOpen(false)); document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; if (toolbarOptionsOpen) setToolbarOptionsOpen(false); else if (pageSettingsOpen) setPageSettingsOpen(false); else if (readingSettingsOpen) setReadingSettingsOpen(false); }); - els.showReadingFurniture?.addEventListener("change", () => { - showReadingFurniture = els.showReadingFurniture.checked; - syncReadingLayerVisibility(); - }); - els.showReadingBackground?.addEventListener("change", () => { - showReadingBackground = els.showReadingBackground.checked; - syncReadingLayerVisibility(); - }); loadLayoutPrefs(); normalizePaneRatios(); initToolbarOptions(); @@ -4674,6 +5347,7 @@ initFilePaneCloseAll(); initPageWheelNav(); initPageViewControls(); - if (document.getElementById("btn-demo")) loadDemo(); + if (document.body.classList.contains("demo-loading")) doclangEmptyState?.setDemoLoading(true); + if (doclangToolbar?.btnDemo) loadDemo(); //#endregion })(); From 63dafb1bf507cd3617a46506bac680a657a28f07 Mon Sep 17 00:00:00 2001 From: DKL Date: Tue, 18 Aug 2026 14:54:47 +0200 Subject: [PATCH 3/7] Split into three major view components. Modularise further. Signed-off-by: DKL --- core/src/components/base/document-base.ts | 205 + .../src/components/cursor-hint/cursor-hint.ts | 30 + core/src/components/file-pane/file-pane.ts | 3 - .../src/components/markup-pane/markup-pane.ts | 90 +- .../{ => components/markup-pane}/markup.ts | 4 +- core/src/components/page-nav/page-nav.ts | 13 - .../page-view-pane}/overlay.ts | 225 +- .../page-view-pane/page-view-pane.ts | 997 ++- .../components/reading-pane/reading-pane.ts | 180 +- .../{ => components/reading-pane}/rendered.ts | 97 +- core/src/components/toolbar/toolbar.ts | 110 +- core/src/{ => doclang}/document.ts | 6 +- core/src/{xml.ts => doclang/dom.ts} | 26 +- core/src/{ => doclang}/types.ts | 2 +- core/src/{ => doclang}/zip.ts | 2 +- core/src/main.ts | 1240 +--- core/src/xml-overlay.ts | 81 - viewer.js | 5456 +++++++++-------- 18 files changed, 4445 insertions(+), 4322 deletions(-) create mode 100644 core/src/components/base/document-base.ts rename core/src/{ => components/markup-pane}/markup.ts (99%) rename core/src/{ => components/page-view-pane}/overlay.ts (84%) rename core/src/{ => components/reading-pane}/rendered.ts (93%) rename core/src/{ => doclang}/document.ts (99%) rename core/src/{xml.ts => doclang/dom.ts} (93%) rename core/src/{ => doclang}/types.ts (98%) rename core/src/{ => doclang}/zip.ts (99%) delete mode 100644 core/src/xml-overlay.ts diff --git a/core/src/components/base/document-base.ts b/core/src/components/base/document-base.ts new file mode 100644 index 0000000..8ff7274 --- /dev/null +++ b/core/src/components/base/document-base.ts @@ -0,0 +1,205 @@ +/** + * Base class for doclang web components that render document content. + * + * Supports three ways to provide the DocLang document: + * + * 1. `src` attribute — a URL (http:, data:, blob:) pointing to either a + * raw XML markup string or a .dclx/.zip archive. The component fetches + * and parses it automatically. + * + * 2. Inline child ` diff --git a/viewer.js b/viewer.js index fb8a232..4b00ba3 100644 --- a/viewer.js +++ b/viewer.js @@ -28,8 +28,8 @@ var t$4 = globalThis; var e$7 = t$4.ShadowRoot && (void 0 === t$4.ShadyCSS || t$4.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype; var s$4 = Symbol(); - var o$7 = /* @__PURE__ */ new WeakMap(); - var n$6 = class { + var o$8 = /* @__PURE__ */ new WeakMap(); + var n$7 = class { constructor(t, e, o) { if (this._$cssResult$ = !0, o !== s$4) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); this.cssText = t, this.t = e; @@ -39,7 +39,7 @@ const s = this.t; if (e$7 && void 0 === t) { const e = void 0 !== s && 1 === s.length; - e && (t = o$7.get(s)), void 0 === t && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), e && o$7.set(s, t)); + e && (t = o$8.get(s)), void 0 === t && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), e && o$8.set(s, t)); } return t; } @@ -47,7 +47,7 @@ return this.cssText; } }; - var r$6 = (t) => new n$6("string" == typeof t ? t : t + "", void 0, s$4); + var r$6 = (t) => new n$7("string" == typeof t ? t : t + "", void 0, s$4); var S$1 = (s, o) => { if (e$7) s.adoptedStyleSheets = o.map((t) => t instanceof CSSStyleSheet ? t : t.styleSheet); else for (const e of o) { @@ -66,7 +66,7 @@ * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */ var { is: i$4, defineProperty: e$6, getOwnPropertyDescriptor: h$4, getOwnPropertyNames: r$5, getOwnPropertySymbols: o$6, getPrototypeOf: n$5 } = Object, a$1 = globalThis, c$4 = a$1.trustedTypes, l$2 = c$4 ? c$4.emptyScript : "", p$2 = a$1.reactiveElementPolyfillSupport, d$2 = (t, s) => t, u$3 = { + */ var { is: i$5, defineProperty: e$6, getOwnPropertyDescriptor: h$4, getOwnPropertyNames: r$5, getOwnPropertySymbols: o$7, getPrototypeOf: n$6 } = Object, a$1 = globalThis, c$4 = a$1.trustedTypes, l$2 = c$4 ? c$4.emptyScript : "", p$2 = a$1.reactiveElementPolyfillSupport, d$2 = (t, s) => t, u$3 = { toAttribute(t, s) { switch (s) { case Boolean: @@ -95,7 +95,7 @@ } return i; } - }, f$3 = (t, s) => !i$4(t, s), b$1 = { + }, f$3 = (t, s) => !i$5(t, s), b$1 = { attribute: !0, type: String, converter: u$3, @@ -141,13 +141,13 @@ } static _$Ei() { if (this.hasOwnProperty(d$2("elementProperties"))) return; - const t = n$5(this); + const t = n$6(this); t.finalize(), void 0 !== t.l && (this.l = [...t.l]), this.elementProperties = new Map(t.elementProperties); } static finalize() { if (this.hasOwnProperty(d$2("finalized"))) return; if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(d$2("properties"))) { - const t = this.properties, s = [...r$5(t), ...o$6(t)]; + const t = this.properties, s = [...r$5(t), ...o$7(t)]; for (const i of s) this.createProperty(i, t[i]); } const t = this[Symbol.metadata]; @@ -298,13 +298,13 @@ * SPDX-License-Identifier: BSD-3-Clause */ var t$3 = globalThis; - var i$3 = (t) => t; + var i$4 = (t) => t; var s$3 = t$3.trustedTypes; var e$5 = s$3 ? s$3.createPolicy("lit-html", { createHTML: (t) => t }) : void 0; var h$3 = "$lit$"; - var o$5 = `lit$${Math.random().toFixed(9).slice(2)}$`; - var n$4 = "?" + o$5; - var r$4 = `<${n$4}>`; + var o$6 = `lit$${Math.random().toFixed(9).slice(2)}$`; + var n$5 = "?" + o$6; + var r$4 = `<${n$5}>`; var l$1 = document; var c$3 = () => l$1.createComment(""); var a = (t) => null === t || "object" != typeof t && "function" != typeof t; @@ -340,7 +340,7 @@ let a, u, d = -1, f = 0; for (; f < s.length && (c.lastIndex = f, u = c.exec(s), null !== u);) f = c.lastIndex, c === v$1 ? "!--" === u[1] ? c = _ : void 0 !== u[1] ? c = m$1 : void 0 !== u[2] ? (y.test(u[2]) && (n = RegExp("" === u[0] ? (c = n ?? v$1, d = -1) : void 0 === u[1] ? d = -2 : (d = c.lastIndex - u[2].length, a = u[1], c = void 0 === u[3] ? p$1 : "\"" === u[3] ? $ : g) : c === $ || c === g ? c = p$1 : c === _ || c === m$1 ? c = v$1 : (c = p$1, n = void 0); const x = c === p$1 && t[i + 1].startsWith("/>") ? " " : ""; - l += c === v$1 ? s + r$4 : d >= 0 ? (e.push(a), s.slice(0, d) + h$3 + s.slice(d) + o$5 + x) : s + o$5 + (-2 === d ? i : x); + l += c === v$1 ? s + r$4 : d >= 0 ? (e.push(a), s.slice(0, d) + h$3 + s.slice(d) + o$6 + x) : s + o$6 + (-2 === d ? i : x); } return [V(t, l + (t[s] || "") + (2 === i ? "" : 3 === i ? "" : "")), e]; }; @@ -357,7 +357,7 @@ for (; null !== (r = P.nextNode()) && d.length < u;) { if (1 === r.nodeType) { if (r.hasAttributes()) for (const t of r.getAttributeNames()) if (t.endsWith(h$3)) { - const i = v[a++], s = r.getAttribute(t).split(o$5), e = /([.?@])?(.*)/.exec(i); + const i = v[a++], s = r.getAttribute(t).split(o$6), e = /([.?@])?(.*)/.exec(i); d.push({ type: 1, index: l, @@ -365,12 +365,12 @@ strings: s, ctor: "." === e[1] ? I : "?" === e[1] ? L : "@" === e[1] ? z : H }), r.removeAttribute(t); - } else t.startsWith(o$5) && (d.push({ + } else t.startsWith(o$6) && (d.push({ type: 6, index: l }), r.removeAttribute(t)); if (y.test(r.tagName)) { - const t = r.textContent.split(o$5), i = t.length - 1; + const t = r.textContent.split(o$6), i = t.length - 1; if (i > 0) { r.textContent = s$3 ? s$3.emptyScript : ""; for (let s = 0; s < i; s++) r.append(t[s], c$3()), P.nextNode(), d.push({ @@ -380,16 +380,16 @@ r.append(t[i], c$3()); } } - } else if (8 === r.nodeType) if (r.data === n$4) d.push({ + } else if (8 === r.nodeType) if (r.data === n$5) d.push({ type: 2, index: l }); else { let t = -1; - for (; -1 !== (t = r.data.indexOf(o$5, t + 1));) d.push({ + for (; -1 !== (t = r.data.indexOf(o$6, t + 1));) d.push({ type: 7, index: l - }), t += o$5.length - 1; + }), t += o$6.length - 1; } l++; } @@ -484,8 +484,8 @@ } _$AR(t = this._$AA.nextSibling, s) { for (this._$AP?.(!1, !0, s); t !== this._$AB;) { - const s = i$3(t).nextSibling; - i$3(t).remove(), t = s; + const s = i$4(t).nextSibling; + i$4(t).remove(), t = s; } } setConnected(t) { @@ -559,8 +559,8 @@ }; var j$1 = { M: h$3, - P: o$5, - A: n$4, + P: o$6, + A: n$5, C: 1, L: N, R, @@ -591,7 +591,7 @@ * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ var s$2 = globalThis; - var i$2 = class extends y$1 { + var i$3 = class extends y$1 { constructor() { super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; } @@ -613,9 +613,9 @@ return E; } }; - i$2._$litElement$ = !0, i$2["finalized"] = !0, s$2.litElementHydrateSupport?.({ LitElement: i$2 }); - var o$4 = s$2.litElementPolyfillSupport; - o$4?.({ LitElement: i$2 }); + i$3._$litElement$ = !0, i$3["finalized"] = !0, s$2.litElementHydrateSupport?.({ LitElement: i$3 }); + var o$5 = s$2.litElementPolyfillSupport; + o$5?.({ LitElement: i$3 }); (s$2.litElementVersions ??= []).push("4.2.2"); //#endregion //#region node_modules/@lit/reactive-element/decorators/custom-element.js @@ -635,14 +635,14 @@ * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */ var o$3 = { + */ var o$4 = { attribute: !0, type: String, converter: u$3, reflect: !1, hasChanged: f$3 }; - var r$3 = (t = o$3, e, r) => { + var r$3 = (t = o$4, e, r) => { const { kind: n, metadata: i } = r; let s = globalThis.litPropertyMetadata.get(i); if (void 0 === s && globalThis.litPropertyMetadata.set(i, s = /* @__PURE__ */ new Map()), "setter" === n && ((t = Object.create(t)).wrapped = !0), s.set(r.name, t), "accessor" === n) { @@ -666,7 +666,7 @@ } throw Error("Unsupported decorator location: " + n); }; - function n$3(t) { + function n$4(t) { return (e, o) => "object" == typeof o ? r$3(t, e, o) : ((t, e, o) => { const r = e.hasOwnProperty(o); return e.constructor.createProperty(o, t), r ? Object.getOwnPropertyDescriptor(e, o) : void 0; @@ -679,7 +679,7 @@ * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ function r$2(r) { - return n$3({ + return n$4({ ...r, state: !0, attribute: !1 @@ -704,7 +704,7 @@ _$litDirective$: t, values: e }); - var i$1 = class { + var i$2 = class { constructor(t) {} get _$AU() { return this._$AM._$AU; @@ -725,7 +725,7 @@ * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */ var e$3 = class extends i$1 { + */ var e$3 = class extends i$2 { constructor(i) { if (super(i), this.it = A, i.type !== t$1.CHILD) throw Error(this.constructor.directiveName + "() can only be used in child bindings"); } @@ -744,14 +744,14 @@ } }; e$3.directiveName = "unsafeHTML", e$3.resultType = 1; - var o$2 = e$4(e$3); + var o$3 = e$4(e$3); //#endregion //#region node_modules/lit-html/directives/class-map.js /** * @license * Copyright 2018 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */ var e$2 = e$4(class extends i$1 { + */ var e$2 = e$4(class extends i$2 { constructor(t) { if (super(t), t.type !== t$1.ATTRIBUTE || "class" !== t.name || t.strings?.length > 2) throw Error("`classMap()` can only be used in the `class` attribute and must be the only part in the attribute."); } @@ -789,7 +789,7 @@ /** — floating tooltip that follows the pointer */ var OFFSET = 10; var MARGIN = 8; - var DoclangCursorHint = class DoclangCursorHint extends i$2 { + var DoclangCursorHint = class DoclangCursorHint extends i$3 { static styles = r$6(cursor_hint_default); _content = null; _isHtml = false; @@ -797,22 +797,6 @@ _hidden = true; _left = 0; _top = 0; - _onHint = (e) => { - const { html, text, clientX, clientY } = e.detail; - if (html !== void 0) this.showHtml(html, clientX, clientY); - else if (text !== void 0) this.show(text, clientX, clientY); - }; - _onHide = () => this.hide(); - connectedCallback() { - super.connectedCallback(); - document.addEventListener("doclang-hint", this._onHint); - document.addEventListener("doclang-hint-hide", this._onHide); - } - disconnectedCallback() { - super.disconnectedCallback(); - document.removeEventListener("doclang-hint", this._onHint); - document.removeEventListener("doclang-hint-hide", this._onHide); - } hide() { this._content = null; this._isHtml = false; @@ -844,7 +828,7 @@ }; const style = `left:${this._left}px;top:${this._top}px`; if (this._isHtml && typeof this._content === "string") return b``; if (typeof this._content === "string") return b`