From 8f327c9ac8f96f755f85dc1c1f9d571d9bf23e4e Mon Sep 17 00:00:00 2001 From: Quixos Template Publisher Date: Sat, 1 Jan 2000 00:00:00 +0000 Subject: [PATCH] Export template source from e33714a5ca38429d1bdb77a961a14461697944f5:examples/local-first/packages/language-reader-presentation --- .quixos-template-source.json | 4 + .yarn/plugins/yarn-plugin-nixify.cjs | 1 + .yarnrc.yml | 29 + RENDERER_PROVENANCE.md | 38 + components.nix | 10 + flake.lock | 108 ++ flake.nix | 57 ++ package.json | 12 + package.qx | 44 + queries/cached-results.graphql | 13 + queries/conversation.graphql | 21 + queries/document-content.graphql | 9 + queries/document.graphql | 20 + queries/navigation.graphql | 1 + queries/reader.graphql | 20 + queries/vocabulary-lists.graphql | 6 + queries/vocabulary.graphql | 19 + quixos.check.json | 4 + quixos.lock | 22 + quixos.react.json | 33 + src/annotation-display.ts | 70 ++ src/annotator-config.tsx | 194 ++++ src/cache.ts | 58 ++ src/cached-results.tsx | 84 ++ src/canvas/Surface.tsx | 483 +++++++++ src/canvas/Tooltip.tsx | 159 +++ src/canvas/draw.ts | 142 +++ src/canvas/interaction.ts | 31 + src/canvas/layout.ts | 562 +++++++++++ src/canvas/line-breaks.ts | 36 + src/canvas/measure.ts | 25 + src/canvas/outline.ts | 118 +++ src/canvas/page-size.ts | 18 + src/canvas/pages.ts | 82 ++ src/canvas/processing.ts | 32 + src/canvas/selection.ts | 88 ++ src/canvas/text.ts | 66 ++ src/canvas/tooltip-position.ts | 20 + src/canvas/types.ts | 34 + src/config-edits.ts | 26 + src/config-schema.ts | 40 + src/controls.tsx | 299 ++++++ src/drafts.ts | 19 + src/editor.tsx | 1378 ++++++++++++++++++++++++++ src/extensions.tsx | 141 +++ src/model.ts | 64 ++ src/page-views.ts | 74 ++ src/preview.tsx | 192 ++++ src/processing.ts | 271 +++++ src/reader-extensions.tsx | 73 ++ src/render-hooks.ts | 53 + src/sources.tsx | 496 +++++++++ src/style.ts | 346 +++++++ src/tasks.tsx | 336 +++++++ src/texts.tsx | 258 +++++ src/view.tsx | 1097 ++++++++++++++++++++ src/vocabulary.tsx | 420 ++++++++ src/work.ts | 224 +++++ tests/browser-probe.ts | 245 +++++ tests/canvas.test.ts | 925 +++++++++++++++++ tests/work.test.ts | 521 ++++++++++ yarn-project.nix | 164 +++ yarn.lock | 39 + 63 files changed, 10474 insertions(+) create mode 100644 .quixos-template-source.json create mode 100644 .yarn/plugins/yarn-plugin-nixify.cjs create mode 100644 .yarnrc.yml create mode 100644 RENDERER_PROVENANCE.md create mode 100644 components.nix create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 package.json create mode 100644 package.qx create mode 100644 queries/cached-results.graphql create mode 100644 queries/conversation.graphql create mode 100644 queries/document-content.graphql create mode 100644 queries/document.graphql create mode 100644 queries/navigation.graphql create mode 100644 queries/reader.graphql create mode 100644 queries/vocabulary-lists.graphql create mode 100644 queries/vocabulary.graphql create mode 100644 quixos.check.json create mode 100644 quixos.lock create mode 100644 quixos.react.json create mode 100644 src/annotation-display.ts create mode 100644 src/annotator-config.tsx create mode 100644 src/cache.ts create mode 100644 src/cached-results.tsx create mode 100644 src/canvas/Surface.tsx create mode 100644 src/canvas/Tooltip.tsx create mode 100644 src/canvas/draw.ts create mode 100644 src/canvas/interaction.ts create mode 100644 src/canvas/layout.ts create mode 100644 src/canvas/line-breaks.ts create mode 100644 src/canvas/measure.ts create mode 100644 src/canvas/outline.ts create mode 100644 src/canvas/page-size.ts create mode 100644 src/canvas/pages.ts create mode 100644 src/canvas/processing.ts create mode 100644 src/canvas/selection.ts create mode 100644 src/canvas/text.ts create mode 100644 src/canvas/tooltip-position.ts create mode 100644 src/canvas/types.ts create mode 100644 src/config-edits.ts create mode 100644 src/config-schema.ts create mode 100644 src/controls.tsx create mode 100644 src/drafts.ts create mode 100644 src/editor.tsx create mode 100644 src/extensions.tsx create mode 100644 src/model.ts create mode 100644 src/page-views.ts create mode 100644 src/preview.tsx create mode 100644 src/processing.ts create mode 100644 src/reader-extensions.tsx create mode 100644 src/render-hooks.ts create mode 100644 src/sources.tsx create mode 100644 src/style.ts create mode 100644 src/tasks.tsx create mode 100644 src/texts.tsx create mode 100644 src/view.tsx create mode 100644 src/vocabulary.tsx create mode 100644 src/work.ts create mode 100644 tests/browser-probe.ts create mode 100644 tests/canvas.test.ts create mode 100644 tests/work.test.ts create mode 100644 yarn-project.nix create mode 100644 yarn.lock diff --git a/.quixos-template-source.json b/.quixos-template-source.json new file mode 100644 index 0000000..2515d4a --- /dev/null +++ b/.quixos-template-source.json @@ -0,0 +1,4 @@ +{ + "monorepoCommit": "e33714a5ca38429d1bdb77a961a14461697944f5", + "sourcePath": "examples/local-first/packages/language-reader-presentation" +} diff --git a/.yarn/plugins/yarn-plugin-nixify.cjs b/.yarn/plugins/yarn-plugin-nixify.cjs new file mode 100644 index 0000000..44f7f79 --- /dev/null +++ b/.yarn/plugins/yarn-plugin-nixify.cjs @@ -0,0 +1 @@ +module.exports={name:"yarn-plugin-nixify",factory:function(e){var t;return(()=>{"use strict";var n={d:(e,t)=>{for(var r in t)n.o(t,r)&&!n.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},r={};n.r(r),n.d(r,{default:()=>E});const a=e("@yarnpkg/core"),o=e("clipanion");class i extends o.Command{constructor(...e){super(...e),this.locator=o.Option.String({required:!1})}async execute(){const e=await a.Configuration.find(this.context.cwd,this.context.plugins),{project:t}=await a.Project.find(e,this.context.cwd),n=await a.Cache.find(e),r=e.makeFetcher();return(await a.StreamReport.start({configuration:e,stdout:this.context.stdout},(async e=>{if(this.locator){const{locatorHash:o}=a.structUtils.parseLocator(this.locator,!0),i=t.originalPackages.get(o);if(!i)return void e.reportError(0,`Invalid locator: ${this.locator}`);await r.fetch(i,{checksums:t.storedChecksums,project:t,cache:n,fetcher:r,report:e})}else await e.startTimerPromise("Resolution step",(async()=>{await t.resolveEverything({report:e,lockfileOnly:!0})})),await e.startTimerPromise("Fetch step",(async()=>{await t.fetchEverything({cache:n,report:e,fetcher:r})}))}))).exitCode()}}i.paths=[["nixify","fetch"]];const s=e("@yarnpkg/fslib"),c=e("crypto");class l extends o.Command{constructor(...e){super(...e),this.locator=o.Option.String(),this.source=o.Option.String(),this.installLocation=o.Option.String()}async execute(){const e=await a.Configuration.find(this.context.cwd,this.context.plugins),{project:t}=await a.Project.find(e,this.context.cwd);return await t.restoreInstallState({restoreResolutions:!1}),(await a.StreamReport.start({configuration:e,stdout:this.context.stdout},(async n=>{await t.resolveEverything({report:n,lockfileOnly:!0});const r=a.structUtils.parseLocator(this.locator,!0),o=t.storedPackages.get(r.locatorHash);if(!o)return void n.reportError(0,`Invalid locator: ${this.locator}`);const i=s.ppath.join(t.cwd,this.installLocation);await s.xfs.mkdirpPromise(s.ppath.dirname(i)),await a.execUtils.execvp("cp",["-R",this.source,i],{cwd:t.cwd,strict:!0}),await a.execUtils.execvp("chmod",["-R","u+w",i],{cwd:t.cwd,strict:!0});const l=(0,c.createHash)("sha512");l.update(process.versions.node),e.triggerHook((e=>e.globalHashGeneration),t,(e=>{l.update("\0"),l.update(e)}));const d=l.digest("hex"),p=new Map,h=e=>{let n=p.get(e.locatorHash);if(void 0!==n)return n;const r=t.storedPackages.get(e.locatorHash);if(void 0===r)throw new Error("Assertion failed: The package should have been registered");const o=(0,c.createHash)("sha512");o.update(e.locatorHash),p.set(e.locatorHash,"");for(const e of r.dependencies.values()){const n=t.storedResolutions.get(e.descriptorHash);if(void 0===n)throw new Error(`Assertion failed: The resolution (${a.structUtils.prettyDescriptor(t.configuration,e)}) should have been registered`);const r=t.storedPackages.get(n);if(void 0===r)throw new Error("Assertion failed: The package should have been registered");o.update(h(r))}return n=o.digest("hex"),p.set(e.locatorHash,n),n},u=(0,c.createHash)("sha512").update(d).update(h(o)).update(i).digest("hex");t.storedBuildState.set(o.locatorHash,u),await t.persistInstallStateFile()}))).exitCode()}}l.paths=[["nixify","inject-build"]];const d=e("@yarnpkg/plugin-pnp"),p=JSON.stringify,h=(e,t,n=!1)=>t.split("\n").map((t=>t||n?e+t:t)).join("\n"),u=(e,t)=>{let n=e;for(const[e,r]of Object.entries(t))if("string"==typeof r&&(n=n.replace(new RegExp(`@@${e}@@`,"g"),r)),"boolean"==typeof r)for(;;){const t=n.split("\n"),a=t.indexOf(`#@@ IF ${e}`),o=t.indexOf(`#@@ ENDIF ${e}`);if(-1===a||o{if(!n)return;const o=s.npath.toPortablePath(this.binDir);for(const[r,a]of n.manifest.bin){const n=s.ppath.join(o,r),i=s.ppath.join(t.cwd,s.npath.toPortablePath(a));await this.writeWrapper(n,i,{configuration:e,project:t})}if(e.get("installNixBinariesForDependencies")){await t.resolveEverything({report:r,lockfileOnly:!0});const n=await a.scriptUtils.getPackageAccessibleBinaries(t.topLevelWorkspace.anchoredLocator,{project:t});for(const[r,[a,i]]of n.entries()){const n=s.ppath.join(o,r);await this.writeWrapper(n,s.npath.toPortablePath(i),{configuration:e,project:t})}}}))).exitCode()}async writeWrapper(e,t,{configuration:n,project:r}){let a;switch(n.get("nodeLinker")){case"pnp":{const e=(0,d.getPnpPath)(r),n=[];await s.xfs.existsPromise(e.cjs)&&n.push(`--require "${s.npath.fromPortablePath(e.cjs)}"`),await s.xfs.existsPromise(e.esmLoader)&&n.push(`--experimental-loader "${(0,f.pathToFileURL)(s.npath.fromPortablePath(e.esmLoader)).href}"`),a=u("#!/bin/sh\nexport NODE_OPTIONS='@@NODE_OPTIONS@@'\nexec '@@NODE_PATH@@' '@@BINARY_PATH@@' \"$@\"\n",{NODE_PATH:process.execPath,NODE_OPTIONS:n.join(" "),BINARY_PATH:t});break}case"node-modules":a=u("#!/bin/sh\nexec '@@NODE_PATH@@' '@@BINARY_PATH@@' \"$@\"\n",{NODE_PATH:process.execPath,BINARY_PATH:t});break;default:throw Error("Assertion failed: Invalid nodeLinker")}await s.xfs.writeFilePromise(e,a),await s.xfs.chmodPromise(e,493)}}g.paths=[["nixify","install-bin"]];const m=e("os"),y=e("@yarnpkg/plugin-patch"),b=(e,t)=>(0,c.createHash)(e).update(t).digest(),x=(e,t,{storePath:n="/nix/store",recursive:r=!1}={})=>{const[a,o]=t.split("-"),i=Buffer.from(o,"base64").toString("hex"),c=b("sha256",`fixed:out:${r?"r:":""}${a}:${i}:`).toString("hex"),l=(e=>{let t="",n=[...e].reverse().map((e=>e.toString(2).padStart(8,"0"))).join("");for(;n;)t+="0123456789abcdfghijklmnpqrsvwxyz"[parseInt(n.slice(0,5),2)],n=n.slice(5);return t})(((e,t)=>{const n=Buffer.alloc(20);for(let t=0;te.replace(/^\.+/,"").replace(/[^a-zA-Z0-9+._?=-]+/g,"-").slice(0,207)||"unknown",w=(e,t="sha512")=>t+"-"+Buffer.from(e,"hex").toString("base64"),k=e=>Buffer.from(e.split("-")[1],"base64").toString("hex"),I=2**32-1,P=(e,...t)=>{let n=0;const r=t.map((e=>{const t=Buffer.from(e);if(t.byteLength>I)throw Error(`NAR string too long: ${t.byteLength}`);return n+=8+8*Math.ceil(t.byteLength/8),t})),a=Buffer.alloc(n);let o=0;for(const e of r)a.writeUInt32LE(e.byteLength,o),e.copy(a,o+8),o+=8+8*Math.ceil(e.byteLength/8);e.write(a)},$=async(e,t,n)=>{if(t>I)throw Error(`NAR string too long: ${t}`);const r=Buffer.alloc(8);r.writeUInt32LE(t),e.write(r);for await(const t of n)e.write(t);const a=8-t%8;8!==a&&e.write(Buffer.alloc(a))},N=a.YarnVersion?.startsWith("3.")||!1,E={commands:[i,l,g],hooks:{afterAllInstalled:async(e,t)=>{!1!==t.persistProject&&e.configuration.get("enableNixify")&&await(async(e,t)=>{const{configuration:n,cwd:r}=e,{cache:o,report:i}=t,l=await s.xfs.realpathPromise(s.npath.toPortablePath((0,m.tmpdir)()));if(e.cwd.startsWith(l))return void i.reportInfo(0,`Skipping Nixify, because ${e.cwd} appears to be a temporary directory`);const d=n.get("nixExprPath"),f=n.get("yarnPath");let g;if(null===f){let e=(await s.xfs.readFilePromise(process.argv[1])).toString();if(e.startsWith("#!/nix/store/")){const t=e.substring(e.indexOf("\n")+1);e=`#!/usr/bin/env node\n${t}`}const t=a.hashUtils.makeHash(Buffer.from(e));g=["fetchurl {",` url = "https://repo.yarnpkg.com/${a.YarnVersion}/packages/yarnpkg-cli/bin/yarn.js";`,` hash = "${w(t)}";`,"}"].join("\n ")}else f.startsWith(r)?g="./"+s.ppath.relative(s.ppath.dirname(d),f):(g=p(f),i.reportWarning(0,`The Yarn path ${f} is outside the project - it may not be reachable by the Nix build`));const b=n.get("cacheFolder");let I;if(b.startsWith(r))I=p(s.ppath.relative(r,b));else{if(N||!n.get("enableGlobalCache"))throw Error(`The cache folder ${b} is outside the project, this is currently not supported`);I='".yarn/cache"'}const E=new Set;for(const e of n.sources.values())for(const t of e.split(", "))t.startsWith("<")||E.add(t);for(const e of E)s.ppath.resolve(r,e).startsWith(r)||i.reportWarning(0,`The config file ${e} is outside the project - it may not be reachable by the Nix build`);const D="./"+s.ppath.relative(s.ppath.dirname(d),s.ppath.resolve(r,"yarn.lock")),S=new Map,_=new Set(await s.xfs.readdirPromise(o.cwd)),L={unstablePackages:e.conditionalLocators};for(const t of e.storedPackages.values()){const{locatorHash:n}=t,r=e.storedChecksums.get(n),a=N?o.getLocatorPath(t,r||null,L):o.getLocatorPath(t,r||null);if(!a)continue;if(!_.has(s.ppath.basename(a)))continue;const i=r?o.getChecksumFilename(t,r):o.getVersionFilename(t);S.set(i,{pkg:t,checksum:r,cachePath:a})}const O=new Map,A=n.get("individualNixPackaging");let j="",T="";if(A){for(const[e,{pkg:t,checksum:n,cachePath:r}]of S.entries()){const o=a.structUtils.stringifyLocator(t),i=n?n.split("/").pop():await a.hashUtils.checksumFile(r);O.set(o,{cachePath:r,filename:e,hash:w(i)})}j="cacheEntries = {\n";for(const e of[...O.keys()].sort()){const t=O.get(e);j+=`${p(e)} = { ${[`filename = ${p(t.filename)};`,`hash = "${t.hash}";`].join(" ")} };\n`}j+="};"}else{const e=(0,c.createHash)("sha512");P(e,"nix-archive-1","(","type","directory");for(const t of[...S.keys()].sort()){const{cachePath:n}=S.get(t),{size:r}=await s.xfs.statPromise(n);P(e,"entry","(","name",t,"node","(","type","regular","contents"),await $(e,r,s.xfs.createReadStream(n)),P(e,")",")")}P(e,")"),e.end();for await(const t of e)T=w(t)}const C=n.get("isolatedNixBuilds");let R=new Set,F=[],B=[];const H=n.get("nodeLinker"),U=n.get("pnpUnpluggedFolder"),M=(t,n=new Set)=>{const r=a.structUtils.stringifyLocator(t);if(O.has(r)&&n.add(r),a.structUtils.isVirtualLocator(t)){const r=e.storedPackages.get(a.structUtils.devirtualizeLocator(t).locatorHash);if(!r)throw Error("Assertion failed: The locator should have been registered");M(r,n)}if(t.reference.startsWith("patch:")){const r=e.storedPackages.get(y.patchUtils.parseLocator(t).sourceLocator.locatorHash);if(!r)throw Error("Assertion failed: The locator should have been registered");M(r,n)}for(const r of t.dependencies.values()){const t=e.storedResolutions.get(r.descriptorHash);if(!t)throw Error("Assertion failed: The descriptor should have been registered");const a=e.storedPackages.get(t);if(!a)throw Error("Assertion failed: The locator should have been registered");M(a,n)}return n};for(const t of e.storedBuildState.keys()){const n=e.storedPackages.get(t);if(!n)throw Error("Assertion failed: The locator should have been registered");if(!C.includes(n.name))continue;let r;if("pnp"!==H)throw Error(`The nodeLinker ${H} is not supported for isolated Nix builds`);r=s.ppath.relative(e.cwd,s.ppath.join(U,a.structUtils.slugifyLocator(n),a.structUtils.getIdentVendorPath(n)));let o=n;if(a.structUtils.isVirtualLocator(o)){const{locatorHash:t}=a.structUtils.devirtualizeLocator(o),n=e.storedPackages.get(t);if(!n)throw Error("Assertion failed: The locator should have been registered");o=n}const i=a.structUtils.stringifyLocator(o),c=a.structUtils.stringifyLocator(n),l=`isolated.${p(i)}`;if(!R.has(o)){R.add(o);const e=[`pname = ${p(n.name)};`,`version = ${p(n.version)};`,`reference = ${p(o.reference)};`];if(A){const t=[...M(n)].sort().map((e=>`${p(e)}\n`)).join("");t&&e.push(`locators = [\n${t}];`)}const t=`override${V=n.name,V.split(/[^a-zA-Z0-9]+/g).filter((e=>e)).map((e=>{return(t=e).slice(0,1).toUpperCase()+t.slice(1);var t})).join("")}Attrs`;B.push(`${l} = optionalOverride (args.${t} or null) (mkIsolatedBuild { ${e.join(" ")} });`)}0===F.length&&F.push("# Copy in isolated builds."),F.push(`echo 'injecting build for ${n.name}'`,"yarn nixify inject-build \\",` ${p(c)} \\`,` \${${l}} \\`,` ${p(r)}`)}var V;if(F.length>0&&F.push("echo 'running yarn install'"),null==t.mode||0===C.length){const t=e.topLevelWorkspace.manifest.name,o=t?a.structUtils.stringifyIdent(t):"workspace",c=u("# This file is generated by running \"yarn install\" inside your project.\n# Manual changes might be lost - proceed with caution!\n\n{ lib, stdenv, nodejs, git, cacert, fetchurl, writeShellScript, writeShellScriptBin }:\n{ src, overrideAttrs ? null, ... } @ args:\n\nlet\n\n yarnBin = @@YARN_BIN@@;\n\n cacheFolder = @@CACHE_FOLDER@@;\n lockfile = @@LOCKFILE@@;\n\n # Call overrideAttrs on a derivation if a function is provided.\n optionalOverride = fn: drv:\n if fn == null then drv else drv.overrideAttrs fn;\n\n # Simple stub that provides the global yarn command.\n yarn = writeShellScriptBin \"yarn\" ''\n exec '${nodejs}/bin/node' '${yarnBin}' \"$@\"\n '';\n\n # Common attributes between Yarn derivations.\n drvCommon = {\n # Make sure the build uses the right Node.js version everywhere.\n buildInputs = [ nodejs yarn ];\n # All dependencies should already be cached.\n yarn_enable_network = \"0\";\n # Tell node-gyp to use the provided Node.js headers for native code builds.\n npm_config_nodedir = nodejs;\n };\n\n # Comman variables that we set in a Nix build, but not in a Nix shell.\n buildVars = ''\n # Make Yarn produce friendlier logging for automated builds.\n export CI=1\n # Tell node-pre-gyp to never fetch binaries / always build from source.\n export npm_config_build_from_source=true\n '';\n\n#@@ IF COMBINED_DRV\n cacheDrv = stdenv.mkDerivation {\n name = \"yarn-cache\";\n buildInputs = [ yarn git cacert ];\n buildCommand = ''\n cp --reflink=auto --recursive '${src}' ./src\n cd ./src/\n ${buildVars}\n HOME=\"$TMP\" yarn_enable_global_cache=false yarn_cache_folder=\"$out\" \\\n yarn nixify fetch\n rm $out/.gitignore\n '';\n outputHashMode = \"recursive\";\n outputHash = \"@@COMBINED_HASH@@\";\n };\n#@@ ENDIF COMBINED_DRV\n#@@ IF INDIVIDUAL_DRVS\n # Create derivations for fetching dependencies.\n cacheDrvs = let\n in lib.mapAttrs (locator: { filename, hash }: stdenv.mkDerivation {\n name = lib.strings.sanitizeDerivationName locator;\n buildInputs = [ yarn git cacert ];\n buildCommand = ''\n cd '${src}'\n ${buildVars}\n HOME=\"$TMP\" yarn_enable_global_cache=false yarn_cache_folder=\"$TMP\" \\\n yarn nixify fetch ${lib.escapeShellArg locator}\n # Because we change the cache dir, Yarn may generate a different name.\n mv \"$TMP/$(sed 's/-[^-]*\\.[^-]*$//' <<< \"$outputFilename\")\"-* $out\n '';\n outputFilename = filename;\n outputHash = hash;\n }) cacheEntries;\n\n # Create a shell snippet to copy dependencies from a list of derivations.\n mkCacheBuilderForDrvs = drvs:\n writeShellScript \"collect-yarn-cache\" (lib.concatMapStrings (drv: ''\n cp --reflink=auto ${drv} '${drv.outputFilename}'\n '') drvs);\n#@@ ENDIF INDIVIDUAL_DRVS\n\n#@@ IF NEED_ISOLATED_BUILD_SUPPRORT\n#@@ IF INDIVIDUAL_DRVS\n # Create a shell snippet to copy dependencies from a list of locators.\n mkCacheBuilderForLocators = let\n pickCacheDrvs = map (locator: cacheDrvs.${locator});\n in locators:\n mkCacheBuilderForDrvs (pickCacheDrvs locators);\n#@@ ENDIF INDIVIDUAL_DRVS\n\n # Create a derivation that builds a module in isolation.\n mkIsolatedBuild = { pname, version, reference, locators ? [] }: stdenv.mkDerivation (drvCommon // {\n inherit pname version;\n dontUnpack = true;\n\n configurePhase = ''\n ${buildVars}\n unset yarn_enable_nixify # plugin is not present\n '';\n\n buildPhase = ''\n mkdir -p .yarn/cache\n#@@ IF COMBINED_DRV\n cp --reflink=auto --recursive ${cacheDrv}/* .yarn/cache/\n#@@ ENDIF COMBINED_DRV\n#@@ IF INDIVIDUAL_DRVS\n pushd .yarn/cache > /dev/null\n source ${mkCacheBuilderForLocators locators}\n popd > /dev/null\n#@@ ENDIF INDIVIDUAL_DRVS\n\n echo '{ \"dependencies\": { \"${pname}\": \"${reference}\" } }' > package.json\n install -m 0600 ${lockfile} ./yarn.lock\n export yarn_global_folder=\"$TMP\"\n export yarn_enable_global_cache=false\n export yarn_enable_immutable_installs=false\n yarn\n '';\n\n installPhase = ''\n unplugged=( .yarn/unplugged/${pname}-*/node_modules/* )\n if [[ ! -e \"''${unplugged[@]}\" ]]; then\n echo >&2 \"Could not find the unplugged path for ${pname}\"\n exit 1\n fi\n\n mv \"$unplugged\" $out\n '';\n });\n#@@ ENDIF NEED_ISOLATED_BUILD_SUPPRORT\n\n # Main project derivation.\n project = stdenv.mkDerivation (drvCommon // {\n inherit src;\n name = @@PROJECT_NAME@@;\n\n configurePhase = ''\n ${buildVars}\n\n # Copy over the Yarn cache.\n rm -fr '${cacheFolder}'\n mkdir -p '${cacheFolder}'\n#@@ IF COMBINED_DRV\n cp --reflink=auto --recursive ${cacheDrv}/* '${cacheFolder}/'\n#@@ ENDIF COMBINED_DRV\n#@@ IF INDIVIDUAL_DRVS\n pushd '${cacheFolder}' > /dev/null\n source ${mkCacheBuilderForDrvs (lib.attrValues cacheDrvs)}\n popd > /dev/null\n#@@ ENDIF INDIVIDUAL_DRVS\n\n # Yarn may need a writable home directory.\n export yarn_global_folder=\"$TMP\"\n\n # Ensure global cache is disabled. Cache must be part of our output.\n touch .yarnrc.yml\n sed -i -e '/^enableGlobalCache/d' .yarnrc.yml\n echo 'enableGlobalCache: false' >> .yarnrc.yml\n\n # Some node-gyp calls may call out to npm, which could fail due to an\n # read-only home dir.\n export HOME=\"$TMP\"\n\n # running preConfigure after the cache is populated allows for\n # preConfigure to contain substituteInPlace for dependencies as well as the\n # main project. This is necessary for native bindings that maybe have\n # hardcoded values.\n runHook preConfigure\n\n@@ISOLATED_INTEGRATION@@\n\n # Run normal Yarn install to complete dependency installation.\n yarn install --immutable --immutable-cache\n\n runHook postConfigure\n '';\n\n buildPhase = ''\n runHook preBuild\n runHook postBuild\n '';\n\n installPhase = ''\n runHook preInstall\n\n # Move the package contents to the output directory.\n if grep -q '\"workspaces\"' package.json; then\n # We can't use `yarn pack` in a workspace setup, because it only\n # packages the outer workspace.\n mkdir -p \"$out/libexec\"\n mv $PWD \"$out/libexec/$name\"\n else\n # - If the package.json has a `files` field, only files matching those patterns are copied\n # - Otherwise all files are copied.\n yarn pack --out package.tgz\n mkdir -p \"$out/libexec/$name\"\n tar xzf package.tgz --directory \"$out/libexec/$name\" --strip-components=1\n\n cp --reflink=auto .yarnrc* \"$out/libexec/$name\"\n cp --reflink=auto ${lockfile} \"$out/libexec/$name/yarn.lock\"\n cp --reflink=auto --recursive .yarn \"$out/libexec/$name\"\n\n # Copy the Yarn linker output into the package.\n#@@ IF USES_PNP_LINKER\n cp --reflink=auto .pnp.* \"$out/libexec/$name\"\n#@@ ENDIF USES_PNP_LINKER\n#@@ IF USES_NM_LINKER\n cp --reflink=auto --recursive node_modules \"$out/libexec/$name\"\n#@@ ENDIF USES_NM_LINKER\n fi\n\n cd \"$out/libexec/$name\"\n\n # Invoke a plugin internal command to setup binaries.\n mkdir -p \"$out/bin\"\n yarn nixify install-bin $out/bin\n\n#@@ IF USES_NM_LINKER\n # A package with node_modules doesn't need the cache\n yarn cache clean\n#@@ ENDIF USES_NM_LINKER\n\n runHook postInstall\n '';\n\n passthru = {\n inherit nodejs;\n yarn-freestanding = yarn;\n yarn = writeShellScriptBin \"yarn\" ''\n exec '${yarn}/bin/yarn' --cwd '${overriddenProject}/libexec/${overriddenProject.name}' \"$@\"\n '';\n };\n });\n\n overriddenProject = optionalOverride overrideAttrs project;\n\n@@CACHE_ENTRIES@@\n@@ISOLATED@@\nin overriddenProject\n",{PROJECT_NAME:p(o),YARN_BIN:g,LOCKFILE:D,INDIVIDUAL_DRVS:A,COMBINED_DRV:!A,COMBINED_HASH:T,CACHE_FOLDER:I,CACHE_ENTRIES:j,ISOLATED:B.join("\n"),ISOLATED_INTEGRATION:h(" ",F.join("\n")),NEED_ISOLATED_BUILD_SUPPRORT:F.length>0,USES_PNP_LINKER:"pnp"===n.get("nodeLinker"),USES_NM_LINKER:"node-modules"===n.get("nodeLinker")}).replace(/\n\n\n+/g,"\n\n");if(await s.xfs.mkdirpPromise(s.ppath.dirname(d)),await s.xfs.writeFilePromise(d,c),n.get("generateDefaultNix")){const e=s.ppath.join(r,"default.nix"),t=s.ppath.join(r,"flake.nix");s.xfs.existsSync(e)||s.xfs.existsSync(t)||(await s.xfs.writeFilePromise(e,"# This is a minimal `default.nix` by yarn-plugin-nixify. You can customize it\n# as needed, it will not be overwritten by the plugin.\n\n{ pkgs ? import { } }:\n\npkgs.callPackage ./yarn-project.nix { } { src = ./.; }\n"),i.reportInfo(0,"A minimal default.nix was created. You may want to customize it."))}}n.get("enableNixPreload")&&s.xfs.existsSync(s.npath.toPortablePath("/nix/store"))&&await s.xfs.mktempPromise((async t=>{const n=["--add-fixed","sha512"],r=[];if(A)for(const[e,{cachePath:n,hash:a}]of O.entries()){const o=v(e),i=x(o,a);if(!s.xfs.existsSync(i)){const e=s.ppath.join(t,k(a).slice(0,7));await s.xfs.mkdirPromise(e);const i=s.ppath.join(e,o);await s.xfs.copyFilePromise(n,i),r.push(i)}}else{n.unshift("--recursive");const e=x("yarn-cache",T,{recursive:!0});if(!s.xfs.existsSync(e)){const e=s.ppath.join(t,"yarn-cache");await s.xfs.mkdirPromise(e);for(const[t,{cachePath:n}]of S.entries()){const r=s.ppath.join(e,t);await s.xfs.copyFilePromise(n,r)}r.push(e)}}try{const t=r.length;for(;0!==r.length;){const t=r.splice(0,100);await a.execUtils.execvp("nix-store",[...n,...t],{cwd:e.cwd,strict:!0})}0!==t&&i.reportInfo(0,A?`Preloaded ${t} packages into the Nix store`:"Preloaded cache into the Nix store")}catch(e){if("ENOENT"!==e.code)throw e}}))})(e,t)}},configuration:{enableNixify:{description:"If false, disables the Nixify plugin hook that generates Nix expressions",type:a.SettingsType.BOOLEAN,default:!0},nixExprPath:{description:"Path of the file where the project Nix expression will be written to",type:a.SettingsType.ABSOLUTE_PATH,default:"./yarn-project.nix"},generateDefaultNix:{description:"If true, a default.nix will be generated if it does not exist",type:a.SettingsType.BOOLEAN,default:!0},enableNixPreload:{description:"If true, cached packages will be preloaded into the Nix store",type:a.SettingsType.BOOLEAN,default:!0},individualNixPackaging:{description:"If true, generate one Nix derivation per package. If false, use a single derivation for the entire cache folder.",type:a.SettingsType.BOOLEAN,default:!1},isolatedNixBuilds:{description:"Dependencies with a build step that can be built in an isolated derivation",type:a.SettingsType.STRING,default:[],isArray:!0},installNixBinariesForDependencies:{description:"If true, the Nix output 'bin' directory will also contain executables for binaries defined by dependencies",type:a.SettingsType.BOOLEAN,default:!1}}};t=r})(),t}}; \ No newline at end of file diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..35c498d --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,29 @@ +approvedGitRepositories: + - "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/*" + +enableScripts: true + +generateDefaultNix: false + +individualNixPackaging: true + +nodeLinker: node-modules + +npmMinimalAgeGate: 0 + +plugins: + - checksum: 271de9a785321a37c564b8e2a6b5cd689850b20572d5fee8ec0a6b8e7a5d4cfb59e533524af2b4a537e00da12828abd062eaec6978556e3dd9149a020db2591b + path: .yarn/plugins/yarn-plugin-nixify.cjs + spec: "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/yarn-plugin-nixify-patched/raw/commit/4528fdd20b30d869262443b3f044549810e75fb8/dist/yarn-plugin-nixify.js" + +supportedArchitectures: + cpu: + - current + - x64 + - arm64 + libc: + - current + - glibc + os: + - current + - linux diff --git a/RENDERER_PROVENANCE.md b/RENDERER_PROVENANCE.md new file mode 100644 index 0000000..284ece9 --- /dev/null +++ b/RENDERER_PROVENANCE.md @@ -0,0 +1,38 @@ +# Renderer provenance + +This renderer follows the measured source-span layout and aligned overhead +annotations from Fluxtapose's `reading-chapter-ui`, including its later highlight +and underline work. The recovered source was the Nix-cached package at +`/nix/store/2ka0jcdpyijmpjsrwqkap682l4xk88zi-source`, rather than the earlier +prose-probe prototype. Fluxtapose's `reading-app-2` references package revision +`4e479c1a74c13d7467071da60b98fc148128dd99`; exact cache revision is unconfirmed. + +Recovered file SHA-256: + +| File | SHA-256 | +| ------------------ | ---------------------------------------------------------------- | +| text-layout.ts | e6c330f60b7f1c16235454d1ce9c7656ef2379e744322ac13826753831e13d72 | +| text-rendering.ts | ce2ea4f15f4d3b86d4b8f72d6ac7221cb131c996d19ec24052d80e3235012f77 | +| text-processing.ts | 7d8a72aa2d8791ad37351d1c7c872419b288b5e0e51214983a29ff48c8465038 | + +The original shared source/annotation flow is preserved: every annotation source +has one row per physical text line. Long words and translations request horizontal +padding around their source ranges and continue across subsequent lines. A forward +capacity constraint replaces the ten-pass spacing loop; collisions never create +additional annotation rows. Conflicting ranges from the same source share an +ordered horizontal run. Row heights use all glyphs/ranges, and mixed font sizes +share a baseline. Text keeps original +UTF-16 offsets, including leading spaces and grapheme clusters. Measurement is +injected for tests and uses a bounded cache in browsers. Canvas drawing and +selection have no Konva or memoizee dependency. + +Legal wrap positions use the pinned `@cto.af/linebreak` 4.0.3 implementation of +Unicode 17 UAX #14, intersected with Intl grapheme boundaries. Intl word +segmentation remains responsible for word targeting and preferred word affinity. +Source text and overhead labels use the same line-breaking layer. Closing marks +stay with preceding text, opening brackets stay with following text, and +annotation padding cannot insert a break inside those punctuation spans. Oversized +words and numbers use emergency tailoring of the alphabetic/numeric rules while +retaining punctuation rules; viewports narrower than an inseparable span still +require clipping. The package's Yarn lock and generated Nix dependency closure +retain the library and its transitive dependencies for reproducible browser builds. diff --git a/components.nix b/components.nix new file mode 100644 index 0000000..7dab2d1 --- /dev/null +++ b/components.nix @@ -0,0 +1,10 @@ +{ + "component:reading:reader" = { + entry = "src/view.tsx"; + export = "Reader"; + }; + "component:reading:vocabulary" = { + entry = "src/vocabulary.tsx"; + export = "Vocabulary"; + }; +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..54403f0 --- /dev/null +++ b/flake.lock @@ -0,0 +1,108 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "helpers": { + "flake": false, + "locked": { + "lastModified": 1790852599, + "narHash": "sha256-MdNtZpcsoguLPSCpAy9Naz6fkVPrftbZQbz1Aj7Zm9A=", + "ref": "refs/tags/quixos-reachability/91679e8cc82133093cb0b4f9e519cbb39a5bba4f", + "rev": "91679e8cc82133093cb0b4f9e519cbb39a5bba4f", + "revCount": 54, + "type": "git", + "url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git" + }, + "original": { + "ref": "refs/tags/quixos-reachability/91679e8cc82133093cb0b4f9e519cbb39a5bba4f", + "rev": "91679e8cc82133093cb0b4f9e519cbb39a5bba4f", + "type": "git", + "url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1783776592, + "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "protocol": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1791261504, + "narHash": "sha256-QSk9DWR7n5KL6cMHzmSKKXNIyaFA0s8Nu+wXjhhlENI=", + "ref": "refs/tags/quixos-reachability/5b44c8154dc038efd87cf1cbdd2c00b66bfd4f87", + "rev": "5b44c8154dc038efd87cf1cbdd2c00b66bfd4f87", + "revCount": 184, + "type": "git", + "url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git" + }, + "original": { + "ref": "refs/tags/quixos-reachability/5b44c8154dc038efd87cf1cbdd2c00b66bfd4f87", + "rev": "5b44c8154dc038efd87cf1cbdd2c00b66bfd4f87", + "type": "git", + "url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git" + } + }, + "root": { + "inputs": { + "flake-utils": [ + "protocol", + "flake-utils" + ], + "helpers": "helpers", + "nixpkgs": [ + "protocol", + "nixpkgs" + ], + "protocol": "protocol" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..e25bae3 --- /dev/null +++ b/flake.nix @@ -0,0 +1,57 @@ +{ + inputs.protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/tags/quixos-reachability/5b44c8154dc038efd87cf1cbdd2c00b66bfd4f87&rev=5b44c8154dc038efd87cf1cbdd2c00b66bfd4f87"; + inputs.nixpkgs.follows = "protocol/nixpkgs"; + inputs.flake-utils.follows = "protocol/flake-utils"; + inputs.helpers = { + url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git?ref=refs/tags/quixos-reachability/91679e8cc82133093cb0b4f9e519cbb39a5bba4f&rev=91679e8cc82133093cb0b4f9e519cbb39a5bba4f"; + flake = false; + }; + outputs = + { + nixpkgs, + flake-utils, + helpers, + ... + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + camino = import (helpers + "/quixos-package-helpers.nix"); + in + { + quixosPackages.checkedArtifacts = + context: + let + pkgs = camino.packageSet { inherit nixpkgs system context; }; + dependencies = camino.mkCaminoYarnDependencies { + inherit pkgs; + src = ./.; + }; + testedSource = + pkgs.runCommand "language-reader-presentation-tested-source" + { + nativeBuildInputs = [ + pkgs.nodejs_24 + pkgs.esbuild + ]; + } + '' + export NODE_PATH=${dependencies} + esbuild ${./.}/tests/canvas.test.ts --bundle --platform=node --format=esm --outfile=canvas.mjs + node canvas.mjs + esbuild ${./.}/tests/work.test.ts --bundle --platform=node --format=esm --outfile=work.mjs + node work.mjs + cp -r ${./.} "$out" + ''; + + in + (camino.mkCaminoReactPackage { + inherit pkgs context dependencies; + src = testedSource; + modules = import ./components.nix; + }); + devShells.default = camino.mkCaminoYarnShell { inherit pkgs; }; + } + ); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..30ff053 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "@quixos-example/language-reader-presentation", + "private": true, + "type": "module", + "packageManager": "yarn@4.18.0", + "scripts": { + "typecheck": "qx-workspace check" + }, + "dependencies": { + "@cto.af/linebreak": "4.0.3" + } +} diff --git a/package.qx b/package.qx new file mode 100644 index 0000000..7723a18 --- /dev/null +++ b/package.qx @@ -0,0 +1,44 @@ +import interface LanguageReader; +import interface ReadingReader; +import interface ReadingDocument; +import interface ReadingFeature; +import interface ReadingJob; +import interface ReadingWord; +import interface ReadingVocabulary; +import interface AgentRequest; +import interface AgentThread; +import interface AgentItem; +import interface AgentQuestion; +import interface StandaloneView; +import interface ReactComponent; +import interface PresentationMount; +import interface PresentationState; +import interface Presentation; +import interface ReadingNavigation; +import interface ReadingServices; +import interface GutenbergCatalog; +external atom ReadingJobObject id "atom:reading:job"; +external atom ReadingWordObject id "atom:reading:word"; +external atom AgentRequestObject id "atom:org.quixos.agent:request"; +external atom AgentThreadObject id "atom:org.quixos.agent:thread"; +external atom AgentItemObject id "atom:org.quixos.agent:item"; +external atom AgentQuestionObject id "atom:org.quixos.agent:question"; +package LanguageReaderPresentation id "package:reading:presentation" revision "package:reading:presentation@1" { + module Reader id "component:reading:reader" implements ReactComponent.render; + module Vocabulary id "component:reading:vocabulary" implements StandaloneView.render; + query VocabularyInputs id "reading:ui:vocabulary-inputs" root ReadingVocabulary document "queries/vocabulary.graphql" operation "Vocabulary" {replica;view ReadingWordObject as ReadingWord;max rows 300;max result-bytes 1048576;} + query VocabularyView id "reading:ui:vocabulary" root ReadingVocabulary document "queries/vocabulary.graphql" operation "Vocabulary" {local from VocabularyInputs;watch;view ReadingWordObject as ReadingWord;max rows 300;max result-bytes 1048576;} + query VocabularyListsInputs id "reading:ui:vocabulary-lists-inputs" root LanguageReader document "queries/vocabulary-lists.graphql" operation "VocabularyLists" {replica;max rows 40;} + query VocabularyListsView id "reading:ui:vocabulary-lists" root LanguageReader document "queries/vocabulary-lists.graphql" operation "VocabularyLists" {local from VocabularyListsInputs;watch;max rows 40;} + query fragment Navigation id "reading:ui:navigation" root ReadingNavigation document "queries/navigation.graphql" fragment "Navigation" {max rows 500;max depth 6;} + query Inputs id "reading:ui:inputs" root LanguageReader document "queries/reader.graphql" operation "Reader" {replica;view AgentRequestObject as AgentRequest;view ReadingJobObject as ReadingJob;view AgentThreadObject as AgentThread;view AgentItemObject as AgentItem;view AgentQuestionObject as AgentQuestion;max rows 3000;max depth 12;max result-bytes 8388608;} + query View id "reading:ui:view" root LanguageReader document "queries/reader.graphql" operation "Reader" {local from Inputs;watch;view AgentRequestObject as AgentRequest;view ReadingJobObject as ReadingJob;view AgentThreadObject as AgentThread;view AgentItemObject as AgentItem;view AgentQuestionObject as AgentQuestion;max rows 3000;max depth 12;max result-bytes 8388608;} + query DocumentContentInputs id "reading:ui:document-content-inputs" root ReadingDocument document "queries/document-content.graphql" operation "DocumentContent" {replica;max rows 8;max result-bytes 8388608;} + query DocumentContentView id "reading:ui:document-content" root ReadingDocument document "queries/document-content.graphql" operation "DocumentContent" {local from DocumentContentInputs;watch;max rows 8;max result-bytes 8388608;} + query ConversationInputs id "reading:ui:conversation-inputs" root ReadingJob document "queries/conversation.graphql" operation "Conversation" {replica;view AgentRequestObject as AgentRequest;view AgentThreadObject as AgentThread;view AgentItemObject as AgentItem;view AgentQuestionObject as AgentQuestion;max rows 1000;max depth 8;max result-bytes 8388608;} + query ConversationView id "reading:ui:conversation" root ReadingJob document "queries/conversation.graphql" operation "Conversation" {local from ConversationInputs;watch;view AgentRequestObject as AgentRequest;view AgentThreadObject as AgentThread;view AgentItemObject as AgentItem;view AgentQuestionObject as AgentQuestion;max rows 1000;max depth 8;max result-bytes 8388608;} + query DocumentInputs id "reading:ui:document-inputs" root ReadingDocument document "queries/document.graphql" operation "Document" {replica;view AgentRequestObject as AgentRequest;max rows 5000;max depth 7;max result-bytes 8388608;} + query DocumentView id "reading:ui:document" root ReadingDocument document "queries/document.graphql" operation "Document" {local from DocumentInputs;watch;view AgentRequestObject as AgentRequest;max rows 5000;max depth 7;max result-bytes 8388608;} + query CachedResultsInputs id "reading:ui:cached-results-inputs" root ReadingDocument document "queries/cached-results.graphql" operation "CachedResults" {replica;view AgentRequestObject as AgentRequest;max rows 1100;max depth 7;max result-bytes 8388608;} + query CachedResultsView id "reading:ui:cached-results" root ReadingDocument document "queries/cached-results.graphql" operation "CachedResults" {local from CachedResultsInputs;watch;view AgentRequestObject as AgentRequest;max rows 1100;max depth 7;max result-bytes 8388608;} +} diff --git a/queries/cached-results.graphql b/queries/cached-results.graphql new file mode 100644 index 0000000..579a1c7 --- /dev/null +++ b/queries/cached-results.graphql @@ -0,0 +1,13 @@ +query CachedResults($after: Cursor) { + root { + results(first: 256, after: $after) { + entries { node { + configuration + data { key kind featureRevision sourceRevision start end status output error createdAt } + feature {_camino {ref}} + request {progress {status reason}} + } } + pageInfo {hasNextPage endCursor} + } + } +} diff --git a/queries/conversation.graphql b/queries/conversation.graphql new file mode 100644 index 0000000..62dc011 --- /dev/null +++ b/queries/conversation.graphql @@ -0,0 +1,21 @@ +query Conversation { + root { + reader { _camino { ref } } + configuration + data { key kind status output error } + request { + progress { status reason attempt generation } + questions(first: 8) { entries { node { data {key title options status answer attempt generation} } } } + } + thread { + data { title kind } + active { + progress { status reason attempt generation } + questions(first: 8) { entries { node { data {key title options status answer attempt generation} } } } + } + items(first: 100, orderBy: {data: {sequence: DESC}}) { + entries { node { request { _camino {ref} } data {key kind text phase sequence} } } + } + } + } +} diff --git a/queries/document-content.graphql b/queries/document-content.graphql new file mode 100644 index 0000000..11cfc7e --- /dev/null +++ b/queries/document-content.graphql @@ -0,0 +1,9 @@ +query DocumentContent { + root { + library { _camino { ref } } + reader { _camino { ref } } + canonical { data { title } } + adaptation + data { title text sourceRevision source url attribution } + } +} diff --git a/queries/document.graphql b/queries/document.graphql new file mode 100644 index 0000000..67097df --- /dev/null +++ b/queries/document.graphql @@ -0,0 +1,20 @@ +query Document { + root { + reader {_camino {ref}} + canonical {data {title}} + adaptation + data { title text sourceRevision source url attribution } + jobs(first: 1024) { entries { node { + configuration + data { key kind featureRevision sourceRevision start end status output error createdAt } + feature { _camino { ref } } + request { progress { status reason } } + } } } + results(first: 1024) { entries { node { + configuration + data { key kind featureRevision sourceRevision start end status output error createdAt } + feature { _camino { ref } } + request { progress { status reason } } + } } pageInfo {hasNextPage endCursor} } + } +} diff --git a/queries/navigation.graphql b/queries/navigation.graphql new file mode 100644 index 0000000..ac67695 --- /dev/null +++ b/queries/navigation.graphql @@ -0,0 +1 @@ +fragment Navigation on ReadingNavigation { document { _camino { ref } } reading { displayMode position } annotationTarget {start end} viewports {client width height expiresAt} cursor { anchor focus size } ui {page panel section sourceTab} settingsReader {_camino {ref} library {_camino {ref}} vocabulary {_camino {ref}} palette {text accent} data {name size width font background configRevision} features(first:200) {entries {node {_camino {ref}}}}} settingsFeature {_camino {ref}} builder {style reveal color options} prompts {name change followup generate} draft { title text custom } } diff --git a/queries/reader.graphql b/queries/reader.graphql new file mode 100644 index 0000000..45d9253 --- /dev/null +++ b/queries/reader.graphql @@ -0,0 +1,20 @@ +query Reader { + root { + name + archivedTypes + readers(first: 40) { entries { node { library {_camino {ref}} vocabulary {_camino {ref}} palette {text accent} data {name size width font background configRevision} features(first: 200) {entries {node {_camino {ref}}}} } } } + defaultVocabulary {_camino {ref}} + defaultReader {_camino {ref}} + inferenceProfile + documents(first: 100) { entries { node { archived data { title source url attribution } } } } + features(first: 200) { entries { node { authoring {_camino {ref}} options data { name processor enabled configRevision } } } } + tasks(first: 20, where: {data: {kind: {in: ["customize", "text"]}}}, orderBy: {data: {createdAt: DESC}}) { entries { node { + reader {_camino {ref}} + document {_camino {ref}} + configuration + data { key kind status error createdAt } + request { progress { status reason attempt generation } } + thread { data { title kind } active { progress {status reason attempt generation} } } + } } } + } +} diff --git a/queries/vocabulary-lists.graphql b/queries/vocabulary-lists.graphql new file mode 100644 index 0000000..556b110 --- /dev/null +++ b/queries/vocabulary-lists.graphql @@ -0,0 +1,6 @@ +query VocabularyLists($search: String!, $after: Cursor) { + root { vocabularies(first: 30, after: $after, where: {name: {containsInsensitive: $search}}, orderBy: {name: ASC}) { + entries {node {name}} + pageInfo {hasNextPage endCursor} + } } +} diff --git a/queries/vocabulary.graphql b/queries/vocabulary.graphql new file mode 100644 index 0000000..c0871bb --- /dev/null +++ b/queries/vocabulary.graphql @@ -0,0 +1,19 @@ +query Vocabulary($search: String!, $after: Cursor) { + root { + name + draft { + word + } + words(first: 250, after: $after, where: { text: { containsInsensitive: $search } }, orderBy: { text: ASC }) { + entries { + node { + text + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +} diff --git a/quixos.check.json b/quixos.check.json new file mode 100644 index 0000000..528d4e1 --- /dev/null +++ b/quixos.check.json @@ -0,0 +1,4 @@ +{ + "backend": "execution", + "queryFrontends": ["react"] +} diff --git a/quixos.lock b/quixos.lock new file mode 100644 index 0000000..85abd8c --- /dev/null +++ b/quixos.lock @@ -0,0 +1,22 @@ +quixos-lock version 1 { + quixos source { repository "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git"; commit "e33714a5ca38429d1bdb77a961a14461697944f5"; } + interface LanguageReader source { repository "https://repos.quixos.org/quixos-local-first-examples/interface-language-reader.git"; commit "1944b25327ba71fec27194f28a584efa6f4c3cca"; } + interface ReadingReader source { repository "https://repos.quixos.org/quixos-local-first-examples/interface-reading-reader.git"; commit "815ce87c87337d29bdd2611fe6741dff26f89a2c"; } + interface ReadingDocument source { repository "https://repos.quixos.org/quixos-local-first-examples/interface-reading-document.git"; commit "83aa9b69840ca1cda3cb25a9fbf6c4cdc2f4acfa"; } + interface ReadingFeature source { repository "https://repos.quixos.org/quixos-local-first-examples/interface-reading-feature.git"; commit "46b7a4cbdf982b3bc013f7b6790433043742baee"; } + interface ReadingJob source { repository "https://repos.quixos.org/quixos-local-first-examples/interface-reading-job.git"; commit "e06437b9b23f427c977ea19b5a0930a2ccae92fe"; } + interface ReadingWord source { repository "https://repos.quixos.org/quixos-local-first-examples/interface-reading-word.git"; commit "b1496ba5435dae96dd50814bd6a65f8ab3f89b04"; } + interface ReadingVocabulary source { repository "https://repos.quixos.org/quixos-local-first-examples/interface-reading-vocabulary.git"; commit "8475d219b8a37da1c0acb477e175c96fba9127c7"; } + interface AgentRequest source { repository "https://repos.quixos.org/quixos-studio/interface-agent-request.git"; commit "d9bb364113d6590770f4733d3a9d42364e3b8806"; } + interface AgentThread source { repository "https://repos.quixos.org/quixos-studio/interface-agent-thread.git"; commit "9d689bbeecfc3321f97bfbaa9adf405eeee89e5c"; } + interface AgentItem source { repository "https://repos.quixos.org/quixos-studio/interface-agent-item.git"; commit "a80f9eebbefe6fdfe8b86db3103eed1a4b8e55f7"; } + interface AgentQuestion source { repository "https://repos.quixos.org/quixos-studio/interface-agent-question.git"; commit "c6fb1da16e29c599c1ba0dc510faa926615f0686"; } + interface StandaloneView source { repository "https://repos.quixos.org/quixos-studio/interface-react-standalone-view.git"; commit "67b00f7ff8df87a477e12c5668c42684d8fa1708"; } + interface ReactComponent source { repository "https://repos.quixos.org/quixos-studio/interface-react-component.git"; commit "6ba635dbef73a0ae545933c052635c0ebad8d5c5"; } + interface PresentationMount source { repository "https://repos.quixos.org/quixos-studio/interface-presentation-mount.git"; commit "30566340643355d8509bf116c2c3d91f09173d98"; } + interface PresentationState source { repository "https://repos.quixos.org/quixos-studio/interface-presentation-state.git"; commit "94db4272bcca52fbb23325bfa305d7331ea08602"; } + interface Presentation source { repository "https://repos.quixos.org/quixos-studio/interface-presentation.git"; commit "2e5e9381120e14fb52b61373aec6b9c0b75de425"; } + interface ReadingNavigation source { repository "https://repos.quixos.org/quixos-local-first-examples/interface-reading-navigation.git"; commit "5cc4350af96ef6a4bfb5d2d17a0a9637207971c4"; } + interface ReadingServices source { repository "https://repos.quixos.org/quixos-local-first-examples/interface-reading-services.git"; commit "3e8bfba9030384d366600d05cdf16bb6c3b3d6a4"; } + interface GutenbergCatalog source { repository "https://repos.quixos.org/quixos-local-first-examples/interface-gutenberg-catalog.git"; commit "af88f1abc529f82f4464e4c0ed99cd2fff6b82ea"; } +} diff --git a/quixos.react.json b/quixos.react.json new file mode 100644 index 0000000..f17fc1d --- /dev/null +++ b/quixos.react.json @@ -0,0 +1,33 @@ +{ + "modules": { + "Reader": { + "inputs": [ + { + "name": "nav", + "query": "Navigation", + "root": { + "from": "input", + "path": ["navigation"] + }, + "arguments": { + "record": {} + } + }, + { + "name": "body", + "query": "View", + "root": { + "from": "input", + "path": ["subject"] + }, + "arguments": { + "record": {} + } + } + ] + }, + "Vocabulary": { + "inputs": [] + } + } +} diff --git a/src/annotation-display.ts b/src/annotation-display.ts new file mode 100644 index 0000000..74dac37 --- /dev/null +++ b/src/annotation-display.ts @@ -0,0 +1,70 @@ +import type { ConfigValues } from "./annotator-config.js"; +import type { UnderlinePattern } from "./canvas/types.js"; +type Item = { + start: number; + end: number; + text: string; + tag: string; + color?: string; + fontFamily?: string; + fontSize?: number; + underlinePattern?: UnderlinePattern; + placement?: "above" | "below"; + style?: "tooltip"; + reveal?: "hover"; +}; +/** Authors install display-only filtering/styling here. Raw semantic results stay cached. */ +export const annotationDisplays: Record< + string, + (items: Item[], options: ConfigValues, context?: { source: string; vocabulary: ReadonlySet }) => Item[] +> = { + names: (items, options) => + items.map((item) => ({ ...item, color: String(options[item.tag + "Color"] ?? "#66866b") })), + grammar: (items, options) => + items.map((item) => ({ ...item, color: String(options[item.tag + "Color"] ?? options.otherColor ?? "#66866b") })), + frenchEasyVocabulary: (items, options) => + items.filter((item) => { + const match = /^cefr:(A1|A2):(noun|adjective|verb)$/.exec(item.tag); + return ( + !!match && + item.text.trim().length > 0 && + (match[1] === "A1" || (options.maximumLevel ?? "A2") === "A2") && + options[match[2] + "Enabled"] !== false && + // Never display a noun without its requested French article. + (match[2] !== "noun" || /^(?:(?:le|la|les|un|une|des|du)\s|l[’']|de\s+(?:la\s|l[’']))/i.test(item.text.trim())) + ); + }), + frenchAdvancedEnglish: (items, options) => + items.filter( + (item) => + item.text.trim().length > 0 && + (item.tag === "cefr:C2" || (item.tag === "cefr:C1" && (options.minimumLevel ?? "C1") === "C1")), + ), + pinyin: (items, options, context) => + items.flatMap((item) => { + const saved = + options.vocabularyMode === true && context?.vocabulary.has(context.source.slice(item.start, item.end)); + // Retain saved words even below the HSK threshold. Never reflow on hover. + if (saved) return [{ ...item, style: "tooltip" as const, reveal: "hover" as const }]; + const match = /^hsk:(\d+)$/.exec(item.tag); + return !match || Number(match[1]) > Number(options.minimumLevel ?? 2) ? [item] : []; + }), +}; +export function presentAnnotations( + processor: string, + items: Item[], + options: ConfigValues, + context?: { source: string; vocabulary: ReadonlySet }, +) { + return (annotationDisplays[processor]?.(items, options, context) ?? items).map((item) => { + if ( + (item.underlinePattern && !["solid", "dashed", "squiggly"].includes(item.underlinePattern)) || + (item.placement && item.placement !== "above" && item.placement !== "below") || + (item.color && !/^#[0-9a-f]{6}$/i.test(item.color)) || + (item.fontFamily && (item.fontFamily.length > 160 || /[\r\n\x00]/.test(item.fontFamily))) || + (item.fontSize !== undefined && (!Number.isFinite(item.fontSize) || item.fontSize < 6 || item.fontSize > 72)) + ) + throw Error("Invalid annotator appearance"); + return item; + }); +} diff --git a/src/annotator-config.tsx b/src/annotator-config.tsx new file mode 100644 index 0000000..1e221b0 --- /dev/null +++ b/src/annotator-config.tsx @@ -0,0 +1,194 @@ +import { LocalInput, LocalSelect } from "@quixos/camino-react/ui"; +import { ColorPicker, FontPicker, NumberControl, Options, Switch, UnderlinePreview } from "./controls.js"; +import type { ComponentType } from "react"; +import type { AnnotationRender } from "./render-hooks.js"; + +import { configDefaults, type ConfigValue, type ConfigValues, type ConfigNode } from "./config-schema.js"; +export type { ConfigValue, ConfigValues, ConfigField, ConfigNode } from "./config-schema.js"; +export type AnnotatorConfigProps = { + fields: ConfigNode[]; + values: ConfigValues; + disabled: boolean; + change: (key: string, value: ConfigValue) => void; +}; +// Authors may install a purpose-built inline component here. The component edits +// the same validated processor options as the generated controls; it does not +// own persistence or model calls. Every control, including appearance, is opt-in. +export const annotatorConfigViews: Record> = { + pinyin: ({ fields, values, disabled, change }) => ( +
+ + change("minimumLevel", event.target.value)} + /> + + {Array.from({ length: 10 }, (_, level) => ( + +
+ {Array.from({ length: 10 }, (_, level) => ( + {level || "All"} + ))} +
+ {Number(values.minimumLevel ?? 2) ? `Above HSK ${values.minimumLevel ?? 2}` : "All words"} + field.key !== "minimumLevel")} + values={values} + disabled={disabled} + change={change} + /> +
+ ), +}; +export function configValues(raw: string): ConfigValues { + try { + const value = JSON.parse(raw); + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; + } catch { + return {}; + } +} +export function configDefinition(raw: string): { + fields: ConfigNode[]; + description: string; + cache?: { identity: string; inputs: string[]; previousRevisions?: string[] }; + renders?: AnnotationRender[]; +} { + try { + const value = JSON.parse(raw); + return { + fields: value.fields ?? [], + description: value.description ?? "", + cache: value.cache, + renders: value.renders, + }; + } catch { + return { fields: [], description: "" }; + } +} +export const configFields = (raw: string) => configDefinition(raw).fields; +export function CustomConfig({ + processor, + fields, + values, + change, + disabled, +}: AnnotatorConfigProps & { processor: string }) { + const Custom = annotatorConfigViews[processor]; + if (Custom) return ; + return ( +
+ {fields.map((field) => + field.kind === "group" ? ( +
+ {field.label} + + change(field.key, { + ...configDefaults(field.fields), + ...(typeof values[field.key] === "object" ? (values[field.key] as ConfigValues) : {}), + [key]: value, + }) + } + /> +
+ ) : ( +
+ {field.label} + {field.presentation === "font" ? ( + change(field.key, value)} + /> + ) : field.kind === "select" ? ( + ({ + ...option, + ...(field.presentation === "underline" + ? { preview: } + : {}), + }))} + disabled={disabled} + change={(value) => change(field.key, value)} + /> + ) : field.kind === "toggle" ? ( + change(field.key, value)} + /> + ) : field.kind === "color" ? ( + change(field.key, value)} + /> + ) : field.kind === "number" ? ( + change(field.key, value)} + /> + ) : ( + change(field.key, e.target.value)} + /> + )} +
+ ), + )} +
+ ); +} + +// Creation suggestions only: these are never injected into a built annotator. +export const fontFields: ConfigNode[] = [ + { key: "fontFamily", label: "Annotation font", kind: "text", presentation: "font", default: "" }, + { key: "fontSize", label: "Annotation font size", kind: "number", default: 13, min: 6, max: 72 }, +]; +export const underlineFields: ConfigNode[] = [ + { + key: "underlinePattern", + label: "Underline pattern", + kind: "select", + presentation: "underline", + default: "solid", + options: ["solid", "dashed", "squiggly"].map((value) => ({ + value, + label: value[0].toUpperCase() + value.slice(1), + })), + }, +]; diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..acbfc2a --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,58 @@ +/** Cache only semantic inputs. Scope/context stay in the source-range identity; generation is scheduling. + * This pure wire-format helper is shared by the runtime and presentation. + */ +export function cacheConfiguration( + identity: string, + scope: string, + _generation: string, + options: Record, + inputs: readonly string[], +) { + return JSON.stringify({ + processor: identity, + scope, + options: Object.fromEntries( + [...inputs] + .sort() + .map((key) => [ + key, + key + .split(".") + .reduce( + (value, part) => + value && typeof value === "object" ? (value as Record)[part] : undefined, + options, + ), + ]), + ), + }); +} + +/** Compare persisted semantic configurations without scheduling choices. The + * original wire format retained generation; removing it preserves old results + * when an author switches between preparation and intent-driven generation. */ +export function sameCacheConfiguration(a: string, b: string) { + if (a === b) return true; + try { + const semantic = (raw: string) => { + const { processor, scope, options } = JSON.parse(raw); + if ( + typeof processor !== "string" || + typeof scope !== "string" || + !options || + typeof options !== "object" || + Array.isArray(options) + ) + return null; + return JSON.stringify({ + processor, + scope, + options: Object.fromEntries(Object.entries(options).sort(([a], [b]) => a.localeCompare(b))), + }); + }; + const left = semantic(a); + return left !== null && left === semantic(b); + } catch { + return false; + } +} diff --git a/src/cached-results.tsx b/src/cached-results.tsx new file mode 100644 index 0000000..9350db7 --- /dev/null +++ b/src/cached-results.tsx @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { referenceOf, useCaminoQuery, type ExactReference } from "@quixos/camino-react"; +import { queries } from "./gen/client.js"; +import { asInterface, type JobNode } from "./model.js"; +import { mergeCachedAnnotationEntries } from "./work.js"; + +type Entry = { node: JobNode }; + +/** Successes outlive request history. Read every retained page instead of + * treating the first bounded graph query as the entire semantic cache. */ +export function useCachedAnnotations(reference?: ExactReference | null) { + const query = useCaminoQuery(queries.DocumentView, { + enabled: !!reference, + root: reference ? asInterface(reference, "document") : undefined, + input: {}, + admission: { query: queries.DocumentInputs, input: {} }, + }); + const [pages, setPages] = useState<{ object: string; entries: Record }>({ + object: "", + entries: {}, + }); + const report = useCallback( + (cursor: string, entries: readonly Entry[] | null) => { + const object = reference?.object ?? ""; + setPages((previous) => { + const next = previous.object === object ? { ...previous.entries } : {}; + if (entries) next[cursor] = entries; + else delete next[cursor]; + return { object, entries: next }; + }); + }, + [reference?.object], + ); + const node = + query.status === "ready" && referenceOf(query.data.root).object === reference?.object ? query.data.root : null; + const entries = useMemo( + () => + mergeCachedAnnotationEntries( + [ + ...(node?.results.entries ?? []), + ...(pages.object === reference?.object ? Object.values(pages.entries).flat() : []), + ], + node?.jobs.entries ?? [], + ), + [node, pages, reference?.object], + ); + return { + ready: !!node, + entries, + continuation: + node?.results.pageInfo.hasNextPage && node.results.pageInfo.endCursor ? ( + + ) : null, + }; +} + +function CachedResultsTail({ + reference, + cursor, + report, +}: { + reference: ExactReference; + cursor: string; + report(cursor: string, entries: readonly Entry[] | null): void; +}) { + const query = useCaminoQuery(queries.CachedResultsView, { + root: asInterface(reference, "document"), + input: { after: cursor }, + admission: { query: queries.CachedResultsInputs, input: { after: cursor } }, + }); + const results = query.status === "ready" ? query.data.root.results : null; + useEffect(() => { + if (results) report(cursor, results.entries); + return () => report(cursor, null); + }, [results, cursor, report]); + return results?.pageInfo.hasNextPage && results.pageInfo.endCursor ? ( + + ) : null; +} diff --git a/src/canvas/Surface.tsx b/src/canvas/Surface.tsx new file mode 100644 index 0000000..1571379 --- /dev/null +++ b/src/canvas/Surface.tsx @@ -0,0 +1,483 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { layoutText } from "./layout.js"; +import { canvasMeasure } from "./measure.js"; +import { drawText } from "./draw.js"; +import { contextTarget, copyText, hitTest, moveSelection, selectionRange, type Selection } from "./selection.js"; +import { wordAt } from "./text.js"; +import { textInteractionAt } from "./interaction.js"; +import { readingPages, pageAt, readingWindow, type ReadingWindow } from "./pages.js"; +import { Tooltip, type TooltipContent } from "./Tooltip.js"; +import { rangeRects } from "./outline.js"; +import { rangeOutline } from "./outline.js"; +import { intersects, type Annotation, type Layout, type Range, type Rect } from "./types.js"; +import type { PageSize } from "./page-size.js"; +import { coalesceProcessing, type ProcessingRegion } from "./processing.js"; + +export type CanvasContextTarget = { range: Range; kind: "word" | "selection"; anchor: Rect }; + +export function Surface({ + text, + annotations, + textColor = "#252923", + accentColor = "#39745b", + fontSize = 23, + fontFamily = '"Noto Serif CJK SC", "Songti SC", Georgia, serif', + visible, + onFocus, + onHover, + onWindow, + mode = "pages", + position = 0, + selection, + onSelection, + hover = null, + emphasis = null, + focus = null, + tooltips = [], + onDismissTooltip, + processing = [], + pageSize, + onSize, + onContextMenu, + interactive = true, +}: { + text: string; + annotations: Annotation[]; + textColor?: string; + accentColor?: string; + fontSize?: number; + fontFamily?: string; + visible: (id: string) => boolean; + onFocus: (range: Range) => void; + onHover: (range: Range | null) => void; + onWindow: (window: ReadingWindow) => void; + mode?: string; + position?: number; + selection: Selection; + onSelection: (selection: Selection) => void; + hover?: Range | null; + emphasis?: Range | null; + focus?: Range | null; + tooltips?: (Annotation & { label: string; trigger: "hover" | "click" })[]; + onDismissTooltip: () => void; + processing?: ProcessingRegion[]; + pageSize?: PageSize; + onSize?: (size: PageSize) => void; + onContextMenu?: (target: CanvasContextTarget) => void; + interactive?: boolean; +}) { + const viewport = useRef(null), + canvas = useRef(null); + const [availableSize, setSize] = useState({ width: 640, height: 600 }), + [scrollOffset, setScrollOffset] = useState(0), + [anchor, setAnchor] = useState(position), + [fragment, setFragment] = useState(0), + [fonts, setFonts] = useState(0); + const [labelHover, setLabelHover] = useState(null); + const size = mode === "pages" && pageSize ? pageSize : availableSize; + const previousText = useRef(text); + const restoreScroll = useRef(mode === "scroll" ? position : null); + const drag = useRef<{ anchor: number; moved: boolean; selecting: boolean; pointer: number } | null>(null); + const measure = useMemo(() => { + if (typeof document === "undefined") return null; + const context = document.createElement("canvas").getContext("2d"); + return context ? canvasMeasure(context) : null; + }, [fonts]); + const layout = useMemo(() => { + if (!measure) return null; + return layoutText({ + text, + width: size.width, + font: { family: fontFamily, size: fontSize }, + annotations, + measure, + breakAt: mode === "pages" ? anchor : 0, + }); + }, [text, annotations, size.width, fontSize, fontFamily, measure, mode, anchor]); + const pages = useMemo(() => (layout ? readingPages(layout, size.height, anchor) : []), [layout, size.height, anchor]); + // An expanded replacement can occupy several physical pages at one source + // offset. Retain its fragment while ordinary pages stay source-anchored. + const anchoredPages = pages.flatMap((page, index) => (page.start === anchor ? [index] : [])); + const pageIndex = anchoredPages[Math.min(fragment, anchoredPages.length - 1)] ?? pageAt(pages, anchor), + page = pages[pageIndex]; + const top = (mode === "pages" ? (page?.top ?? 0) : 0) + scrollOffset; + const end = mode === "pages" ? (page?.bottom ?? size.height) : (layout?.height ?? size.height); + const drawHeight = Math.max(0, Math.min(size.height, end - top)); + function turnPage(index: number, clearSelection = true) { + const bounded = Math.max(0, Math.min(pages.length - 1, index)), + target = pages[bounded]; + if (target) { + setFragment(pages.slice(0, bounded).filter((page) => page.start === target.start).length); + setAnchor(target.start); + onDismissTooltip(); + if (clearSelection) onSelection({ anchor: target.start, focus: target.start }); + if (viewport.current) viewport.current.scrollTop = 0; + setScrollOffset(0); + onHover(null); + setLabelHover(null); + } + } + useEffect(() => { + let closed = false; + void document.fonts?.ready.then(() => { + if (!closed) setFonts((value) => value + 1); + }); + return () => { + closed = true; + }; + }, []); + useLayoutEffect(() => { + if (!viewport.current) return; + const observer = new ResizeObserver(([entry]) => { + if (entry.contentRect.width <= 0 || entry.contentRect.height <= 0) { + onSize?.({ width: 0, height: 0 }); + return; + } + const measured = { + width: Math.max(20, Math.floor(entry.contentRect.width)), + height: Math.max(1, Math.floor(entry.contentRect.height)), + }; + setSize(measured); + onSize?.(measured); + }); + observer.observe(viewport.current); + return () => observer.disconnect(); + }, [onSize]); + useLayoutEffect(() => { + if (!canvas.current || !layout) return; + const ratio = Math.min(3, window.devicePixelRatio || 1); + const width = Math.ceil(size.width * ratio), + height = Math.ceil(size.height * ratio); + if (canvas.current.width !== width) canvas.current.width = width; + if (canvas.current.height !== height) canvas.current.height = height; + const context = canvas.current.getContext("2d"); + if (!context) return; + context.resetTransform(); + context.clearRect(0, 0, width, height); + context.scale(ratio, ratio); + drawText(context, layout, { + top, + height: drawHeight, + selection, + visible, + hover: labelHover ?? hover, + emphasis, + textColor, + accentColor, + }); + }, [ + layout, + top, + size.width, + size.height, + drawHeight, + selection, + visible, + hover, + labelHover, + emphasis, + textColor, + accentColor, + ]); + useEffect(() => { + if (layout && pages.length) onWindow(readingWindow(layout, pages, pageIndex, mode, top, size.height)); + }, [layout, pages, pageIndex, mode, top, size.height, onWindow]); + useLayoutEffect(() => { + const appended = text.startsWith(previousText.current) && text !== previousText.current; + previousText.current = text; + if (appended) return; + drag.current = null; + if (viewport.current) viewport.current.scrollTop = 0; + setScrollOffset(0); + setAnchor(position); + setFragment(0); + restoreScroll.current = mode === "scroll" ? position : null; + onHover(null); + setLabelHover(null); + }, [text, mode]); + useLayoutEffect(() => { + if ( + layout && + pages.length && + readingWindow(layout, pages, pageIndex, mode, top, size.height).visible.start === position + ) + return; + setAnchor(position); + setFragment(0); + if (mode === "scroll") restoreScroll.current = position; + }, [position]); + useLayoutEffect(() => { + // Keep the clicked word visible when its first results add overhead rows. + // Only explicit page navigation dismisses that click intent. + if (mode === "pages" && focus && page && (focus.start < page.start || focus.start >= page.end)) + setAnchor(focus.start); + }, [layout, focus, mode]); + useLayoutEffect(() => { + if (mode !== "scroll" || restoreScroll.current === null || !layout || !viewport.current) return; + const row = layout.lines.find((line) => line.glyphs.some((glyph) => glyph.end > restoreScroll.current!)); + const offset = row ? Math.min(row.y, Math.max(0, layout.height - size.height)) : 0; + viewport.current.scrollTop = offset ? offset + 24 : 0; + setScrollOffset(offset); + restoreScroll.current = null; + }, [layout, mode, size.height]); + function offset(event: { clientX: number; clientY: number }) { + if (!layout || !canvas.current) return 0; + const bounds = canvas.current.getBoundingClientRect(); + return hitTest(layout, event.clientX - bounds.left, Math.min(end - 0.001, event.clientY - bounds.top + top)); + } + function interaction(event: { clientX: number; clientY: number }) { + if (!layout || !canvas.current) return null; + const bounds = canvas.current.getBoundingClientRect(); + const x = event.clientX - bounds.left, + y = Math.min(end, event.clientY - bounds.top + top); + if (y >= end || y < top) return null; + return textInteractionAt(layout, x, y, visible); + } + function focusedWord(event: { clientX: number; clientY: number }) { + return interaction(event)?.range ?? null; + } + const processingPaths = useMemo(() => { + const ranges = new Map(); + for (const item of coalesceProcessing(processing, text)) { + const key = `${item.range.start}:${item.range.end}`, + previous = ranges.get(key); + ranges.set(key, previous ? { ...item, label: previous.label + ", " + item.label } : item); + } + return [...ranges.values()].map((item) => ({ ...item, path: layout ? rangeOutline(layout, item.range, 4) : "" })); + }, [layout, processing, text]); + + const hoveredTips = + hover && !labelHover ? tooltips.filter((item) => item.trigger === "hover" && intersects(item, hover)) : []; + const clickedTips = focus ? tooltips.filter((item) => item.trigger === "click" && intersects(item, focus)) : []; + const tipRange = clickedTips.length ? focus : hoveredTips.length ? hover : null; + // Keep the last anchor available during the short hover-to-card crossing. + const lastTipRange = useRef(null); + if (tipRange) lastTipRange.current = tipRange; + const activeTips = clickedTips.length ? clickedTips : hoveredTips; + const tip: TooltipContent | null = activeTips.length + ? { + key: activeTips.map((item) => item.id).join(":"), + trigger: clickedTips.length ? "click" : "hover", + items: activeTips, + } + : null; + const getTipAnchor = useCallback(() => { + if (!layout || !canvas.current || !lastTipRange.current) return null; + const rect = rangeRects(layout, lastTipRange.current).find( + (rect) => rect.y + rect.height > top && rect.y < top + drawHeight, + ); + if (!rect) return null; + const bounds = canvas.current.getBoundingClientRect(); + const y = bounds.top + Math.max(0, rect.y - top), + height = Math.min(rect.height, drawHeight - Math.max(0, rect.y - top)); + if (y + height < 0 || y > window.innerHeight) return null; + return { x: bounds.left + rect.x, y, width: rect.width, height }; + }, [layout, top, drawHeight]); + const selected = selectionRange(selection); + function openContextMenu(event?: { clientX: number; clientY: number }) { + if (!interactive || !onContextMenu || !layout || !canvas.current) return false; + const bounds = canvas.current.getBoundingClientRect(); + const rectangles = rangeRects(layout, selected).filter( + (rect) => rect.y + rect.height > top && rect.y < top + drawHeight, + ); + const insideSelection = event + ? rectangles.some( + (rect) => + event.clientX >= bounds.left + rect.x && + event.clientX <= bounds.left + rect.x + rect.width && + event.clientY >= bounds.top + rect.y - top && + event.clientY <= bounds.top + rect.y - top + rect.height, + ) + : selected.end > selected.start; + const target = contextTarget( + selection, + event ? focusedWord(event) : wordAt(text, Math.min(selection.focus, Math.max(0, text.length - 1))), + insideSelection, + ); + if (!target) return false; + const rect = rectangles[0] ?? rangeRects(layout, target.range).find((rect) => rect.y + rect.height > top); + const anchor = event + ? { x: event.clientX, y: event.clientY, width: 0, height: 0 } + : rect + ? { ...rect, x: bounds.left + rect.x, y: bounds.top + rect.y - top } + : { x: bounds.left, y: bounds.top, width: 0, height: 0 }; + canvas.current.focus({ preventScroll: true }); + onDismissTooltip(); + onHover(null); + onContextMenu({ ...target, anchor }); + return true; + } + return ( + <> + +
{ + setScrollOffset(Math.max(0, event.currentTarget.scrollTop - (mode === "scroll" ? 24 : 0))); + onHover(null); + setLabelHover(null); + }} + > +
+ { + if (!interactive || event.button !== 0 || !layout) return; + event.currentTarget.focus({ preventScroll: true }); + event.currentTarget.setPointerCapture(event.pointerId); + if (!focusedWord(event)) onDismissTooltip(); + const position = offset(event), + anchor = event.shiftKey ? selection.anchor : position; + drag.current = { anchor, moved: false, selecting: event.shiftKey, pointer: event.pointerId }; + onSelection({ anchor, focus: position }); + }} + onPointerMove={(event) => { + if (!interactive) return; + const active = drag.current; + if (!active) { + const target = interaction(event); + const range = target?.kind === "label" ? target.range : null; + setLabelHover((previous) => + previous?.start === range?.start && previous?.end === range?.end ? previous : range, + ); + onHover(target?.kind === "word" ? target.range : null); + return; + } + const focus = offset(event); + if (focus !== active.anchor) active.moved = true; + onSelection({ anchor: active.anchor, focus }); + const bounds = viewport.current?.getBoundingClientRect(); + if (mode === "scroll" && bounds && viewport.current) { + if (event.clientY < bounds.top + 30) viewport.current.scrollTop -= 18; + if (event.clientY > bounds.bottom - 30) viewport.current.scrollTop += 18; + } + }} + onPointerUp={(event) => { + const active = drag.current; + if (!active || active.pointer !== event.pointerId) return; + drag.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + if (!active.moved && !active.selecting) { + const range = focusedWord(event); + if (range) onFocus(range); + } + }} + onPointerCancel={() => { + drag.current = null; + onHover(null); + setLabelHover(null); + }} + onPointerLeave={() => { + if (!drag.current) { + onHover(null); + setLabelHover(null); + } + }} + onDoubleClick={(event) => { + if (!interactive) return; + const range = focusedWord(event); + if (range) { + onSelection({ anchor: range.start, focus: range.end }); + } + }} + onContextMenu={(event) => { + if (openContextMenu(event)) event.preventDefault(); + }} + onCopy={(event) => { + if (selected.end > selected.start) { + event.preventDefault(); + event.clipboardData.setData("text/plain", text.slice(selected.start, selected.end)); + } + }} + onKeyDown={(event) => { + if (!interactive || !layout) return; + if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { + if (openContextMenu()) event.preventDefault(); + return; + } + if ( + (event.ctrlKey || event.metaKey) && + event.key.toLowerCase() === "c" && + selected.end > selected.start + ) { + event.preventDefault(); + void copyText(text.slice(selected.start, selected.end)).catch(() => {}); + return; + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "a") { + event.preventDefault(); + onSelection({ anchor: 0, focus: text.length }); + return; + } + if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) { + event.preventDefault(); + const next = moveSelection(layout, selection, event.key, event.shiftKey); + onSelection(next); + const line = layout.lines.find((row) => + row.glyphs.some((glyph) => glyph.start <= next.focus && glyph.end >= next.focus), + ); + if (line && viewport.current) { + if (mode === "pages" && (next.focus < page.start || next.focus >= page.end)) { + turnPage(pageAt(pages, next.focus), false); + } else if (line.y < top) + viewport.current.scrollTop = Math.max(0, line.y - (mode === "pages" ? page.top : 0) - 30); + else if (line.y + line.height > top + size.height) + viewport.current.scrollTop = + line.y + line.height - (mode === "pages" ? page.top : 0) - size.height + 30; + } + } + }} + /> + +
+ + {mode === "pages" && ( + + )} + + ); +} diff --git a/src/canvas/Tooltip.tsx b/src/canvas/Tooltip.tsx new file mode 100644 index 0000000..fc49bcc --- /dev/null +++ b/src/canvas/Tooltip.tsx @@ -0,0 +1,159 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { tooltipPosition } from "./tooltip-position.js"; +import type { Rect } from "./types.js"; +export type TooltipContent = { + key: string; + trigger: "hover" | "click"; + items: { id: string; label: string; text: string; color: string }[]; +}; +/** React content in the browser's top layer: ancestor canvas/scroll clipping and + * transforms do not constrain it. A brief exit grace lets readers select/copy + * hover content without delaying cancellation of model hover intent. */ +export function Tooltip({ + content, + getAnchor, + dismiss, +}: { + content: TooltipContent | null; + getAnchor: () => Rect | null; + dismiss: () => void; +}) { + const panel = useRef(null), + inside = useRef(false), + timer = useRef | null>(null); + // Only clear native selection owned by this tooltip. Canvas/source and other + // UI selections survive dismissal. Hover notes remain selectable for copying. + const clearOwnSelection = (element = panel.current) => { + const root = element?.getRootNode() as (Document & { getSelection?: () => Selection | null }) | undefined; + const selection = root?.getSelection?.() ?? window.getSelection(); + if ( + selection && + element && + ((selection.anchorNode && element.contains(selection.anchorNode)) || + (selection.focusNode && element.contains(selection.focusNode))) + ) + selection.removeAllRanges(); + }; + const hide = () => { + clearOwnSelection(); + setShown(null); + }; + const [shown, setShown] = useState(content); + const cancel = () => { + if (timer.current) clearTimeout(timer.current); + timer.current = null; + }; + useEffect(() => { + cancel(); + if (content) setShown(content); + else if (shown?.trigger === "click") hide(); + else if (!inside.current) timer.current = setTimeout(() => hide(), 180); + return cancel; + }, [content]); + useEffect(() => { + const close = (event: KeyboardEvent) => { + if (event.key === "Escape") { + cancel(); + inside.current = false; + hide(); + dismiss(); + } + }; + const outside = (event: PointerEvent) => { + if (panel.current && !event.composedPath().includes(panel.current)) { + cancel(); + inside.current = false; + hide(); + dismiss(); + } + }; + if (!shown) return; + document.addEventListener("keydown", close); + document.addEventListener("pointerdown", outside, true); + return () => { + document.removeEventListener("keydown", close); + document.removeEventListener("pointerdown", outside, true); + }; + }, [!!shown, dismiss]); + useLayoutEffect(() => { + const element = panel.current; + if (!element || !shown) return; + element.setAttribute("popover", "manual"); + element.showPopover(); + const position = () => { + const anchor = getAnchor(); + if (!anchor) { + element.hidePopover(); + return; + } + if (!element.matches(":popover-open")) element.showPopover(); + const box = element.getBoundingClientRect(); + const pos = tooltipPosition(anchor, box.width, box.height, { + width: window.innerWidth, + height: window.innerHeight, + }); + element.style.left = pos.left + "px"; + element.style.top = pos.top + "px"; + element.style.setProperty("--tooltip-arrow", pos.arrow + "px"); + element.dataset.side = pos.side; + }; + position(); + const observer = new ResizeObserver(position); + observer.observe(element); + window.addEventListener("resize", position); + document.addEventListener("scroll", position, true); + return () => { + observer.disconnect(); + window.removeEventListener("resize", position); + document.removeEventListener("scroll", position, true); + if (element.matches(":popover-open")) element.hidePopover(); + }; + }, [shown, getAnchor]); + useEffect(() => { + const element = panel.current; + return () => clearOwnSelection(element); + }, [shown?.key]); + if (!shown) return null; + return ( +
{ + inside.current = true; + cancel(); + }} + onPointerLeave={() => { + inside.current = false; + if (!content) { + cancel(); + hide(); + } + }} + > + {shown.trigger === "click" && ( + + )} +
+ {shown.items.map((item) => ( +
+ {item.label} +

{item.text}

+
+ ))} +
+
+ ); +} diff --git a/src/canvas/draw.ts b/src/canvas/draw.ts new file mode 100644 index 0000000..0eb9070 --- /dev/null +++ b/src/canvas/draw.ts @@ -0,0 +1,142 @@ +import { fontString } from "./measure.js"; +import { selectionRects, type Selection } from "./selection.js"; +import type { AnnotationBox, Layout } from "./types.js"; + +/** Keep alignment marks beside the label's ink. Their lengths follow the + * overhang relative to the projected source, without striking through text. */ +export function labelGuideSegments(box: AnnotationBox) { + if (!box.guide) return []; + const gap = box.font.size * 0.15; + const length = (overhang: number) => Math.min(box.font.size * 0.65, Math.max(box.font.size * 0.25, overhang)); + const middle = box.y + box.height / 2; + const segments: { start: number; end: number; y: number; cap: number }[] = []; + const cap = box.font.size * 0.15 * (box.annotation.placement === "below" ? -1 : 1); + const left = box.guide.start - box.x; + const right = box.x + box.width - box.guide.end; + if (left > 0) segments.push({ start: box.x - gap - length(left), end: box.x - gap, y: middle, cap }); + if (right > 0) + segments.push({ start: box.x + box.width + gap, end: box.x + box.width + gap + length(right), y: middle, cap }); + return segments; +} + +/** Every pattern fits the same band, so appearance changes cannot repaginate. */ +export function drawUnderline(context: CanvasRenderingContext2D, box: AnnotationBox) { + if (box.width <= 0) return; + if (!box.annotation.underlinePattern || box.annotation.underlinePattern === "solid") { + context.fillRect(box.x, box.y, box.width, box.height); + return; + } + context.save(); + context.beginPath(); + context.rect(box.x, box.y, box.width, box.height); + context.clip(); + context.strokeStyle = box.annotation.color; + context.lineWidth = 1.5; + context.lineCap = "round"; + context.setLineDash(box.annotation.underlinePattern === "dashed" ? [4, 3] : []); + context.lineDashOffset = 0; + context.beginPath(); + const middle = box.y + box.height / 2; + context.moveTo(box.x, middle); + if (box.annotation.underlinePattern === "dashed") context.lineTo(box.x + box.width, middle); + else { + for (let x = 0.5; x < box.width; x += 0.5) context.lineTo(box.x + x, middle + Math.sin((x * Math.PI) / 3) * 0.7); + context.lineTo(box.x + box.width, middle + Math.sin((box.width * Math.PI) / 3) * 0.7); + } + context.stroke(); + context.restore(); +} +export function drawText( + context: CanvasRenderingContext2D, + layout: Layout, + { + top = 0, + height = layout.height, + selection = { anchor: 0, focus: 0 }, + visible = () => true, + hover = null, + emphasis = null, + textColor = "#252923", + accentColor = "#39745b", + }: { + textColor?: string; + accentColor?: string; + top?: number; + height?: number; + selection?: Selection; + visible?: (id: string) => boolean; + hover?: { start: number; end: number } | null; + emphasis?: { start: number; end: number } | null; + } = {}, +) { + // The pointer's current source/label wins over a sidebar reference. Sidebar + // panels can disappear before a pointer-leave event is delivered. + const reference = hover ?? emphasis; + context.save(); + context.translate(0, -top); + context.beginPath(); + context.rect(0, top, layout.width, height); + context.clip(); + for (const line of layout.lines) { + const lineTop = Math.min(line.y, ...line.overhead.map((box) => box.y)); + if (line.y + line.height < top || lineTop >= top + height) continue; + for (const box of line.decorations) { + if (box.annotation.style === "color" || box.annotation.style === "replace") continue; + if (!visible(box.annotation.id)) continue; + context.fillStyle = box.annotation.color; + context.globalAlpha = box.annotation.style === "highlight" ? 0.22 : 0.85; + if (box.annotation.style === "underline") drawUnderline(context, box); + else context.fillRect(box.x, box.y, box.width, box.height); + } + context.globalAlpha = 1; + context.font = fontString(layout.font); + context.fillStyle = textColor; + for (const glyph of line.glyphs) { + context.font = fontString(glyph.font ?? layout.font); + context.fillStyle = textColor; + for (const box of line.decorations) + if ( + (box.annotation.style === "color" || box.annotation.style === "replace") && + visible(box.annotation.id) && + box.annotation.start < glyph.end && + box.annotation.end > glyph.start + ) + context.fillStyle = box.annotation.color; + if (reference && glyph.start < reference.end && glyph.end > reference.start && /[\p{L}\p{N}]/u.test(glyph.text)) + context.fillStyle = accentColor; + context.fillText(glyph.text === "\t" ? " " : glyph.text, glyph.x, glyph.baseline); + } + for (const box of line.overhead) { + if (!visible(box.annotation.id)) continue; + const guides = labelGuideSegments(box); + if (guides.length) { + context.save(); + context.strokeStyle = box.annotation.color; + context.globalAlpha = 0.14; + context.lineWidth = 1; + context.beginPath(); + for (const guide of guides) { + context.moveTo(guide.start, guide.y); + context.lineTo(guide.end, guide.y); + const outer = guide.end < box.x ? guide.start : guide.end; + context.moveTo(outer, guide.y); + context.lineTo(outer, guide.y + guide.cap); + } + context.stroke(); + context.restore(); + } + context.save(); + context.beginPath(); + context.rect(box.x, box.y, box.width, box.height); + context.clip(); + context.font = fontString(box.font); + context.fillStyle = box.annotation.color; + context.fillText(box.lines[0], box.x, box.baseline); + context.restore(); + } + } + context.fillStyle = "#6c90c6"; + context.globalAlpha = 0.24; + for (const rect of selectionRects(layout, selection)) context.fillRect(rect.x, rect.y, rect.width, rect.height); + context.restore(); +} diff --git a/src/canvas/interaction.ts b/src/canvas/interaction.ts new file mode 100644 index 0000000..95a85c8 --- /dev/null +++ b/src/canvas/interaction.ts @@ -0,0 +1,31 @@ +import { hoverWordAt } from "./text.js"; +import type { Layout, Range } from "./types.js"; + +export type TextInteraction = { kind: "label" | "word"; range: Range }; +/** Labels emphasize their reference without masquerading as a word hover. + * Hit only visible label/text ink; annotation padding is not another word. */ +export function textInteractionAt( + layout: Layout, + x: number, + y: number, + visible: (id: string) => boolean, +): TextInteraction | null { + let low = 0, + high = layout.lines.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (layout.lines[middle].y + layout.lines[middle].height < y) low = middle + 1; + else high = middle; + } + const row = layout.lines[low]; + const label = row?.overhead.find( + (box) => + visible(box.annotation.id) && x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height, + ); + if (label) return { kind: "label", range: { start: label.annotation.start, end: label.annotation.end } }; + const glyph = row?.glyphs.find( + (unit) => y >= unit.y && y <= unit.y + unit.height + 8 && x >= unit.x && x <= unit.x + unit.width, + ); + const range = glyph ? hoverWordAt(layout.text, glyph.start) : null; + return range ? { kind: "word", range } : null; +} diff --git a/src/canvas/layout.ts b/src/canvas/layout.ts new file mode 100644 index 0000000..e1d340c --- /dev/null +++ b/src/canvas/layout.ts @@ -0,0 +1,562 @@ +import { graphemes } from "./text.js"; +import { lineSpans } from "./line-breaks.js"; +import type { Annotation, AnnotationBox, Font, Glyph, Layout, Line, Measure, Metrics } from "./types.js"; + +const gap = 5; +type Cursor = { row: number; x: number }; +type Unit = ReturnType[number] & { metrics: Metrics; font: Font; left: number; right: number }; +type Label = { annotation: Annotation; font: Font; first: number; last: number; width: number }; +type Group = { feature: string; first: number; last: number; labels: Label[]; width: number }; +type Fragment = { row: number; x: number; text: string; metrics: Metrics; label: Label }; +const lane = (annotation: Annotation) => `${annotation.placement ?? "above"}:${annotation.feature}`; + +function lowerBound(items: T[], before: (item: T) => boolean): number { + let low = 0, + high = items.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (before(items[mid])) low = mid + 1; + else high = mid; + } + return low; +} + +// Padding participates in the same horizontal flow as text, even across lines. +// Keep x == width on the preceding row so an explicit newline is not doubled. +function advance(cursor: Cursor, amount: number, width: number): Cursor { + if (amount <= width - cursor.x) return { row: cursor.row, x: cursor.x + amount }; + const remaining = amount - (width - cursor.x); + const rows = Math.ceil(remaining / width); + return { row: cursor.row + rows, x: remaining - (rows - 1) * width }; +} +const position = (cursor: Cursor, width: number) => cursor.row * width + cursor.x; + +// Point updates/range sums keep width reservations cheap for large documents. +class Widths { + private tree: Float64Array; + constructor(units: Unit[]) { + this.tree = new Float64Array(units.length + 1); + units.forEach((unit, index) => this.add(index, unit.metrics.width)); + } + add(index: number, amount: number) { + for (let i = index + 1; i < this.tree.length; i += i & -i) this.tree[i] += amount; + } + private prefix(end: number): number { + let total = 0; + for (let i = end; i > 0; i -= i & -i) total += this.tree[i]; + return total; + } + range(first: number, last: number) { + return this.prefix(last + 1) - this.prefix(first); + } +} + +function flowLabel(label: Label, start: Cursor, width: number, measure: Measure) { + const margin = Math.min(gap, width / 4); + let cursor = advance(start, margin, width), + row = "", + rowX = cursor.x; + const fragments: Fragment[] = []; + function flush() { + if (row) fragments.push({ row: cursor.row, x: rowX, text: row, metrics: measure(row, label.font), label }); + row = ""; + rowX = cursor.x; + } + function newline() { + flush(); + cursor = { row: cursor.row + 1, x: margin }; + rowX = cursor.x; + } + function piece(text: string) { + const metrics = measure(row + text, label.font); + if (rowX + metrics.width > width - margin + 0.001) newline(); + const own = measure(text, label.font); + // A single grapheme may be wider than a pathological viewport. Clip that + // grapheme instead of looping or inventing another vertical annotation row. + if (!row && own.width > width - margin * 2) { + rowX = 0; + cursor.x = width; + fragments.push({ row: cursor.row, x: 0, text, metrics: { ...own, width }, label }); + rowX = cursor.x; + return; + } + row += text; + cursor.x = rowX + measure(row, label.font).width; + } + for (const span of lineSpans(label.annotation.text)) { + const token = label.annotation.text.slice(span.start, span.end); + const content = token.replace(/[\r\n]+$/u, ""); + if (measure(content, label.font).width <= width - margin * 2) piece(content); + else { + for (const chunk of lineSpans(content, true)) { + const fragment = content.slice(chunk.start, chunk.end); + if (measure(fragment, label.font).width <= width - margin * 2) piece(fragment); + else for (const unit of graphemes(fragment)) piece(unit.text); + } + } + if (content.length < token.length) newline(); + } + flush(); + return { fragments, cursor: advance(cursor, margin, width) }; +} + +function flowGroup(group: Group, start: Cursor, width: number, measure: Measure) { + let cursor = start; + const fragments: Fragment[] = []; + for (const label of group.labels) { + const flow = flowLabel(label, cursor, width, measure); + cursor = flow.cursor; + fragments.push(...flow.fragments); + } + return { fragments, cursor }; +} + +/** Fluxtapose's shared source/annotation flow: one fixed row per feature on + * each physical text line. Labels ask for horizontal source padding and wrap + * through subsequent lines; collisions never create extra annotation rows. + */ +export function layoutText({ + text, + width, + annotations = [], + font = { family: '"Noto Serif CJK SC", "Songti SC", serif', size: 23 }, + measure, + breakAt = 0, +}: { + text: string; + width: number; + annotations?: Annotation[]; + font?: Font; + measure: Measure; + breakAt?: number; +}): Layout { + if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(font.size) || font.size <= 0) + throw Error("Invalid text geometry"); + const valid = annotations.filter( + (a) => + Number.isInteger(a.start) && Number.isInteger(a.end) && a.start >= 0 && a.end <= text.length && a.start < a.end, + ); + const replacements = valid + .filter((a) => a.style === "replace") + .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0) || a.start - b.start); + const accepted: Annotation[] = []; + for (const item of replacements) + if (!accepted.some((a) => a.start < item.end && a.end > item.start)) accepted.push(item); + accepted.sort((a, b) => a.start - b.start); + const source = graphemes(text), + units: Unit[] = []; + let replacementIndex = 0, + formatIndex = 0; + const formats = valid.filter((a) => a.style === "bold" || a.style === "italic").sort((a, b) => a.start - b.start); + let activeFormats: Annotation[] = []; + for (let index = 0; index < source.length; index++) { + const unit = source[index], + replacement = accepted[replacementIndex]; + if (replacement && unit.end > replacement.start) { + if (unit.start >= replacement.end) replacementIndex++; + else { + const styled: Font = { ...font }; + for (const item of formats) + if (item.start < replacement.end && item.end > replacement.start) { + if (item.style === "bold") styled.weight = "bold"; + if (item.style === "italic") styled.italic = true; + } + const measured = measure(replacement.text, styled); + const pieces = + measured.width <= width && !/[\r\n]/.test(replacement.text) + ? [{ text: replacement.text }] + : graphemes(replacement.text); + for (const piece of pieces) + units.push({ + start: replacement.start, + end: replacement.end, + text: piece.text, + left: 0, + right: 0, + font: styled, + metrics: measure(piece.text, styled), + }); + while (index + 1 < source.length && source[index + 1].start < replacement.end) index++; + replacementIndex++; + continue; + } + } + const styled: Font = { ...font }; + while (formatIndex < formats.length && formats[formatIndex].start < unit.end) + activeFormats.push(formats[formatIndex++]); + activeFormats = activeFormats.filter((a) => a.end > unit.start); + for (const item of activeFormats) { + if (item.style === "bold") styled.weight = "bold"; + if (item.style === "italic") styled.italic = true; + } + units.push({ + ...unit, + left: 0, + right: 0, + font: styled, + metrics: measure(/[\r\n]/.test(unit.text) ? "" : unit.text === "\t" ? " " : unit.text, styled), + }); + } + const byFeature = new Map(); + const priorities = new Map(); + for (const annotation of valid) { + if (annotation.style !== "overhead" || !annotation.text) continue; + let first = lowerBound(units, (unit) => unit.end <= annotation.start); + let last = lowerBound(units, (unit) => unit.start < annotation.end) - 1; + while (first <= last && /[\r\n]/.test(units[first].text)) first++; + while (last >= first && /[\r\n]/.test(units[last].text)) last--; + if (first > last) continue; + const labelFont = { family: annotation.fontFamily || font.family, size: annotation.fontSize ?? 13 }; + const label = { + annotation, + font: labelFont, + first, + last, + width: measure(annotation.text.replace(/\r\n|[\r\n]/g, " "), labelFont).width + Math.min(gap * 2, width / 2), + }; + const feature = lane(annotation); + const labels = byFeature.get(feature) ?? []; + labels.push(label); + byFeature.set(feature, labels); + priorities.set(feature, Math.min(priorities.get(feature) ?? Infinity, annotation.priority ?? 0)); + } + const features = [...byFeature.keys()].sort((a, b) => priorities.get(a)! - priorities.get(b)! || a.localeCompare(b)); + const groups: Group[] = []; + for (const [feature, labels] of byFeature) { + labels.sort((a, b) => a.first - b.first || a.last - b.last || a.annotation.id.localeCompare(b.annotation.id)); + let current: Group | undefined; + for (const label of labels) { + // Conflicting ranges from one source share a horizontal run in source + // order. They never get silently promoted to a second feature row. + if (!current || label.first > current.last) { + current = { feature, first: label.first, last: label.last, labels: [], width: 0 }; + groups.push(current); + } + current.last = Math.max(current.last, label.last); + current.labels.push(label); + current.width += label.width; + } + } + // Punctuation and brackets can glue neighboring graphemes even when word + // segmentation separates them. Move annotation reservations to the outside + // of those small spans, so padding cannot push a closing mark onto a new row. + // Ordinary letters may split only as an emergency; word/label preferences + // below still keep them together whenever their reserved width fits. + const displayed = units.map((unit) => unit.text).join(""); + const displayStarts: number[] = []; + let displayOffset = 0; + for (const unit of units) { + displayStarts.push(displayOffset); + displayOffset += unit.text.length; + } + const glue = new Map(), + glueEnds = new Map(); + const gluedSpans: { first: number; last: number }[] = []; + for (const span of lineSpans(displayed, true)) { + const first = lowerBound(displayStarts, (start) => start < span.start); + let last = lowerBound(displayStarts, (start) => start < span.end) - 1; + while (last > first && /^[^\S\r\n]+$/u.test(units[last].text)) last--; + if (last <= first || units.slice(first, last + 1).some((unit) => /[\r\n]/u.test(unit.text))) continue; + const ink = units.slice(first, last + 1).reduce((total, unit) => total + unit.metrics.width, 0); + if (ink > width) continue; // A pathological viewport still needs emergency clipping. + gluedSpans.push({ first, last }); + for (let index = first; index <= last; index++) glueEnds.set(index, last); + glue.set(first, last); + } + // Adjacent labels from one feature within an inseparable source span must + // share a flow too. Moving their padding outside that span must not cause + // independent label runs to collide (e.g. a word label and punctuation label). + const joined: Group[] = []; + for (const group of groups) { + const previous = joined.at(-1); + if (previous?.feature === group.feature && (glueEnds.get(previous.last) ?? previous.last) >= group.first) { + previous.last = Math.max(previous.last, group.last); + previous.labels.push(...group.labels); + previous.width += group.width; + } else joined.push(group); + } + groups.splice(0, groups.length, ...joined); + const widths = new Widths(units); + // Reserve short ranges first, so sentence labels can reuse room requested by + // word labels. Padding surrounds the range rather than stretching its glyphs. + for (const group of [...groups].sort((a, b) => a.last - a.first - (b.last - b.first))) { + const extra = Math.max(0, group.width - widths.range(group.first, group.last)) / 2; + units[group.first].left += extra; + units[group.last].right += extra; + widths.add(group.first, extra); + widths.add(group.last, extra); + } + for (const { first, last } of gluedSpans) { + let left = 0, + right = 0; + for (let index = first; index <= last; index++) { + const unit = units[index]; + left += unit.left; + right += unit.right; + widths.add(index, -unit.left - unit.right); + unit.left = unit.right = 0; + } + units[first].left = left; + units[last].right = right; + widths.add(first, left); + widths.add(last, right); + } + // Prefer word boundaries, and keep a short overhead label with its entire + // source span. Oversized words/labels still flow across physical lines. + const keep = new Map(); + const legalStarts = new Map(), + legalEnds = new Map(); + for (const span of lineSpans(displayed)) { + const first = lowerBound(displayStarts, (start) => start < span.start); + let last = lowerBound(displayStarts, (start) => start < span.end) - 1; + while (last >= first && /^[^\S\r\n]+$/u.test(units[last].text)) last--; + for (let index = first; index <= last; index++) { + legalStarts.set(index, first); + legalEnds.set(index, last); + } + } + const remember = (start: number, end: number) => { + let first = lowerBound(units, (u) => u.end <= start), + last = lowerBound(units, (u) => u.start < end) - 1; + first = legalStarts.get(first) ?? first; + last = legalEnds.get(last) ?? last; + if ( + last >= first && + !units.slice(first, last + 1).some((u) => /[\r\n]/.test(u.text)) && + widths.range(first, last) <= width + ) + keep.set(first, Math.max(keep.get(first) ?? first, last)); + }; + for (const word of new Intl.Segmenter(undefined, { granularity: "word" }).segment(text)) + if (word.isWordLike) remember(word.index, word.index + word.segment.length); + for (const span of lineSpans(displayed)) { + const first = lowerBound(displayStarts, (start) => start < span.start); + let last = lowerBound(displayStarts, (start) => start < span.end) - 1; + while (last >= first && /^[^\S\r\n]+$/u.test(units[last].text)) last--; + if (last >= first) remember(units[first].start, units[last].end); + } + for (const group of groups) + if (group.width <= width && group.labels.every((l) => !/[\r\n]/.test(l.annotation.text))) + remember(units[group.first].start, units[group.last].end); + const closing = new Map(); + for (const group of groups) { + const end = glueEnds.get(group.last) ?? group.last; + const list = closing.get(end) ?? []; + list.push(group); + closing.set(end, list); + } + const starts: Cursor[] = [], + drawn: Cursor[] = [], + rows: Glyph[][] = [[]], + fragments: Fragment[] = []; + let cursor: Cursor = { row: 0, x: 0 }; + const ensureRow = (row: number) => { + while (rows.length <= row) rows.push([]); + }; + for (const [index, unit] of units.entries()) { + if (unit.start === breakAt && breakAt > 0 && cursor.x > 0) cursor = { row: cursor.row + 1, x: 0 }; + if (/[\r\n]/.test(unit.text)) { + starts[index] = { ...cursor }; + cursor = { row: cursor.row + 1, x: 0 }; + ensureRow(cursor.row); + continue; + } + // Collapse indentation and separators that would overflow visually. Keep + // the canonical source and its UTF-16 offsets intact for selection/copy. + let collapsed = + /^[^\S\r\n]+$/u.test(unit.text) && (cursor.x === 0 || cursor.x + unit.metrics.width > width + 0.001); + const last = keep.get(index); + if (!collapsed && last !== undefined && cursor.x > 0 && cursor.x + widths.range(index, last) > width + 0.001) + cursor = { row: cursor.row + 1, x: 0 }; + // When even the unpadded glyph needs to wrap, its annotation reservation + // starts on that next line too. A short label cannot linger on the old line. + if (!collapsed && cursor.x > 0 && cursor.x + Math.min(width, unit.metrics.width) > width + 0.001) + cursor = { row: cursor.row + 1, x: 0 }; + // An annotation can move a whole span beginning with a separator to the + // next row; that separator must collapse there too. + collapsed ||= cursor.x === 0 && /^[^\S\r\n]+$/u.test(unit.text); + starts[index] = { ...cursor }; + cursor = advance(cursor, collapsed ? 0 : unit.left, width); + const glued = glue.get(index); + if (glued !== undefined && cursor.x > 0) { + const ink = units.slice(index, glued + 1).reduce((total, unit) => total + unit.metrics.width, 0); + if (cursor.x + ink > width + 0.001) cursor = { row: cursor.row + 1, x: 0 }; + } + const drawnWidth = collapsed ? 0 : Math.min(width, unit.metrics.width); + if (cursor.x > 0 && cursor.x + drawnWidth > width + 0.001) cursor = { row: cursor.row + 1, x: 0 }; + ensureRow(cursor.row); + const glyphRow = cursor.row; + drawn[index] = { ...cursor }; + const glyph: Glyph = { + ...unit, + x: cursor.x, + y: 0, + width: unit.metrics.width, + height: unit.metrics.ascent + unit.metrics.descent, + baseline: 0, + advance: drawnWidth, + }; + if (!collapsed) rows[cursor.row].push(glyph); + cursor = advance(cursor, drawnWidth + (collapsed ? 0 : unit.right), width); + const ending = closing.get(index) ?? []; + // Enforce wrapped label capacity when its source range closes. This is a + // forward constraint, so padding converges in one source pass, without the + // original ten-try limit or an arbitrary collision fallback. + for (const group of ending) { + const flow = flowGroup(group, starts[group.first], width, measure); + if (position(flow.cursor, width) > position(cursor, width)) cursor = flow.cursor; + } + for (const group of ending) { + const start = starts[group.first]; + // Center over the actual source ink, not the enclosing reservations: + // nested word labels can give the two ends different amounts of padding. + const firstInk = drawn[group.first], + lastInk = drawn[group.last]; + const desired = + (position(firstInk, width) + position(lastInk, width) + units[group.last].metrics.width - group.width) / 2; + const candidate = advance(start, Math.max(0, desired - position(start, width)), width); + const centered = flowGroup(group, candidate, width, measure); + // Word wrapping can use more room than the measured label width. Find + // the nearest centered start that still fits the reserved source band. + let flow = centered; + if (position(flow.cursor, width) > position(cursor, width) + 0.001) { + let low = 0, + high = Math.max(0, desired - position(start, width)); + flow = flowGroup(group, start, width, measure); + for (let attempt = 0; attempt < 12; attempt++) { + const middle = (low + high) / 2; + const trial = flowGroup(group, advance(start, middle, width), width, measure); + if (position(trial.cursor, width) <= position(cursor, width) + 0.001) { + low = middle; + flow = trial; + } else high = middle; + } + } + fragments.push(...flow.fragments); + } + glyph.advance = Math.max(drawnWidth, (cursor.row === glyphRow ? cursor.x : width) - glyph.x); + ensureRow(cursor.row); + } + const byRow = new Map(); + for (const fragment of fragments) { + ensureRow(fragment.row); + const list = byRow.get(fragment.row) ?? []; + list.push(fragment); + byRow.set(fragment.row, list); + } + // Decoration ranges use the same final source geometry as selection. + const decorationsByRow = new Map(); + const populated = rows.map((glyphs, index) => ({ glyphs, index })).filter((row) => row.glyphs.length); + for (const annotation of [ + ...valid.filter((a) => ["highlight", "underline", "color"].includes(a.style)), + ...accepted, + ]) { + let index = lowerBound(populated, (row) => row.glyphs.at(-1)!.end <= annotation.start); + while (index < populated.length && populated[index].glyphs[0].start < annotation.end) { + const row = populated[index++].index, + list = decorationsByRow.get(row) ?? []; + list.push(annotation); + decorationsByRow.set(row, list); + } + } + const lines: Line[] = []; + let y = 0; + for (const [rowIndex, glyphs] of rows.entries()) { + const overhead: AnnotationBox[] = [], + decorations: AnnotationBox[] = []; + const rowFragments = byRow.get(rowIndex) ?? []; + // The first source sits closest to the base text. Missing sources collapse + // on this physical line rather than reserving an empty annotation lane. + // A modest interline allowance absorbs short labels without reserving a + // full lane on every line. More annotation space grows only as needed. + const rowTop = y; + const addLane = (layer: number, members: Fragment[]) => { + const laneAscent = Math.max(...members.map((member) => member.metrics.ascent)); + const laneDescent = Math.max(...members.map((member) => member.metrics.descent)); + for (const member of members) { + const sourceGlyphs = glyphs.filter( + (glyph) => glyph.end > member.label.annotation.start && glyph.start < member.label.annotation.end, + ); + const first = sourceGlyphs[0], + last = sourceGlyphs.at(-1); + const sourceStart = first?.x, + sourceEnd = last ? last.x + last.width : undefined; + const overhang = + sourceStart === undefined || sourceEnd === undefined + ? 0 + : Math.max(0, sourceStart - member.x) + Math.max(0, member.x + member.metrics.width - sourceEnd); + overhead.push({ + annotation: member.label.annotation, + x: member.x, + y: y + laneAscent - member.metrics.ascent, + width: member.metrics.width, + height: member.metrics.ascent + member.metrics.descent, + font: member.label.font, + lines: [member.text], + layer, + baseline: y + laneAscent, + // A font-relative tolerance leaves ordinary short glosses clean. + // The guide is the source projection, never the annotation's padding. + ...(overhang > font.size * 0.7 && sourceStart !== undefined && sourceEnd !== undefined + ? { guide: { start: sourceStart, end: sourceEnd } } + : {}), + }); + } + y += laneAscent + laneDescent + gap; + }; + for (const [layer, feature] of [...features.entries()].reverse()) { + const members = rowFragments.filter( + (fragment) => lane(fragment.label.annotation) === feature && fragment.label.annotation.placement !== "below", + ); + if (members.length) addLane(layer, members); + } + y = Math.max(y, rowTop + font.size * 0.45); + const ascent = Math.max(font.size * 0.85, ...glyphs.map((glyph) => measure(glyph.text, glyph.font ?? font).ascent)); + const descent = Math.max( + font.size * 0.22, + ...glyphs.map((glyph) => measure(glyph.text, glyph.font ?? font).descent), + ); + const baseY = y; + for (const glyph of glyphs) { + glyph.y = baseY; + glyph.height = ascent + descent; + glyph.baseline = baseY + ascent; + } + const underlineFeatures = [ + ...new Set((decorationsByRow.get(rowIndex) ?? []).filter((a) => a.style === "underline").map((a) => a.feature)), + ].sort(); + for (const annotation of decorationsByRow.get(rowIndex) ?? []) { + const first = lowerBound(glyphs, (glyph) => glyph.end <= annotation.start); + const last = lowerBound(glyphs, (glyph) => glyph.start < annotation.end) - 1; + const layer = annotation.style === "underline" ? underlineFeatures.indexOf(annotation.feature) : 0; + decorations.push({ + annotation, + x: glyphs[first].x, + y: annotation.style === "underline" ? baseY + ascent + descent + 3 + layer * 5 : baseY, + width: Math.max(0, Math.min(width, glyphs[last].x + glyphs[last].width) - glyphs[first].x), + height: annotation.style === "underline" ? 3 : ascent + descent, + font, + lines: [], + layer, + baseline: 0, + }); + } + y += ascent + descent + Math.max(12, underlineFeatures.length * 5 + 6); + let hasBelow = false; + for (const [layer, feature] of features.entries()) { + const members = rowFragments.filter( + (fragment) => lane(fragment.label.annotation) === feature && fragment.label.annotation.placement === "below", + ); + if (members.length) { + addLane(layer, members); + hasBelow = true; + } + } + if (hasBelow) + y += Math.max(12, font.size * (features.some((feature) => feature.startsWith("above:")) ? 0.65 : 0.3)); + const height = y - baseY; + lines.push({ x: 0, y: baseY, width, height, glyphs, overhead, decorations }); + if (!glyphs.length) y += font.size * 0.4; + } + return { text, width, height: y, font, lines }; +} diff --git a/src/canvas/line-breaks.ts b/src/canvas/line-breaks.ts new file mode 100644 index 0000000..ebfae5e --- /dev/null +++ b/src/canvas/line-breaks.ts @@ -0,0 +1,36 @@ +import { PASS, Rules } from "@cto.af/linebreak"; +import type { Range } from "./types.js"; +import { graphemes } from "./text.js"; + +const normal = new Rules(); +// Emergency wrapping may split an oversized word or number, but still obeys +// the punctuation, bracket, combining-mark and nonbreaking-space rules. This +// is UAX #14 tailoring, not a list of special cases for a particular language. +const emergency = new Rules(); +for (const rule of ["LB23", "LB25", "LB28", "LB29"]) emergency.replaceRule(rule, () => PASS); +const cached = new Map(); + +/** Legal UTF-16 source spans for line wrapping. Intl.Segmenter still owns word + * targeting; line breaking is a separate Unicode operation. */ +export function lineSpans(text: string, emergencyWrap = false): Range[] { + let entry = cached.get(text); + if (!entry) { + const spans = (rules: Rules): Range[] => { + let start = 0; + const boundaries = new Set(graphemes(text).map((unit) => unit.end)); + return Array.from(rules.breaks(text)) + .filter(({ position }) => boundaries.has(position)) + .map(({ position }) => { + const range = { start, end: position }; + start = position; + return range; + }); + }; + entry = { normal: spans(normal), emergency: spans(emergency) }; + if (text.length >= 256 && text.length <= 120000) { + if (cached.size >= 4) cached.delete(cached.keys().next().value!); + cached.set(text, entry); + } + } + return entry[emergencyWrap ? "emergency" : "normal"]; +} diff --git a/src/canvas/measure.ts b/src/canvas/measure.ts new file mode 100644 index 0000000..8a80f86 --- /dev/null +++ b/src/canvas/measure.ts @@ -0,0 +1,25 @@ +import type { Font, Measure } from "./types.js"; + +export const fontString = (font: Font) => + `${font.italic ? "italic " : ""}${font.weight === "bold" ? "bold " : ""}${font.size}px ${font.family}`; + +// A bounded per-surface cache replaces the original global memoizee cache. +// Font loading creates a new measure/layout so fallback metrics cannot persist. +export function canvasMeasure(context: CanvasRenderingContext2D, maximum = 12000): Measure { + const cache = new Map>(); + return (text, font) => { + const key = `${fontString(font)}/${text}`; + const cached = cache.get(key); + if (cached) return cached; + context.font = fontString(font); + const metrics = context.measureText(text); + const value = { + width: Math.max(0, metrics.width), + ascent: Math.max(metrics.actualBoundingBoxAscent || 0, font.size * 0.85), + descent: Math.max(metrics.actualBoundingBoxDescent || 0, font.size * 0.22), + }; + if (cache.size >= maximum) cache.clear(); + cache.set(key, value); + return value; + }; +} diff --git a/src/canvas/outline.ts b/src/canvas/outline.ts new file mode 100644 index 0000000..3e196be --- /dev/null +++ b/src/canvas/outline.ts @@ -0,0 +1,118 @@ +import type { Layout, Range, Rect } from "./types.js"; +const sourceLines = new WeakMap(); +type SourceRect = Rect & Range; +function rangeRows(layout: Layout, range: Range): SourceRect[] { + // Layout source offsets are monotonic, including empty/blank lines. Search + // the first candidate row rather than scanning a long document on every hover. + let populated = sourceLines.get(layout); + if (!populated) { + populated = layout.lines.filter((line) => line.glyphs.length); + sourceLines.set(layout, populated); + } + let low = 0, + high = populated.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (populated[middle].glyphs.at(-1)!.end <= range.start) low = middle + 1; + else high = middle; + } + const rects: SourceRect[] = []; + for (let index = low; index < populated.length; index++) { + const line = populated[index]; + if (line.glyphs[0].start >= range.end) break; + const members = line.glyphs.filter((glyph) => glyph.start < range.end && glyph.end > range.start); + if (!members.length || members.every((glyph) => /^\s*$/.test(glyph.text))) continue; + rects.push({ + start: members[0].start, + end: members.at(-1)!.end, + x: members[0].x, + y: Math.min(...members.map((glyph) => glyph.y)), + width: members.at(-1)!.x + members.at(-1)!.width - members[0].x, + height: Math.max(...members.map((glyph) => glyph.height)), + }); + } + return rects; +} +export function rangeRects(layout: Layout, range: Range): Rect[] { + return rangeRows(layout, range).map(({ start, end, ...rect }) => rect); +} +type Point = { x: number; y: number }; +function rounded(points: Point[], radius: number) { + const corners = points.map((point, index) => { + const before = points[(index + points.length - 1) % points.length], + after = points[(index + 1) % points.length]; + const incoming = Math.hypot(point.x - before.x, point.y - before.y), + outgoing = Math.hypot(after.x - point.x, after.y - point.y); + const distance = Math.min(radius, incoming / 2, outgoing / 2); + return { + point, + before: { + x: point.x + ((before.x - point.x) * distance) / incoming, + y: point.y + ((before.y - point.y) * distance) / incoming, + }, + after: { + x: point.x + ((after.x - point.x) * distance) / outgoing, + y: point.y + ((after.y - point.y) * distance) / outgoing, + }, + }; + }); + return ( + corners + .map( + ({ point, before, after }, index) => + `${index ? "L" : "M"}${before.x},${before.y}Q${point.x},${point.y} ${after.x},${after.y}`, + ) + .join("") + "Z" + ); +} +/** A broad band: only the first and last rows follow source edges. Paragraph + * gaps and intermediate short rows stay inside one quiet, full-width outline. */ +export function rangeOutline(layout: Layout, range: Range, padding = 3): string { + const rows = rangeRows(layout, range); + if (!rows.length) return ""; + const first = rows[0], + last = rows.at(-1)!; + const left = 1, + right = layout.width - 1; + const firstLeft = Math.max(left, first.x - padding); + const lastRight = Math.min(right, last.x + last.width + padding); + const top = Math.max(1, first.y - padding), + bottom = last.y + last.height + padding; + let points: Point[]; + if (rows.length === 1) { + points = [ + { x: firstLeft, y: top }, + { x: lastRight, y: top }, + { x: lastRight, y: bottom }, + { x: firstLeft, y: bottom }, + ]; + } else { + const firstJoin = first.y + first.height + padding; + const lastJoin = last.y - padding; + // Very tightly stacked rows get a plain box rather than a crossing contour. + points = + firstJoin >= lastJoin + ? [ + { x: left, y: top }, + { x: right, y: top }, + { x: right, y: bottom }, + { x: left, y: bottom }, + ] + : [ + { x: firstLeft, y: top }, + { x: right, y: top }, + { x: right, y: lastJoin }, + { x: lastRight, y: lastJoin }, + { x: lastRight, y: bottom }, + { x: left, y: bottom }, + { x: left, y: firstJoin }, + { x: firstLeft, y: firstJoin }, + ]; + } + points = points.filter((p, i) => { + const before = points[(i + points.length - 1) % points.length], + after = points[(i + 1) % points.length]; + return !((before.x === p.x && p.x === after.x) || (before.y === p.y && p.y === after.y)); + }); + return rounded(points, 4); +} diff --git a/src/canvas/page-size.ts b/src/canvas/page-size.ts new file mode 100644 index 0000000..6ec999d --- /dev/null +++ b/src/canvas/page-size.ts @@ -0,0 +1,18 @@ +export type PageSize = { width: number; height: number }; +export type ViewportReport = PageSize & { client: string; expiresAt: number }; +/** Only live attachments constrain pages. Our measured size supersedes our + * own published report immediately, including while a resize is in flight. */ +export function sharedPageSize( + local: PageSize, + reports: readonly ViewportReport[], + client: string, + now: number, +): PageSize { + return reports.reduce( + (size, report) => + report.client !== client && report.expiresAt > now && report.width > 0 && report.height > 0 + ? { width: Math.min(size.width, report.width), height: Math.min(size.height, report.height) } + : size, + local, + ); +} diff --git a/src/canvas/pages.ts b/src/canvas/pages.ts new file mode 100644 index 0000000..4b0d354 --- /dev/null +++ b/src/canvas/pages.ts @@ -0,0 +1,82 @@ +import type { Layout, Line, Range } from "./types.js"; +export type ReadingPage = Range & { top: number; bottom: number }; +export type ReadingWindow = { visible: Range; nearby: Range }; +const lineTop = (line: Line) => Math.min(line.y, ...line.overhead.map((box) => box.y)); +export function readingPages(layout: Layout, height: number, anchor = 0): ReadingPage[] { + // A reading position is a source offset, never a page number. Keep its row + // at the beginning of a page when annotation arrival or resizing reflows it. + const anchored = anchor > 0 ? layout.lines.find((line) => line.glyphs.some((g) => g.end > anchor)) : undefined; + const pages: ReadingPage[] = []; + let top = 0, + first = 0, + end = 0, + populated = false; + for (const line of layout.lines) { + if (populated && (line === anchored || line.y + line.height - top > height)) { + // Annotation-only continuation rows belong to their source page. An + // exceptionally tall source row can scroll within its page. + if (line.glyphs.length) { + pages.push({ top, bottom: lineTop(line), start: first, end }); + top = lineTop(line); + first = line.glyphs[0].start; + populated = false; + } + } + if (line.glyphs.length) { + if (!populated) first = line.glyphs[0].start; + end = line.glyphs.at(-1)!.end; + populated = true; + } + } + if (populated || !pages.length) pages.push({ top, bottom: layout.height, start: first, end }); + // Assign inter-line whitespace/newlines to the preceding page. Source + // coordinates remain global and independent of pagination/reflow. + for (let i = 0; i < pages.length; i++) { + pages[i].start = i === 0 ? 0 : pages[i].start; + pages[i].end = Math.max(pages[i].end, pages[i + 1]?.start ?? layout.text.length); + } + return pages; +} +export function pageAt(pages: ReadingPage[], offset: number) { + let low = 0, + high = pages.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (pages[middle].end <= offset) low = middle + 1; + else high = middle; + } + return Math.min(low, Math.max(0, pages.length - 1)); +} +function sourceWindow(layout: Layout, top: number, bottom: number): Range { + let start = layout.text.length, + end = 0; + for (const line of layout.lines) { + if (line.y + line.height < top) continue; + if (lineTop(line) > bottom) break; + if (line.glyphs.length) { + start = Math.min(start, line.glyphs[0].start); + end = line.glyphs.at(-1)!.end; + } + } + return end > start ? { start, end } : { start: 0, end: 0 }; +} +export function readingWindow( + layout: Layout, + pages: ReadingPage[], + index: number, + mode: string, + top: number, + height: number, +): ReadingWindow { + if (mode === "pages") { + const page = pages[index]; + return { + visible: { start: page.start, end: page.end }, + nearby: { start: pages[Math.max(0, index - 1)].start, end: pages[Math.min(pages.length - 1, index + 1)].end }, + }; + } + return { + visible: sourceWindow(layout, top, top + height), + nearby: sourceWindow(layout, Math.max(0, top - height), top + height * 2), + }; +} diff --git a/src/canvas/processing.ts b/src/canvas/processing.ts new file mode 100644 index 0000000..f8b21e9 --- /dev/null +++ b/src/canvas/processing.ts @@ -0,0 +1,32 @@ +import type { Range } from "./types.js"; + +export type ProcessingRegion = { feature: string; range: Range; label: string; color: string }; + +/** Presentation-only union. Keep annotator identity until its queued and active + * scopes have been joined; job boundaries still belong to the scheduler/cache. */ +export function coalesceProcessing(regions: readonly ProcessingRegion[], text: string): ProcessingRegion[] { + const features = new Map(); + for (const region of regions) { + if (region.range.end <= region.range.start) continue; + const members = features.get(region.feature) ?? []; + members.push(region); + features.set(region.feature, members); + } + const result: ProcessingRegion[] = []; + for (const members of features.values()) { + members.sort((a, b) => a.range.start - b.range.start || a.range.end - b.range.end); + let previous: ProcessingRegion | undefined; + for (const region of members) { + if ( + previous && + (region.range.start <= previous.range.end || /^\s*$/u.test(text.slice(previous.range.end, region.range.start))) + ) { + previous.range.end = Math.max(previous.range.end, region.range.end); + } else { + previous = { ...region, range: { ...region.range } }; + result.push(previous); + } + } + } + return result; +} diff --git a/src/canvas/selection.ts b/src/canvas/selection.ts new file mode 100644 index 0000000..0b46b7b --- /dev/null +++ b/src/canvas/selection.ts @@ -0,0 +1,88 @@ +import { graphemes, snapOffset } from "./text.js"; +import type { Layout, Range, Rect } from "./types.js"; + +export type Selection = { anchor: number; focus: number }; +export const selectionRange = ({ anchor, focus }: Selection): Range => ({ + start: Math.min(anchor, focus), + end: Math.max(anchor, focus), +}); +export function contextTarget( + selection: Selection, + word: Range | null, + insideSelection: boolean, +): { range: Range; kind: "word" | "selection" } | null { + const range = selectionRange(selection); + if (insideSelection && range.end > range.start) return { range, kind: "selection" }; + return word && word.end > word.start ? { range: word, kind: "word" } : null; +} +export async function copyText(text: string) { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch {} + } + const active = document.activeElement as HTMLElement | null; + const field = document.createElement("textarea"); + field.value = text; + field.readOnly = true; + field.style.cssText = "position:fixed;opacity:0;left:0;top:0"; + document.body.append(field); + field.select(); + const copied = document.execCommand("copy"); + field.remove(); + active?.focus(); + if (!copied) throw Error("Copy was unavailable. Open Original text to copy it directly."); +} +export function hitTest(layout: Layout, x: number, y: number): number { + const populated = layout.lines.filter((line) => line.glyphs.length); + if (!populated.length) return 0; + const line = populated.reduce((closest, candidate) => + Math.abs(candidate.y + candidate.glyphs[0].height / 2 - y) < Math.abs(closest.y + closest.glyphs[0].height / 2 - y) + ? candidate + : closest, + ); + for (const glyph of line.glyphs) if (x < glyph.x + glyph.advance / 2) return glyph.start; + return line.glyphs[line.glyphs.length - 1].end; +} +export function selectionRects(layout: Layout, selection: Selection): Rect[] { + const range = selectionRange(selection); + return layout.lines.flatMap((line) => { + const glyphs = line.glyphs.filter((glyph) => glyph.start < range.end && glyph.end > range.start); + if (!glyphs.length) return []; + const first = glyphs[0], + last = glyphs[glyphs.length - 1]; + return [ + { + x: first.x, + y: line.y, + width: last.x + last.advance - first.x, + height: Math.max(...glyphs.map((glyph) => glyph.height)), + }, + ]; + }); +} +export function moveSelection(layout: Layout, selection: Selection, key: string, shift: boolean): Selection { + let focus = selection.focus; + const range = selectionRange(selection); + if (!shift && range.start !== range.end && (key === "ArrowLeft" || key === "ArrowRight")) + focus = key === "ArrowLeft" ? range.start : range.end; + else if (key === "ArrowLeft" || key === "ArrowRight") { + const bounds = [0, ...graphemes(layout.text).map((unit) => unit.end)]; + const index = bounds.indexOf(snapOffset(layout.text, focus)); + focus = bounds[Math.max(0, Math.min(bounds.length - 1, index + (key === "ArrowLeft" ? -1 : 1)))]; + } else { + const row = layout.lines.findIndex((line) => + line.glyphs.some((glyph) => glyph.start <= focus && glyph.end >= focus), + ); + const current = layout.lines[Math.max(0, row)]; + const target = layout.lines[Math.max(0, Math.min(layout.lines.length - 1, row + (key === "ArrowUp" ? -1 : 1)))]; + if (key === "Home") focus = current?.glyphs[0]?.start ?? focus; + else if (key === "End") focus = current?.glyphs.at(-1)?.end ?? focus; + else { + const glyph = current?.glyphs.find((unit) => unit.start >= focus) ?? current?.glyphs.at(-1); + if (glyph && target) focus = hitTest(layout, glyph.x, target.y + target.glyphs[0].height / 2); + } + } + return { anchor: shift ? selection.anchor : focus, focus }; +} diff --git a/src/canvas/text.ts b/src/canvas/text.ts new file mode 100644 index 0000000..c36a270 --- /dev/null +++ b/src/canvas/text.ts @@ -0,0 +1,66 @@ +import type { Range } from "./types.js"; + +// All ranges refer to the original UTF-16 source. Never trim paragraph text: +// Fluxtapose's trimmed paragraphs broke selection and annotations after spaces. +const graphemeSegmenter = new Intl.Segmenter("zh", { granularity: "grapheme" }); +const wordSegmenter = new Intl.Segmenter("zh", { granularity: "word" }); +let lastWords: { text: string; ranges: Range[]; wordStarts: Set } | null = null; +const segmented = new Map(); +let cachedUnits = 0; +export function graphemes(text: string): (Range & { text: string })[] { + const cached = segmented.get(text); + if (cached) return cached; + const units = Array.from(graphemeSegmenter.segment(text), ({ segment, index }) => ({ + text: segment, + start: index, + end: index + segment.length, + })); + // Retain documents across layout changes, without filling the cache with + // every small annotation label. Bound both object count and retained units. + if (text.length >= 256 && units.length <= 120000) { + while (segmented.size >= 4 || cachedUnits + units.length > 120000) { + const oldest = segmented.keys().next().value!; + cachedUnits -= segmented.get(oldest)!.length; + segmented.delete(oldest); + } + segmented.set(text, units); + cachedUnits += units.length; + } + return units; +} +export function wordAt(text: string, offset: number): Range { + if (lastWords?.text !== text) { + const words = Array.from(wordSegmenter.segment(text)); + lastWords = { + text, + ranges: words.map(({ index, segment }) => ({ + start: index, + end: index + segment.length, + })), + wordStarts: new Set(words.filter((word) => word.isWordLike).map((word) => word.index)), + }; + } + const ranges = lastWords.ranges; + let low = 0, + high = ranges.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (ranges[mid].end <= offset) low = mid + 1; + else high = mid; + } + if (ranges[low] && offset >= ranges[low].start) return { start: ranges[low].start, end: ranges[low].end }; + return { start: text.length, end: text.length }; +} +/** Segmenter classifies word-like spans using Unicode data, including CJK. + * Selection still uses wordAt, where selecting punctuation is legitimate. */ +export function hoverWordAt(text: string, offset: number): Range | null { + const range = wordAt(text, offset); + return lastWords?.wordStarts.has(range.start) ? range : null; +} +export function snapOffset(text: string, offset: number, direction = 1): number { + const bounded = Math.max(0, Math.min(text.length, offset)); + for (const unit of graphemes(text)) { + if (bounded > unit.start && bounded < unit.end) return direction < 0 ? unit.start : unit.end; + } + return bounded; +} diff --git a/src/canvas/tooltip-position.ts b/src/canvas/tooltip-position.ts new file mode 100644 index 0000000..56f7bb7 --- /dev/null +++ b/src/canvas/tooltip-position.ts @@ -0,0 +1,20 @@ +import type { Rect } from "./types.js"; +/** Viewport coordinates; prefer above the word, flip below, then clamp. */ +export function tooltipPosition( + anchor: Rect, + width: number, + height: number, + viewport: { width: number; height: number }, +) { + const margin = 12, + gap = 10, + center = anchor.x + anchor.width / 2; + const left = Math.max(margin, Math.min(viewport.width - width - margin, center - width / 2)); + const above = anchor.y - height - gap; + const side = above >= margin ? "above" : "below"; + const top = Math.max( + margin, + Math.min(viewport.height - height - margin, side === "above" ? above : anchor.y + anchor.height + gap), + ); + return { left, top, side, arrow: Math.max(16, Math.min(width - 16, center - left)) }; +} diff --git a/src/canvas/types.ts b/src/canvas/types.ts new file mode 100644 index 0000000..be1ba73 --- /dev/null +++ b/src/canvas/types.ts @@ -0,0 +1,34 @@ +export type Range = { start: number; end: number }; +export type Rect = { x: number; y: number; width: number; height: number }; +export type Font = { family: string; size: number; weight?: "normal" | "bold"; italic?: boolean }; +export type Metrics = { width: number; ascent: number; descent: number }; +export type Measure = (text: string, font: Font) => Metrics; +export type UnderlinePattern = "solid" | "dashed" | "squiggly"; +export type Annotation = Range & { + activation?: Range; + id: string; + feature: string; + text: string; + reveal?: "always" | "click" | "hover"; + style: "overhead" | "highlight" | "underline" | "color" | "sidebar" | "tooltip" | "replace" | "bold" | "italic"; + placement?: "above" | "below"; + color: string; + underlinePattern?: UnderlinePattern; + fontFamily?: string; + fontSize?: number; + priority?: number; +}; +export type Glyph = Range & Rect & { text: string; baseline: number; advance: number; font?: Font }; +export type AnnotationBox = Rect & { + annotation: Annotation; + lines: string[]; + font: Font; + layer: number; + baseline: number; + guide?: { start: number; end: number }; +}; +export type Line = Rect & { glyphs: Glyph[]; overhead: AnnotationBox[]; decorations: AnnotationBox[] }; +export type Layout = { text: string; width: number; height: number; font: Font; lines: Line[] }; +export const intersects = (a: Range, b: Range) => a.start < b.end && a.end > b.start; +export const rectIntersects = (a: Rect, b: Rect) => + a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; diff --git a/src/config-edits.ts b/src/config-edits.ts new file mode 100644 index 0000000..2212aa6 --- /dev/null +++ b/src/config-edits.ts @@ -0,0 +1,26 @@ +import type { ConfigValue, ConfigValues } from "./config-schema.js"; + +export type OptionEdit = { path: string[]; value: ConfigValue }; + +/** A group component returns its struct; save only the leaves it actually changed. */ +export function optionEdits(before: ConfigValue | undefined, after: ConfigValue, path: string[]): OptionEdit[] { + if (typeof after === "object") { + const previous = typeof before === "object" ? before : {}; + return Object.entries(after).flatMap(([key, value]) => optionEdits(previous[key], value, [...path, key])); + } + return before === after ? [] : [{ path, value: after }]; +} + +/** Merge into the newest local snapshot, preserving edits to other controls. */ +export function applyOptionEdits(values: ConfigValues, edits: OptionEdit[]): ConfigValues { + const result = structuredClone(values); + for (const { path, value } of edits) { + let group = result; + for (const key of path.slice(0, -1)) { + if (typeof group[key] !== "object") group[key] = {}; + group = group[key] as ConfigValues; + } + group[path.at(-1)!] = value; + } + return result; +} diff --git a/src/config-schema.ts b/src/config-schema.ts new file mode 100644 index 0000000..e46727a --- /dev/null +++ b/src/config-schema.ts @@ -0,0 +1,40 @@ +export type ConfigValue = string | number | boolean | ConfigValues; +export type ConfigValues = { [key: string]: ConfigValue }; +export type ConfigField = { + key: string; + label: string; + kind: "color" | "number" | "select" | "toggle" | "text"; + default: string | number | boolean; + min?: number; + max?: number; + computation?: boolean; + presentation?: "font" | "underline" | "position"; + options?: { value: string; label: string }[]; +}; +export type ConfigGroup = { key: string; label: string; kind: "group"; fields: ConfigNode[] }; +export type ConfigNode = ConfigField | ConfigGroup; +export function configDefaults(fields: ConfigNode[]): ConfigValues { + return Object.fromEntries( + fields.map((field) => [field.key, field.kind === "group" ? configDefaults(field.fields) : field.default]), + ); +} +/** Include declared defaults inside partially saved groups, keeping other saved values. */ +export function configWithDefaults(fields: ConfigNode[], saved: ConfigValues): ConfigValues { + const values = { ...configDefaults(fields), ...saved }; + for (const field of fields) { + if (field.kind !== "group") continue; + const group = saved[field.key]; + values[field.key] = configWithDefaults(field.fields, group && typeof group === "object" ? group : {}); + } + return values; +} +export function configAt(values: ConfigValues, path: string): ConfigValues { + const result = path + ? path + .split(".") + .reduce< + ConfigValue | undefined + >((value, key) => (value && typeof value === "object" ? value[key] : undefined), values) + : values; + return result && typeof result === "object" ? result : {}; +} diff --git a/src/controls.tsx b/src/controls.tsx new file mode 100644 index 0000000..342b7f7 --- /dev/null +++ b/src/controls.tsx @@ -0,0 +1,299 @@ +import { LocalInput } from "@quixos/camino-react/ui"; +import { useEffect, useState, type ReactNode } from "react"; +export function Options({ + label, + value, + options, + change, + disabled = false, +}: { + label: string; + value: string; + options: readonly { value: string; label: string; preview?: ReactNode }[]; + change: (value: string) => void; + disabled?: boolean; +}) { + return ( +
option.preview) ? " lr-visual-options" : "")} + role="radiogroup" + aria-label={label} + > + {options.map((option) => ( + + ))} +
+ ); +} +export function UnderlinePreview({ pattern }: { pattern: string }) { + return ( + + ); +} +export function Switch({ + label, + value, + change, + disabled = false, +}: { + label: string; + value: boolean; + change: (value: boolean) => void; + disabled?: boolean; +}) { + return ( + + ); +} +const colors = [ + ["#fafaf6", "Paper"], + ["#ffffff", "White"], + ["#f1eadc", "Warm"], + ["#edf1e9", "Pale green"], + ["#53796c", "Sage"], + ["#6a8aac", "Blue"], + ["#bf8364", "Terracotta"], + ["#8a75a5", "Lavender"], + ["#d4ac53", "Gold"], + ["#303a36", "Charcoal"], +]; +export function ColorPicker({ + label, + value, + change, + disabled = false, + background = false, +}: { + label: string; + value: string; + change: (value: string) => void; + disabled?: boolean; + background?: boolean; +}) { + const [draft, setDraft] = useState(value); + useEffect(() => setDraft(value), [value]); + const commit = () => { + if (/^#[0-9a-f]{6}$/i.test(draft) && draft !== value) change(draft); + }; + return ( +
+
+ {colors + .filter((_, i) => (background ? i < 4 : i >= 4)) + .map(([color, name]) => ( +
+ + setDraft(e.target.value)} + onBlur={commit} + /> +
+ ); +} +const fonts = [ + ["Noto Serif CJK SC", "Noto Serif"], + ["Noto Sans CJK SC", "Noto Sans"], + ["STKaiti", "STKaiti"], + ["Kaiti SC", "Kaiti SC"], + ["KaiTi", "KaiTi"], + ["Georgia", "Georgia"], + ["system-ui", "System"], + ["serif", "Serif"], + ["sans-serif", "Sans serif"], + ["ui-monospace", "Monospace"], +]; +const quote = (font: string) => (font.includes(" ") ? `"${font}"` : font); +export function FontPicker({ + label, + value, + change, + disabled = false, + inherit = false, +}: { + label: string; + value: string; + change: (value: string) => void; + disabled?: boolean; + inherit?: boolean; +}) { + const selected = value + .split(",") + .map((s) => s.trim().replace(/^['"]|['"]$/g, "")) + .filter(Boolean); + const update = (next: string[]) => change(next.map(quote).join(", ")); + return ( +
+ {inherit && ( + + )} +
+ {selected.map((font, index) => ( + + {font} + {index > 0 && ( + + )} + + + ))} +
+
+ {fonts + .filter(([font]) => !selected.includes(font)) + .map(([font, name]) => ( + + ))} +
+ + Fonts are tried from left to right. Missing fonts use the next fallback; Kai fonts must be installed. + +
+ ); +} +export function NumberControl({ + label, + value, + change, + min = 6, + max = 72, + disabled = false, +}: { + label: string; + value: number; + change: (value: number) => void; + min?: number; + max?: number; + disabled?: boolean; +}) { + const [draft, setDraft] = useState(String(value)); + useEffect(() => setDraft(String(value)), [value]); + const step = (next: number) => { + setDraft(String(next)); + change(next); + }; + const commit = () => { + const n = Number(draft); + if (Number.isFinite(n) && n >= min && n <= max && n !== value) change(n); + else setDraft(String(value)); + }; + return ( +
+ + setDraft(e.target.value)} + onBlur={commit} + /> + +
+ ); +} diff --git a/src/drafts.ts b/src/drafts.ts new file mode 100644 index 0000000..e3776c8 --- /dev/null +++ b/src/drafts.ts @@ -0,0 +1,19 @@ +import { useCrdtText, useLiveField, type ExactReference } from "@quixos/camino-react"; +import { interfaces } from "./gen/client.js"; + +/** Clear only the submitted text, preserving a newer collaborative draft. */ +export function usePrompt(reference: ExactReference, path: "name" | "change" | "followup" | "generate") { + const operation = interfaces.ReadingNavigation.prompts.edit; + const draft = useCrdtText({ key: reference.object + "/prompt/" + path, operation, reference, path: [path] }); + const field = useLiveField({ key: reference.object + "/prompt-clear/" + path, operation, reference }); + return { + ...draft, + clearSubmitted(expected: string) { + const current = field.current(); + if (current.status !== "ready") return; + const snapshot = current.binding.getSnapshot(); + if (snapshot.value[path] === expected) + current.binding.edit((editor) => editor.spliceText([path], 0, expected.length, ""), snapshot); + }, + }; +} diff --git a/src/editor.tsx b/src/editor.tsx new file mode 100644 index 0000000..792d9ca --- /dev/null +++ b/src/editor.tsx @@ -0,0 +1,1378 @@ +import { applyOptionEdits, optionEdits } from "./config-edits.js"; +import { configDefaults, configWithDefaults } from "./config-schema.js"; +import { resolveRenders } from "./render-hooks.js"; +import { readerExtensionSettings } from "./reader-extensions.js"; +import { usePrompt } from "./drafts.js"; +import { + LocalInput, + LocalSelect, + LocalTextArea, + Choice, + LocalChoice, + Toggle, + useCommandControl, + TextArea, + TextInput, +} from "@quixos/camino-react/ui"; +import { useEffect, useRef, useState } from "react"; +import { + referenceOf, + useCaminoMembers, + useCrdtText, + useInterface, + useMutationFeedback, + type ExactReference, +} from "@quixos/camino-react"; +import { ColorPicker, FontPicker, NumberControl, Options, Switch } from "./controls.js"; +import { configDefinition } from "./annotator-config.js"; +import { authoringTitle, FeatureConversation } from "./tasks.js"; +import { ReaderPreview } from "./preview.js"; +import { interfaces } from "./gen/client.js"; +import { + CustomConfig, + fontFields, + underlineFields, + configFields, + configValues, + type ConfigValues, + type ConfigValue, +} from "./annotator-config.js"; +import { + accepted, + errorMessage, + type Catalog, + type FeatureNode, + type ReaderNode, + type Root, + type NavigationValues, + type DocNode, +} from "./model.js"; + +export const appearances = [ + { key: "overhead", title: "Text labels", description: "Words or notes above or below the text." }, + { key: "highlight", title: "Highlight", description: "Color behind the text." }, + { key: "underline", title: "Underline", description: "A line beneath the text." }, + { key: "color", title: "Text color", description: "Change the color of the text." }, + { key: "tooltip", title: "Tooltip", description: "A floating note on hover or click." }, + { key: "sidebar", title: "Sidebar", description: "Definitions, translations, or longer notes." }, + { key: "replace", title: "Replace text", description: "Display different text in place of a source span." }, + { key: "bold", title: "Bold", description: "Emphasize selected words." }, + { key: "italic", title: "Italic", description: "Set selected words in italics." }, +]; +export const revealOptions = (style: string) => [ + ...(["tooltip", "sidebar"].includes(style) ? [] : [{ value: "always", label: "Permanent" }]), + ...(["overhead", "replace", "bold", "italic"].includes(style) ? [] : [{ value: "hover", label: "On hover" }]), + { value: "click", label: "On click" }, +]; +export function Preview({ + style, + color = "#53796c", + placement = "above", + pattern = "solid", +}: { + style: string; + color?: string; + placement?: string; + pattern?: string; +}) { + return ( + + ); +} +export function Modal({ + title, + children, + close, + wide = false, + heading, +}: { + title: string; + children: React.ReactNode; + close: () => void; + wide?: boolean; + heading?: React.ReactNode; +}) { + const panel = useRef(null), + previous = useRef(null); + useEffect(() => { + previous.current = (panel.current?.getRootNode() as Document | ShadowRoot)?.activeElement ?? document.activeElement; + panel.current?.focus(); + return () => { + if (previous.current instanceof HTMLElement && previous.current.isConnected) previous.current.focus(); + }; + }, []); + return ( +
{ + if (event.target === event.currentTarget) close(); + }} + > +
{ + if ((event.target as Element).closest("dialog[open], [popover]:popover-open")) return; + if (event.key === "Escape") close(); + if (event.key === "Tab") { + const elements = Array.from( + panel.current?.querySelectorAll( + "button:not([disabled]),input:not([disabled]),textarea:not([disabled]),select:not([disabled]),a[href]", + ) ?? [], + ); + const active = + (panel.current?.getRootNode() as Document | ShadowRoot)?.activeElement ?? document.activeElement; + const first = elements[0], + last = elements[elements.length - 1]; + if (event.shiftKey && (active === first || active === panel.current)) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && active === last) { + event.preventDefault(); + first?.focus(); + } + } + }} + > +
+
{heading ??

{title}

}
+ +
+ {children} +
+
+ ); +} +export function FeatureToggle({ + feature, + onIntent, +}: { + feature: FeatureNode; + onIntent?: (enabled: boolean | null) => void; +}) { + const ops = useInterface(interfaces.ReadingFeature, referenceOf(feature)); + const [intent, setIntent] = useState(null), + [busy, setBusy] = useState(false), + [error, setError] = useState(""); + const enabled = intent ?? feature.data.enabled; + useEffect(() => { + if (intent !== null && intent === feature.data.enabled) { + setIntent(null); + onIntent?.(null); + } + }, [intent, feature.data.enabled, onIntent]); + return ( +
+ { + if (ops.status !== "ready") return; + setIntent(value); + onIntent?.(value); + setBusy(true); + setError(""); + const ticket = ops.value.setEnabled.call(value); + void ticket.local.catch((e) => { + setIntent(null); + onIntent?.(null); + setError(errorMessage(e)); + }); + void ticket.settled.then( + (result) => { + setBusy(false); + if (result.status !== "accepted") { + setIntent(null); + onIntent?.(null); + setError("Could not change this annotator"); + } + }, + (e) => { + setBusy(false); + setIntent(null); + onIntent?.(null); + setError(errorMessage(e)); + }, + ); + }} + /> + {enabled ? "Enabled" : "Disabled"} + {error && {error}} +
+ ); +} + +/** Reader reassignment checks the complete inverse relation, not a paginated query window. */ +function ReaderAssignmentInputs({ + reader, + ready, +}: { + reader: ReaderNode; + ready: React.Dispatch>>; +}) { + const ref = referenceOf(reader); + const inputs = useCaminoMembers({ + subject: ref, + interface: "interface:reading:reader@1", + members: ["reading:reader:documents", "reading:reader:features"], + complete: { "reading:reader:documents": 512, "reading:reader:features": 512 }, + }); + useEffect(() => { + const available = inputs.status === "ready"; + ready((current) => (current[ref.object] === available ? current : { ...current, [ref.object]: available })); + }, [inputs.status, ref.object, ready]); + return null; +} + +export function ReaderSettings({ + library, + document, + root, + catalog, + initialReader, + navigation, + navigationValues, + previewDocument = null, + previewCursor = 0, + close, +}: { + library: ExactReference; + document?: ExactReference; + root: Root; + catalog: Catalog; + initialReader?: string; + navigation: ExactReference; + navigationValues: NavigationValues; + previewDocument?: DocNode | null; + previewCursor?: number; + close: () => void; +}) { + const api = useInterface(interfaces.LanguageReader, library), + nav = useInterface({ selectSettings: interfaces.ReadingNavigation.selectSettings }, navigation); + const chosen = + navigationValues.settingsReader?._camino.ref.object ?? + initialReader ?? + root.defaultReader?._camino.ref.object ?? + ""; + const settingsWrite = useRef(null); + const section = + navigationValues.ui.section === "feature" + ? (navigationValues.settingsFeature?._camino.ref.object ?? "general") + : navigationValues.ui.section; + const setSection = (section: string) => { + if (nav.status !== "ready") return; + const feature = root.features.entries.find((e) => referenceOf(e.node).object === section)?.node; + const ticket = nav.value.selectSettings.call( + { + reader: null, + feature: feature ? referenceOf(feature) : null, + section: feature ? "feature" : section, + open: false, + document: null, + }, + { requires: settingsWrite.current ? [settingsWrite.current] : [] }, + ); + settingsWrite.current = ticket.operationId; + void accepted(ticket).catch((e) => { + if (settingsWrite.current === ticket.operationId) settingsWrite.current = null; + setError(errorMessage(e)); + }); + }; + const selectReader = (ref: ExactReference) => { + if (nav.status !== "ready") return; + const ticket = nav.value.selectSettings.call( + { + reader: asReader(ref), + feature: null, + section: "general", + open: false, + document: document + ? { ...document, expectation: { kind: "interface", id: "interface:reading:document@1" } } + : null, + }, + { requires: settingsWrite.current ? [settingsWrite.current] : [] }, + ); + settingsWrite.current = ticket.operationId; + void accepted(ticket).catch((e) => { + if (settingsWrite.current === ticket.operationId) settingsWrite.current = null; + setError(errorMessage(e)); + }); + }; + const selectFeature = (ref: ExactReference) => { + if (nav.status !== "ready") return; + const ticket = nav.value.selectSettings.call( + { + reader: null, + feature: { ...ref, expectation: { kind: "interface", id: "interface:reading:feature@1" } }, + section: "feature", + open: false, + document: null, + }, + { requires: settingsWrite.current ? [settingsWrite.current] : [] }, + ); + settingsWrite.current = ticket.operationId; + void accepted(ticket).catch((e) => { + if (settingsWrite.current === ticket.operationId) settingsWrite.current = null; + setError(errorMessage(e)); + }); + }; + const setChosen = (object: string) => { + const reader = readers.find((r) => referenceOf(r).object === object); + if (reader) selectReader(referenceOf(reader)); + }; + const [error, setError] = useState(""), + [creating, setCreating] = useState(false), + [assignmentReady, setAssignmentReady] = useState>({}); + const readers = root.readers.entries.map((e) => e.node); + const retainedReader = navigationValues.settingsReader; + if (retainedReader && !readers.some((r) => referenceOf(r).object === referenceOf(retainedReader).object)) + readers.push(retainedReader); + const reader = readers.find((r) => referenceOf(r).object === chosen); + const members = new Set(reader?.features.entries.map((e) => referenceOf(e.node).object) ?? []), + features = root.features.entries.map((e) => e.node).filter((f) => members.has(referenceOf(f).object)); + const selected = features.find((f) => referenceOf(f).object === section); + const settingsExtension = readerExtensionSettings.find((extension) => section === `extension:${extension.id}`); + const ExtensionSettings = settingsExtension?.Settings; + const [previewReader, setPreviewReader] = useState(null), + [previewFeature, setPreviewFeature] = useState(null); + const shownReader = + previewReader && reader && referenceOf(previewReader).object === referenceOf(reader).object + ? previewReader + : reader; + const shownFeatures = features.map((feature) => + previewFeature && referenceOf(previewFeature).object === referenceOf(feature).object ? previewFeature : feature, + ); + async function add(copy: boolean) { + if (api.status !== "ready" || !reader || creating) return; + setCreating(true); + try { + const ref = await accepted( + api.value.addReader.call({ + name: copy ? reader.data.name + " copy" : "New reader", + copy: copy ? { ...referenceOf(reader), expectation: { kind: "atom", id: "atom:reading:reader" } } : null, + }), + ); + selectReader(ref); + } catch (e) { + setError(errorMessage(e)); + } finally { + setCreating(false); + } + } + return ( + +
+ ({ + value: referenceOf(r).object, + label: r.data.name, + disabled: !assignmentReady[referenceOf(r).object], + }))} + /> +
+ +
+ New reader +
+ + +
+
+
+ {readers.map((r) => ( + + ))} +
+
+ +
+ + {reader && + (selected ? ( + + ) : ExtensionSettings ? ( + + ) : section === "new" || section === "templates" ? ( + setSection("general")} + /> + ) : ( + + ))} + {reader && selected && !selected.authoring && ( + + )} + {reader && ( + e.node) + .filter( + (t) => + t.data.kind === "customize" && + t.reader?._camino.ref.object === referenceOf(reader).object && + (selected + ? selected.authoring?._camino.ref.object === referenceOf(t).object + : section === "general"), + )} + /> + )} +
+ {shownReader && ( + + )} +
+ {error &&

{error}

} +
+ ); +} +const asReader = (ref: ExactReference) => ({ + ...ref, + expectation: { kind: "interface" as const, id: "interface:reading:reader@1" as const }, +}); +function GeneralReader({ + reader, + onPreviewChange, +}: { + reader: ReaderNode; + onPreviewChange?: (reader: ReaderNode | null) => void; +}) { + const api = useInterface(interfaces.ReadingReader, referenceOf(reader)), + feedback = useMutationFeedback(); + const [intent, setIntent] = useState(null), + [paletteIntent, setPaletteIntent] = useState(null), + [saveError, setSaveError] = useState(""); + const saved = useRef(reader.data), + queued = useRef>({}), + running = useRef(false); + saved.current = reader.data.configRevision >= saved.current.configRevision ? reader.data : saved.current; + const d = intent ?? reader.data, + [name, setName] = useState(d.name), + [width, setWidth] = useState(d.width); + useEffect(() => { + onPreviewChange?.({ ...reader, data: d, palette: paletteIntent ?? reader.palette }); + }, [reader, d, paletteIntent, onPreviewChange]); + useEffect(() => () => onPreviewChange?.(null), [onPreviewChange]); + useEffect(() => { + if (paletteIntent && paletteIntent.text === reader.palette.text && paletteIntent.accent === reader.palette.accent) + setPaletteIntent(null); + }, [reader.palette, paletteIntent]); + useEffect(() => { + setName(d.name); + setWidth(d.width); + }, [d.name, d.width]); + useEffect(() => { + if ( + !running.current && + !Object.keys(queued.current).length && + intent && + reader.data.configRevision >= intent.configRevision + ) + setIntent(null); + }, [reader.data, intent]); + function update(changes: Partial) { + if (api.status !== "ready") return; + setIntent((previous) => ({ ...saved.current, ...previous, ...changes })); + Object.assign(queued.current, changes); + if (running.current) return; + running.current = true; + setSaveError(""); + void (async () => { + try { + while (Object.keys(queued.current).length) { + const next = queued.current; + queued.current = {}; + const { configRevision, ...settings } = saved.current; + const value = { ...settings, ...next }; + await accepted(api.value.configure.call({ ...value, expectedRevision: configRevision })); + saved.current = { ...value, configRevision: configRevision + 1 }; + } + setIntent(saved.current); + } catch (error) { + queued.current = {}; + setIntent(null); + saved.current = reader.data; + setSaveError(errorMessage(error)); + } finally { + running.current = false; + } + })(); + } + const widths = [640, 800, 1000, 1200, 1600, 0]; + return ( +
+

General

+ {saveError &&

{saveError}

} +

Applies to all texts using this reader.

+ +
+
+ Text size +
+ update({ size })} /> +
+
+
+ Text width + Text stays centered beside your notes. +
+
+ setWidth(widths[Number(e.target.value)])} + onPointerUp={() => width !== d.width && update({ width })} + onKeyUp={() => width !== d.width && update({ width })} + /> + {width ? width + " px" : "Full width"} +
+
+
+

Reading fonts

+ update({ font })} /> +
+
+

Text color

+ { + if (api.status !== "ready") return; + const palette = { ...reader.palette, ...paletteIntent, text }; + setPaletteIntent(palette); + feedback.track(api.value.palette.set(palette)); + }} + /> +
+
+

Accent color

+ { + if (api.status !== "ready") return; + const palette = { ...reader.palette, ...paletteIntent, accent }; + setPaletteIntent(palette); + feedback.track(api.value.palette.set(palette)); + }} + /> +
+
+

Background color

+ update({ background })} + /> +
+ {feedback.message &&

{feedback.message}

} +
+ ); +} + +function AnnotatorLibrary({ + library, + reader, + catalog, + archivedTypes, + added, + initiallyOpen, +}: { + library: ExactReference; + reader: ReaderNode; + catalog: Catalog; + archivedTypes: readonly string[]; + added: (ref: ExactReference) => void; + initiallyOpen: boolean; +}) { + const api = useInterface(interfaces.LanguageReader, library); + const [selected, setSelected] = useState(null), + [values, setValues] = useState({}), + [title, setTitle] = useState(""), + [archiveIntent, setArchiveIntent] = useState>({}), + [busy, setBusy] = useState(false), + [error, setError] = useState(""); + useEffect(() => { + setArchiveIntent((previous) => + Object.fromEntries( + Object.entries(previous).filter(([key, archived]) => archivedTypes.includes(key) !== archived), + ), + ); + }, [archivedTypes]); + const isArchived = (key: string) => archiveIntent[key] ?? archivedTypes.includes(key); + const item = catalog.find((candidate) => candidate.key === selected); + const definition = configDefinition(item?.config ?? "{}"); + const channels = resolveRenders(definition.renders, values); + const style = channels[0]?.style ?? "color"; + const choose = (candidate: Catalog[number]) => { + const fields = configDefinition(candidate.config).fields; + setValues(configDefaults(fields)); + setTitle(candidate.label); + setSelected(candidate.key); + setError(""); + }; + async function archive(candidate: Catalog[number], archived: boolean) { + if (api.status !== "ready") return; + setArchiveIntent((previous) => ({ ...previous, [candidate.key]: archived })); + setError(""); + try { + await accepted(api.value.archiveType.call({ processor: candidate.key, archived })); + if (selected === candidate.key && archived) setSelected(null); + } catch (error) { + setArchiveIntent((previous) => { + const next = { ...previous }; + delete next[candidate.key]; + return next; + }); + setError(errorMessage(error)); + } + } + async function add() { + if (api.status !== "ready" || !item) return; + setBusy(true); + setError(""); + try { + added( + await accepted( + api.value.addFeature.call({ + authoring: null, + reader: { ...referenceOf(reader), expectation: { kind: "atom", id: "atom:reading:reader" } }, + name: title.trim() || item.label, + processor: item.key, + options: JSON.stringify(values), + }), + ), + ); + } catch (error) { + setError(errorMessage(error)); + } finally { + setBusy(false); + } + } + return ( +
+ + + + Use an existing type + Add another annotator with its own settings. + + + +
+ {item ? ( +
+ +
+

{item.label}

+

{definition.description}

+
+ + setValues((previous) => ({ ...previous, [key]: value }))} + /> +
+ + {style === "overhead" + ? "Text labels" + : (appearances.find((appearance) => appearance.key === style)?.title ?? style)}{" "} + ·{" "} + {channels[0]?.reveal === "click" ? "On click" : channels[0]?.reveal === "hover" ? "On hover" : "Always"} + + +
+
+ ) : ( + <> +
+ {catalog + .filter((candidate) => !isArchived(candidate.key)) + .map((candidate) => { + const config = configDefinition(candidate.config); + return ( +
+ + +
+ ); + })} + {!catalog.some((candidate) => !isArchived(candidate.key)) && ( +

No types in the library. Restore an archived type or create one below.

+ )} +
+ {catalog.some((candidate) => isArchived(candidate.key)) && ( +
+ Archived types ({catalog.filter((candidate) => isArchived(candidate.key)).length}) +

Existing annotators are unaffected.

+ {catalog + .filter((candidate) => isArchived(candidate.key)) + .map((candidate) => ( +
+ {candidate.label} + +
+ ))} +
+ )} + + )} + {error &&

{error}

} +
+
+ ); +} +function LabelPosition({ + value, + change, + disabled = false, +}: { + value: string; + change: (value: string) => void; + disabled?: boolean; +}) { + return ( +
+ Label position + + note + mot + + ), + }, + { + value: "below", + label: "Below", + preview: ( + + note + mot + + ), + }, + ]} + change={change} + /> +
+ ); +} +function NewAnnotator({ + library, + reader, + navigation, + navigationValues, + catalog, + archivedTypes, + added, + libraryOpen, + created, +}: { + library: ExactReference; + reader: ReaderNode; + navigation: ExactReference; + navigationValues: NavigationValues; + catalog: Catalog; + archivedTypes: readonly string[]; + added: (ref: ExactReference) => void; + libraryOpen: boolean; + created: () => void; +}) { + const api = useInterface(interfaces.LanguageReader, library), + nav = useInterface(interfaces.ReadingNavigation, navigation); + const title = usePrompt(navigation, "name"); + const instructions = useCrdtText({ + key: navigation.object + "/annotator-instructions", + reference: navigation, + operation: interfaces.ReadingNavigation.draft.edit, + path: ["custom"], + }); + const name = title.text, + text = instructions.text; + const { style, reveal, color } = navigationValues.builder, + options = configValues(navigationValues.builder.options); + const updateBuilder = (changes: Partial) => { + if (nav.status === "ready") + void accepted(nav.value.builder.set({ ...navigationValues.builder, ...changes })).catch((e) => + setError(errorMessage(e)), + ); + }; + const setStyle = (style: string) => + updateBuilder({ + style, + reveal: revealOptions(style).some((o) => o.value === reveal) ? reveal : revealOptions(style)[0].value, + }); + const setReveal = (reveal: string) => updateBuilder({ reveal }); + const setColor = (color: string) => updateBuilder({ color }); + const [busy, setBusy] = useState(false), + [error, setError] = useState(""); + async function build() { + await Promise.all([title.flush(), instructions.flush()]); + if (api.status !== "ready") return; + setBusy(true); + setError(""); + try { + await accepted( + api.value.customize.call({ + feature: null, + reader: { ...referenceOf(reader), expectation: { kind: "atom", id: "atom:reading:reader" } }, + configuration: JSON.stringify({ name, style, reveal, color, ...options, instructions: text }), + text: text.trim(), + createdAt: new Date().toISOString(), + }), + ); + created(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setBusy(false); + } + } + return ( +
+ +
+ Create a new type +
+ +

How should it appear?

+
+ {appearances + .filter((a) => !["underline", "color", "bold", "italic"].includes(a.key)) + .map((a) => ( + + ))} +
+ {["highlight", "underline", "color", "bold", "italic"].includes(style) && ( +
+ Format + ["highlight", "underline", "color", "bold", "italic"].includes(a.key)) + .map((a) => ({ + value: a.key, + label: a.title, + preview: ( + + mot + + ), + }))} + change={setStyle} + /> +
+ )} +
+ + {["overhead", "highlight", "underline", "color"].includes(style) && ( + + )} +
+ {style === "underline" && ( + updateBuilder({ options: JSON.stringify({ ...options, [key]: value }) })} + /> + )} + {style === "overhead" && ( +
+ updateBuilder({ options: JSON.stringify({ ...options, placement }) })} + /> +
+ Label font and size + updateBuilder({ options: JSON.stringify({ ...options, [key]: value }) })} + /> +
+
+ )} +