Export template source from 122c862dccc4a1825f19cc8d4eb1095e5661f1ea:examples/local-first/packages/language-reader-presentation

This commit is contained in:
Quixos Template Publisher
2000-01-01 00:00:00 +00:00
commit 4c0ba3970f
61 changed files with 9997 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"monorepoCommit": "122c862dccc4a1825f19cc8d4eb1095e5661f1ea",
"sourcePath": "examples/local-first/packages/language-reader-presentation"
}
File diff suppressed because one or more lines are too long
+29
View File
@@ -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
+38
View File
@@ -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.
+10
View File
@@ -0,0 +1,10 @@
{
"component:reading:reader" = {
entry = "src/view.tsx";
export = "Reader";
};
"component:reading:vocabulary" = {
entry = "src/vocabulary.tsx";
export = "Vocabulary";
};
}
Generated
+108
View File
@@ -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
}
+57
View File
@@ -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; };
}
);
}
+12
View File
@@ -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"
}
}
+44
View File
@@ -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<interface LanguageReader,interface ReadingNavigation>.render;
module Vocabulary id "component:reading:vocabulary" implements StandaloneView<interface ReadingVocabulary>.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;}
}
+13
View File
@@ -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}
}
}
}
+21
View File
@@ -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} } }
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
query DocumentContent {
root {
library { _camino { ref } }
reader { _camino { ref } }
canonical { data { title } }
adaptation
data { title text sourceRevision source url attribution }
}
}
+20
View File
@@ -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} }
}
}
+1
View File
@@ -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 } }
+20
View File
@@ -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 style reveal generation scope color 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} } }
} } }
}
}
+6
View File
@@ -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}
} }
}
+19
View File
@@ -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
}
}
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"backend": "execution",
"queryFrontends": ["react"]
}
+22
View File
@@ -0,0 +1,22 @@
quixos-lock version 1 {
quixos source { repository "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git"; commit "122c862dccc4a1825f19cc8d4eb1095e5661f1ea"; }
interface LanguageReader source { repository "https://repos.quixos.org/quixos-local-first-examples/interface-language-reader.git"; commit "06624fd6a5d9761f74e3cc862e7bc0696cd78f21"; }
interface ReadingReader source { repository "https://repos.quixos.org/quixos-local-first-examples/interface-reading-reader.git"; commit "2ae966b8b05ea05b05818c2ca3378bd8b3a112cf"; }
interface ReadingDocument source { repository "https://repos.quixos.org/quixos-local-first-examples/interface-reading-document.git"; commit "544956d30f88e6f65bf10f571e4d09ebbbb77284"; }
interface ReadingFeature source { repository "https://repos.quixos.org/quixos-local-first-examples/interface-reading-feature.git"; commit "ef2316f66784f4fb9250dadecc09750bcbd6f776"; }
interface ReadingJob source { repository "https://repos.quixos.org/quixos-local-first-examples/interface-reading-job.git"; commit "cc078cccb5488b89e11e4cd4e9fe009211a50746"; }
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 "05c791c093b1151a49b0e058609b9d01147c8549"; }
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"; }
}
+33
View File
@@ -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": []
}
}
}
+79
View File
@@ -0,0 +1,79 @@
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<string> }) => Item[]
> = {
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<string> },
) {
const decorated = items.map((item) => ({
...item,
color:
typeof (options[item.tag + "Color"] ?? options.otherColor) === "string"
? String(options[item.tag + "Color"] ?? options.otherColor)
: undefined,
fontFamily: String(options.fontFamily ?? ""),
fontSize: Number(options.fontSize ?? 13),
underlinePattern: (options.underlinePattern ?? item.underlinePattern ?? "solid") as UnderlinePattern,
placement: (options.placement ?? item.placement ?? "above") as "above" | "below",
}));
return (annotationDisplays[processor]?.(decorated, options, context) ?? decorated).map((item) => {
if (
(item.underlinePattern && !["solid", "dashed", "squiggly"].includes(item.underlinePattern)) ||
(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))) ||
!Number.isFinite(item.fontSize) ||
item.fontSize! < 6 ||
item.fontSize! > 72
)
throw Error("Invalid annotator appearance");
return item;
});
}
+189
View File
@@ -0,0 +1,189 @@
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";
export type ConfigValue = string | number | boolean;
export type ConfigValues = Record<string, ConfigValue>;
export type ConfigField = {
key: string;
label: string;
kind: "color" | "number" | "select" | "toggle" | "text";
default: ConfigValue;
min?: number;
max?: number;
computation?: boolean;
options?: { value: string; label: string }[];
};
export type AnnotatorConfigProps = {
fields: ConfigField[];
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, model calls, or the generic appearance/reveal preferences.
export const annotatorConfigViews: Record<string, ComponentType<AnnotatorConfigProps>> = {
pinyin: ({ values, disabled, change }) => (
<div className="lr-hsk-control">
<label htmlFor="lr-hsk-level">Words above your HSK level</label>
<LocalInput
id="lr-hsk-level"
type="range"
min="0"
max="9"
step="1"
list="lr-hsk-notches"
aria-label="Your HSK level"
aria-valuetext={Number(values.minimumLevel ?? 2) ? `HSK ${values.minimumLevel ?? 2}` : "All words"}
value={Number(values.minimumLevel ?? 2)}
disabled={disabled}
onChange={(event) => change("minimumLevel", event.target.value)}
/>
<datalist id="lr-hsk-notches">
{Array.from({ length: 10 }, (_, level) => (
<option key={level} value={level} />
))}
</datalist>
<div className="lr-hsk-labels">
{Array.from({ length: 10 }, (_, level) => (
<span key={level}>{level || "All"}</span>
))}
</div>
<strong>{Number(values.minimumLevel ?? 2) ? `Above HSK ${values.minimumLevel ?? 2}` : "All words"}</strong>
</div>
),
};
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: ConfigField[];
settings: { color?: boolean; font?: boolean; when?: string[] };
description: string;
preset?: { style: string; reveal: string };
cache?: { identity: string; inputs: string[]; previousRevisions?: string[] };
renders?: AnnotationRender[];
} {
try {
const value = JSON.parse(raw);
return Array.isArray(value)
? { fields: value, settings: {}, description: "" }
: {
fields: value.fields ?? [],
settings: value.settings ?? {},
description: value.description ?? "",
preset: value.preset,
cache: value.cache,
renders: value.renders,
};
} catch {
return { fields: [], settings: {}, 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 <Custom fields={fields} values={values} change={change} disabled={disabled} />;
return (
<div className="lr-processor-settings">
{fields.map((field) => (
<div key={field.key} className="lr-field">
<span>{field.label}</span>
{field.key === "fontFamily" ? (
<FontPicker
label={field.label}
value={String(values[field.key] ?? field.default)}
disabled={disabled}
inherit
change={(value) => change(field.key, value)}
/>
) : field.kind === "select" ? (
<Options
label={field.label}
value={String(values[field.key] ?? field.default)}
options={(field.options ?? []).map((option) => ({
...option,
...(field.key === "underlinePattern" ? { preview: <UnderlinePreview pattern={option.value} /> } : {}),
}))}
disabled={disabled}
change={(value) => change(field.key, value)}
/>
) : field.kind === "toggle" ? (
<Switch
label={field.label}
value={Boolean(values[field.key] ?? field.default)}
disabled={disabled}
change={(value) => change(field.key, value)}
/>
) : field.kind === "color" ? (
<ColorPicker
label={field.label}
value={String(values[field.key] ?? field.default)}
disabled={disabled}
change={(value) => change(field.key, value)}
/>
) : field.kind === "number" ? (
<NumberControl
label={field.label}
value={Number(values[field.key] ?? field.default)}
disabled={disabled}
min={field.min}
max={field.max}
change={(value) => change(field.key, value)}
/>
) : (
<LocalInput
aria-label={field.label}
value={String(values[field.key] ?? field.default)}
disabled={disabled}
onChange={(e) => change(field.key, e.target.value)}
/>
)}
</div>
))}
</div>
);
}
export const fontFields: ConfigField[] = [
{
key: "fontFamily",
label: "Annotation font",
kind: "select",
default: "",
options: [
{ value: "", label: "Match the reading text" },
{ value: '"Noto Serif", Georgia, serif', label: "Serif" },
{ value: "system-ui, sans-serif", label: "Sans serif" },
{ value: "ui-monospace, monospace", label: "Monospace" },
],
},
{ key: "fontSize", label: "Annotation font size", kind: "number", default: 13, min: 6, max: 72 },
];
export const underlineFields: ConfigField[] = [
{
key: "underlinePattern",
label: "Underline pattern",
kind: "select",
default: "solid",
computation: false,
options: [
{ value: "solid", label: "Solid" },
{ value: "dashed", label: "Dashed" },
{ value: "squiggly", label: "Squiggly" },
],
},
];
+45
View File
@@ -0,0 +1,45 @@
/** 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<string, unknown>,
inputs: readonly string[],
) {
return JSON.stringify({
processor: identity,
scope,
options: Object.fromEntries([...inputs].sort().map((key) => [key, options[key]])),
});
}
/** 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;
}
}
+84
View File
@@ -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<string, readonly Entry[]> }>({
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 ? (
<CachedResultsTail
key={reference!.object}
reference={reference!}
cursor={node.results.pageInfo.endCursor}
report={report}
/>
) : 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 ? (
<CachedResultsTail reference={reference} cursor={results.pageInfo.endCursor} report={report} />
) : null;
}
+483
View File
@@ -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<HTMLDivElement>(null),
canvas = useRef<HTMLCanvasElement>(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<Range | null>(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<Layout | null>(() => {
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<string, (typeof processing)[number]>();
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<Range | null>(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 (
<>
<Tooltip content={tip} getAnchor={getTipAnchor} dismiss={onDismissTooltip} />
<div
className="reading-surface"
data-view={mode}
data-source-start={page?.start}
data-source-end={page?.end}
ref={viewport}
onScroll={(event) => {
setScrollOffset(Math.max(0, event.currentTarget.scrollTop - (mode === "scroll" ? 24 : 0)));
onHover(null);
setLabelHover(null);
}}
>
<div className="reading-viewport" style={{ width: size.width, height: size.height, marginInline: "auto" }}>
<canvas
ref={canvas}
className="reading-canvas"
style={{ width: size.width, height: size.height }}
tabIndex={interactive ? 0 : -1}
role="textbox"
aria-readonly="true"
aria-label="Reading text"
aria-multiline="true"
aria-describedby="reading-canvas-help"
onPointerDown={(event) => {
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;
}
}
}}
/>
<svg className="reading-overlay" width={size.width} height={drawHeight} aria-hidden="true">
<g transform={`translate(0,${-top})`}>
{processingPaths.map(({ range, label, color, path }) => (
<path
key={`${range.start}:${range.end}`}
className="reading-processing"
d={path}
style={{ stroke: color }}
>
<title>{label}</title>
</path>
))}
</g>
</svg>
</div>
<div
aria-hidden="true"
style={{ height: mode === "scroll" ? Math.max(0, (layout?.height ?? 0) - size.height + 24) : 0 }}
/>
<p className="reading-sr" id="reading-canvas-help">
Drag to select text; double-click selects a word. Shift and arrow keys extend selection. Press Control or
Command C to copy. Original text follows.
</p>
<p className="reading-sr">{text}</p>
</div>
{mode === "pages" && (
<nav className="reading-pagination" aria-label="Reading pages">
<button disabled={pageIndex <= 0} onClick={() => turnPage(pageIndex - 1)}>
Previous page
</button>
<span role="status">
Page {pageIndex + 1} of {Math.max(1, pages.length)}
</span>
<button disabled={pageIndex >= pages.length - 1} onClick={() => turnPage(pageIndex + 1)}>
Next page
</button>
</nav>
)}
</>
);
}
+159
View File
@@ -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<HTMLDivElement>(null),
inside = useRef(false),
timer = useRef<ReturnType<typeof setTimeout> | 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 (
<div
ref={panel}
role="tooltip"
aria-label="Reading annotation"
className="lr-annotation-tooltip"
onPointerEnter={() => {
inside.current = true;
cancel();
}}
onPointerLeave={() => {
inside.current = false;
if (!content) {
cancel();
hide();
}
}}
>
{shown.trigger === "click" && (
<button
aria-label="Close annotation"
className="lr-tooltip-close"
onClick={() => {
cancel();
inside.current = false;
hide();
dismiss();
}}
>
×
</button>
)}
<div className="lr-tooltip-content">
{shown.items.map((item) => (
<section key={item.id} style={{ borderColor: item.color }}>
<small>{item.label}</small>
<p>{item.text}</p>
</section>
))}
</div>
</div>
);
}
+142
View File
@@ -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();
}
+31
View File
@@ -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;
}
+562
View File
@@ -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<typeof graphemes>[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<T>(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<string, Label[]>();
const priorities = new Map<string, number>();
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<number, number>(),
glueEnds = new Map<number, number>();
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<number, number>();
const legalStarts = new Map<number, number>(),
legalEnds = new Map<number, number>();
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<number, Group[]>();
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<number, Fragment[]>();
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<number, Annotation[]>();
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 };
}
+36
View File
@@ -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<string, { normal: Range[]; emergency: Range[] }>();
/** 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"];
}
+25
View File
@@ -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<string, ReturnType<Measure>>();
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;
};
}
+118
View File
@@ -0,0 +1,118 @@
import type { Layout, Range, Rect } from "./types.js";
const sourceLines = new WeakMap<Layout, Layout["lines"]>();
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);
}
+18
View File
@@ -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,
);
}
+82
View File
@@ -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),
};
}
+32
View File
@@ -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<string, ProcessingRegion[]>();
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;
}
+88
View File
@@ -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 };
}
+66
View File
@@ -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<number> } | null = null;
const segmented = new Map<string, (Range & { text: string })[]>();
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;
}
+20
View File
@@ -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)) };
}
+34
View File
@@ -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;
+299
View File
@@ -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 (
<div
className={"lr-options" + (options.some((option) => option.preview) ? " lr-visual-options" : "")}
role="radiogroup"
aria-label={label}
>
{options.map((option) => (
<button
type="button"
role="radio"
aria-checked={value === option.value}
aria-label={option.label}
disabled={disabled}
key={option.value}
onClick={() => change(option.value)}
>
{option.preview && (
<span className="lr-option-preview" aria-hidden="true">
{option.preview}
</span>
)}
<span>{option.label}</span>
</button>
))}
</div>
);
}
export function UnderlinePreview({ pattern }: { pattern: string }) {
return (
<svg viewBox="0 0 96 18" className="lr-stroke-preview" aria-hidden="true">
{pattern === "squiggly" ? (
<path d="M4 9 Q8 2 12 9 T20 9 T28 9 T36 9 T44 9 T52 9 T60 9 T68 9 T76 9 T84 9 T92 9" />
) : (
<path d="M4 9 H92" strokeDasharray={pattern === "dashed" ? "8 6" : undefined} />
)}
</svg>
);
}
export function Switch({
label,
value,
change,
disabled = false,
}: {
label: string;
value: boolean;
change: (value: boolean) => void;
disabled?: boolean;
}) {
return (
<button
type="button"
className="lr-switch"
role="switch"
aria-label={label}
aria-checked={value}
disabled={disabled}
onClick={() => change(!value)}
>
<span />
</button>
);
}
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 (
<div className="lr-color-picker" role="group" aria-label={label + " palette"}>
<div className="lr-swatches">
{colors
.filter((_, i) => (background ? i < 4 : i >= 4))
.map(([color, name]) => (
<button
type="button"
aria-label={`${label}: ${name}`}
aria-pressed={color.toLowerCase() === value.toLowerCase()}
style={{ background: color }}
disabled={disabled}
key={color}
onClick={() => change(color)}
/>
))}
</div>
<label className="lr-custom-color" title="Choose a custom color">
<LocalInput
type="color"
aria-label={`Custom ${label}`}
value={/^#[0-9a-f]{6}$/i.test(draft) ? draft : value}
disabled={disabled}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
/>
<span>Custom</span>
</label>
<LocalInput
className="lr-color-hex"
aria-label={label}
value={draft}
disabled={disabled}
maxLength={7}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
/>
</div>
);
}
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 (
<div className="lr-font-picker" aria-label={label}>
{inherit && (
<button
type="button"
className="lr-font-inherit"
aria-pressed={!value}
onClick={() => change("")}
disabled={disabled}
>
Use the reading font
</button>
)}
<div className="lr-font-tags" aria-label="Font fallback order">
{selected.map((font, index) => (
<span key={font} style={{ fontFamily: quote(font) }}>
{font}
{index > 0 && (
<button
type="button"
aria-label={`Move ${font} earlier`}
disabled={disabled}
onClick={() => {
const next = [...selected];
[next[index - 1], next[index]] = [next[index], next[index - 1]];
update(next);
}}
>
←
</button>
)}
<button
type="button"
aria-label={`Remove ${font} font`}
disabled={disabled || (!inherit && selected.length === 1)}
onClick={() => update(selected.filter((f) => f !== font))}
>
×
</button>
</span>
))}
</div>
<div className="lr-font-choices">
{fonts
.filter(([font]) => !selected.includes(font))
.map(([font, name]) => (
<button
type="button"
key={font}
style={{ fontFamily: quote(font) }}
disabled={disabled}
onClick={() => update([font, ...selected])}
>
{name}
</button>
))}
</div>
<small>
Fonts are tried from left to right. Missing fonts use the next fallback; Kai fonts must be installed.
</small>
</div>
);
}
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 (
<div className="lr-number-control">
<button
type="button"
aria-label={`Decrease ${label}`}
disabled={disabled || value <= min}
onClick={() => step(Math.max(min, value - 1))}
>
−
</button>
<LocalInput
aria-label={label}
type="number"
min={min}
max={max}
value={draft}
disabled={disabled}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
/>
<button
type="button"
aria-label={`Increase ${label}`}
disabled={disabled || value >= max}
onClick={() => step(Math.min(max, value + 1))}
>
+
</button>
</div>
);
}
+19
View File
@@ -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);
},
};
}
+1437
View File
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import type { ComponentType, ReactNode } from "react";
import type { ExactReference } from "@quixos/camino-react";
import type { Annotation, Range } from "./canvas/types.js";
import type { CanvasContextTarget } from "./canvas/Surface.js";
import type { ReaderNode } from "./model.js";
/** One source context for annotations, sidebar panels and word/selection actions.
* Extensions are ordinary React modules; their data and writes use graph contracts.
* Browser-only menu placement never becomes durable application state. */
export type ReaderContext = {
library: ExactReference;
document: ExactReference;
sourceRevision: string;
text: string;
reader?: ReaderNode;
target: Range | null;
targetKind: "word" | "selection";
annotations: readonly Annotation[];
/** Link a contribution's referenced range back to the canvas. */
highlight(range: Range | null): void;
};
export type ReaderAction = {
id: string;
label: string;
disabled?: boolean;
run(): void | Promise<void>;
content?: ReactNode;
};
export type ReaderExtension = {
id: string;
title: string;
Sidebar?: ComponentType<{ context: ReaderContext; actions: readonly ReaderAction[] }>;
/** Panels are reader-wide by default; target-specific panels can opt out
* until a word or selection is available without reserving sidebar space. */
sidebarVisible?(context: ReaderContext): boolean;
actions?(context: ReaderContext): readonly ReaderAction[];
Settings?: ComponentType<{
library: ExactReference;
reader: ReaderNode;
defaultVocabulary?: ExactReference | null;
}>;
};
export function extensionActions(extensions: readonly ReaderExtension[], context: ReaderContext) {
return extensions.flatMap((extension) => extension.actions?.(context) ?? []);
}
/** React popover escapes canvas/ancestor clipping. The host provides source-aware
* actions, and owns keyboard focus, dismissal and action error reporting. */
export function WordMenu({
target,
actions,
close,
report,
}: {
target: CanvasContextTarget;
actions: readonly ReaderAction[];
close(): void;
report(error: unknown): void;
}) {
const panel = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const node = panel.current!;
const previous = (node.getRootNode() as Document | ShadowRoot).activeElement;
// Contextmenu fires before the opening gesture's pointerup. Native auto
// light-dismiss treats that release as an outside click and closes it.
node.setAttribute("popover", "manual");
node.showPopover();
const bounds = node.getBoundingClientRect();
node.style.left = Math.max(8, Math.min(target.anchor.x, innerWidth - bounds.width - 8)) + "px";
node.style.top =
Math.max(8, Math.min(target.anchor.y + target.anchor.height, innerHeight - bounds.height - 8)) + "px";
node.querySelector<HTMLButtonElement>("button:not(:disabled)")?.focus();
return () => {
if (node.matches(":popover-open")) node.hidePopover();
if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true });
};
}, [target]);
useEffect(() => {
const node = panel.current!;
const dismiss = (event: Event) => {
if ((event as ToggleEvent).newState === "closed") close();
};
const outside = (event: Event) => {
if (!event.composedPath().includes(node)) close();
};
const blur = () => close();
node.addEventListener("toggle", dismiss);
document.addEventListener("pointerdown", outside, true);
window.addEventListener("blur", blur);
return () => {
node.removeEventListener("toggle", dismiss);
document.removeEventListener("pointerdown", outside, true);
window.removeEventListener("blur", blur);
};
}, [close]);
return (
<div
ref={panel}
role="menu"
aria-label="Text actions"
className="lr-word-menu"
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
close();
}
if (["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) {
event.preventDefault();
const buttons = [...event.currentTarget.querySelectorAll<HTMLButtonElement>("button:not(:disabled)")];
const active = (event.currentTarget.getRootNode() as Document | ShadowRoot).activeElement;
const current = buttons.indexOf(active as HTMLButtonElement);
const index =
event.key === "Home"
? 0
: event.key === "End"
? buttons.length - 1
: (current + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length;
buttons[index]?.focus();
}
}}
>
{actions.map((action) => (
<button
type="button"
role="menuitem"
key={action.id}
disabled={action.disabled}
onClick={() => {
close();
void Promise.resolve()
.then(() => action.run())
.catch(report);
}}
>
{action.content ?? action.label}
</button>
))}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import type { ExactReference, ManagedReference } from "@quixos/camino-react";
import type { ComponentProps as ReactProps } from "react";
import type { Modules } from "./gen/client.js";
export type Root = ReactProps<Modules["Reader"]>["input"]["body"]["root"];
export type NavigationValues = ReactProps<Modules["Reader"]>["input"]["nav"]["root"];
export type FeatureNode = Root["features"]["entries"][number]["node"];
export type ReaderNode = Root["readers"]["entries"][number]["node"];
export type TaskNode = Root["tasks"]["entries"][number]["node"];
export type Catalog = readonly {
key: string;
label: string;
strategy: string;
scope: string;
processorRevision: string;
config: string;
}[];
export type DocNode = {
_camino: { ref: ExactReference };
data: { title: string; text: string; sourceRevision: string; source: string; url: string; attribution: string };
reader: { _camino: { ref: ExactReference } } | null;
canonical: { data: { title: string } } | null;
adaptation: string;
jobs: { entries: readonly { node: JobNode }[] };
};
export type JobNode = {
configuration: string;
_camino: { ref: ExactReference };
data: {
key: string;
kind: string;
featureRevision: number;
sourceRevision: string;
start: number;
end: number;
status: string;
output: string;
error: string;
createdAt: string;
};
feature: { _camino: { ref: ExactReference } } | null;
request: { _camino: { ref: ExactReference }; progress: { status: string; reason: string } } | null;
};
export const asInterface = <Name extends string>(
ref: ExactReference,
name: Name,
): ManagedReference<"interface", `interface:reading:${Name}@1`> => ({
...ref,
expectation: { kind: "interface", id: `interface:reading:${name}@1` },
});
export const asAtom = (ref: ExactReference, name: string): ExactReference => ({
...ref,
expectation: { kind: "atom", id: `atom:reading:${name}` },
});
export const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
/** Remote and transaction operations have the same accepted/rejected ticket boundary. */
export async function accepted<T>(ticket: { settled: Promise<{ status: string; value?: T }> }): Promise<T> {
const outcome = await ticket.settled;
if (outcome.status !== "accepted") throw Error(JSON.stringify(outcome));
return outcome.value as T;
}
+74
View File
@@ -0,0 +1,74 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCaminoClient, type ExactReference } from "@quixos/camino-react";
import { interfaces } from "./gen/client.js";
import type { PageSize, ViewportReport } from "./canvas/page-size.js";
import { sharedPageSize } from "./canvas/page-size.js";
/** Leased viewport measurements belong to the shared navigation, not reader
* preferences. Only mounted paged views renew them; a lost tab expires. */
export function usePageViews(navigation: ExactReference, reports: readonly ViewportReport[], enabled: boolean) {
const connection = useCaminoClient();
const [client] = useState(() => crypto.randomUUID());
const [local, setLocal] = useState<PageSize>({ width: 640, height: 600 });
const [now, setNow] = useState(Date.now);
const latest = useRef<PageSize | null>(null);
const send = useRef<((size: PageSize) => Promise<void>) | null>(null);
const reportSize = useCallback((size: PageSize) => {
latest.current = size;
if (!size.width || !size.height) {
void send.current?.(size);
return;
}
setLocal((previous) => (previous.width === size.width && previous.height === size.height ? previous : size));
}, []);
useEffect(() => {
if (!enabled) return;
// Own the consumer lease so removal is sent BEFORE releasing it. The
// useInterface lease is already disposed by React's earlier cleanup.
const lease = connection.lease();
let closed = false,
report: ((size: PageSize) => Promise<void>) | undefined;
const opening = lease
.acquire(interfaces.ReadingNavigation, navigation)
.then((ready) => {
if (closed) return;
report = async (size) => {
try {
await ready.reportViewport.call({ client, ...size }).settled;
} catch {
/* A disappearing session is recovered by report expiry. */
}
};
send.current = report;
if (latest.current) void report(latest.current);
})
.catch(() => {});
const heartbeat = setInterval(() => {
if (latest.current) void report?.(latest.current);
}, 10000);
const clock = setInterval(() => setNow(Date.now()), 1000);
const release = () => void report?.({ width: 0, height: 0 });
window.addEventListener("pagehide", release);
return () => {
closed = true;
send.current = null;
clearInterval(heartbeat);
clearInterval(clock);
window.removeEventListener("pagehide", release);
void opening
.then(async () => {
await report?.({ width: 0, height: 0 });
await lease.release();
})
.catch(() => {});
};
}, [connection, enabled, navigation.object, client]);
useEffect(() => {
if (!enabled) return;
const timer = setTimeout(() => {
if (latest.current) void send.current?.(latest.current);
}, 100);
return () => clearTimeout(timer);
}, [enabled, local]);
return { reportSize, pageSize: enabled ? sharedPageSize(local, reports, client, now) : local };
}
+192
View File
@@ -0,0 +1,192 @@
import { useMemo } from "react";
import { referenceOf, type ExactReference } from "@quixos/camino-react";
import { type Catalog, type DocNode, type FeatureNode, type ReaderNode } from "./model.js";
import { useCachedAnnotations } from "./cached-results.js";
import { cacheConfiguration, sameCacheConfiguration } from "./cache.js";
import { configDefinition, configValues } from "./annotator-config.js";
import { presentAnnotations } from "./annotation-display.js";
import { renderAnnotations } from "./render-hooks.js";
import { processorMatches, selectSuccessfulAnnotationJobs } from "./work.js";
import { Surface } from "./canvas/Surface.js";
import { wordAt } from "./canvas/text.js";
import { intersects, type Annotation, type Range } from "./canvas/types.js";
import { useVocabulary } from "./vocabulary.js";
const ignore = () => {};
/** A paragraph near the reading position, bounded without cutting a source word. */
export function previewRange(text: string, position: number): Range {
const cursor = Math.max(0, Math.min(text.length, position));
const paragraphStart = cursor > 0 ? text.lastIndexOf("\n", cursor - 1) + 1 : 0;
const newline = text.indexOf("\n", cursor);
const paragraphEnd = newline < 0 ? text.length : newline;
const start =
paragraphEnd - paragraphStart > 500
? Math.max(paragraphStart, wordAt(text, Math.max(paragraphStart, cursor - 120)).start)
: paragraphStart;
const end = Math.min(paragraphEnd, start + 500);
return { start, end: end < paragraphEnd ? wordAt(text, end - 1).end : paragraphEnd };
}
/** Settings observes existing results only. It deliberately has no processing
* hook, admission call, mutation, or sample-generation effect. */
export function ReaderPreview({
document,
cursor,
reader,
features,
selected,
catalog,
defaultVocabulary,
}: {
document: DocNode | null;
cursor: number;
reader: ReaderNode;
features: FeatureNode[];
selected?: FeatureNode;
catalog: Catalog;
defaultVocabulary?: ExactReference | null;
}) {
const cached = useCachedAnnotations(document ? referenceOf(document) : null);
const vocabulary = useVocabulary(reader.vocabulary ? referenceOf(reader.vocabulary) : defaultVocabulary);
const ready = cached.ready;
const jobs = useMemo(
() => (ready ? cached.entries.map(({ node }) => node) : (document?.jobs.entries.map(({ node }) => node) ?? [])),
[ready, cached.entries, document?.jobs],
);
const text = document?.data.text ?? "";
const excerpt = useMemo(() => previewRange(text, cursor), [text, cursor]);
const annotations = useMemo(() => {
const result: Annotation[] = [];
for (const [order, feature] of features.entries()) {
if (!feature.data.enabled) continue;
const entry = catalog.find((item) => item.key === feature.data.processor);
if (!entry) continue;
const definition = configDefinition(entry.config);
const options = {
...Object.fromEntries(definition.fields.map((field) => [field.key, field.default])),
...configValues(feature.options),
};
const configuration = cacheConfiguration(
definition.cache?.identity ?? feature.data.processor,
feature.data.scope,
feature.data.generation,
options,
definition.cache?.inputs ?? [],
);
const seen = new Set<string>();
for (const job of selectSuccessfulAnnotationJobs(
jobs,
(job) =>
job.data.sourceRevision === document?.data.sourceRevision &&
processorMatches(job.data, entry.processorRevision, definition.cache?.previousRevisions ?? []) &&
sameCacheConfiguration(job.configuration, configuration),
)) {
try {
const items = JSON.parse(job.data.output).items;
if (!Array.isArray(items)) continue;
for (const [index, item] of presentAnnotations(feature.data.processor, items, options, {
source: text,
vocabulary: vocabulary.known,
}).entries()) {
if (
!Number.isInteger(item.start) ||
!Number.isInteger(item.end) ||
item.start < 0 ||
item.end > text.length ||
item.end <= item.start ||
!intersects(item, excerpt)
)
continue;
const key = `${item.start}:${item.end}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(
...renderAnnotations(
{
...item,
id: `${referenceOf(feature).object}:${job.data.key}:${index}`,
feature: referenceOf(feature).object,
activation: { start: job.data.start, end: job.data.end },
priority:
(feature.data.scope === "word" ? 0 : feature.data.scope === "sentence" ? 1 : 2) * features.length +
order,
style: item.style ?? (feature.data.style as Annotation["style"]),
reveal: item.reveal ?? (feature.data.reveal as Annotation["reveal"]),
color: item.color ?? feature.data.color,
},
text,
item.style ? undefined : definition.renders,
),
);
}
} catch {
// An invalid or incomplete saved result cannot break the settings UI.
}
}
}
return result;
}, [jobs, features, catalog, document?.data.sourceRevision, text, excerpt, vocabulary.words]);
const selectedId = selected ? referenceOf(selected).object : null;
const example = annotations.find((item) => item.feature === selectedId && item.reveal !== "always");
const visible = (item: Annotation) =>
item.reveal === "always" || (!!example && item.feature === selectedId && intersects(item, example));
const shown = annotations.filter(visible);
const canvasAnnotations = shown
.filter((item) => !["sidebar", "tooltip"].includes(item.style))
.map((item) => ({
...item,
start: Math.max(excerpt.start, item.start) - excerpt.start,
end: Math.min(excerpt.end, item.end) - excerpt.start,
}));
const notes = shown.filter((item) => ["sidebar", "tooltip"].includes(item.style)).slice(0, 3);
return (
<aside className="lr-reading-preview" aria-label="Reader preview">
{vocabulary.continuation}
{cached.continuation}
<header>
<h3>Preview</h3>
{document && <span title={document.data.title}>{document.data.title}</span>}
</header>
{document && text ? (
<>
<div className="lr-reading-preview-surface" style={{ background: reader.data.background }}>
<Surface
key={`${document._camino.ref.object}:${excerpt.start}`}
text={text.slice(excerpt.start, excerpt.end)}
annotations={canvasAnnotations}
fontSize={reader.data.size}
fontFamily={reader.data.font}
textColor={reader.palette.text}
accentColor={reader.palette.accent}
mode="scroll"
interactive={false}
selection={{ anchor: 0, focus: 0 }}
onSelection={ignore}
visible={() => true}
onFocus={ignore}
onHover={ignore}
onWindow={ignore}
onDismissTooltip={ignore}
/>
</div>
{notes.map((item) => (
<div className={`lr-reading-preview-note lr-reading-preview-note-${item.style}`} key={item.id}>
<strong>{text.slice(item.start, item.end)}</strong>
<p>{item.text}</p>
</div>
))}
<p className="lr-reading-preview-status">
{!ready
? "Loading saved annotations…"
: !annotations.length && selected
? "No saved results for these options."
: "Saved annotations"}
</p>
</>
) : (
<p className="lr-reading-preview-empty">Open a text to preview reader settings.</p>
)}
</aside>
);
}
+268
View File
@@ -0,0 +1,268 @@
import { useEffect, useRef, useState } from "react";
import { referenceOf, useInterface } from "@quixos/camino-react";
import { interfaces } from "./gen/client.js";
import { sameCacheConfiguration } from "./cache.js";
import { accepted, errorMessage, type DocNode, type FeatureNode } from "./model.js";
import { preparationQueue, processorMatches, sameScope } from "./work.js";
import type { Range } from "./canvas/types.js";
import type { ReadingWindow } from "./canvas/pages.js";
import type { AnnotationRender } from "./render-hooks.js";
type Planned = { feature: FeatureNode; range: Range; context: string; planning?: "click" | "hover" };
/** One admission/cache path for proximity preparation, clicks, hover and retry.
* A changed window replaces unsent plans. Admitted jobs retain their results.
* Model preparation has two slots, shared by independent scopes. */
export function useProcessing({
document: doc,
annotationsReady,
features,
revisions,
revisionAliases = {},
computations,
renders,
window,
error,
}: {
document: DocNode;
annotationsReady: boolean;
features: FeatureNode[];
revisions: Record<string, string>;
revisionAliases?: Record<string, readonly string[]>;
computations: Record<string, string>;
renders: Record<string, AnnotationRender[] | undefined>;
window: ReadingWindow;
error: (message: string) => void;
}) {
const ops = useInterface(
{ plan: interfaces.ReadingDocument.plan, generate: interfaces.ReadingDocument.generate },
referenceOf(doc),
);
const jobs = doc.jobs.entries.map(({ node }) => node);
const [planning, setPlanning] = useState<{ context: string; items: Planned[] }>({ context: "", items: [] }),
[tick, setTick] = useState(0);
const [clickScopes, setClickScopes] = useState<Record<string, Range[]>>({});
const [intent, setIntent] = useState<Planned[]>([]);
const submitted = useRef(new Set<string>()),
admitting = useRef(new Map<string, Planned>()),
focusSerial = useRef({ click: 0, hover: 0 });
const featureKey = features
.map(
(f) =>
`${referenceOf(f).object}:${computations[referenceOf(f).object]}:${f.data.enabled}:${revisions[f.data.processor]}`,
)
.join("|");
const context = `${referenceOf(doc).object}:${doc.data.sourceRevision}:${featureKey}`;
const currentContext = useRef(context);
currentContext.current = context;
const planningContext = [
context,
window.nearby.start,
window.nearby.end,
window.visible.start,
window.visible.end,
].join(":");
const plans = planning.context === planningContext ? planning.items : [];
const matches = (job: (typeof jobs)[number], { feature, range }: Planned) =>
processorMatches(job.data, revisions[feature.data.processor], revisionAliases[feature.data.processor]) &&
sameScope(
{ ...job.data, configuration: job.configuration },
doc.data.sourceRevision,
computations[referenceOf(feature).object],
range,
);
const keyFor = ({ feature, range }: Planned) =>
[
doc.data.sourceRevision,
computations[referenceOf(feature).object],
revisions[feature.data.processor],
range.start,
range.end,
].join(":");
function request(feature: FeatureNode, range: Range, retry = false): boolean {
if (!annotationsReady || ops.status !== "ready" || range.end <= range.start || !revisions[feature.data.processor])
return false;
const item = { feature, range, context },
key = keyFor(item);
if (!retry && (submitted.current.has(key) || jobs.some((job) => matches(job, item)))) return false;
submitted.current.add(key);
setIntent((previous) => [...previous.filter((item) => keyFor(item) !== key), item]);
admitting.current.set(key, item);
void accepted(
ops.value.generate.call({
feature: { ...referenceOf(feature), expectation: { kind: "atom", id: "atom:reading:feature" } },
...range,
retry,
createdAt: new Date().toISOString(),
}),
).catch((cause) => {
admitting.current.delete(key);
setIntent((previous) => previous.filter((item) => item.context !== context || keyFor(item) !== key));
error(errorMessage(cause));
setTick((value) => value + 1);
});
return true;
}
useEffect(() => {
let closed = false;
setPlanning({ context: planningContext, items: [] });
if (!annotationsReady || ops.status !== "ready" || window.nearby.end <= window.nearby.start) return;
const timer = setTimeout(() => {
void Promise.all(
features
.filter((f) => f.data.enabled && f.data.generation !== "focus")
.map(async (feature) => {
const ranges = await ops.value.plan.call({
feature: { ...referenceOf(feature), expectation: { kind: "atom", id: "atom:reading:feature" } },
...window.nearby,
visibleStart: window.visible.start,
visibleEnd: window.visible.end,
kind: "nearby",
});
return ranges.map((range) => ({ feature, range, context }));
}),
)
.then((all) => {
if (!closed) setPlanning({ context: planningContext, items: all.flat() });
})
.catch((cause) => {
if (!closed) error(errorMessage(cause));
});
}, 180);
return () => {
closed = true;
clearTimeout(timer);
};
}, [planningContext, ops.status, annotationsReady]);
useEffect(() => {
for (const [key, item] of admitting.current)
if (jobs.some((job) => matches(job, item))) admitting.current.delete(key);
setIntent((previous) => {
const next = previous.filter((item) => item.context === context && !jobs.some((job) => matches(job, item)));
return next.length === previous.length ? previous : next;
});
const queue = nearbyQueue();
for (const { feature, range } of queue.admit) request(feature, range);
}, [planning, doc.jobs.entries, context, tick, planningContext]);
function nearbyQueue() {
const pending = jobs.filter(
(job) =>
job.data.kind === "annotation" &&
job.data.status === "queued" &&
job.data.sourceRevision === doc.data.sourceRevision &&
features.some(
(f) =>
f.data.enabled &&
f.data.generation !== "focus" &&
sameCacheConfiguration(computations[referenceOf(f).object], job.configuration) &&
processorMatches(job.data, revisions[f.data.processor], revisionAliases[f.data.processor]),
),
);
const inAdmission = [...admitting.current.values()].filter(
(item) =>
item.context === context &&
item.feature.data.generation === "section" &&
!jobs.some((job) => matches(job, item)),
);
return preparationQueue(
annotationsReady && ops.status === "ready"
? plans
.filter(
(item) => item.context === context && item.feature.data.enabled && revisions[item.feature.data.processor],
)
.map((item) => ({
...item,
key: keyFor(item),
configuration: computations[referenceOf(item.feature).object],
local: item.feature.data.generation === "local",
}))
: [],
window.visible,
pending.length + inAdmission.length,
submitted.current,
new Set(plans.filter((item) => jobs.some((job) => matches(job, item))).map(keyFor)),
);
}
async function focus(range: Range, kind: "click" | "hover") {
const serial = ++focusSerial.current[kind];
if (kind === "click") setClickScopes({});
const selected = features.filter(
(f) =>
f.data.enabled &&
f.data.generation === "focus" &&
(renders[f.data.processor] ?? [{ reveal: f.data.reveal }]).some((render) =>
kind === "hover" ? render.reveal === "hover" : render.reveal !== "hover",
),
);
// Covering the clicked word does not imply a cache hit for the newly
// planned sentence/passage. Show intent now; exact scope identity decides
// reuse after planning, even while operation-input admission is pending.
setIntent((previous) => [
...previous.filter((item) => item.planning !== kind),
...selected.map((feature) => ({ feature, range, context, planning: kind })),
]);
if (!annotationsReady || ops.status !== "ready") return;
await Promise.all(
selected.map(async (feature) => {
try {
const ranges = await ops.value.plan.call({
feature: { ...referenceOf(feature), expectation: { kind: "atom", id: "atom:reading:feature" } },
...range,
visibleStart: range.start,
visibleEnd: range.end,
kind: "focus",
});
if (serial === focusSerial.current[kind] && currentContext.current === context) {
setIntent((previous) =>
previous.filter(
(item) => !(item.planning === kind && referenceOf(item.feature).object === referenceOf(feature).object),
),
);
if (kind === "click")
setClickScopes((previous) => ({ ...previous, [referenceOf(feature).object]: [...ranges] }));
for (const scope of ranges) request(feature, scope);
}
} catch (cause) {
if (serial === focusSerial.current[kind] && currentContext.current === context) {
setIntent((previous) =>
previous.filter(
(item) => !(item.planning === kind && referenceOf(item.feature).object === referenceOf(feature).object),
),
);
error(errorMessage(cause));
}
}
}),
);
}
useEffect(
() => () => {
focusSerial.current.click++;
focusSerial.current.hover++;
},
[],
);
return {
ready:
annotationsReady &&
ops.status === "ready" &&
features.filter((feature) => feature.data.enabled).every((feature) => !!revisions[feature.data.processor]),
request,
focus,
clickScopes,
pending: [
...nearbyQueue().waiting,
...intent.filter(
(item) =>
item.context === context &&
features.some(
(feature) => referenceOf(feature).object === referenceOf(item.feature).object && feature.data.enabled,
) &&
!jobs.some((job) => matches(job, item)),
),
],
cancelHover: () => {
focusSerial.current.hover++;
setIntent((previous) => previous.filter((item) => item.planning !== "hover"));
},
};
}
+73
View File
@@ -0,0 +1,73 @@
import type { ExactReference } from "@quixos/camino-react";
import { Icon } from "@quixos/camino-react/ui";
import { copyText } from "./canvas/selection.js";
import type { ReaderExtension } from "./extensions.js";
import { VocabularyLabel, VocabularySettings } from "./vocabulary.js";
/** Register reader-wide configuration alongside the extension's contributions.
* The host retains this section in shared presentation navigation. */
export const readerExtensionSettings: readonly Pick<ReaderExtension, "id" | "title" | "Settings">[] = [
{ id: "vocabulary", title: "Vocabulary", Settings: VocabularySettings },
];
/** Install application extensions here. A sidebar and a context menu share the
* same source-aware actions; neither needs to pretend to be an annotator. */
export function createReaderExtensions(vocabulary: {
vocabulary: ExactReference | null;
name: string;
known: ReadonlySet<string>;
busy: string | null;
ready: boolean;
remember(word: string): Promise<void>;
report(error: unknown): void;
}): ReaderExtension[] {
return [
{
id: "source",
title: "Text",
actions: (context) => {
const text = context.target ? context.text.slice(context.target.start, context.target.end) : "";
return text ? [{ id: "copy", label: "Copy original text", run: () => copyText(text) }] : [];
},
},
{
...readerExtensionSettings[0]!,
sidebarVisible: (context) => !!context.target,
actions: (context) => {
const word = context.target ? context.text.slice(context.target.start, context.target.end).trim() : "";
if (!word || word.length > 100 || !vocabulary.vocabulary) return [];
const saved = vocabulary.known.has(word);
return [
{
id: "vocabulary.save",
label: saved ? "Saved to vocabulary" : vocabulary.busy === word ? "Saving…" : "Add to vocabulary",
disabled: saved || !!vocabulary.busy || !vocabulary.ready,
run: () => vocabulary.remember(word),
},
];
},
Sidebar: ({ context, actions }) =>
!context.target ? null : (
<section className="lr-word-actions" aria-label="Word actions">
<div className="lr-word-action-buttons">
{actions.map((action) => (
<button
type="button"
key={action.id}
disabled={action.disabled}
onClick={() => void Promise.resolve().then(action.run).catch(vocabulary.report)}
>
{action.id === "vocabulary.save" && <Icon name="plus" size={14} />} {action.content ?? action.label}
</button>
))}
</div>
{vocabulary.vocabulary && (
<small>
Saved in <VocabularyLabel reference={vocabulary.vocabulary} />
</small>
)}
</section>
),
},
];
}
+22
View File
@@ -0,0 +1,22 @@
import type { Annotation } from "./canvas/types.js";
/** Several views of one cached semantic item. These are authored presentation
* choices, not separate computations or independently generated annotations. */
export type AnnotationRender = {
style: Annotation["style"];
underlinePattern?: Annotation["underlinePattern"];
placement?: Annotation["placement"];
reveal: "always" | "click" | "hover";
content?: "result" | "source";
};
export function renderAnnotations(item: Annotation, source: string, renders: readonly AnnotationRender[] | undefined) {
if (!renders?.length) return [item];
return renders.map(
(render, index): Annotation => ({
...item,
id: `${item.id}:render:${index}`,
...render,
text: render.content === "source" ? source.slice(item.start, item.end) : item.text,
}),
);
}
+496
View File
@@ -0,0 +1,496 @@
import { GenerateText } from "./texts.js";
import { LocalInput, LocalSelect, LocalTextArea, Choice, Toggle, useCommandControl } from "@quixos/camino-react/ui";
import { useEffect, useRef, useState } from "react";
import { useCrdtText, useInterface, type ExactReference } from "@quixos/camino-react";
import { TextArea, TextInput } from "@quixos/camino-react/ui";
import { interfaces } from "./gen/client.js";
import { Modal } from "./editor.js";
import { accepted, asInterface, errorMessage, type Root, type NavigationValues } from "./model.js";
export function Importer({
library,
root,
initialReader,
navigation,
navigationValues,
close,
added,
}: {
library: ExactReference;
root: Root;
initialReader?: string | undefined;
navigation: ExactReference;
navigationValues: NavigationValues;
close: () => void;
added: (ref: ExactReference) => void;
}) {
const lib = useInterface(interfaces.LanguageReader, library),
services = useInterface(interfaces.ReadingServices, asInterface(library, "services")),
nav = useInterface(interfaces.ReadingNavigation, navigation);
const books = useInterface(
{ search: interfaces.GutenbergCatalog.search, readBook: interfaces.GutenbergCatalog.readBook },
{ ...library, expectation: { kind: "interface", id: "interface:gutenberg:catalog@1" } },
);
const title = useCrdtText({
key: navigation.object + "/reading-title",
operation: interfaces.ReadingNavigation.draft.edit,
reference: navigation,
path: ["title"],
});
const text = useCrdtText({
key: navigation.object + "/reading-text",
operation: interfaces.ReadingNavigation.draft.edit,
reference: navigation,
path: ["text"],
});
const tab = navigationValues.ui.sourceTab;
const setTab = (sourceTab: string) => {
if (nav.status === "ready")
void accepted(nav.value.ui.set({ ...navigationValues.ui, sourceTab })).catch((e) => setError(errorMessage(e)));
};
const [query, setQuery] = useState(""),
[results, setResults] = useState<readonly { title: string; description: string }[]>([]),
[busy, setBusy] = useState(false),
[error, setError] = useState("");
const latest = useRef(0),
mounted = useRef(true);
const [bookResults, setBookResults] = useState<
readonly { ebook: number; title: string; author: string; language: string }[]
>([]);
useEffect(
() => () => {
mounted.current = false;
latest.current++;
},
[],
);
async function add(input: { title: string; text: string; source: string; url: string; attribution: string }) {
if (lib.status !== "ready" || nav.status !== "ready") return;
const ref = await accepted(lib.value.addDocument.call(input));
await accepted(nav.value.open.call(asInterface(ref, "document")));
added(ref);
}
async function paste() {
setBusy(true);
setError("");
try {
await Promise.all([title.flush(), text.flush()]);
await add({
title: title.text.trim() || "Untitled text",
text: text.text,
source: "pasted text",
url: "",
attribution: "",
});
} catch (error) {
setError(errorMessage(error));
} finally {
if (mounted.current) setBusy(false);
}
}
async function file(file: File | undefined) {
if (!file) return;
setBusy(true);
setError("");
try {
if (file.size > 600000) throw Error("Choose a UTF-8 text file smaller than 600 KB.");
await add({
title: file.name.replace(/\.(txt|md)$/i, ""),
text: await file.text(),
source: "text file",
url: "",
attribution: "",
});
} catch (error) {
setError(errorMessage(error));
} finally {
if (mounted.current) setBusy(false);
}
}
async function search() {
if (services.status !== "ready") return;
const generation = ++latest.current;
setBusy(true);
setError("");
try {
if (tab === "books" && books.status === "ready") {
const result = await books.value.search.call(query);
if (generation === latest.current) setBookResults(result);
} else {
const result = await services.value.searchWikipedia.call(query);
if (generation === latest.current) setResults(result);
}
} catch (error) {
if (generation === latest.current) setError(errorMessage(error));
} finally {
if (generation === latest.current && mounted.current) setBusy(false);
}
}
async function article(articleTitle: string) {
if (services.status !== "ready") return;
setBusy(true);
setError("");
try {
await add(await services.value.readWikipedia.call(articleTitle));
} catch (error) {
setError(errorMessage(error));
} finally {
if (mounted.current) setBusy(false);
}
}
async function book(ebook: number) {
if (books.status !== "ready") return;
setBusy(true);
setError("");
try {
const value = await books.value.readBook.call({ ebook });
await add({
title: value.book.title,
text: value.text,
source: "Project Gutenberg",
url: value.url,
attribution: `${value.book.author} · Project Gutenberg #${ebook} · Public domain in the United States.`,
});
} catch (error) {
setError(errorMessage(error));
} finally {
if (mounted.current) setBusy(false);
}
}
return (
<Modal title="Add text" close={close}>
<div className="lr-tabs" role="tablist" aria-label="Text sources">
{[
["paste", "Paste text"],
["generate", "Generate"],
["file", "Text file"],
["wikipedia", "Wikipedia"],
["books", "Public-domain books"],
].map(([key, label]) => (
<button key={key} role="tab" aria-selected={tab === key} onClick={() => setTab(key)}>
{label}
</button>
))}
</div>
{tab === "generate" && (
<GenerateText
library={library}
root={root}
initialReader={initialReader}
navigation={navigation}
added={(ref) => {
if (nav.status === "ready")
void accepted(nav.value.open.call(asInterface(ref, "document")))
.then(() => added(ref))
.catch((e) => setError(errorMessage(e)));
}}
/>
)}
{tab === "paste" && (
<>
<label className="lr-field">
Title
<TextInput binding={title} placeholder="A name for this text" maxLength={200} />
</label>
<label className="lr-field">
Original text
<TextArea binding={text} rows={12} placeholder="Paste a story, article, or passage…" />
</label>
<footer>
<small>Your draft saves as you type.</small>
<button
className="lr-primary"
disabled={busy || lib.status !== "ready" || !text.ready || !title.ready}
onClick={() => void paste()}
>
{busy ? "Adding…" : "Start reading"}
</button>
</footer>
</>
)}
{tab === "file" && (
<div className="lr-file">
<h3>Bring a passage from your files</h3>
<p>UTF-8 plain text or Markdown. Original text is preserved for selection and annotations.</p>
<LocalInput
aria-label="Choose a text file"
type="file"
accept=".txt,.md,text/plain,text/markdown"
disabled={busy}
onChange={(event) => void file(event.target.files?.[0])}
/>
</div>
)}
{tab === "books" && (
<>
<p className="lr-muted">Search the workspace’s Project Gutenberg catalog. Loads a bounded opening excerpt.</p>
<form
className="lr-search"
onSubmit={(event) => {
event.preventDefault();
void search();
}}
>
<LocalInput
aria-label="Search public-domain books"
placeholder="Chinese, a title, or an author"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<button disabled={busy}>Search</button>
</form>
<div className="lr-search-results">
{bookResults.map((result) => (
<button key={result.ebook} disabled={busy} onClick={() => void book(result.ebook)}>
<strong>{result.title}</strong>
<small>
{result.author} · {result.language}
</small>
</button>
))}
</div>
</>
)}
{tab === "wikipedia" && (
<>
<p className="lr-muted">
Search Chinese Wikipedia. Articles retain their source link and license attribution.
</p>
<form
className="lr-search"
onSubmit={(event) => {
event.preventDefault();
void search();
}}
>
<LocalInput
aria-label="Search Wikipedia"
placeholder="Try 茶 or 北京"
value={query}
maxLength={160}
onChange={(event) => setQuery(event.target.value)}
/>
<button disabled={busy || !query.trim()}>Search</button>
</form>
<div className="lr-search-results">
{results.map((result) => (
<button key={result.title} disabled={busy} onClick={() => void article(result.title)}>
<strong>{result.title}</strong>
<small>{result.description} ↗</small>
</button>
))}
</div>
<small>Text extracts can omit tables, formulas and media. No background crawling.</small>
</>
)}
{busy && <p role="status">Loading…</p>}
{error && (
<p className="lr-error" role="alert">
{error}
</p>
)}
</Modal>
);
}
export function SpeechSettings({ library, close }: { library: ExactReference; close: () => void }) {
const api = useInterface(interfaces.ReadingServices, asInterface(library, "services"));
const [key, setKey] = useState(""),
[voice, setVoice] = useState("JBFqnCBsd6RMkjVDRZzb"),
[configured, setConfigured] = useState(false),
[message, setMessage] = useState(""),
[busy, setBusy] = useState(false);
useEffect(() => {
let mounted = true;
if (api.status === "ready")
void api.value.speechStatus
.call(null)
.then((data) => {
if (mounted) {
setConfigured(data.configured);
setVoice(data.voice);
}
})
.catch((error) => {
if (mounted) setMessage(errorMessage(error));
});
return () => {
mounted = false;
};
}, [api.status]);
async function save(clear = false) {
if (api.status !== "ready") return;
setBusy(true);
setMessage("");
try {
await accepted(api.value.configureSpeech.call({ key: clear ? "" : key.trim() || null, voice }));
const state = await api.value.speechStatus.call(null);
setConfigured(state.configured);
setKey("");
setMessage(clear ? "API key removed." : "Read-aloud settings saved.");
} catch (error) {
setMessage(errorMessage(error));
} finally {
setBusy(false);
}
}
return (
<Modal title="Read aloud" close={close}>
<p className="lr-muted">
One ElevenLabs account and voice for this workspace. Playback sends only the passage you choose to ElevenLabs
and uses your account’s credits.
</p>
<label className="lr-field">
ElevenLabs API key
<LocalInput
type="password"
autoComplete="off"
value={key}
onChange={(event) => setKey(event.target.value)}
placeholder={configured ? "Key configured · enter to replace" : "Add your API key"}
maxLength={500}
/>
</label>
<label className="lr-field">
Voice ID
<LocalInput value={voice} onChange={(event) => setVoice(event.target.value)} maxLength={100} />
</label>
<p className="lr-muted">The key stays in private workspace state. Use a voice that reads Mandarin well.</p>
<footer>
{configured && (
<button disabled={busy} onClick={() => void save(true)}>
Remove key
</button>
)}
<button className="lr-primary" disabled={busy || api.status !== "ready"} onClick={() => void save()}>
Save settings
</button>
</footer>
{message && <p role="status">{message}</p>}
</Modal>
);
}
export function Playback({ library, text }: { library: ExactReference; text: string }) {
const api = useInterface(interfaces.ReadingServices, asInterface(library, "services"));
const [busy, setBusy] = useState(false),
[playing, setPlaying] = useState(false),
[error, setError] = useState("");
const audio = useRef<HTMLAudioElement | null>(null),
url = useRef<string | null>(null),
generation = useRef(0);
function stop() {
generation.current++;
audio.current?.pause();
audio.current = null;
if (url.current) URL.revokeObjectURL(url.current);
url.current = null;
setPlaying(false);
}
useEffect(() => {
stop();
setBusy(false);
return () => {
generation.current++;
audio.current?.pause();
if (url.current) URL.revokeObjectURL(url.current);
};
}, [text]);
async function play() {
if (api.status !== "ready") return;
if (playing) {
stop();
return;
}
const pass = ++generation.current;
setBusy(true);
setError("");
try {
if (!(await api.value.speechStatus.call(null)).configured)
throw Error("Add your ElevenLabs API key in Read-aloud settings.");
if (Array.from(text).length > 1200) throw Error("Select a passage of up to 1,200 characters to read aloud.");
const bytes = await api.value.speak.call(text);
if (pass !== generation.current) return;
url.current = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: "audio/mpeg" }));
audio.current = new Audio(url.current);
audio.current.onended = stop;
await audio.current.play();
if (pass === generation.current) setPlaying(true);
} catch (error) {
if (pass === generation.current) {
setError(errorMessage(error));
stop();
setBusy(false);
}
} finally {
if (pass === generation.current) setBusy(false);
}
}
return (
<div className="lr-playback">
<button disabled={busy || api.status !== "ready"} onClick={() => void play()}>
{busy ? "Preparing audio…" : playing ? "■ Stop" : "▷ Read aloud"}
</button>
{error && (
<span role="alert" className="lr-playback-error">
{error}
</span>
)}
</div>
);
}
export function Rewrite({
document: reference,
library,
close,
created,
}: {
document: ExactReference;
created: (ref: ExactReference) => void;
library: ExactReference;
close: () => void;
}) {
const api = useInterface(interfaces.ReadingDocument, reference);
const lib = useInterface(interfaces.LanguageReader, library);
const [level, setLevel] = useState("beginner"),
[busy, setBusy] = useState(false),
[error, setError] = useState("");
async function rewrite() {
if (api.status !== "ready" || lib.status !== "ready") return;
setBusy(true);
try {
const ref = await accepted(api.value.rewrite.call({ difficulty: level, createdAt: new Date().toISOString() }));
created(ref);
close();
} catch (error) {
setError(errorMessage(error));
setBusy(false);
}
}
return (
<Modal title="Adapt text" close={close}>
<p className="lr-muted">Create a new text at a different level. Your original stays in the library.</p>
<div className="lr-levels">
{[
["beginner", "Beginner Chinese", "Common vocabulary and shorter sentences."],
["intermediate", "Everyday Chinese", "Natural phrasing, moderate vocabulary."],
["advanced", "Advanced Chinese", "More nuanced vocabulary and expression."],
["english", "Translate to English", "Prepare an English version as you read."],
].map(([key, title, description]) => (
<button aria-pressed={level === key} key={key} onClick={() => setLevel(key)}>
<strong>{title}</strong>
<small>{description}</small>
</button>
))}
</div>
<footer>
<small>Generated adaptations can contain errors.</small>
<button
className="lr-primary"
disabled={busy || api.status !== "ready" || lib.status !== "ready"}
onClick={() => void rewrite()}
>
Create variant
</button>
</footer>
{error && <p role="alert">{error}</p>}
</Modal>
);
}
+342
View File
@@ -0,0 +1,342 @@
export const readingStyle = `
.lr-app{--ink:#303a36;--muted:#7c827b;--line:#e6e6de;--green:#3e6957;position:relative;display:flex;flex:0 1 auto;height:calc(100dvh - var(--reader-top,120px));max-height:100%;min-height:0;background:#fafaf6;color:var(--ink);font:14px/1.5 system-ui,sans-serif;overflow:hidden}
.lr-app *{box-sizing:border-box}
.lr-app button,.lr-app input,.lr-app select,.lr-app textarea{font:inherit}
.lr-app button{cursor:pointer;color:#303a36;border:1px solid var(--line);border-radius:8px;background:#fff;padding:8px 12px;transition:background .15s}
.lr-app button:hover{background:#f1f3ec}
.lr-app button:disabled{opacity:.5;cursor:default}
.lr-app button:focus-visible,.lr-app input:focus-visible,.lr-app textarea:focus-visible,.lr-app select:focus-visible,.lr-app summary:focus-visible{outline:2px solid #8bae91;outline-offset:3px}
.lr-app h1,.lr-app h2,.lr-app h3,.lr-app p{margin:0}
.lr-app a{color:var(--green)}
.lr-app small{font-size:11px}
.lr-app .lr-primary{background:var(--green);color:#fff;border-color:var(--green)}
.lr-eyebrow{font-size:10px;letter-spacing:.15em;color:var(--muted);font-weight:650}
.lr-muted{color:var(--muted);font-size:13px}
.lr-error{color:#a04f43;padding:12px;background:#fcf1ed;border-radius:8px;overflow-wrap:anywhere}
.lr-known{display:flex;flex-wrap:wrap;gap:4px;max-height:180px;overflow:auto;padding-top:10px}
.lr-known button{padding:3px 6px}
.lr-main{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column}
.lr-document-head{display:flex;justify-content:space-between;align-items:center;padding:24px 30px 22px;border-bottom:1px solid var(--line);gap:20px;flex-wrap:wrap}
.lr-document-head h2{font-family:"Noto Serif CJK SC",Georgia,serif;font-size:26px;font-weight:500;margin-top:6px}
.lr-toolbar{display:flex;gap:7px;align-items:center}
.lr-toolbar button{font-size:12px;padding:7px 10px}
.lr-reading-layout{display:flex;flex:1;min-height:0}
.lr-page{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;max-width:1100px}
.reading-surface{position:relative;overflow:auto;flex:1;min-height:300px;padding:24px;overscroll-behavior:contain;scrollbar-gutter:stable}
.reading-viewport{position:sticky;top:24px;isolation:isolate}
.reading-canvas{outline:none;position:relative;display:block;touch-action:none;user-select:none}
.reading-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:pre-wrap}
.lr-source{padding:12px 24px;border-top:1px solid var(--line);display:flex;align-items:center;gap:14px;flex-wrap:wrap;color:var(--muted);font-size:11px}
.lr-source button{border:0;background:transparent;padding:0;font-size:11px}
.lr-source small{flex-basis:100%;font-size:10px}
.lr-plain{min-height:240px;border:0;padding:24px;width:100%;background:#fff;resize:vertical;white-space:pre-wrap}
.lr-margin{width:250px;flex-shrink:0;padding:24px 20px;background:inherit;border-left:1px solid var(--line);overflow:auto}
.lr-feature-toggle{display:flex;align-items:center;gap:9px;padding:12px 0;font-size:12px;cursor:pointer}
.lr-feature-toggle i,.lr-existing-row summary i{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.lr-feature-toggle input{accent-color:var(--green);width:13px}
.lr-feature-toggle small{display:block;color:var(--muted);font-size:10px}
.lr-context{margin-top:0;padding-top:0}
.lr-context h3{font-size:23px;font-weight:500;margin:12px 0}
.lr-context>.lr-muted{margin-top:14px}
.lr-note{border-left:2px solid;padding:8px 12px;margin:14px 0;background:color-mix(in srgb,var(--green) 5%,transparent)}
.lr-note small{color:var(--muted)}
.lr-note p{white-space:pre-wrap;margin-top:5px;font-size:13px}
.lr-context-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:16px}
.lr-context-actions button{font-size:11px}
.lr-reading-hint{margin-top:26px!important;font-size:10px;color:#979b93;line-height:1.8}
.lr-feedback{font-size:12px;margin-top:12px!important;overflow-wrap:anywhere}
.lr-job{font-size:12px;margin-top:12px;color:var(--muted)}
.lr-job button{font-size:11px;padding:4px 8px;margin-top:5px}
.lr-empty{display:grid;place-content:center;height:100%;gap:18px;text-align:center}
.lr-backdrop{position:absolute;inset:0;z-index:30;background:#273a303d;backdrop-filter:blur(3px);display:flex;justify-content:center;align-items:center;padding:24px}
.lr-modal{background:#fafbf8;border:1px solid #dfe5dc;border-radius:18px;width:min(740px,100%);max-height:calc(100% - 48px);overflow:auto;padding:28px;box-shadow:0 20px 70px #23332835}
.lr-modal>header{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:18px}
.lr-modal h2{font:26px Georgia,serif;margin-top:5px}
.lr-modal h3{font-size:15px;font-weight:550;margin:20px 0 12px}
.lr-modal>.lr-muted{margin-bottom:18px}
.lr-icon{font-size:22px!important;padding:2px 10px!important;border:0!important;background:transparent!important}
.lr-field{display:flex;flex-direction:column;gap:7px;font-size:12px;margin:14px 0;flex:1;min-width:0}
.lr-field input,.lr-field select,.lr-field textarea,.lr-search input,.lr-conversation textarea{background:#fff;border:1px solid #dcded5;border-radius:7px;padding:10px;color:var(--ink);width:100%;resize:vertical}
.lr-field input[type=color]{height:42px;min-width:70px;padding:5px}
.lr-fields{display:flex;gap:16px}
.lr-appearance-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.lr-app .lr-appearance{padding:0;overflow:hidden;text-align:left;display:flex;flex-direction:column;background:#fff}
.lr-app .lr-appearance[aria-pressed=true],.lr-levels button[aria-pressed=true]{border-color:#61836e;box-shadow:0 0 0 1px #61836e}
.lr-appearance strong{font-size:13px;padding:12px 14px 0}
.lr-appearance>small{padding:5px 14px 14px;color:var(--muted)}
.lr-preview{height:90px;background:#f5f6ee;display:flex;align-items:center;justify-content:center;position:relative;gap:12px;width:100%;font-family:"Noto Serif CJK SC",Georgia,serif;font-size:21px}
.lr-preview b{font-weight:400}
.lr-preview-label{position:absolute;top:13px;right:32px;color:var(--annotation);font:12px system-ui}
.lr-preview-highlight b{background:color-mix(in srgb,var(--annotation) 24%,transparent);border-radius:4px}
.lr-preview-underline b{border-bottom:2px solid var(--annotation)}
.lr-preview-sidebar{font-size:16px;justify-content:space-around;padding:10px}
.lr-preview-margin{font:10px/1.8 system-ui;border-left:2px solid var(--annotation);padding-left:9px;max-width:100px;color:var(--muted)}
.lr-modal footer{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-top:22px;border-top:1px solid var(--line);padding-top:20px}
.lr-modal footer small{color:var(--muted)}
.lr-existing{margin:18px 0}
.lr-existing-row{border-bottom:1px solid var(--line);padding:10px 0;font-size:12px}
.lr-existing-row summary{display:flex;gap:10px;align-items:center;cursor:pointer}
.lr-existing-row summary small{margin-left:auto;color:var(--muted)}
.lr-danger{margin-left:16px;color:#9c544a!important;font-size:11px!important}
.lr-tabs{display:flex;gap:8px;margin-bottom:18px}
.lr-tabs button[aria-selected=true]{background:#e8eee2;border-color:#b6c9ae}
.lr-file{text-align:center;padding:30px 0;display:flex;flex-direction:column;gap:20px}
.lr-search{display:flex;gap:10px}
.lr-search-results{display:flex;flex-direction:column;gap:8px;margin:18px 0}
.lr-search-results button{text-align:left}
.lr-search-results small{display:block;color:var(--muted);margin-top:4px}
.lr-levels{display:grid;gap:10px}
.lr-levels button{text-align:left;padding:15px}
.lr-levels strong,.lr-levels small{display:block}
.lr-levels small{color:var(--muted);margin-top:4px}
.lr-playback{position:relative}
.lr-playback-error{position:absolute;right:0;top:40px;width:250px;background:#fff1eb;padding:12px;border:1px solid #ecd1c3;border-radius:8px;font-size:12px;z-index:10}
.lr-size{position:relative;font-family:Georgia,serif}
.lr-size summary{cursor:pointer;padding:7px}
.lr-size label{position:absolute;right:0;top:36px;background:#fff;box-shadow:0 2px 12px #0002;padding:14px;width:220px;max-width:calc(100vw - 32px);border-radius:12px;z-index:10}
.lr-task-create{display:flex;gap:20px;align-items:center;justify-content:space-between}
.lr-task-create small{color:var(--muted);max-width:320px}
.lr-conversation{border:1px solid var(--line);border-radius:12px;margin-top:24px;background:#fff;overflow:hidden}
.lr-conversation>header{padding:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;border-bottom:1px solid var(--line);font-size:12px}
.lr-conversation>header span{color:var(--muted);font-size:11px}
.lr-transcript{max-height:310px;overflow:auto;padding:16px;display:flex;flex-direction:column;gap:12px}
.lr-bubble{max-width:94%;font-size:13px;white-space:pre-wrap;overflow-wrap:anywhere}
.lr-bubble small{color:var(--muted);font-size:10px}
.lr-bubble-user{background:#edf2e8;padding:10px 12px;align-self:flex-end;border-radius:10px}
.lr-bubble p{margin-top:4px}
.lr-conversation>form{padding:14px;display:flex;gap:10px;align-items:flex-end;background:#f8f9f4}
.lr-question{padding:14px;background:#fff8e7}
.lr-question .lr-field{margin-top:0}
.lr-app .qx-ui-choice{min-width:0;width:100%}
.reading-overlay{position:absolute;inset:0;pointer-events:none;overflow:hidden}
.reading-processing{stroke-width:1.3;stroke-linecap:round;stroke-dasharray:2 5;fill:#8aab8310;animation:lr-chase 2s linear infinite}
@keyframes lr-chase{to{stroke-dashoffset:-28}
}
@media(prefers-reduced-motion:reduce){.reading-processing{animation:none}
}
.lr-library{overflow:auto;flex:1;padding:36px clamp(20px,5vw,70px)}
.lr-library-head{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap;margin-bottom:36px}
.lr-library h1{font:34px Georgia,serif;margin:8px 0}
.lr-readings{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:18px}
.lr-readings button{text-align:left;display:flex;flex-direction:column;gap:12px;padding:24px;min-height:180px}
.lr-readings button[aria-current]{border-color:#8bae91}
.lr-readings strong{font-size:18px;font-weight:500}
.lr-readings small{color:var(--muted)}
.lr-book-mark{font:30px Georgia,serif;color:var(--green)}
.lr-vocabulary{margin-top:36px;border-top:1px solid var(--line);padding-top:24px}
.lr-vocabulary summary{cursor:pointer}
.lr-app .lr-library-link{display:block;border:0;background:transparent;padding:0;margin-bottom:10px;font-size:12px;color:var(--muted)}
.lr-preview-color b{color:var(--annotation)}
.lr-processor-settings{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:0 16px}
.lr-processor-settings input[type=checkbox]{width:auto;align-self:flex-start}
.lr-config-save{display:flex;gap:8px;margin:12px 0}
.lr-page{max-width:none}
.reading-pagination{display:flex;align-items:center;justify-content:center;gap:18px;padding:10px 24px;border-top:1px solid var(--line);font-size:12px}.reading-pagination button{font-size:11px}.lr-view-mode{width:100px}
@media(max-width:1100px){.lr-margin{width:215px;padding:22px 16px}
.lr-document-head{padding:22px}
.lr-toolbar{flex-wrap:wrap}
}
@media(max-width:780px){.lr-app{position:relative}
.lr-reading-layout{overflow:auto;display:block}
.lr-page{height:65vh;min-height:360px}
.lr-margin{width:100%;border-left:0;border-top:1px solid var(--line)}
.lr-feature-toggle{display:inline-flex;margin-right:16px}
.lr-context{margin-top:14px}
.lr-document-head h2{font-size:22px}
.lr-document-head{padding:16px;gap:12px}
.lr-toolbar{gap:4px}
.lr-toolbar button{padding:6px}
.lr-backdrop{padding:10px}
.lr-modal{max-height:calc(100% - 20px);padding:20px}
.lr-modal h2{font-size:23px}
.lr-appearance-grid{gap:8px}
.lr-preview{font-size:18px}
.lr-fields{flex-wrap:wrap}
.lr-task-create{align-items:flex-start}
.lr-task-create small{max-width:180px}
}
@media(max-width:520px){.lr-eyebrow{font-size:9px;letter-spacing:.08em}
.reading-surface{padding:24px}
.lr-document-head{padding:14px 12px}
.lr-toolbar button{font-size:10px}
.lr-preview-margin{font-size:9px}
.lr-modal footer{align-items:flex-start;flex-wrap:wrap}
.lr-preview-label{right:15px}
.lr-main{min-width:0}
}
.lr-annotation-tooltip{position:fixed;inset:auto;margin:0;padding:16px;box-sizing:border-box;width:max-content;max-width:min(340px,calc(100vw - 24px));max-height: min(420px,calc(100vh - 24px));overflow:visible;border:1px solid #deded6;border-radius:10px;background:#fffefb;color:#29332d;box-shadow:0 6px 24px #263c2926;font:14px/1.55 ui-sans-serif,system-ui,sans-serif;user-select:text;white-space:normal;overflow-wrap:anywhere}
.lr-annotation-tooltip::before{content:"";position:absolute;left:var(--tooltip-arrow);width:12px;height:12px;background:#fffefb;border:1px solid #deded6;transform:translateX(-50%) rotate(45deg);z-index:-1}
.lr-annotation-tooltip[data-side=above]::before{bottom:-7px;border-top:0;border-left:0}
.lr-annotation-tooltip[data-side=below]::before{top:-7px;border-bottom:0;border-right:0}
.lr-tooltip-content{max-height:min(360px,calc(100vh - 70px));overflow:auto}.lr-annotation-tooltip section{border-left:2px solid;padding-left:10px;}.lr-annotation-tooltip section+section{margin-top:12px}.lr-annotation-tooltip small{display:block;font-size:11px;color:#788078}.lr-annotation-tooltip p{margin:4px 0 0;white-space:pre-wrap}
.lr-annotation-tooltip:has(.lr-tooltip-close){padding-right:30px}
.lr-app .lr-tooltip-close{position:absolute;right:5px;top:3px;background:transparent;border:0;padding:2px 6px;color:#899188;font-size:18px;line-height:1.2}
.lr-app .lr-settings-modal{width:min(1120px,100%);height:calc(100% - 16px);max-height:100%;padding:0;border-radius:12px;display:flex;flex-direction:column;overflow:hidden}
.lr-settings-modal>header{padding:18px 24px;margin:0;border-bottom:1px solid var(--line)}
.lr-settings-modal h2{font:20px system-ui;margin:0}
.lr-settings-top{padding:8px 24px;display:flex;gap:10px;align-items:center;border-bottom:1px solid var(--line);flex-wrap:wrap}
.lr-settings-top>.lr-field{max-width:320px;margin:5px 0}
.lr-settings-top button{font-size:12px}
.lr-settings-body{display:flex;flex:1;min-height:0}
.lr-settings-nav{width:220px;flex-shrink:0;padding:18px 12px;overflow:auto;background:#f0f2ec;border-right:1px solid var(--line)}
.lr-settings-nav button{display:flex;gap:9px;align-items:center;width:100%;text-align:left;border:0;background:transparent;margin:2px 0;padding:10px;overflow-wrap:anywhere}
.lr-settings-nav button[aria-current=page]{background:#dfe8db;color:#274c38}
.lr-settings-nav i{width:7px;height:7px;flex-shrink:0;border-radius:50%}
.lr-settings-nav small{margin-left:auto}
.lr-nav-label{font-size:12px;color:var(--muted);padding:18px 10px 6px}
.lr-settings-content{flex:1;min-width:0;overflow:auto;padding:24px 32px}
.lr-breadcrumb{font-size:12px;color:var(--muted);margin-bottom:20px;display:flex;align-items:center;flex-wrap:wrap}
.lr-breadcrumb button{border:0;background:transparent;padding:0;color:var(--green)}
.lr-premades{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-top:20px}
.lr-premades button{text-align:left;padding:18px}
.lr-premades small{display:block;margin-top:6px;color:var(--muted)}
.lr-intents{display:flex;gap:6px;flex-wrap:wrap}
.lr-intents button{font-size:12px}
.lr-settings-content .lr-existing-row h3{display:flex;align-items:center;gap:12px}
.lr-settings-content .lr-existing-row h3 small{margin-left:auto;color:var(--muted)}
.lr-preview-source b{position:relative;display:inline-block}
.lr-preview-label{position:absolute;bottom:100%;top:auto;left:50%;right:auto;transform:translateX(-50%);white-space:nowrap;padding-bottom:5px}
.lr-preview-tip-label{position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);padding:3px 7px;background:#fffefb;border:1px solid #deded6;border-radius:5px;box-shadow:0 2px 6px #263c291a;font:10px/1.4 system-ui;color:var(--annotation);white-space:nowrap}
.lr-preview-tip-label::after{content:"";position:absolute;bottom:-4px;left:calc(50% - 3px);width:6px;height:6px;background:#fffefb;border-bottom:1px solid #deded6;border-right:1px solid #deded6;transform:rotate(45deg)}
.lr-preview-bold b{font-weight:700}.lr-preview-italic b{font-style:italic}
.lr-reader-threads{margin-top:30px;border-top:1px solid var(--line);padding-top:10px}
.lr-authoring-thread{border:1px solid var(--line);border-radius:8px;margin:10px 0;background:#fff;overflow:hidden}
.lr-authoring-thread>summary{padding:14px;display:flex;gap:14px;cursor:pointer;font-size:13px}
.lr-authoring-thread>summary span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lr-authoring-thread>summary small{flex-shrink:0;color:var(--muted)}
.lr-authoring-thread .lr-conversation{margin:0;border:0;border-top:1px solid var(--line);border-radius:0}
.lr-request-config{background:#f2f5ee;border:1px solid var(--line);border-radius:8px;padding:16px}
.lr-request-config dl{display:flex;flex-wrap:wrap;gap:10px 20px;font-size:12px}
.lr-request-config dt{color:var(--muted)}.lr-request-config dd{margin:0}
.lr-request-config p{font-size:13px;white-space:pre-wrap}
.lr-variant-status{padding:12px 24px;font-size:12px;border-top:1px solid var(--line);background:#f5f7f0;display:flex;align-items:center;gap:12px}
@media(max-width:650px){.lr-settings-nav{width:145px;padding:12px 6px}.lr-settings-content{padding:18px 14px}.lr-settings-top{padding:8px 14px}.lr-settings-nav button{font-size:12px}.lr-settings-content .lr-fields{flex-direction:column;gap:0}}
.lr-text-region{flex:1;min-width:0;min-height:0;display:flex;justify-content:center;background:inherit}
.lr-page{width:100%;flex:0 1 auto}
.lr-margin{width:320px;flex:0 0 320px}
.lr-app .lr-settings-modal{width:min(1040px,100%);height:min(800px,calc(100% - 16px))}
.lr-settings-top{align-items:flex-end;padding:14px 24px;gap:8px}
.lr-settings-top>.lr-field{flex:0 1 260px;max-width:260px;margin:0 8px 0 0}
.lr-settings-top button{height:39px;white-space:nowrap}
.lr-settings-content{padding:24px 28px}.lr-settings-section{max-width:650px;margin:0 auto}
.lr-settings-section h3{margin:0 0 8px}.lr-settings-section h4{font-size:13px;font-weight:600;margin:0 0 14px}
.lr-name-field{max-width:380px;margin:24px 0}
.lr-setting-row{display:flex;gap:24px;align-items:center;justify-content:space-between;padding:22px 0;border-bottom:1px solid var(--line)}
.lr-setting-row strong{font-size:13px;font-weight:500}.lr-setting-row small{display:block;color:var(--muted);font-size:12px}
.lr-setting-block{padding:24px 0;border-bottom:1px solid var(--line)}.lr-width-control{width:250px;max-width:50%}.lr-width-control input{width:100%;accent-color:var(--green)}
.lr-app .lr-number-control{display:flex;align-items:center;gap:0;width:130px;flex-shrink:0;border:1px solid var(--line);border-radius:8px;background:#fff;overflow:hidden}
.lr-app .lr-number-control button{border:0;border-radius:0;padding:9px;font-size:17px;background:transparent}
.lr-app .lr-number-control input{border:0;border-radius:0;width:54px;text-align:center;padding:8px 2px;appearance:textfield}
.lr-options{display:flex;gap:6px;flex-wrap:wrap;margin:4px 0}.lr-options button{padding:7px 12px;font-size:12px}
.lr-options button[aria-checked=true]{background:#e4eee6;border-color:#8daa95;color:#284d38}
.lr-font-tags{display:flex;gap:6px;flex-wrap:wrap;min-height:38px;align-items:center;padding:8px;border:1px solid var(--line);border-radius:8px;background:#fff}
.lr-font-tags>span{background:#eef1eb;border-radius:5px;padding:4px 7px;font-size:12px;display:flex;gap:5px;align-items:center}
.lr-font-tags button{border:0;padding:0 2px;background:transparent;font-family:system-ui;line-height:1.2}
.lr-font-choices{display:flex;gap:6px;flex-wrap:wrap;margin:10px 0}.lr-font-choices button{font-size:12px;padding:5px 10px}
.lr-font-picker>small{color:var(--muted)}.lr-font-inherit{margin:0 0 8px;font-size:12px!important}.lr-font-inherit[aria-pressed=true]{background:#e4eee6}
.lr-color-picker{display:flex;gap:10px;align-items:center;flex-wrap:wrap;min-height:40px}
.lr-swatches{display:flex;gap:8px}.lr-app .lr-swatches button{width:28px;height:28px;min-width:28px;border-radius:50%;padding:0;border:1px solid #0002}
.lr-swatches button[aria-pressed=true]{outline:2px solid var(--green);outline-offset:3px}
.lr-custom-color{position:relative;display:flex;align-items:center;gap:6px;border:1px solid var(--line);padding:5px 8px;border-radius:7px;font-size:11px;background:#fff}
.lr-app .lr-custom-color input[type=color]{width:22px;min-width:22px;height:24px;border:0;padding:0;border-radius:4px;cursor:pointer}
.lr-app input.lr-color-hex{width:88px;padding:7px 8px;font:12px ui-monospace,monospace}
.lr-app .lr-switch{position:relative;display:inline-flex;align-items:center;width:36px;height:22px;padding:3px;border:0;border-radius:14px;background:#c7ccc6;flex-shrink:0}
.lr-app .lr-switch[aria-checked=true]{background:var(--green)}.lr-switch>span{display:block;width:16px;height:16px;border-radius:50%;background:white;box-shadow:0 1px 3px #0002;transform:translateX(0);transition:transform .12s}
.lr-switch[aria-checked=true]>span{transform:translateX(14px)}
.lr-annotator-head{display:flex;gap:20px;align-items:flex-start;justify-content:space-between}.lr-annotator-head .lr-feature-toggle{padding:0;font-size:11px;white-space:nowrap}
.lr-output-summary{font-size:12px;color:var(--muted);margin:0 0 24px!important}
.lr-processor-settings{display:flex;flex-direction:column;gap:8px}.lr-annotator-footer{display:flex;justify-content:space-between;align-items:center;gap:16px;border-top:1px solid var(--line);margin-top:26px;padding-top:18px}
.lr-annotator-footer small{color:var(--muted)}.lr-annotator-footer .lr-danger{margin:0;white-space:nowrap}
@media(max-width:780px){.lr-text-region{display:flex}.lr-margin{width:100%;flex-basis:auto}.lr-page{height:65vh}.lr-annotator-head{flex-wrap:wrap}.lr-settings-nav{width:170px}}
@media(max-width:550px){.lr-settings-top{gap:6px}.lr-settings-top>.lr-field{flex-basis:100%;max-width:none}.lr-settings-top button{height:34px}.lr-settings-content{padding:20px 14px}.lr-settings-nav{width:130px}.lr-setting-row{flex-wrap:wrap;gap:12px}.lr-width-control{width:100%;max-width:none}.lr-annotator-footer{align-items:flex-start;flex-direction:column}.lr-swatches{gap:5px}}
.lr-view-mode{width:auto;margin:0;}
.lr-font-tags:empty{display:none}
.lr-processor-settings .lr-options:has(>button:nth-child(10)){display:grid;grid-template-columns:repeat(5,minmax(0,1fr))}
.reading-surface[data-view=scroll] .reading-viewport{top:0}
.reading-surface[data-view=pages]{overflow:hidden;scrollbar-gutter:auto;min-height:0}
.lr-building-status{align-self:flex-start;display:flex;gap:8px;align-items:center;margin:8px 30px;font-size:12px!important}.lr-building-annotator{display:flex;align-items:center;gap:10px;padding:10px 12px;color:var(--muted);font-size:13px}.lr-building-annotator small{display:block;font-size:11px;margin-top:3px}.lr-spinner{width:14px;height:14px;flex:none;border:2px solid var(--line);border-top-color:var(--green);border-radius:50%;animation:lr-spin 1s linear infinite}@keyframes lr-spin{to{transform:rotate(360deg)}}
@media(prefers-reduced-motion:reduce){.lr-spinner{animation:none}}
.lr-hsk-control{display:flex;flex-direction:column;gap:12px;margin:16px 0}.lr-hsk-control input{width:100%;accent-color:var(--green)}.lr-hsk-labels{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}
.lr-source-link{font-size:11px;display:inline-block;margin-top:6px}
.lr-modal{--ink:#303a36;--green:#3e6957;color:var(--ink)}
/* Text library: compact document rows with a separate, quiet archive action. */
.lr-library{padding:42px clamp(22px,5vw,72px);background:#faf8f3;color:#292e32}
.lr-library-head{max-width:1060px;margin:0 auto 28px;align-items:center}.lr-library h1{font:40px/1.2 Georgia,serif;letter-spacing:-.025em}.lr-library-head p{font-size:12px;color:#7c827b;margin-top:8px}.lr-library .lr-toolbar{gap:10px}.lr-library .lr-primary{display:flex;align-items:center;gap:7px;background:#303d36;border-color:#303d36;padding:10px 15px}
.lr-library-filter{max-width:1060px;margin:0 auto 20px}.lr-text-list{max-width:1060px;margin:auto;border-top:1px solid #d9ddd4}.lr-text-row{display:flex;align-items:center;border-bottom:1px solid #d9ddd4;gap:14px;padding:3px 0}.lr-app .lr-text-open{display:flex;align-items:center;gap:18px;text-align:left;flex:1;padding:19px 12px;border:0;background:transparent;min-width:0}.lr-text-open:hover{background:#f0f0e8}.lr-text-icon{width:40px;height:48px;display:flex;align-items:center;justify-content:center;background:#efeee6;border:1px solid #e0dfd3;border-radius:3px;color:#818475;flex:none}.lr-text-title{display:flex;flex:1;min-width:0;gap:5px;flex-direction:column}.lr-text-title strong{font:21px/1.3 Georgia,serif;overflow-wrap:anywhere}.lr-text-title small{color:#7c827b;font-size:11px}.lr-app .lr-text-archive{background:transparent;border:0;color:#8d9389;padding:10px}.lr-text-archive:hover{color:#48584c}.lr-text-open>svg{color:#a0a598}.lr-text-row>.lr-error{flex-basis:100%}
/* Settings header owns the reader selection; content stays in a readable column. */
.lr-settings-modal>header{padding:16px 22px;background:#f1f1e9}
.lr-settings-top{padding:0;display:flex;align-items:stretch;justify-content:flex-start;gap:0;flex-wrap:nowrap;background:#f7f7f0}
.lr-reader-actions{display:flex;align-items:center;gap:8px;padding:12px 18px;flex-shrink:0}
.lr-reader-actions button{height:34px;font-size:12px;padding:5px 11px}
.lr-settings-top>.lr-reader-choice{flex:1;min-width:0;border-right:1px solid var(--line)}
.lr-settings-top .lr-reader-choice .qx-ui-choice-trigger{width:100%;height:100%;min-height:60px;border:0!important;border-radius:0!important;box-shadow:none!important;background:transparent!important;padding:12px 22px!important;font-size:18px!important;font-weight:550;justify-content:space-between}
.lr-settings-top .lr-reader-choice .qx-ui-choice-trigger:hover{background:#ecefe5!important;color:var(--accent)}
.lr-settings-body{min-height:0}.lr-settings-nav{background:#f5f5ed;width:200px}.lr-settings-content{padding:28px 34px;max-width:none}.lr-settings-content> :is(.lr-general-config,.lr-existing-feature,.lr-new-annotator){max-width:680px}.lr-new-reader{position:relative;font-size:11px}.lr-new-reader summary{cursor:pointer;padding:6px 10px;border:1px solid var(--line);background:white;border-radius:6px;list-style:none}.lr-new-reader>div{position:absolute;right:0;top:calc(100% + 6px);padding:5px;display:grid;gap:4px;min-width:190px;background:white;box-shadow:0 8px 24px #0002;border:1px solid var(--line);border-radius:8px;z-index:6}.lr-new-reader button{text-align:left}
.lr-ask-change{margin-top:24px;border-top:1px solid var(--line);padding-top:20px}.lr-ask-change summary{cursor:pointer;display:grid;grid-template-columns:1fr auto;gap:3px 16px;padding:0;list-style:none;color:#4d6655;font-weight:550}.lr-ask-change summary:after{content:'+';grid-column:2;grid-row:1 / span 2;align-self:center;font-size:20px;font-weight:400}.lr-ask-change[open] summary:after{content:'−'}.lr-ask-change summary small{grid-column:1;color:#8a9086;font-weight:400;font-size:11px}.lr-ask-change form{display:flex;flex-direction:column;gap:10px;margin-top:14px;align-items:flex-end}.lr-ask-change textarea{width:100%;min-height:100px;border:1px solid #d9ddd4;border-radius:8px;padding:12px;background:white;resize:vertical}.lr-ask-change button{font-size:12px}
.lr-generation-status{padding:12px 14px;border-left:2px solid #829582;background:#f0f2eb;font-size:12px}.lr-generate-text footer button{display:inline-flex;align-items:center;gap:7px}.lr-generate-text .lr-field{margin-bottom:20px}
.lr-app .qx-ui-segments{flex:none}.lr-app .qx-ui-segments button{border:0;border-radius:5px;padding:6px 9px;font-size:11px;background:transparent}.lr-app .qx-ui-segments button[aria-checked=true]{background:white}.lr-app .qx-ui-segments .qx-ui-icon{width:14px;height:14px}
@media(max-width:600px){.lr-library{padding:24px 18px}.lr-library-head .lr-toolbar{flex-wrap:wrap}.lr-reader-heading{gap:7px;flex-wrap:wrap}.lr-reader-heading select{max-width:220px;font-size:15px!important}.lr-settings-nav{width:145px}.lr-settings-content{padding:20px 17px}.lr-text-title strong{font-size:18px}}
/* Word actions belong alongside notes, independently of annotators. */
.lr-word-actions{margin-top:22px;border-top:1px solid var(--line);padding-top:14px}.lr-word-action-buttons{display:flex;flex-wrap:wrap;gap:8px}.lr-word-action-buttons button{display:inline-flex;align-items:center;gap:5px;font-size:12px;padding:8px 10px}.lr-word-actions>small{display:block;margin-top:10px;font-size:11px;color:var(--muted)}
.lr-word-menu:popover-open{position:fixed;inset:auto;margin:0;min-width:190px;padding:5px;background:#fff;border:1px solid #d8dfd6;border-radius:10px;box-shadow:0 10px 35px #263a342b;color:#29362d;z-index:1000}.lr-word-menu button{display:block;text-align:left;width:100%;padding:10px 12px;background:transparent;border:0;border-radius:5px;font:13px/1.4 system-ui,sans-serif;color:inherit}.lr-word-menu button:is(:hover,:focus-visible){background:#eef2e9;outline:none}.lr-word-menu button:disabled{color:#8b9388}
.lr-vocabulary-app{min-height:100dvh!important;background:#faf8f3;overflow:auto!important}.lr-vocabulary-editor{width:100%;max-width:780px;margin:auto;padding:28px 0}.lr-vocabulary-app>.lr-vocabulary-editor{padding:48px clamp(20px,5vw,60px)}.lr-vocabulary-editor>header{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:22px}.lr-vocabulary-editor h2{font:30px/1.2 Georgia,serif;margin:0}.lr-vocabulary-editor .lr-nav-label{padding:0;margin-bottom:8px}.lr-vocab-count{font:11px system-ui,sans-serif;color:var(--muted);white-space:nowrap}.lr-vocab-tools{display:flex;gap:12px;flex-wrap:wrap;margin:24px 0 18px}.lr-vocab-tools form{display:flex;gap:8px;flex:1;min-width:180px}.lr-vocab-tools input{min-width:0;width:100%}.lr-vocab-search{display:flex;align-items:center;gap:8px;flex:1;min-width:180px;border:1px solid var(--line);background:white;border-radius:7px;padding-left:10px}.lr-vocab-search input{border:0!important}.lr-vocab-rows{list-style:none;padding:0;margin:0;border-top:1px solid var(--line)}.lr-vocab-rows li{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);padding:11px 4px}.lr-vocab-rows li>span{font:20px/1.4 var(--reading-font,Georgia,serif);overflow-wrap:anywhere}.lr-vocab-empty{text-align:center;padding:36px 12px;background:#f2f3ea;border-radius:10px;color:#71806e}.lr-vocab-empty h3{font:20px Georgia,serif;color:var(--ink);margin:12px 0 6px}.lr-vocab-empty p{font-size:12px}.lr-vocab-attachment{display:flex;gap:12px;align-items:center;flex-wrap:wrap;border:1px solid var(--line);background:#fff;padding:14px;border-radius:10px;margin:20px 0}.lr-vocab-attachment>div{flex:1;min-width:100px}.lr-vocabulary-settings>p{color:var(--muted);font-size:13px;max-width:420px;line-height:1.6}.lr-vocab-picker-preview{padding:16px}.lr-vocab-picker-preview>h3{font:24px Georgia,serif}.lr-vocab-picker-preview>p{font-size:13px;color:#798174;line-height:1.6}.lr-vocab-preview>p{font-size:11px;color:#798174}.lr-vocab-preview>div{display:flex;gap:8px;flex-wrap:wrap}.lr-vocab-preview span{padding:6px 9px;border:1px solid #dbe0d3;border-radius:5px;background:#f4f6ef}
/* The settings preview is a separate saved-result view, never a generation surface. */
.lr-app .lr-settings-modal:has(.lr-settings-with-preview){width:min(1280px,100%)}
.lr-reading-preview{flex:0 0 320px;min-width:0;min-height:0;overflow:auto;padding:24px 18px;background:#f0f2eb;border-left:1px solid var(--line)}
.lr-reading-preview>header{display:flex;flex-direction:column;gap:6px;margin:0 0 18px}.lr-reading-preview h3{font-size:13px;font-weight:650}.lr-reading-preview>header span{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lr-reading-preview-surface{height:420px;max-height:54vh;min-height:220px;border:1px solid #d8ddd2;border-radius:6px;box-shadow:0 2px 8px #303a3607;overflow:hidden;pointer-events:none}
.lr-reading-preview-surface .reading-surface{height:100%;min-height:0;padding:16px 12px;overflow:hidden;scrollbar-gutter:auto}.lr-reading-preview-surface .reading-viewport{top:0}
.lr-reading-preview-note{padding:12px 13px;background:#fffefb;border-left:2px solid var(--green);margin-top:12px;font-size:12px;overflow-wrap:anywhere}.lr-reading-preview-note strong{font-weight:550}.lr-reading-preview-note p{margin-top:5px;color:var(--muted);white-space:pre-wrap}.lr-reading-preview-note-tooltip{border:1px solid var(--line);border-radius:6px}
.lr-reading-preview-status{font-size:10px;color:var(--muted);padding:12px 2px}.lr-reading-preview-empty{font-size:12px;color:var(--muted);padding:8px 0}
@media(max-width:1050px){.lr-reading-preview{flex-basis:250px;padding:22px 12px}.lr-settings-with-preview .lr-settings-content{padding:24px 20px}.lr-settings-with-preview .lr-settings-nav{width:170px}}
@media(max-width:800px){.lr-settings-with-preview{display:grid;grid-template-columns:140px minmax(0,1fr);grid-template-rows:minmax(280px,1fr) 220px;overflow:auto}.lr-settings-with-preview .lr-settings-nav{width:140px;grid-row:1 / span 2}.lr-settings-with-preview .lr-settings-content{grid-column:2;grid-row:1}.lr-reading-preview{grid-column:2;grid-row:2;border-left:0;border-top:1px solid var(--line);padding:12px}.lr-reading-preview>header{margin-bottom:8px}.lr-reading-preview-surface{height:180px;min-height:180px}.lr-reading-preview-status{padding:6px 2px}}
@media(max-width:600px){.lr-settings-top{flex-wrap:wrap}.lr-reader-actions{padding:8px 14px}.lr-settings-top>.lr-reader-choice{flex-basis:100%;border-right:0;border-bottom:1px solid var(--line)}.lr-settings-top .lr-reader-choice .qx-ui-choice-trigger{min-height:48px;font-size:16px!important;padding:10px 14px!important}}
/* Creation asks for a design; installed annotators expose their own controls. */
.lr-new-annotator{max-width:680px;margin:0 auto;padding-bottom:22px}
.lr-type-library{border:1px solid #dce1d5;border-radius:12px;background:#f5f6ee;overflow:hidden;margin-bottom:26px}
.lr-type-library>summary{display:flex;align-items:center;gap:12px;padding:15px 17px;cursor:pointer;list-style:none}
.lr-type-library>summary::-webkit-details-marker{display:none}.lr-type-library>summary:hover{background:#ecefe3}
.lr-library-symbol{font-size:25px;font-weight:400;color:var(--accent)}.lr-type-library>summary>span:nth-child(2){flex:1}
.lr-type-library>summary strong{display:block;font-size:13px;font-weight:600}.lr-type-library>summary small{display:block;font-size:11px;color:var(--muted);margin-top:4px}.lr-library-chevron{font-size:19px;color:var(--muted);transition:transform .15s}.lr-type-library[open] .lr-library-chevron{transform:rotate(180deg)}
.lr-type-library-content{padding:0 14px 14px}.lr-type-list{display:grid;gap:5px;max-height:370px;overflow:auto;overscroll-behavior:contain}
.lr-type-card{display:flex;align-items:stretch;background:#fffefb;border:1px solid #e1e5da;border-radius:8px;overflow:hidden;min-width:0}
.lr-app .lr-type-card-select{display:flex;align-items:center;gap:11px;flex:1;min-width:0;border:0;background:transparent;text-align:left;padding:11px 12px;border-radius:0;box-shadow:none}
.lr-type-card-select:hover{background:#eef2e6!important}.lr-type-card-select>span:nth-child(2){flex:1;min-width:0}.lr-type-card-select strong{display:block;font-size:12px;font-weight:600}.lr-type-card-select small{display:block;font-size:10px;color:var(--muted);line-height:1.45;margin-top:3px}
.lr-type-mark{width:30px;height:32px;display:grid;place-items:center;font:23px/1 Georgia,serif;color:var(--accent);flex-shrink:0}.lr-type-card-arrow{font-size:21px;color:#94a08b}
.lr-app .lr-type-archive{padding:0 11px;border:0;border-left:1px solid #edf0e7;background:transparent;box-shadow:none;border-radius:0;flex-shrink:0;display:grid;place-items:center;color:#89937f}
.lr-type-archive:hover{color:var(--ink)!important;background:#f3f4ec!important}.lr-type-archive svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linejoin:round;stroke-linecap:round}
.lr-archived-types{padding:14px 3px 0;font-size:11px;color:var(--muted)}.lr-archived-types summary{cursor:pointer}.lr-archived-types>p{font-size:10px;margin:9px 0}.lr-archived-types>div{display:flex;align-items:center;justify-content:space-between;padding:6px 0;gap:12px}.lr-archived-types button{font-size:10px;padding:4px 8px}
.lr-type-instance{background:#fffefb;padding:14px;border:1px solid #e1e5da;border-radius:8px}.lr-app .lr-type-back{padding:0;border:0;box-shadow:none;background:transparent;color:var(--accent);font-size:11px;margin-bottom:16px}.lr-type-instance h4{margin:0;font:600 16px/1.4 system-ui,sans-serif}.lr-type-instance header>p{margin:6px 0 18px;font-size:12px;color:var(--muted);line-height:1.5}.lr-type-instance footer{display:flex;align-items:center;justify-content:space-between;gap:12px;border-top:1px solid var(--line);padding-top:14px;margin-top:17px}.lr-type-instance footer>span{font-size:10px;color:var(--muted)}.lr-type-instance footer>button{font-size:12px;white-space:nowrap}
.lr-create-divider{display:flex;align-items:center;gap:14px;color:var(--muted);font-size:10px;font-weight:600;letter-spacing:.06em;margin:0 0 22px}.lr-create-divider::after{content:"";height:1px;flex:1;background:var(--line)}
.lr-new-title{margin-bottom:22px!important}.lr-new-title input{font-size:16px!important;font-weight:500}.lr-create-heading{font-size:13px!important;margin:0 0 12px!important}
.lr-new-annotator .lr-appearance-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-bottom:20px}
.lr-new-annotator .lr-appearance{border:1px solid #dde2d5!important;border-radius:9px!important;box-shadow:none;transition:border-color .12s,background .12s}
.lr-new-annotator .lr-appearance[aria-pressed=true]{border-color:var(--accent)!important;box-shadow:0 0 0 1px var(--accent)!important;background:#edf1e5!important}
.lr-new-annotator .lr-appearance strong{font-size:11px;font-weight:600;padding:9px 11px 0}.lr-new-annotator .lr-appearance>small{font-size:10px;line-height:1.4;padding:4px 11px 10px}
.lr-new-annotator .lr-preview{height:70px;font:15px/1.5 Georgia,serif;background:#f7f8f1;flex-shrink:0;overflow:hidden;gap:6px;white-space:nowrap;padding:8px}
.lr-preview-source{position:relative}.lr-preview-source b{font-weight:400}.lr-preview-label{font:10px/1.3 system-ui,sans-serif;padding-bottom:3px}.lr-preview-below .lr-preview-label{top:100%;bottom:auto;padding:3px 0 0}
.lr-preview-tip-label{font-size:8px;bottom:calc(100% + 5px);padding:2px 5px}.lr-new-annotator .lr-preview-sidebar{font-size:12px;gap:7px;white-space:normal;justify-content:center}.lr-new-annotator .lr-preview-margin{font:8px/1.4 system-ui,sans-serif;padding-left:6px}
.lr-preview-line-dashed b{border-bottom-style:dashed}.lr-preview-line-squiggly b{border-bottom:0;text-decoration:underline wavy var(--annotation);text-underline-offset:5px}
.lr-creation-options{display:grid;gap:18px;padding:18px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin:18px 0}.lr-creation-options>.lr-field{margin:0}
.lr-format-picker{margin:18px 0}.lr-visual-options{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:7px;width:100%;background:transparent;padding:0;border:0;overflow:visible}
.lr-app .lr-visual-options>button{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;border:1px solid #dde2d5;border-radius:7px;background:#fffefb;padding:8px 6px;min-height:60px;min-width:0;font-size:10px;font-weight:500;color:var(--muted);box-shadow:none}
.lr-app .lr-visual-options>button[aria-checked=true]{background:#edf2e7;border-color:var(--accent);color:var(--accent);box-shadow:0 0 0 1px var(--accent)}.lr-option-preview{display:grid;place-items:center;width:100%;height:27px;color:var(--accent)}
.lr-format-sample{font:20px/1.3 Georgia,serif;color:var(--ink)}.lr-format-sample-highlight{background:color-mix(in srgb,var(--annotation) 24%,transparent);padding:0 3px;border-radius:3px}.lr-format-sample-underline{text-decoration:underline solid var(--annotation);text-underline-offset:4px}.lr-format-sample-color{color:var(--annotation)}.lr-format-sample-bold{font-weight:700}.lr-format-sample-italic{font-style:italic}
.lr-stroke-preview{width:min(96px,100%);height:18px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round}
.lr-label-position .lr-visual-options{max-width:260px}.lr-position-preview{display:flex;flex-direction:column;align-items:center;font:18px/1 Georgia,serif;color:var(--ink);gap:3px}.lr-position-preview small{font:9px/1 system-ui,sans-serif;color:var(--accent)}.lr-position-below small{order:1}.lr-label-position .lr-option-preview{height:35px}
.lr-label-font-options{border:1px solid var(--line);border-radius:8px;background:#f7f8f1;margin:16px 0;padding:11px 13px}.lr-label-font-options>summary{cursor:pointer;font-size:11px;color:var(--muted)}.lr-label-font-options .lr-processor-settings{margin-top:15px}.lr-build-instructions{margin-top:24px!important;font-weight:600}.lr-build-instructions textarea{font-size:13px!important;line-height:1.6!important;font-weight:400;padding:13px!important;background:#fffefb!important}
.lr-new-annotator .lr-intents{display:flex;gap:6px;flex-wrap:wrap;margin:-3px 0 22px}.lr-new-annotator .lr-intents button{font-size:10px;border:1px solid var(--line);padding:5px 8px;background:#f7f8f1;border-radius:5px}
.lr-create-footer{display:flex;align-items:center;justify-content:space-between;gap:15px;border-top:1px solid var(--line);padding-top:17px}.lr-create-footer>small{max-width:260px;font-size:10px;line-height:1.5;color:var(--muted)}.lr-create-footer>button{font-size:12px;white-space:nowrap}
@media(max-width:1050px){.lr-new-annotator .lr-appearance-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.lr-format-picker .lr-visual-options{grid-auto-flow:row;grid-template-columns:repeat(3,minmax(0,1fr))}.lr-create-footer{align-items:flex-start;flex-direction:column}}
@media(max-width:600px){.lr-type-library>summary{padding:12px;gap:8px}.lr-type-library>summary small{font-size:10px}.lr-type-library-content{padding:0 8px 8px}.lr-type-card-select{padding:9px!important;gap:7px!important}.lr-type-mark{width:22px;font-size:21px}.lr-type-card-select small{font-size:9px}.lr-type-instance{padding:11px}.lr-type-instance footer{align-items:flex-start;flex-direction:column}.lr-new-annotator .lr-preview{font-size:13px}.lr-new-annotator .lr-appearance strong{font-size:10px;padding:8px}.lr-new-annotator .lr-appearance>small{display:none}.lr-format-picker .lr-visual-options{grid-template-columns:repeat(2,minmax(0,1fr))}}
`;
+336
View File
@@ -0,0 +1,336 @@
import { usePrompt } from "./drafts.js";
import { LocalInput, TextArea, Markdown } from "@quixos/camino-react/ui";
import { useState } from "react";
import { referenceOf, useCaminoQuery, useCrdtText, useInterface, type ExactReference } from "@quixos/camino-react";
import { interfaces, queries } from "./gen/client.js";
import { accepted, asInterface, errorMessage, type TaskNode } from "./model.js";
export function FeatureConversation({
tasks,
navigation,
}: {
library: ExactReference;
reader: ExactReference;
navigation: ExactReference;
tasks: TaskNode[];
}) {
if (!tasks.length) return null;
return (
<section className="lr-reader-threads" aria-label="Reader authoring threads">
<h3>Authoring threads</h3>
{tasks.map((task) => (
<FeatureThread key={referenceOf(task).object} task={task} navigation={navigation} />
))}
</section>
);
}
function FeatureThread({ task, navigation }: { task: TaskNode; navigation: ExactReference }) {
const [expanded, setExpanded] = useState(
["queued", "running", "waiting_input"].includes(task.thread?.active?.progress.status ?? task.data.status),
);
return (
<details
className="lr-authoring-thread"
open={expanded}
onToggle={(event) => setExpanded(event.currentTarget.open)}
>
<summary>
<span>
{snapshot(task.configuration)?.name ||
snapshot(task.configuration)?.instructions?.slice(0, 80) ||
"Annotator changes"}
</span>
<small>{task.thread?.active?.progress.status ?? task.data.status}</small>
</summary>
{expanded && <Conversation task={task} navigation={navigation} />}
</details>
);
}
export function authoringTitle(task: TaskNode) {
const config = snapshot(task.configuration);
if (config?.name) return config.name;
const name = task.thread?.data.title;
if (name && name.length <= 64) return name;
const types: Record<string, string> = {
overhead: "Text labels",
highlight: "Highlight",
sidebar: "Sidebar",
tooltip: "Tooltip",
replace: "Replacement",
};
return config?.style ? `${types[config.style] ?? config.style} annotator` : "New annotator";
}
type RequestSnapshot = {
name?: string;
style?: string;
reveal?: string;
color?: string;
fontFamily?: string;
fontSize?: number;
placement?: "above" | "below";
instructions?: string;
};
function snapshot(raw: string): RequestSnapshot | null {
try {
const value = JSON.parse(raw);
return value && typeof value === "object" ? value : null;
} catch {
return null;
}
}
export function RequestConfiguration({ value }: { value: RequestSnapshot }) {
return (
<article className="lr-request-config">
<strong>{value.name || "New Annotator"}</strong>
<dl>
{[
["Type", value.style === "overhead" ? "Text labels" : value.style],
[
"Position",
value.style === "overhead" ? (value.placement === "below" ? "Below the text" : "Above the text") : null,
],
[
"Show annotations",
value.reveal === "always"
? "Always"
: value.reveal === "hover"
? "When hovering"
: value.reveal === "click"
? "When clicked"
: value.reveal,
],
["Color", value.color],
["Font", value.fontFamily],
["Font size", value.fontSize],
]
.filter(([, value]) => value)
.map(([label, value]) => (
<div key={String(label)}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
{value.instructions && <p>{value.instructions}</p>}
</article>
);
}
/** Transcript rendering is independent of the graph controller. Embedded tools
* can supply a React view for their original structured request. */
export function ConversationTranscript({
items,
initial,
}: {
items: { key: string; kind: string; text: string }[];
initial?: React.ReactNode;
}) {
return (
<div className="lr-transcript" role="log" aria-live="polite">
{initial}
{items.map((item) => (
<div className={"lr-bubble lr-bubble-" + item.kind} key={item.key}>
<small>{item.kind === "user" ? "You" : "Agent"}</small>
{item.kind === "assistant" ? <Markdown text={item.text} /> : <p>{item.text}</p>}
</div>
))}
</div>
);
}
export function Conversation({ task, navigation }: { task: TaskNode; navigation: ExactReference }) {
const query = useTask(referenceOf(task));
if (query.status === "error")
return (
<p role="alert" className="lr-error">
Unable to load this conversation.
</p>
);
if (query.status !== "ready") return <p role="status">Loading conversation…</p>;
return <ConversationDetails task={query.data.root} navigation={navigation} />;
}
function useTask(reference: ExactReference) {
return useCaminoQuery(queries.ConversationView, {
root: asInterface(reference, "job"),
input: {},
admission: { query: queries.ConversationInputs, input: {} },
});
}
type DetailedTask = Extract<ReturnType<typeof useTask>, { status: "ready" }>["data"]["root"];
function ConversationDetails({ task, navigation }: { task: DetailedTask; navigation: ExactReference }) {
const draft = usePrompt(navigation, "followup");
const text = draft.text;
const thread = useInterface(
interfaces.AgentThread,
task.thread ? referenceOf(task.thread) : undefined,
!!task.thread,
);
const current = task.thread?.active ?? task.request;
const request = useInterface(interfaces.AgentRequest, current ? referenceOf(current) : undefined, !!current);
const [busy, setBusy] = useState(false),
[error, setError] = useState("");
const progress = current?.progress,
active = progress && !["completed", "failed", "cancelled", "uncertain"].includes(progress.status);
const config = snapshot(task.configuration);
const items = [...(task.thread?.items.entries ?? [])]
.sort((a, b) => Number(a.node.data.sequence) - Number(b.node.data.sequence))
.filter(({ node }) => ["user", "message", "assistant", "notice"].includes(node.data.kind) && node.data.text);
const original = items.find(
({ node }) => node.data.kind === "user" && node.request._camino.ref.object === task.request?._camino.ref.object,
)?.node.data.text;
async function send() {
setBusy(true);
setError("");
try {
await draft.flush();
if (active && request.status === "ready")
await accepted(
request.value.steer.call({
attempt: progress!.attempt,
generation: progress!.generation,
key: crypto.randomUUID(),
text,
profile: null,
createdAt: new Date().toISOString(),
}),
);
else if (thread.status === "ready")
await accepted(
thread.value.send.call({
text,
data: task.reader
? JSON.stringify({ authoringJob: referenceOf(task), reader: task.reader._camino.ref })
: null,
profile: null,
response: null,
sourceKind: "human",
sourceLabel: "Reading feature",
publication: null,
createdAt: new Date().toISOString(),
contexts: task.reader
? [
{
...task.reader._camino.ref,
expectation: { kind: "interface", id: "interface:org.quixos.agent:context-subject@1" },
},
]
: [],
related: null,
retryOf: null,
root: null,
causalDepth: 0,
}),
);
draft.clearSubmitted(text);
} catch (error) {
setError(errorMessage(error));
} finally {
setBusy(false);
}
}
return (
<section className="lr-conversation" aria-label="Feature conversation">
<header>
<strong>Annotator changes</strong>
<span role="status">
{active
? progress.status === "waiting_input"
? "Needs your answer"
: "Working on your feature…"
: progress?.status === "completed"
? "Agent finished"
: progress?.status || task.data.status}
</span>
{active && (
<button
onClick={() => {
if (request.status === "ready")
void accepted(request.value.cancel.call(null)).catch((error) => setError(errorMessage(error)));
}}
>
Stop
</button>
)}
</header>
<ConversationTranscript
items={items
.filter(
({ node }) =>
!(node.data.kind === "user" && node.request._camino.ref.object === task.request?._camino.ref.object),
)
.map(({ node }) => ({ key: referenceOf(node).object, kind: node.data.kind, text: node.data.text }))}
initial={
config ? (
<RequestConfiguration value={config} />
) : original ? (
<article className="lr-request-config">
<strong>Original request</strong>
<p>
{original.slice(0, 240)}
{original.length > 240 ? "…" : ""}
</p>
</article>
) : undefined
}
/>
{!items.length && task.data.output && <p>{task.data.output}</p>}
{(current?.questions.entries ?? [])
.filter(({ node }) => node.data.status === "open" || node.data.status === "pending")
.map(({ node }) => (
<Question key={referenceOf(node).object} question={node} />
))}
{(task.data.error || progress?.reason) && (
<p role="alert" className="lr-error">
{task.data.error || progress?.reason}
</p>
)}
<form
onSubmit={(event) => {
event.preventDefault();
void send();
}}
>
<TextArea
binding={draft}
aria-label="Follow up on this feature"
rows={2}
maxLength={12000}
placeholder={active ? "Add a detail while it works…" : "Refine this feature…"}
/>
<button disabled={busy || !text.trim() || thread.status !== "ready"}>{active ? "Add detail" : "Send"}</button>
</form>
{error && <p role="alert">{error}</p>}
</section>
);
}
function Question({
question,
}: {
question: NonNullable<DetailedTask["request"]>["questions"]["entries"][number]["node"];
}) {
const api = useInterface(interfaces.AgentQuestion, referenceOf(question));
const [answer, setAnswer] = useState(""),
[error, setError] = useState("");
return (
<form
className="lr-question"
onSubmit={(event) => {
event.preventDefault();
if (api.status === "ready")
void accepted(
api.value.answer.call({
answer,
attempt: question.data.attempt,
generation: question.data.generation,
answeredAt: new Date().toISOString(),
}),
).catch((error) => setError(errorMessage(error)));
}}
>
<label className="lr-field">
{question.data.title}
<LocalInput value={answer} onChange={(event) => setAnswer(event.target.value)} />
</label>
<button disabled={!answer.trim()}>Answer</button>
{error && <p role="alert">{error}</p>}
</form>
);
}
+258
View File
@@ -0,0 +1,258 @@
import { usePrompt } from "./drafts.js";
import { useEffect, useState } from "react";
import { useCrdtText, useInterface, referenceOf, type ExactReference } from "@quixos/camino-react";
import { Icon, LocalChoice, TextArea, SegmentedControl } from "@quixos/camino-react/ui";
import { interfaces } from "./gen/client.js";
import { accepted, asInterface, errorMessage, type Root } from "./model.js";
type TextNode = Root["documents"]["entries"][number]["node"];
function TextRow({
node,
open,
selected,
changed,
}: {
node: TextNode;
open: (ref: ExactReference) => void;
selected?: string | undefined;
changed: (id: string, archived: boolean) => void;
}) {
const api = useInterface(
{ archived: { set: interfaces.ReadingDocument.archived.set } },
asInterface(referenceOf(node), "document"),
);
const [busy, setBusy] = useState(false),
[error, setError] = useState("");
const archive = async () => {
if (api.status !== "ready") return;
setBusy(true);
setError("");
try {
await accepted(api.value.archived.set(!node.archived));
changed(referenceOf(node).object, !node.archived);
} catch (e) {
setError(errorMessage(e));
} finally {
setBusy(false);
}
};
return (
<div className="lr-text-row">
<button
className="lr-text-open"
aria-current={selected === referenceOf(node).object ? "page" : undefined}
onClick={() => open(referenceOf(node))}
>
<span className="lr-text-icon">
<Icon name="pages" size={21} />
</span>
<span className="lr-text-title">
<strong>{node.data.title}</strong>
<small>
{node.data.source === "sample"
? "Sample text"
: node.data.source === "generated"
? "Generated text"
: node.data.source}
</small>
</span>
<Icon name="arrow-right" />
</button>
<button
className="lr-text-archive"
disabled={busy || api.status !== "ready"}
aria-label={`${node.archived ? "Restore" : "Archive"} ${node.data.title}`}
onClick={() => void archive()}
>
{node.archived ? "Restore" : <Icon name="archive" />}
</button>
{error && (
<p className="lr-error" role="alert">
{error}
</p>
)}
</div>
);
}
export function Texts({
root,
selected,
open,
add,
speech,
}: {
root: Root;
selected?: string | undefined;
open: (ref: ExactReference) => void;
add: () => void;
speech: () => void;
}) {
const [view, setView] = useState("active"),
[overrides, setOverrides] = useState<Record<string, boolean>>({});
useEffect(() => {
setOverrides((old) => {
const next = { ...old };
for (const { node } of root.documents.entries) {
const id = referenceOf(node).object;
if (next[id] === node.archived) delete next[id];
}
return Object.keys(next).length === Object.keys(old).length ? old : next;
});
}, [root.documents]);
const nodes = root.documents.entries
.map((e) => e.node)
.filter((n) => (overrides[referenceOf(n).object] ?? n.archived) === (view === "archived"));
return (
<div className="lr-library">
<header className="lr-library-head">
<div>
<h1>Texts</h1>
<p>
{nodes.length} {view === "archived" ? "archived " : ""}
{nodes.length === 1 ? "text" : "texts"}
</p>
</div>
<div className="lr-toolbar">
{selected && root.documents.entries.some((e) => referenceOf(e.node).object === selected) && (
<button
onClick={() =>
open(referenceOf(root.documents.entries.find((e) => referenceOf(e.node).object === selected)!.node))
}
>
Back to text
</button>
)}
<button onClick={speech}>Read-aloud settings</button>
<button className="lr-primary" onClick={add}>
<Icon name="plus" />
Add text
</button>
</div>
</header>
<div className="lr-library-filter">
<SegmentedControl
label="Text collection"
value={view}
options={[
{ value: "active", label: "Texts" },
{ value: "archived", label: "Archived" },
]}
onChange={setView}
/>
</div>
<nav className="lr-text-list" aria-label="Texts">
{nodes.map((node) => (
<TextRow
key={referenceOf(node).object}
node={node}
selected={selected}
open={open}
changed={(id, archived) => setOverrides((old) => ({ ...old, [id]: archived }))}
/>
))}
</nav>
{nodes.length === 0 && (
<div className="lr-empty">
<h2>{view === "archived" ? "No archived texts" : "No texts yet"}</h2>
{view === "active" && <button onClick={add}>Add text</button>}
</div>
)}
</div>
);
}
export function GenerateText({
library,
root,
initialReader,
navigation,
added,
}: {
library: ExactReference;
root: Root;
initialReader?: string | undefined;
navigation: ExactReference;
added: (ref: ExactReference) => void;
}) {
const draft = usePrompt(navigation, "generate");
const prompt = draft.text;
const api = useInterface(interfaces.LanguageReader, library),
[reader, setReader] = useState(initialReader ?? root.defaultReader?._camino.ref.object ?? ""),
[busy, setBusy] = useState(false),
[job, setJob] = useState(""),
[error, setError] = useState("");
const task = root.tasks.entries.map((e) => e.node).find((n) => referenceOf(n).object === job);
useEffect(() => {
if (task?.data.status === "ready" && task.document) added(referenceOf(task.document));
}, [task?.data.status, task?.document?._camino.ref.object]);
const failed = !!task && ["failed", "cancelled", "uncertain"].includes(task.data.status);
const generate = async () => {
if (api.status !== "ready" || !reader) return;
setBusy(true);
setError("");
try {
await draft.flush();
const ref = await accepted(
api.value.generateDocument.call({
prompt,
reader: {
...root.readers.entries.find((e) => referenceOf(e.node).object === reader)!.node._camino.ref,
expectation: { kind: "atom", id: "atom:reading:reader" },
},
createdAt: new Date().toISOString(),
}),
);
setJob(ref.object);
draft.clearSubmitted(prompt);
} catch (e) {
setError(errorMessage(e));
} finally {
setBusy(false);
}
};
return (
<div className="lr-generate-text">
<label className="lr-field">
Read with
<LocalChoice
aria-label="Reader for generated text"
value={reader}
onValueChange={setReader}
options={root.readers.entries.map(({ node }) => ({ value: referenceOf(node).object, label: node.data.name }))}
disabled={busy || (!!job && !failed)}
/>
</label>
<label className="lr-field">
What would you like to read?
<TextArea
binding={draft}
aria-label="Text request"
rows={6}
maxLength={4000}
placeholder="A short French story about missing a train, at A2 level…"
disabled={busy || (!!job && !failed)}
/>
</label>
<footer>
<small>Include a language, topic, and difficulty.</small>
<button
className="lr-primary"
disabled={!prompt.trim() || !reader || busy || (!!job && !failed)}
onClick={() => void generate()}
>
<Icon name="spark" />
{busy ? "Starting…" : job && !failed ? "Writing…" : failed ? "Try again" : "Generate text"}
</button>
</footer>
{job && !failed && (
<p role="status" className="lr-generation-status">
Writing your text. You can close this window; it will appear in Texts when ready.
</p>
)}
{(error || failed) && (
<p className="lr-error" role="alert">
{error || task?.data.error || "The text could not be generated."}
</p>
)}
</div>
);
}
+1021
View File
File diff suppressed because it is too large Load Diff
+420
View File
@@ -0,0 +1,420 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
referenceOf,
useCaminoQuery,
useInterface,
useCrdtText,
useLiveField,
objectPresentationURL,
type ExactReference,
} from "@quixos/camino-react";
import {
Button,
Icon,
ObjectLink,
ObjectPicker,
LocalInput,
TextInput,
useCommandControl,
type ObjectPickerRequest,
} from "@quixos/camino-react/ui";
import { interfaces, queries, type Modules } from "./gen/client.js";
import { accepted, asInterface, errorMessage, type ReaderNode } from "./model.js";
import { readingStyle } from "./style.js";
type Word = { _camino: { ref: ExactReference }; text: string };
export function useVocabulary(reference?: ExactReference | null) {
const [pages, setPages] = useState<Record<string, readonly Word[]>>({});
const result = useCaminoQuery(queries.VocabularyView, {
enabled: !!reference,
root: reference ? asInterface(reference, "vocabulary") : undefined,
input: { search: "", after: null },
admission: { query: queries.VocabularyInputs, input: { search: "", after: null } },
});
useEffect(() => {
setPages({});
}, [reference?.object]);
const report = useCallback((cursor: string, words: readonly Word[] | null) => {
setPages((old) => {
const next = { ...old };
if (words) next[cursor] = words;
else delete next[cursor];
return next;
});
}, []);
const node =
result.status === "ready" && referenceOf(result.data.root).object === reference?.object ? result.data.root : null;
const words = useMemo(
() => [
...new Map(
[...(node?.words.entries.map((e) => e.node) ?? []), ...Object.values(pages).flat()].map((word) => [
referenceOf(word).object,
word,
]),
).values(),
],
[node, pages],
);
const known = useMemo(() => new Set(words.map((word) => word.text)), [words]);
return {
node,
words,
known,
ready: !!node,
continuation:
node?.words.pageInfo.hasNextPage && node.words.pageInfo.endCursor ? (
<VocabularyTail
key={reference!.object}
reference={reference!}
cursor={node.words.pageInfo.endCursor}
report={report}
/>
) : null,
};
}
function VocabularyTail({
reference,
cursor,
report,
}: {
reference: ExactReference;
cursor: string;
report(cursor: string, words: readonly Word[] | null): void;
}) {
const result = useCaminoQuery(queries.VocabularyView, {
root: asInterface(reference, "vocabulary"),
input: { search: "", after: cursor },
admission: { query: queries.VocabularyInputs, input: { search: "", after: cursor } },
});
const words = result.status === "ready" ? result.data.root.words : null;
useEffect(() => {
if (words)
report(
cursor,
words.entries.map((e) => e.node),
);
return () => report(cursor, null);
}, [words, cursor, report]);
return words?.pageInfo.hasNextPage && words.pageInfo.endCursor ? (
<VocabularyTail reference={reference} cursor={words.pageInfo.endCursor} report={report} />
) : null;
}
export const Vocabulary: Modules["Vocabulary"] = ({ subject }) => (
<section className="lr-app lr-vocabulary-app">
<style>{readingStyle}</style>
<VocabularyEditor reference={subject} />
</section>
);
export function VocabularyEditor({ reference }: { reference: ExactReference }) {
const vocabulary = useVocabulary(reference);
return vocabulary.ready ? (
<ReadyVocabularyEditor key={reference.object} reference={reference} vocabulary={vocabulary} />
) : (
<p role="status">Opening vocabulary…</p>
);
}
function ReadyVocabularyEditor({
reference,
vocabulary,
}: {
reference: ExactReference;
vocabulary: ReturnType<typeof useVocabulary>;
}) {
const api = useInterface(
{ remember: interfaces.ReadingVocabulary.remember, rename: interfaces.ReadingVocabulary.name.set },
asInterface(reference, "vocabulary"),
);
const name = useCommandControl({
key: reference.object + "/name",
value: vocabulary.node?.name ?? "",
submit: api.status === "ready" ? (value: string) => api.value.rename(value) : null,
});
const draftControl = useCrdtText({
key: reference.object + "/word-draft",
operation: interfaces.ReadingVocabulary.draft.edit,
reference: asInterface(reference, "vocabulary"),
path: ["word"],
});
const draftField = useLiveField({
key: reference.object + "/draft-clear",
operation: interfaces.ReadingVocabulary.draft.edit,
reference: asInterface(reference, "vocabulary"),
});
const draft = draftControl.text;
const [nameDraft, setNameDraft] = useState<string | null>(null);
useEffect(() => {
if (nameDraft === vocabulary.node?.name) setNameDraft(null);
}, [vocabulary.node?.name, nameDraft]);
const [search, setSearch] = useState(""),
[pending, setPending] = useState(false),
[error, setError] = useState("");
const words = vocabulary.words.filter((word) => word.text.toLocaleLowerCase().includes(search.toLocaleLowerCase()));
return (
<div className="lr-vocabulary-editor">
{vocabulary.continuation}
<header>
<div>
<span className="lr-nav-label">Vocabulary list</span>
<h2>{vocabulary.node?.name ?? "Vocabulary"}</h2>
</div>
<span className="lr-vocab-count">
{vocabulary.words.length} {vocabulary.words.length === 1 ? "word" : "words"}
</span>
</header>
<label className="lr-field">
List name
<LocalInput
aria-label="Vocabulary list name"
value={nameDraft ?? name.value ?? vocabulary.node?.name ?? ""}
disabled={!name.ready}
maxLength={100}
onBlur={() => {
if (nameDraft?.trim() && nameDraft !== vocabulary.node?.name) name.request(nameDraft);
else setNameDraft(null);
}}
onChange={(event) => {
setNameDraft(event.target.value);
}}
/>
</label>
<div className="lr-vocab-tools">
<label className="lr-vocab-search">
<Icon name="search" />
<LocalInput
aria-label="Find saved words"
placeholder="Find a word"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</label>
<form
onSubmit={(event) => {
event.preventDefault();
if (api.status !== "ready" || !draft.trim() || pending) return;
setPending(true);
void accepted(api.value.remember.call(draft))
.then(
() => {
const current = draftField.current();
if (current.status === "ready") {
const snapshot = current.binding.getSnapshot();
if (snapshot.value.word === draft)
current.binding.edit((editor) => editor.spliceText(["word"], 0, draft.length, ""), snapshot);
}
},
(e) => setError(errorMessage(e)),
)
.finally(() => setPending(false));
}}
>
<TextInput binding={draftControl} aria-label="New vocabulary word" placeholder="Add a word" maxLength={100} />
<Button type="submit" disabled={pending || api.status !== "ready" || !draft.trim()}>
{pending ? "Adding…" : "Add"}
</Button>
</form>
</div>
{!!draftControl.error && <p role="alert">{errorMessage(draftControl.error)}</p>}
{!vocabulary.ready ? (
<p role="status">Opening vocabulary…</p>
) : words.length ? (
<ul className="lr-vocab-rows">
{words.map((word) => (
<VocabularyWord key={referenceOf(word).object} word={word} report={setError} />
))}
</ul>
) : (
<div className="lr-vocab-empty">
<Icon name="book" size={28} />
<h3>{search ? "No matching words" : "No words saved yet"}</h3>
<p>{search ? "Try another search." : "Save a word from the text or add one above."}</p>
</div>
)}
{(error || name.error) && <p role="alert">{error || name.error}</p>}
</div>
);
}
function VocabularyWord({ word, report }: { word: Word; report(message: string): void }) {
const api = useInterface({ forget: interfaces.ReadingWord.forget }, asInterface(referenceOf(word), "word"));
const [pending, setPending] = useState(false);
return (
<li>
<span>{word.text}</span>
<Button
size="compact"
tone="quiet"
disabled={pending || api.status !== "ready"}
aria-label={"Remove " + word.text}
onClick={() => {
if (api.status !== "ready") return;
setPending(true);
void accepted(api.value.forget.call(null))
.catch((e) => report(errorMessage(e)))
.finally(() => setPending(false));
}}
>
{pending ? "Removing…" : "Remove"}
</Button>
</li>
);
}
export function VocabularySettings({
library,
reader,
defaultVocabulary,
}: {
library: ExactReference;
reader: ReaderNode;
defaultVocabulary?: ExactReference | null;
}) {
const attached = reader.vocabulary ? referenceOf(reader.vocabulary) : defaultVocabulary;
const [picking, setPicking] = useState(false),
[editing, setEditing] = useState(false),
[error, setError] = useState("");
const api = useInterface(
{ attach: interfaces.ReadingReader.vocabulary.connect },
asInterface(referenceOf(reader), "reader"),
);
return (
<section className="lr-vocabulary-settings">
<h3>Vocabulary</h3>
<p>This reader uses a shared list. Saving a word updates that list for every reader attached to it.</p>
<div className="lr-vocab-attachment">
<Icon name="book" />
<div>{attached ? <VocabularyLabel reference={attached} /> : <span>No list selected</span>}</div>
<Button onClick={() => setPicking(true)}>Choose list</Button>
{attached && (
<Button tone="quiet" onClick={() => setEditing(!editing)}>
{editing ? "Close editor" : "Edit list"}
</Button>
)}
</div>
{editing && attached && <VocabularyEditor key={attached.object} reference={attached} />}
{picking && (
<VocabularyPicker
library={library}
value={attached}
close={() => setPicking(false)}
selected={(ref) => {
if (api.status !== "ready") return;
void accepted(api.value.attach(asInterface(ref, "vocabulary"))).then(
() => setPicking(false),
(e) => setError(errorMessage(e)),
);
}}
/>
)}
{error && <p role="alert">{error}</p>}
</section>
);
}
export function VocabularyLabel({ reference }: { reference: ExactReference }) {
const result = useCaminoQuery(queries.VocabularyView, {
root: asInterface(reference, "vocabulary"),
input: { search: "", after: null },
admission: { query: queries.VocabularyInputs, input: { search: "", after: null } },
});
const node = result.status === "ready" ? result.data.root : null;
return (
<ObjectLink reference={reference} href={objectPresentationURL(reference)}>
{node?.name ?? "Vocabulary"}
</ObjectLink>
);
}
function VocabularyPicker({
library,
value,
selected,
close,
}: {
library: ExactReference;
value?: ExactReference | null;
selected(ref: ExactReference): void;
close(): void;
}) {
const [request, setRequest] = useState<ObjectPickerRequest>({ location: "lists", search: "", cursor: null }),
[creating, setCreating] = useState(false),
[error, setError] = useState("");
const input = { search: request.search, after: request.cursor };
const result = useCaminoQuery(queries.VocabularyListsView, {
root: library,
input,
admission: { query: queries.VocabularyListsInputs, input },
});
const api = useInterface({ create: interfaces.LanguageReader.addVocabulary }, library);
const rows = result.status === "ready" ? result.data.root.vocabularies : null;
return (
<ObjectPicker
title="Choose a vocabulary list"
acceptedInterfaces={["interface:reading:vocabulary@1"]}
value={value}
locations={[{ id: "lists", title: "Vocabulary lists", icon: <Icon name="book" /> }]}
request={request}
onBrowse={setRequest}
page={
error
? { status: "error", error }
: rows
? {
status: "ready",
items: rows.entries.map(({ node }) => ({
reference: referenceOf(node),
title: node.name,
kind: "Vocabulary list",
description: "Share this list between readers",
open: { href: objectPresentationURL(referenceOf(node)) },
})),
nextCursor: rows.pageInfo.hasNextPage ? rows.pageInfo.endCursor : null,
}
: result.status === "error"
? { status: "error", error: errorMessage(result.error) }
: { status: "loading" }
}
onSelect={selected}
onCancel={close}
renderPreview={(item) => (
<div className="lr-vocab-picker-preview">
<Icon name="book" size={32} />
<h3>{item.title}</h3>
<p>Attach this list to the reader. Other readers can continue to use it.</p>
<VocabularyPreview reference={item.reference} />
</div>
)}
create={{
label: creating ? "Creating…" : "New vocabulary list",
disabled: creating || api.status !== "ready",
action: () => {
if (api.status !== "ready") return;
setCreating(true);
void accepted(api.value.create.call("New vocabulary list"))
.then(selected, (e) => setError(errorMessage(e)))
.finally(() => setCreating(false));
},
}}
/>
);
}
function VocabularyPreview({ reference }: { reference: ExactReference }) {
const result = useCaminoQuery(queries.VocabularyView, {
root: asInterface(reference, "vocabulary"),
input: { search: "", after: null },
admission: { query: queries.VocabularyInputs, input: { search: "", after: null } },
});
if (result.status !== "ready") return <p role="status">Loading preview…</p>;
const words = result.data.root.words;
return (
<div className="lr-vocab-preview">
<p>
{words.entries.length}
{words.pageInfo.hasNextPage ? "+" : ""} saved words
</p>
<div>
{words.entries.slice(0, 24).map(({ node }) => (
<span key={referenceOf(node).object}>{node.text}</span>
))}
</div>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { sameCacheConfiguration } from "./cache.js";
import type { Range } from "./canvas/types.js";
export type Feature = {
processor: string;
generation: string;
scope: string;
reveal: string;
configRevision: number;
enabled: boolean;
};
export type Job = { sourceRevision: string; featureRevision: number; start: number; end: number; status: string };
export function processorMatches(job: { key: string }, revision: string, previousRevisions: readonly string[] = []) {
return [revision, ...previousRevisions].some((revision) => job.key.startsWith(revision + ":"));
}
export function sameScope(
job: Job & { configuration: string },
sourceRevision: string,
configuration: string,
scope: Range,
) {
return (
job.sourceRevision === sourceRevision &&
sameCacheConfiguration(job.configuration, configuration) &&
job.start === scope.start &&
job.end === scope.end
);
}
type SemanticJob = {
configuration: string;
data: {
key: string;
kind: string;
status: string;
sourceRevision: string;
start: number;
end: number;
createdAt: string;
};
};
/** A successful task replaces its whole semantic scope, including omitted
* annotations. Deduplicating individual items resurrects removed old output. */
export function selectSuccessfulAnnotationJobs<T extends SemanticJob>(
jobs: readonly T[],
matches: (job: T) => boolean,
) {
const scopes = new Map<string, string[]>();
return jobs
.filter((job) => job.data.kind === "annotation" && job.data.status === "ready" && matches(job))
.sort((a, b) => b.data.createdAt.localeCompare(a.data.createdAt))
.filter((job) => {
const key = JSON.stringify([job.data.sourceRevision, job.data.start, job.data.end]);
const configurations = scopes.get(key) ?? [];
if (configurations.some((configuration) => sameCacheConfiguration(configuration, job.configuration)))
return false;
configurations.push(job.configuration);
scopes.set(key, configurations);
return true;
});
}
/** Index pointers are authoritative even if an older successful attempt was
* explicitly restored. Keep attempt history for progress/errors, not as a
* second successful result for the same indexed key. */
export function mergeCachedAnnotationEntries<
T extends {
node: { _camino: { ref: { object: string } }; data: { key: string; kind: string; status: string } };
},
>(cached: readonly T[], history: readonly T[]) {
const keys = new Set<string>();
const entries = new Map<string, T>();
for (const entry of cached) {
if (keys.has(entry.node.data.key)) continue;
keys.add(entry.node.data.key);
entries.set(entry.node._camino.ref.object, entry);
}
for (const entry of history) {
if (entry.node.data.kind === "annotation" && entry.node.data.status === "ready" && keys.has(entry.node.data.key))
continue;
if (!entries.has(entry.node._camino.ref.object)) entries.set(entry.node._camino.ref.object, entry);
}
return [...entries.values()];
}
export type PreparationScope = { key: string; configuration: string; range: Range; local: boolean };
/** Independent scopes may share the two model slots. Nearby features take turns
* at equal distance; completed, failed and already-submitted work is excluded. */
export function preparationQueue<T extends PreparationScope>(
plans: T[],
visible: Range,
occupied: number,
submitted: ReadonlySet<string>,
cached: ReadonlySet<string>,
) {
const groups = new Map<string, T[]>(),
seen = new Set<string>();
for (const plan of plans) {
if (plan.range.end <= plan.range.start || seen.has(plan.key) || submitted.has(plan.key) || cached.has(plan.key))
continue;
seen.add(plan.key);
const group = groups.get(plan.configuration) ?? [];
group.push(plan);
groups.set(plan.configuration, group);
}
const waiting: T[] = [];
for (let index = 0; ; index++) {
let found = false;
for (const group of groups.values())
if (group[index]) {
waiting.push(group[index]);
found = true;
}
if (!found) break;
}
const distance = ({ range }: T) =>
range.end <= visible.start
? visible.start - range.end + 1
: range.start >= visible.end
? range.start - visible.end + 1
: 0;
waiting.sort((a, b) => distance(a) - distance(b));
let slots = Math.max(0, 2 - occupied);
const admit = waiting.filter((plan) => plan.local || slots-- > 0);
return { waiting, admit };
}
/** Intent scheduler for heavy hover work. It never owns the durable model job:
* leaving hover cancels intent; an admitted result remains useful in its cache.
* Local/section/cached reveal happens outside this scheduler, immediately.
*/
export class HoverIntent {
private timer: ReturnType<typeof setTimeout> | null = null;
private generation = 0;
constructor(private readonly delay = 420) {}
cancel() {
this.generation++;
if (this.timer !== null) clearTimeout(this.timer);
this.timer = null;
}
schedule(callback: () => void) {
this.cancel();
const generation = this.generation;
this.timer = setTimeout(() => {
this.timer = null;
if (generation === this.generation) callback();
}, this.delay);
}
}
+245
View File
@@ -0,0 +1,245 @@
import { layoutText } from "../src/canvas/layout.js";
import { canvasMeasure } from "../src/canvas/measure.js";
import { rectIntersects, type Annotation, type Font } from "../src/canvas/types.js";
import { drawText, drawUnderline } from "../src/canvas/draw.js";
import type { AnnotationBox, UnderlinePattern } from "../src/canvas/types.js";
export function sourceGeometry(
text: string,
width: number,
font: Font = { size: 23, family: '"Noto Serif CJK SC", "Songti SC", Georgia, serif' },
) {
const context = document.createElement("canvas").getContext("2d")!;
return layoutText({
text,
width,
font,
measure: canvasMeasure(context),
}).lines.flatMap((line) => line.glyphs);
}
export function verifyBrowserMetrics() {
const canvas = document.createElement("canvas"),
context = canvas.getContext("2d")!;
const text = " 阅读中文,👩🏽‍💻 café é。\n\n另一段阅读材料。";
let cases = 0;
for (const width of [30, 60, 120, 320, 640])
for (const size of [18, 23, 34])
for (const count of [2, 8, 30]) {
const annotations: Annotation[] = Array.from({ length: count }, (_, i) => ({
id: String(i),
feature: "same",
start: i % 5,
end: 6 + (i % 10),
style: "overhead",
text: i % 2 ? "Some long and overlapping explanatory text" : "阅读注解",
color: "#53796c",
fontSize: 10 + (i % 4) * 7,
}));
const layout = layoutText({
text,
width,
font: { size, family: '"Noto Serif CJK SC",Georgia,serif' },
annotations,
measure: canvasMeasure(context),
});
const boxes = layout.lines.flatMap((line) => line.overhead);
for (const line of layout.lines)
if (new Set(line.overhead.map((box) => box.baseline)).size > 1)
throw Error("One annotation source was split into multiple rows");
for (const box of boxes) {
if (box.x < 0 || box.x + box.width > width + 0.01) throw Error("Overhead box outside browser viewport");
for (const other of boxes)
if (box !== other && rectIntersects(box, other))
throw Error("Overhead text overlap with real browser metrics");
for (const line of layout.lines)
for (const glyph of line.glyphs)
if (rectIntersects(box, glyph)) throw Error("Overhead text overlaps source with browser metrics");
}
cases++;
}
const pinyin = ["cháng", "xiǎoyǔ", "zǒuchū", "dìtiě", "zhàn"].map(
(text, i): Annotation => ({
id: String(i),
feature: "pinyin",
start: i * 2,
end: i * 2 + 2,
style: "overhead",
text,
color: "green",
}),
);
const adjacent = layoutText({
text: "一场小雨走出地铁车站",
width: 640,
annotations: pinyin,
measure: canvasMeasure(context),
});
if (
adjacent.lines[0].overhead.length !== pinyin.length ||
new Set(adjacent.lines[0].overhead.map((box) => box.baseline)).size !== 1
)
throw Error("Adjacent pinyin zig-zags with real browser metrics");
const long =
"This is a very long sentence translation that should share source flow across several physical text lines.";
const twoSources = layoutText({
text: "一场小雨。",
width: 120,
annotations: [
...pinyin.slice(0, 2),
{
id: "sentence",
feature: "translation",
start: 0,
end: 5,
style: "overhead",
text: long,
color: "red",
priority: 1,
},
],
measure: canvasMeasure(context),
});
const sentence = twoSources.lines
.flatMap((line) => line.overhead)
.filter((box) => box.annotation.feature === "translation");
if (sentence.length < 3 || sentence.flatMap((box) => box.lines).join("") !== long)
throw Error("Long translation did not flow intact across text lines");
for (const line of twoSources.lines)
for (const feature of ["pinyin", "translation"])
if (
new Set(line.overhead.filter((box) => box.annotation.feature === feature).map((box) => box.baseline)).size > 1
)
throw Error("A word/sentence source gained a collision row");
return { cases, fixedRows: true, font: context.font };
}
import { readingPages } from "../src/canvas/pages.js";
export function sourcePages(text: string, width: number, height: number) {
const context = document.createElement("canvas").getContext("2d")!;
return readingPages(
layoutText({
text,
width,
font: { size: 23, family: '"Noto Serif CJK SC", "Songti SC", Georgia, serif' },
measure: canvasMeasure(context),
}),
height,
);
}
/** Exercise actual canvas rasterization: patterned strokes must stay in their
* band, include gaps/waves, and leave layout and selection geometry unchanged. */
export function verifyUnderlineStyles() {
const patterns: UnderlinePattern[] = ["solid", "dashed", "squiggly"];
const canvas = document.createElement("canvas");
canvas.width = 100;
canvas.height = 40;
const context = canvas.getContext("2d")!;
for (const pattern of patterns) {
context.clearRect(0, 0, canvas.width, canvas.height);
const box: AnnotationBox = {
x: 10,
y: 10,
width: 72,
height: 3,
layer: 0,
baseline: 0,
lines: [],
font: { family: "serif", size: 23 },
annotation: {
id: pattern,
feature: "test",
start: 0,
end: 1,
text: "",
style: "underline",
color: "#53796c",
underlinePattern: pattern,
},
};
drawUnderline(context, box);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
const columns = new Set<number>(),
rows = new Set<number>();
for (let y = 0; y < canvas.height; y++)
for (let x = 0; x < canvas.width; x++)
if (pixels[(y * canvas.width + x) * 4 + 3]) {
if (x < 10 || x >= 82 || y < 10 || y >= 13) throw Error("Underline escapes its decoration band");
columns.add(x);
rows.add(y);
}
if (columns.size < 30) throw Error("Underline was not drawn");
if (pattern === "solid" && columns.size !== 72) throw Error("Solid underline has gaps");
if (pattern === "dashed" && columns.size >= 72) throw Error("Dashed underline has no gaps");
if (pattern === "squiggly" && rows.size !== 3) throw Error("Squiggly underline is flat");
if (context.getLineDash().length) throw Error("Underline leaked its dash state");
}
const text = " Several words wrapping onto another line.\n\tA new paragraph.";
let geometry = "";
for (const pattern of patterns) {
const layout = layoutText({
text,
width: 120,
measure: canvasMeasure(context),
annotations: [
{
id: "underlined",
feature: "test",
start: 0,
end: text.length,
text: "",
style: "underline",
color: "#53796c",
underlinePattern: pattern,
},
],
});
const current = JSON.stringify(
layout.lines.map(({ glyphs, y, height, decorations }) => ({
glyphs,
y,
height,
decorations: decorations.map(({ x, y, width, height }) => ({ x, y, width, height })),
})),
);
if (geometry && current !== geometry) throw Error("Underline pattern changes layout");
geometry = current;
for (const line of layout.lines)
if (/^\s/u.test(line.glyphs[0]?.text ?? "")) throw Error("Rendered line begins with a space");
}
return { patterns: patterns.length, clipped: true, stableGeometry: true };
}
export function underlinePreview() {
const canvas = document.createElement("canvas");
canvas.width = 440;
canvas.height = 200;
const context = canvas.getContext("2d")!;
context.fillStyle = "#fafaf6";
context.fillRect(0, 0, canvas.width, canvas.height);
context.translate(20, 10);
const text = "Solid underline\nDashed underline\nSquiggly underline";
const starts = [0, text.indexOf("Dashed"), text.indexOf("Squiggly")];
const annotations = (["solid", "dashed", "squiggly"] as const).map(
(pattern, i): Annotation => ({
id: pattern,
feature: pattern,
start: starts[i],
end: i === 2 ? text.length : starts[i + 1] - 1,
text: "",
style: "underline",
color: "#53796c",
underlinePattern: pattern,
}),
);
drawText(
context,
layoutText({
text,
width: 400,
font: { family: "Georgia, serif", size: 23 },
measure: canvasMeasure(context),
annotations,
}),
);
return canvas.toDataURL();
}
+925
View File
@@ -0,0 +1,925 @@
import assert from "node:assert/strict";
import { layoutText } from "../src/canvas/layout.js";
import { graphemes, snapOffset, wordAt, hoverWordAt } from "../src/canvas/text.js";
import { textInteractionAt } from "../src/canvas/interaction.js";
import { contextTarget, hitTest, moveSelection, selectionRange, selectionRects } from "../src/canvas/selection.js";
import { lineSpans } from "../src/canvas/line-breaks.js";
import { rectIntersects, type Annotation, type Font, type Layout } from "../src/canvas/types.js";
const measure = (text: string, font: Font) => ({
width: Array.from(text).length * font.size * 0.6,
ascent: font.size * 0.9,
descent: font.size * 0.3,
});
function verify(layout: Layout) {
assert(Number.isFinite(layout.height));
const boxes = layout.lines.flatMap((line) => line.overhead);
for (const box of boxes) {
assert(box.x >= 0 && box.x + box.width <= layout.width + 0.001);
assert(box.y >= 0 && box.y + box.height < layout.height);
for (const other of boxes) if (box !== other) assert(!rectIntersects(box, other), JSON.stringify({ box, other }));
for (const line of layout.lines) for (const glyph of line.glyphs) assert(!rectIntersects(box, glyph));
}
for (const line of layout.lines)
for (const feature of new Set(
line.overhead.map((box) => `${box.annotation.placement ?? "above"}:${box.annotation.feature}`),
)) {
const members = line.overhead.filter(
(box) => `${box.annotation.placement ?? "above"}:${box.annotation.feature}` === feature,
);
assert.equal(new Set(members.map((box) => box.layer)).size, 1, "A source has exactly one annotation row");
assert.equal(new Set(members.map((box) => box.baseline)).size, 1, "Same-source labels share their baseline");
}
for (const line of layout.lines)
for (const glyph of line.glyphs) {
assert.equal(layout.text.slice(glyph.start, glyph.end), glyph.text);
assert.equal(hitTest(layout, glyph.x + glyph.advance * 0.2, glyph.y + glyph.height / 2), glyph.start);
assert.equal(hitTest(layout, glyph.x + glyph.advance * 0.8, glyph.y + glyph.height / 2), glyph.end);
}
}
const text = " 她在北京学习中文。👩🏽‍💻 e\u0301\n\n一杯咖啡,两个朋友。\n最后一行";
for (const width of [30, 45, 80, 140, 320, 680]) {
for (const count of [0, 1, 3, 7, 20]) {
const units = graphemes(text).filter((unit) => !/[\r\n]/.test(unit.text));
const annotations: Annotation[] = Array.from({ length: count }, (_, index) => {
const start = units[index % units.length].start;
return {
id: String(index),
feature: String(index % 3),
start,
end: Math.min(text.length, start + 10),
text: index % 2 ? "a long overhead explanation" : "běi jīng",
style: "overhead",
color: "blue",
fontSize: [10, 13, 30][index % 3],
priority: index % 4,
};
});
verify(layoutText({ text, width, annotations, measure }));
}
}
// Exhaust all pairs of nested/adjacent source spans on a short paragraph.
let cases = 0;
for (const width of [36, 100, 280])
for (let a = 0; a < 6; a++)
for (let b = a + 1; b <= 6; b++)
for (let c = 0; c < 6; c++)
for (let d = c + 1; d <= 6; d++) {
const annotations: Annotation[] = [
{
id: "a",
feature: "same",
start: a,
end: b,
text: "explanation one",
fontSize: 11,
style: "overhead",
color: "red",
},
{
id: "b",
feature: "same",
start: c,
end: d,
text: "另一层注解",
fontSize: 23,
style: "overhead",
color: "green",
},
];
verify(layoutText({ text: "阅读中文的人", width, annotations, measure }));
cases++;
}
// Ordinary adjacent pinyin never alternates vertical positions. Source padding
// grows around the word, keeping its characters together rather than spreading
// the word's internal glyphs to make room for a pronunciation.
const pinyin: Annotation[] = ["cháng", "xiǎoyǔ", "zǒuchū", "dìtiě", "zhàn"].map((text, index) => ({
id: String(index),
feature: "pinyin",
start: index * 2,
end: index * 2 + 2,
text,
style: "overhead",
color: "green",
}));
const aligned = layoutText({ text: "一场小雨走出地铁车站", width: 680, annotations: pinyin, measure });
verify(aligned);
assert.equal(aligned.lines.filter((line) => line.glyphs.length).length, 1);
assert.equal(new Set(aligned.lines[0].overhead.map((box) => box.baseline)).size, 1);
assert.equal(aligned.lines[0].overhead.length, pinyin.length);
for (const annotation of pinyin) {
const glyphs = aligned.lines[0].glyphs.filter(
(glyph) => glyph.start >= annotation.start && glyph.end <= annotation.end,
);
assert(Math.abs(glyphs[1].x - glyphs[0].x - glyphs[0].width) < 0.001);
}
// Long word explanations and sentence translations flow through physical text
// lines, with word/sentence sources on two fixed rows instead of a wrapped box
// creating more rows above a single line of source text.
const longWord = "a surprisingly long explanation with several complete words";
const longSentence =
"The complete sentence has an even longer translation which asks the source text for more horizontal space.";
const flowing = layoutText({
text: "一本书。",
width: 90,
annotations: [
{ id: "word", feature: "words", start: 1, end: 2, text: longWord, style: "overhead", color: "green", priority: 0 },
{
id: "sentence",
feature: "sentences",
start: 0,
end: 4,
text: longSentence,
style: "overhead",
color: "red",
priority: 1,
},
],
measure,
});
verify(flowing);
for (const [feature, expected] of [
["words", longWord],
["sentences", longSentence],
]) {
const boxes = flowing.lines.flatMap((line) => line.overhead).filter((box) => box.annotation.feature === feature);
assert(boxes.length > 1, "Long labels use subsequent physical lines");
assert.equal(boxes.flatMap((box) => box.lines).join(""), expected, "Wrapping retains all label content");
assert.equal(new Set(boxes.map((box) => box.layer)).size, 1);
}
assert(flowing.height > layoutText({ text: "一本书。", width: 90, measure }).height * 4);
const mixed = layoutText({
text: "阅读中文的人",
width: 600,
annotations: [
{ id: "small", feature: "words", start: 0, end: 2, text: "read", fontSize: 10, style: "overhead", color: "red" },
{ id: "large", feature: "words", start: 2, end: 4, text: "Chinese", fontSize: 30, style: "overhead", color: "red" },
],
measure,
});
verify(mixed);
assert.equal(
new Set(mixed.lines[0].overhead.map((box) => box.baseline)).size,
1,
"Mixed font sizes share a source baseline",
);
const shortAtWrap = layoutText({
text: "阅读中",
width: 30,
annotations: [{ id: "short", feature: "words", start: 2, end: 3, text: "x", style: "overhead", color: "green" }],
measure,
});
// Word glosses sit nearest the base; a sentence-only continuation must not
// reserve the absent word lane. Nested reservations center labels on the ink.
const frenchText = "Un libraire lui propose un café.\nIls parlent des voyages et des livres qu'ils aiment.";
const sentenceEnd = frenchText.indexOf("\n");
const frenchAnnotations: Annotation[] = [
{
id: "word",
feature: "words",
start: 0,
end: 2,
text: "a longer article gloss",
style: "overhead",
color: "blue",
priority: 0,
},
{
id: "sentence",
feature: "sentences",
start: 0,
end: sentenceEnd,
text: "A bookseller offers her a coffee.",
style: "overhead",
color: "brown",
priority: 1,
},
{
id: "next",
feature: "sentences",
start: sentenceEnd + 1,
end: frenchText.length,
text: "They talk about trips and books.",
style: "overhead",
color: "brown",
priority: 1,
},
];
const frenchLayout = layoutText({ text: frenchText, width: 1600, annotations: frenchAnnotations, measure });
verify(frenchLayout);
const firstFrench = frenchLayout.lines[0];
const wordBox = firstFrench.overhead.find((box) => box.annotation.id === "word")!;
const sentenceBox = firstFrench.overhead.find((box) => box.annotation.id === "sentence")!;
assert(wordBox.y > sentenceBox.y, "Word lane sits closer to source than sentence lane");
const firstInk = firstFrench.glyphs[0],
lastInk = firstFrench.glyphs.findLast((glyph) => glyph.end === sentenceEnd)!;
assert(
Math.abs(sentenceBox.x + sentenceBox.width / 2 - (firstInk.x + lastInk.x + lastInk.width) / 2) < 0.001,
"Sentence label centers on source ink despite asymmetric word padding",
);
const secondFrench = frenchLayout.lines.find((line) => line.glyphs.some((glyph) => glyph.start === sentenceEnd + 1))!;
assert.equal(secondFrench.overhead.length, 1);
assert(
Math.abs(secondFrench.y - secondFrench.overhead[0].y - secondFrench.overhead[0].height - 5) < 0.001,
"A sentence-only line has no empty word lane",
);
const beforeFrench = layoutText({ text: frenchText, width: 1600, measure });
const afterFrench = layoutText({ text: frenchText, width: 1600, annotations: [], measure });
assert.deepEqual(afterFrench, beforeFrench, "Dismissed annotations release all horizontal and vertical reservations");
assert(
shortAtWrap.lines.find((line) => line.glyphs.some((glyph) => glyph.start === 2))!.overhead.length,
"A short annotation follows its wrapped source glyph",
);
const layout = layoutText({ text, width: 160, measure });
let selection = { anchor: 0, focus: 0 };
for (const unit of graphemes(text)) {
selection = moveSelection(layout, selection, "ArrowRight", true);
assert.equal(selection.focus, unit.end);
}
assert.equal(text.slice(selectionRange(selection).start, selectionRange(selection).end), text);
assert(selectionRects(layout, selection).length > 1);
assert.equal(snapOffset("👩🏽‍💻", 2), "👩🏽‍💻".length);
assert.equal(snapOffset("e\u0301", 1, -1), 0);
assert.equal(wordAt("北京的朋友", 1).end, 2);
assert.equal(layoutText({ text: "", width: 100, measure }).lines.length, 1);
assert.throws(() => layoutText({ text: "a", width: 0, measure }));
console.log(`PASS canvas: ${cases} exhaustive overlap cases, mixed heights, wrapping, Unicode, offsets and selection`);
import { rangeOutline, rangeRects } from "../src/canvas/outline.js";
import { drawText, labelGuideSegments } from "../src/canvas/draw.js";
const fontCalls: string[] = [];
const mixedFonts = layoutText({
text: "你好世界",
width: 400,
measure: (text, font) => {
fontCalls.push(font.family);
return measure(text, font);
},
annotations: [
{ ...pinyin[0], start: 0, end: 2, fontFamily: "serif", fontSize: 12 },
{ ...pinyin[1], start: 2, end: 4, fontFamily: "monospace", fontSize: 30 },
],
});
verify(mixedFonts);
assert(fontCalls.includes("serif") && fontCalls.includes("monospace"));
assert.equal(mixedFonts.lines[0].overhead[0].baseline, mixedFonts.lines[0].overhead[1].baseline);
const ink = layoutText({
text: "你好",
width: 100,
measure,
annotations: [{ ...pinyin[0], start: 0, end: 1, style: "color", color: "#112233" }],
});
const draws: { text: string; color: string }[] = [];
let rectangles = 0;
const context = {
save() {},
restore() {},
translate() {},
fillStyle: "",
beginPath() {},
rect() {},
clip() {},
globalAlpha: 1,
font: "",
fillRect() {
rectangles++;
},
fillText(text: string) {
draws.push({ text, color: this.fillStyle });
},
} as unknown as CanvasRenderingContext2D;
drawText(context, ink);
assert.deepEqual(draws, [
{ text: "你", color: "#112233" },
{ text: "好", color: "#252923" },
]);
assert.equal(rectangles, 0, "Text color never draws a background");
draws.length = 0;
drawText(context, ink, { visible: () => false });
assert(
draws.every((draw) => draw.color === "#252923"),
"Reveal timing also controls source ink",
);
for (const width of [30, 80, 200, 500]) {
const layout = layoutText({ text: "第一行阅读中文。\n第二行。\n第三行继续阅读。", width, measure });
for (let start = 0; start < layout.text.length; start++)
for (let end = start + 1; end <= layout.text.length; end++) {
const path = rangeOutline(layout, { start, end });
assert(!/NaN|Infinity/.test(path));
assert.equal(
(path.match(/M/g) ?? []).length,
rangeRects(layout, { start, end }).length ? 1 : 0,
"One contour across wrapped lines",
);
if (path) assert(path.endsWith("Z"));
}
}
assert.equal(rangeOutline(ink, { start: 2, end: 2 }), "");
const sourceRects = rangeRects(mixedFonts, { start: 0, end: 4 });
assert.equal(sourceRects[0].y, mixedFonts.lines[0].glyphs[0].y, "Contours surround source, below overhead rows");
console.log("PASS source ink reveal, mixed annotation fonts and exhaustive multi-line contour geometry");
import { readingPages, readingWindow, pageAt } from "../src/canvas/pages.js";
const wrapSource =
"Before. Le titre me séduisit: je les emportai chez moi, avec la permission de M. le conservateur; bien entendu, je les dévorai.";
const wrappedLabel: Annotation = {
id: "wrapped-sentence",
feature: "sentence",
start: 8,
end: wrapSource.length,
text: "The title enticed me: I took them home, with the curator's permission; of course, I devoured them.",
style: "overhead",
color: "brown",
};
const centeredWrap = layoutText({ text: wrapSource, width: 380, measure, annotations: [wrappedLabel] });
verify(centeredWrap);
const labelBoxes = centeredWrap.lines.flatMap((l) => l.overhead);
assert(labelBoxes.length > 1, "Sentence translation wraps over source lines rather than sitting at its start");
assert(
centeredWrap.lines.findIndex((l) => l.overhead.includes(labelBoxes[0])) * centeredWrap.width + labelBoxes[0].x >
centeredWrap.lines[0].glyphs.find((g) => g.start === 8)!.x + 10,
"Wrapped translation consumes left centering space",
);
const pageSource = Array.from({ length: 70 }, (_, i) => `A sentence number ${i} with a few words.`).join(" ");
let pageOffset = 0,
visited = "";
for (let i = 0; pageOffset < pageSource.length && i < 200; i++) {
const dynamic = layoutText({ text: pageSource, width: i % 2 ? 270 : 380, measure, breakAt: pageOffset });
const ps = readingPages(dynamic, 180, pageOffset),
current = ps[pageAt(ps, pageOffset)];
assert.equal(current.start, pageOffset, "Reflow preserves the source page start");
assert(current.end > pageOffset, "Next advances the source position");
visited += pageSource.slice(pageOffset, current.end);
pageOffset = current.end;
}
assert.equal(visited, pageSource, "Repeated resize and next loses or repeats no text");
draws.length = 0;
drawText(context, ink, { hover: { start: 0, end: 1 } });
assert.deepEqual(draws, [
{ text: "你", color: "#39745b" },
{ text: "好", color: "#252923" },
]);
for (const width of [60, 250, 600])
for (const height of [80, 300, 600]) {
const text = Array.from({ length: 100 }, (_, i) => `第${i}行,阅读👩🏽‍💻原文。`).join("\n");
const layout = layoutText({ text, width, measure });
const pages = readingPages(layout, height);
assert(pages.length > 1);
assert.equal(pages.map((page) => text.slice(page.start, page.end)).join(""), text);
for (const [i, page] of pages.entries()) {
assert.equal(pageAt(pages, page.start), i);
assert.equal(pageAt(pages, page.end - 1), i);
const window = readingWindow(layout, pages, i, "pages", page.top, height);
assert.deepEqual(window.visible, { start: page.start, end: page.end });
assert.equal(window.nearby.start, pages[Math.max(0, i - 1)].start);
assert.equal(window.nearby.end, pages[Math.min(pages.length - 1, i + 1)].end);
assert(window.nearby.end - window.nearby.start < text.length || pages.length <= 3);
}
const middle = pages[Math.floor(pages.length / 2)];
const scroll = readingWindow(layout, pages, 0, "scroll", middle.top, height);
assert(scroll.nearby.start <= scroll.visible.start && scroll.nearby.end >= scroll.visible.end);
assert(scroll.nearby.start > 0 && scroll.nearby.end < text.length);
}
const tallPages = readingPages(flowing, 30);
assert.equal(tallPages.map((page) => flowing.text.slice(page.start, page.end)).join(""), flowing.text);
assert(
tallPages.every((page) => page.bottom > page.top),
"Continuation labels stay with a source page",
);
console.log(
"PASS hover glyph color, source-stable pages, neighboring-page windows, bounded scroll windows and annotation continuation",
);
// Intermediate rows and paragraph gaps share a quiet, full-width band.
for (const separator of ["\n\n", "\r\n\r\n", "\n \n", "\n\n\n"]) {
const text = "一个很长的段落需要换行,继续阅读。" + separator + "第二段落也需要换行。" + separator + "结束。";
for (const width of [80, 200, 1000]) {
const layout = layoutText({ text, width, measure });
const path = rangeOutline(layout, { start: 0, end: text.length });
assert.equal((path.match(/M/g) ?? []).length, 1, "One contour spans paragraphs");
assert.equal((path.match(/Z/g) ?? []).length, 1);
assert(!/NaN|Infinity/.test(path));
assert((path.match(/Q/g) ?? []).length <= 8, "At most two end-row notches, irrespective of paragraph count");
assert(path.includes(String(width - 1) + ","), "Intermediate rows use the full available width");
}
}
console.log("PASS processing contours span paragraph gaps with bounded, rounded corners");
import { tooltipPosition } from "../src/canvas/tooltip-position.js";
for (const viewport of [
{ width: 1440, height: 1000 },
{ width: 320, height: 480 },
]) {
const width = Math.min(300, viewport.width - 24),
height = 120;
for (const x of [0, 50, viewport.width - 10])
for (const y of [0, 50, viewport.height - 20]) {
const position = tooltipPosition({ x, y, width: 20, height: 30 }, width, height, viewport);
assert(position.left >= 12 && position.left + width <= viewport.width - 12);
assert(position.top >= 12 && position.top + height <= viewport.height - 12);
assert(position.arrow >= 16 && position.arrow <= width - 16);
}
}
assert.equal(
tooltipPosition({ x: 100, y: 200, width: 50, height: 20 }, 200, 100, { width: 800, height: 600 }).side,
"above",
);
assert.equal(
tooltipPosition({ x: 100, y: 10, width: 50, height: 20 }, 200, 100, { width: 800, height: 600 }).side,
"below",
);
const tooltipLayout = layoutText({
text: "你好",
width: 200,
measure,
annotations: [
{ id: "tip", feature: "tip", start: 0, end: 2, text: "A long tooltip", style: "tooltip", color: "#53796c" },
],
});
assert.equal(
tooltipLayout.height,
layoutText({ text: "你好", width: 200, measure }).height,
"React tooltip never displaces canvas text",
);
assert.equal(tooltipLayout.lines[0].decorations.length, 0);
console.log("PASS tooltip flipping, viewport containment, arrow anchoring and unchanged canvas layout");
// Short source spans and their one-line overhead labels move together.
const welcome: Annotation = {
id: "welcome",
feature: "words",
start: 8,
end: 10,
text: "huānyíng",
style: "overhead",
color: "green",
};
for (const width of [100, 140, 180]) {
const layout = layoutText({ text: "星期六我们读书。欢迎来到书店。", width, annotations: [welcome], measure });
const sourceRows = layout.lines.filter((line) => line.glyphs.some((g) => g.start >= 8 && g.end <= 10));
assert.equal(sourceRows.length, 1, "Welcome stays on one physical line");
const labelRow = layout.lines.find((line) => line.overhead.some((box) => box.annotation.id === "welcome"));
assert.equal(labelRow, sourceRows[0], "Short overhead text stays with its source");
for (const page of readingPages(layout, 60)) {
for (const part of new Intl.Segmenter(undefined, { granularity: "word" }).segment(layout.text)) {
if (part.isWordLike && part.segment.length <= 2)
assert(
!(page.end > part.index && page.end < part.index + part.segment.length),
"Page doesn't cut a fitting source word",
);
}
}
}
const replacements = layoutText({
text: "我爱书店。",
width: 300,
measure,
annotations: [
{ id: "r", feature: "r", start: 2, end: 4, text: "bookstore", style: "replace", color: "green" },
{ id: "b", feature: "b", start: 0, end: 1, text: "", style: "bold", color: "green" },
{ id: "i", feature: "i", start: 1, end: 2, text: "", style: "italic", color: "green" },
],
});
assert.equal(
replacements.lines
.flatMap((l) => l.glyphs)
.map((g) => g.text)
.join(""),
"我爱bookstore。",
);
assert.equal(replacements.text, "我爱书店。", "Canonical copy/selection source is unchanged");
assert.equal(replacements.lines[0].glyphs[0].font?.weight, "bold");
assert.equal(replacements.lines[0].glyphs[1].font?.italic, true);
const replacement = replacements.lines[0].glyphs.find((g) => g.text === "bookstore")!;
assert.equal(hitTest(replacements, replacement.x + replacement.advance * 0.2, replacement.y + 2), 2);
assert.equal(hitTest(replacements, replacement.x + replacement.advance * 0.8, replacement.y + 2), 4);
const layeredOutline = layoutText({
text: "阅读中文。欢迎继续阅读。",
width: 100,
measure,
annotations: [
{
id: "lane",
feature: "lane",
start: 0,
end: 12,
text: "a long label that wraps over multiple lines",
fontSize: 30,
style: "overhead",
color: "green",
},
],
});
const paths = rangeOutline(layeredOutline, { start: 0, end: layeredOutline.text.length });
assert(!/NaN|Infinity/.test(paths));
assert.equal((paths.match(/M/g) ?? []).length, 1, "Overhead rows stay inside the broad contour");
console.log(
"PASS word/label affinity, whole-word page boundaries, text replacement source mapping, bold/italic, and overhead-safe processing outlines",
);
const expanded = layoutText({
text: "书。",
width: 50,
measure,
annotations: [
{
id: "long-replacement",
feature: "replace",
start: 0,
end: 1,
text: "a very long explanation\nwith another line",
style: "replace",
color: "green",
},
{ id: "bold-replacement", feature: "bold", start: 0, end: 1, text: "", style: "bold", color: "green" },
],
});
assert.equal(
expanded.lines
.flatMap((l) => l.glyphs)
.map((g) => g.text)
.join("")
.replace(/\s/g, ""),
"averylongexplanationwithanotherline。",
);
assert(
expanded.lines
.flatMap((l) => l.glyphs)
.filter((g) => g.start === 0)
.every((g) => g.font?.weight === "bold"),
);
assert(readingPages(expanded, 60).length > 1, "Expanded replacements can span physical pages");
const expandedPages = readingPages(expanded, 60);
assert(
expandedPages.every((page) => page.end > page.start),
"Replacement pages retain their shared source span",
);
assert.equal(pageAt(expandedPages, 0), 0, "Opening an expanded replacement starts on its first physical page");
console.log("PASS multiline and expanded replacements retain content and inherited typography");
import { sharedPageSize } from "../src/canvas/page-size.js";
const localSize = { width: 900, height: 700 };
const reports = [
{ client: "wide", width: 900, height: 700, expiresAt: 200 },
{ client: "narrow", width: 480, height: 600, expiresAt: 150 },
{ client: "short", width: 800, height: 360, expiresAt: 200 },
{ client: "dead", width: 10, height: 10, expiresAt: 50 },
];
assert.deepEqual(sharedPageSize(localSize, reports, "wide", 100), { width: 480, height: 360 });
assert.deepEqual(sharedPageSize(localSize, reports, "wide", 160), { width: 800, height: 360 });
assert.deepEqual(sharedPageSize(localSize, reports, "wide", 200), localSize);
assert.deepEqual(
sharedPageSize({ width: 1100, height: 800 }, [reports[0]], "wide", 100),
{ width: 1100, height: 800 },
"Resize supersedes our earlier report",
);
const sharedSize = sharedPageSize(localSize, reports, "wide", 100);
const otherSize = sharedPageSize({ width: 480, height: 600 }, reports, "narrow", 100);
assert.deepEqual(sharedSize, otherSize);
const sharedText = "Un texte avec plusieurs phrases.\n\n".repeat(80);
const pageRanges = (size: typeof localSize) =>
readingPages(layoutText({ text: sharedText, width: size.width, measure }), size.height).map(({ start, end }) => ({
start,
end,
}));
assert.deepEqual(
pageRanges(sharedSize),
pageRanges(otherSize),
"Different windows paginate to identical source ranges",
);
console.log("PASS smallest live viewport determines shared pages; expired and own stale reports don't constrain");
// Whitespace belongs to the source, even when it isn't drawn at a line edge.
for (const text of [" one two three\n\t four five", "\t北京 欢迎 你\n 中文", "one two", " \n "])
for (const width of [20, 30, 47, 80, 300]) {
const layout = layoutText({ text, width, measure });
assert.equal(layout.text, text);
for (const line of layout.lines) {
assert(!/^\s/u.test(line.glyphs[0]?.text ?? ""), "No line begins with a drawn separator or indentation");
for (const glyph of line.glyphs) assert.equal(text.slice(glyph.start, glyph.end), glyph.text);
}
const pages = readingPages(layout, 70);
assert.equal(pages.map((page) => text.slice(page.start, page.end)).join(""), text, "Copy retains all whitespace");
}
const indent = layoutText({ text: " word", width: 100, measure });
assert.equal(indent.lines[0].glyphs[0].start, 2);
assert.equal(indent.lines[0].glyphs[0].x, 0);
const wrappedSeparator = layoutText({
text: "one two",
width: 60,
measure,
annotations: [
{ id: "spaced-span", feature: "words", start: 3, end: 7, text: "label", style: "overhead", color: "green" },
],
});
assert(wrappedSeparator.lines.length > 1);
assert(
wrappedSeparator.lines.every((line) => !/^\s/u.test(line.glyphs[0]?.text ?? "")),
"Annotation affinity cannot introduce a leading space",
);
console.log("PASS visual line-leading whitespace suppression preserves original offsets and page/copy content");
import { coalesceProcessing, type ProcessingRegion } from "../src/canvas/processing.js";
const pendingRegion = (feature: string, start: number, end: number): ProcessingRegion => ({
feature,
range: { start, end },
label: `Preparing ${feature}`,
color: feature === "pinyin" ? "#53796c" : "#526c86",
});
const pendingRegions = [
pendingRegion("pinyin", 10, 15),
pendingRegion("translation", 5, 10),
pendingRegion("pinyin", 0, 5),
pendingRegion("pinyin", 5, 10),
pendingRegion("pinyin", 3, 7),
pendingRegion("pinyin", 10, 15),
];
const unchangedRegions = JSON.stringify(pendingRegions);
assert.deepEqual(
coalesceProcessing(pendingRegions, "a".repeat(30)),
[pendingRegion("pinyin", 0, 15), pendingRegion("translation", 5, 10)],
"Adjacent, overlapping and duplicate chunks join only within their annotator",
);
assert.equal(JSON.stringify(pendingRegions), unchangedRegions, "Coalescing never alters jobs or scheduler ranges");
assert.deepEqual(
coalesceProcessing([pendingRegion("pinyin", 0, 3), pendingRegion("pinyin", 6, 9)], "one\n\n two"),
[pendingRegion("pinyin", 0, 9)],
"Paragraph separators don't create seams between pending regions",
);
assert.deepEqual(
coalesceProcessing([pendingRegion("pinyin", 0, 3), pendingRegion("pinyin", 6, 9)], "oneXYZtwo"),
[pendingRegion("pinyin", 0, 3), pendingRegion("pinyin", 6, 9)],
"Completed intervening text splits the outline again",
);
assert.deepEqual(coalesceProcessing([pendingRegion("pinyin", 2, 2)], "one"), []);
console.log("PASS processing outlines coalesce by annotator without covering completed text or mutating work");
// Line breaking and word targeting are different Unicode operations. Chinese
// closing punctuation must stay with preceding ink even when pinyin padding
// causes a wrap; opening punctuation must stay with what follows it.
assert.deepEqual(lineSpans("hello world"), [
{ start: 0, end: 6 },
{ start: 6, end: 11 },
]);
assert.deepEqual(lineSpans("hello\u00a0world"), [{ start: 0, end: 11 }], "Nonbreaking spaces remain unbreakable");
assert(!lineSpans("北京。朋友").some((span) => span.end === 2), "No legal break immediately before the period");
assert(
!lineSpans("北京(朋友)").some((span) => span.end === 3),
"No legal break immediately after an opening bracket",
);
assert(lineSpans("👩🏽‍💻朋友").every((span) => span.start === 0 || span.start >= "👩🏽‍💻".length));
const punctuated = "林雨走出地铁站,发现街角有一家新书店。门口的木牌上写着:(欢迎进来,慢慢读书。)";
for (const width of [45, 70, 100, 130, 210]) {
for (const annotations of [
[],
[
{
id: "sign",
feature: "pinyin",
start: 22,
end: 24,
text: "a very long pronunciation",
style: "overhead",
color: "green",
},
{ id: "shop", feature: "pinyin", start: 16, end: 18, text: "shūdiàn", style: "overhead", color: "green" },
] as Annotation[],
]) {
const result = layoutText({ text: punctuated, width, annotations, measure });
verify(result);
for (const line of result.lines.filter((line) => line.glyphs.length)) {
assert(!/^[。,、)]/u.test(line.glyphs[0].text), `Closing punctuation begins a line at width ${width}`);
assert(!/[(]$/u.test(line.glyphs.at(-1)!.text), `Opening punctuation ends a line at width ${width}`);
}
assert.equal(
result.lines
.flatMap((line) => line.glyphs)
.map((glyph) => glyph.text)
.join(""),
punctuated,
);
}
}
const latinPunctuation = layoutText({ text: "A short (French word), and another word.", width: 130, measure });
for (const line of latinPunctuation.lines.filter((line) => line.glyphs.length)) {
assert(!/^[.,)]/u.test(line.glyphs[0].text));
assert.notEqual(line.glyphs.at(-1)!.text, "(");
}
const annotationPunctuation = layoutText({
text: "中文",
width: 60,
measure,
annotations: [
{
id: "punctuated-label",
feature: "gloss",
start: 0,
end: 2,
text: "读书。中文,朋友。",
style: "overhead",
color: "green",
},
],
});
verify(annotationPunctuation);
assert(annotationPunctuation.lines.flatMap((line) => line.overhead).every((box) => !/^[。,]/u.test(box.lines[0])));
verify(
layoutText({
text: "书。中文,友",
width: 100,
measure,
annotations: [
{
id: "word-label",
feature: "gloss",
start: 0,
end: 1,
text: "explanation one",
fontSize: 11,
style: "overhead",
color: "green",
},
{
id: "punctuation-label",
feature: "gloss",
start: 1,
end: 2,
text: "another explanation",
fontSize: 23,
style: "overhead",
color: "green",
},
],
}),
);
assert.deepEqual(contextTarget({ anchor: 8, focus: 2 }, { start: 3, end: 5 }, true), {
range: { start: 2, end: 8 },
kind: "selection",
});
assert.deepEqual(contextTarget({ anchor: 2, focus: 8 }, { start: 12, end: 15 }, false), {
range: { start: 12, end: 15 },
kind: "word",
});
assert.equal(contextTarget({ anchor: 3, focus: 3 }, null, false), null);
console.log(
"PASS Unicode line-breaking opportunities, Chinese/Latin punctuation with overhead reservations, and context-menu selection targets",
);
// The same label kind may sit above or below, with one fixed lane per
// source/side. Neither labels nor their source reservations collide.
for (const width of [40, 90, 180, 500]) {
const paired = layoutText({
text: "书店欢迎朋友。\n我们一起读书。",
width,
measure,
annotations: [
{
...pinyin[0],
id: "above-a",
feature: "words",
start: 0,
end: 2,
text: "bookshop",
placement: "above",
fontSize: 12,
},
{
...pinyin[0],
id: "above-b",
feature: "words",
start: 2,
end: 4,
text: "welcomes",
placement: "above",
fontSize: 18,
},
{
...pinyin[0],
id: "below-a",
feature: "words",
start: 0,
end: 2,
text: "a place with books and friends",
placement: "below",
fontSize: 14,
},
{
...pinyin[0],
id: "below-b",
feature: "sentences",
start: 0,
end: 7,
text: "The bookshop welcomes friends.",
placement: "below",
fontSize: 22,
},
],
});
verify(paired);
for (const line of paired.lines) {
for (const box of line.overhead) {
if (box.annotation.placement === "below") {
assert(box.y >= line.y + (line.glyphs[0]?.height ?? 0));
assert(box.y + box.height <= line.y + line.height);
} else assert(box.y + box.height < line.y);
assert.deepEqual(
textInteractionAt(paired, box.x + box.width / 2, box.y + box.height / 2, () => true),
{
kind: "label",
range: { start: box.annotation.start, end: box.annotation.end },
},
);
assert.equal(
textInteractionAt(paired, box.x + box.width / 2, box.y + box.height / 2, () => false),
null,
"Hidden labels are not interactive",
);
}
}
const pages = readingPages(paired, 150);
for (const box of paired.lines.flatMap((line) => line.overhead))
assert(
pages.some((page) => box.y >= page.top && box.y + box.height <= page.bottom),
"Both label sides belong to their source page",
);
}
const targetText = "北京,l’ami! 你好。";
assert.deepEqual(hoverWordAt(targetText, 0), { start: 0, end: 2 });
assert.equal(hoverWordAt(targetText, 2), null);
assert.equal(hoverWordAt(targetText, 8), null);
assert.equal(hoverWordAt(targetText, 9), null);
assert.deepEqual(hoverWordAt(targetText, 4), { start: 3, end: 8 });
const hoverLayout = layoutText({ text: "北京,朋友。", width: 300, measure });
for (const glyph of hoverLayout.lines.flatMap((line) => line.glyphs)) {
const hit = textInteractionAt(hoverLayout, glyph.x + glyph.width / 2, glyph.y + glyph.height / 2, () => true);
assert.equal(hit?.kind ?? null, /\p{P}/u.test(glyph.text) ? null : "word");
}
// Guide tolerance follows source font size, rather than screen pixels. A long
// gloss marks the source projection; normal word-sized labels stay unadorned.
const guideLayout = layoutText({
text: "书店",
width: 500,
measure,
annotations: [{ ...pinyin[0], start: 0, end: 2, text: "a surprisingly long bookshop gloss" }],
});
const guideBox = guideLayout.lines[0].overhead[0];
assert(guideBox.guide);
assert.equal(guideBox.guide.start, guideLayout.lines[0].glyphs[0].x);
assert.equal(guideBox.guide.end, guideLayout.lines[0].glyphs.at(-1)!.x + guideLayout.lines[0].glyphs.at(-1)!.width);
const guides = labelGuideSegments(guideBox);
assert.equal(guides.length, 2);
for (const guide of guides) {
assert(
guide.end < guideBox.x || guide.start > guideBox.x + guideBox.width,
"Alignment brackets never cross label text",
);
assert.equal(guide.y, guideBox.y + guideBox.height / 2);
assert(guide.cap > 0, "Above-text brackets point toward their source");
}
assert(
labelGuideSegments({ ...guideBox, annotation: { ...guideBox.annotation, placement: "below" } }).every(
(guide) => guide.cap < 0,
),
);
const shortGuide = layoutText({
text: "书店",
width: 500,
measure,
annotations: [{ ...pinyin[0], start: 0, end: 2, text: "shop" }],
});
assert.equal(shortGuide.lines[0].overhead[0].guide, undefined);
assert.deepEqual(labelGuideSegments(shortGuide.lines[0].overhead[0]), []);
draws.length = 0;
drawText(context, hoverLayout, { hover: { start: 0, end: 6 } });
assert(
draws.filter((item) => /\p{P}/u.test(item.text)).every((item) => item.color === "#252923"),
"Reference emphasis leaves punctuation unchanged",
);
console.log(
"PASS above/below fixed lanes, source-page containment, isolated label hover, Unicode word targeting and font-relative alignment guides",
);
// A sidebar reference is fallback emphasis. It must never mask the current
// source pointer when the old panel remains hovered or disappears mid-request.
const multipleRows = layoutText({ text: "北京。\n朋友。\n书店。\n读书。", width: 200, measure });
for (const range of [
{ start: 4, end: 6 },
{ start: 8, end: 10 },
{ start: 12, end: 14 },
]) {
draws.length = 0;
drawText(context, multipleRows, { hover: range, emphasis: { start: 0, end: 2 } });
const expected = Array.from(multipleRows.text.slice(range.start, range.end));
assert.deepEqual(
draws.filter((item) => item.color === "#39745b").map((item) => item.text),
expected,
"Source hover on every row takes priority over an unrelated sidebar reference",
);
}
draws.length = 0;
drawText(context, multipleRows, { emphasis: { start: 0, end: 2 } });
assert.deepEqual(
draws.filter((item) => item.color === "#39745b").map((item) => item.text),
["北", "京"],
"Sidebar reference still emphasizes source while the pointer is outside the canvas",
);
console.log("PASS current source pointer takes priority over stale sidebar references across multiple rows");
+258
View File
@@ -0,0 +1,258 @@
import assert from "node:assert/strict";
import {
HoverIntent,
sameScope,
processorMatches,
preparationQueue,
selectSuccessfulAnnotationJobs,
mergeCachedAnnotationEntries,
} from "../src/work.js";
const oldProcessor = { key: "processor-v1:scope" };
assert(processorMatches(oldProcessor, "processor-v1"));
assert(
!processorMatches(oldProcessor, "processor-v2"),
"A checked processor edit invalidates saved results even when document and feature settings are unchanged",
);
assert(!processorMatches({ key: "processor-v10:scope" }, "processor-v1"));
const feature = {
processor: "definition",
generation: "focus",
scope: "word",
reveal: "hover",
configRevision: 2,
enabled: true,
};
assert(
sameScope(
{ sourceRevision: "s", configuration: "compute", featureRevision: 2, start: 0, end: 2, status: "ready" },
"s",
"compute",
{
start: 0,
end: 2,
},
),
);
assert(
!sameScope(
{ sourceRevision: "s", configuration: "different", featureRevision: 1, start: 0, end: 2, status: "ready" },
"s",
"compute",
{
start: 0,
end: 2,
},
),
);
const intent = new HoverIntent(10);
let calls: string[] = [];
intent.schedule(() => calls.push("old"));
intent.schedule(() => calls.push("new"));
await new Promise((resolve) => setTimeout(resolve, 30));
assert.deepEqual(calls, ["new"]);
intent.schedule(() => calls.push("left"));
intent.cancel();
await new Promise((resolve) => setTimeout(resolve, 30));
assert.deepEqual(calls, ["new"]);
intent.schedule(() => calls.push("unmounted"));
intent.cancel();
console.log("PASS heavy hover intent, cancellation, scopes and stale cache guards");
const scope = (key: string, configuration: string, start: number, local = false) => ({
key,
configuration,
range: { start, end: start + 10 },
local,
});
const empty = new Set<string>();
const a = scope("a", "pinyin", 0),
b = scope("b", "pinyin", 10),
c = scope("c", "pinyin", 20);
assert.deepEqual(
preparationQueue([a, b, c], { start: 0, end: 30 }, 0, empty, empty).admit,
[a, b],
"Independent sections of the same annotator can use both model slots",
);
const d = scope("d", "names", 0);
assert.deepEqual(
preparationQueue([a, b, c, d], { start: 0, end: 30 }, 0, empty, empty).admit,
[a, d],
"Features take turns when their scopes are equally close",
);
const full = preparationQueue([a, b, c], { start: 0, end: 30 }, 2, empty, empty);
assert.deepEqual(full.admit, []);
assert.deepEqual(full.waiting, [a, b, c], "Unadmitted scopes remain available for queued outlines");
assert.deepEqual(
preparationQueue([a, a, b, c], { start: 0, end: 30 }, 0, new Set(["b"]), new Set(["a"])).waiting,
[c],
"Cached ready or failed scopes, admitted intents and shared-computation duplicates do not get queued outlines",
);
assert.deepEqual(
preparationQueue([a, b, c], { start: 10, end: 20 }, 1, empty, empty).admit,
[b],
"Visible work takes the released slot before neighboring scopes",
);
const local = scope("local", "local", 20, true);
assert.deepEqual(
preparationQueue([a, local], { start: 0, end: 30 }, 2, empty, empty).admit,
[local],
"Local annotations do not wait for inference slots",
);
console.log("PASS queued outlines, bounded independent scope admission, fair features and cache exclusion");
import { presentAnnotations } from "../src/annotation-display.js";
const readings = [
{ start: 0, end: 2, text: "Běijīng", tag: "hsk:3" },
{ start: 2, end: 4, text: "xīngqīliù", tag: "hsk:1" },
];
assert.equal(presentAnnotations("pinyin", readings, { minimumLevel: "2" }).length, 1);
assert.equal(presentAnnotations("pinyin", readings, { minimumLevel: "0" }).length, 2);
assert.equal(
presentAnnotations("pinyinWord", readings, { minimumLevel: "8" }).length,
2,
"Hover includes easy words too",
);
assert.equal(
presentAnnotations("names", [{ ...readings[0], tag: "place" }], { placeColor: "#123456" })[0].color,
"#123456",
);
console.log("PASS local HSK filtering, all-word hover pinyin and category recoloring from raw cache");
import { renderAnnotations } from "../src/render-hooks.js";
import type { Annotation } from "../src/canvas/types.js";
const source = "🙂 Un mot littéraire.";
const semantic: Annotation = {
id: "item",
feature: "source",
start: 6,
end: 9,
text: "word",
style: "replace",
color: "#123456",
};
const outputs = renderAnnotations(semantic, source, [
{ style: "replace", reveal: "always" },
{ style: "color", reveal: "always" },
{ style: "tooltip", reveal: "hover", content: "source" },
]);
assert.deepEqual(
outputs.map(({ style, text, reveal }) => ({ style, text, reveal })),
[
{ style: "replace", text: "word", reveal: "always" },
{ style: "color", text: "word", reveal: "always" },
{ style: "tooltip", text: "mot", reveal: "hover" },
],
);
assert.equal(new Set(outputs.map((output) => output.id)).size, 3);
assert(outputs.every((output) => output.feature === semantic.feature && output.start === 6 && output.end === 9));
assert.equal(semantic.text, "word", "Rendering never mutates cached semantic data");
assert.deepEqual(renderAnnotations(semantic, source, undefined), [semantic]);
console.log("PASS one source rendered as replacement, color and original-text hover tooltip");
assert(
sameScope(
{
sourceRevision: "doc@1",
featureRevision: 4,
start: 0,
end: 6,
status: "ready",
configuration: JSON.stringify({ processor: "pinyin", scope: "word", generation: "section", options: {} }),
},
"doc@1",
JSON.stringify({ processor: "pinyin", scope: "word", options: {} }),
{ start: 0, end: 6 },
),
"Changing preparation timing reuses a persisted semantic result",
);
assert(processorMatches({ key: "verified-previous:light-fast:result" }, "current", ["verified-previous"]));
assert(!processorMatches({ key: "unknown:light-fast:result" }, "current", ["verified-previous"]));
const scopeConfiguration = JSON.stringify({ processor: "pinyin", scope: "word", options: {} });
const savedJob = (object: string, createdAt: string, items: unknown[], key = "current:scope") => ({
_camino: { ref: { object } },
configuration: scopeConfiguration,
data: {
key,
kind: "annotation",
status: "ready",
sourceRevision: "doc@1",
start: 0,
end: 6,
createdAt,
output: JSON.stringify({ items }),
},
});
const oldFacts = savedJob("old", "2026-10-01", [
{ start: 0, end: 2 },
{ start: 2, end: 4 },
]),
clearedFacts = savedJob("cleared", "2026-10-02", []),
narrowedFacts = savedJob("narrowed", "2026-10-03", [{ start: 0, end: 2 }]),
failedFacts = {
...savedJob("failed", "2026-10-04", []),
data: { ...oldFacts.data, status: "failed", createdAt: "2026-10-04" },
};
const accepts = (job: typeof oldFacts) => processorMatches(job.data, "current", ["verified-previous"]);
assert.deepEqual(
selectSuccessfulAnnotationJobs([oldFacts, clearedFacts], accepts),
[clearedFacts],
"An empty successful regeneration clears its old items",
);
assert.deepEqual(
selectSuccessfulAnnotationJobs([oldFacts, clearedFacts, narrowedFacts, failedFacts], accepts),
[narrowedFacts],
"A smaller successful output replaces the whole scope; failure does not erase success",
);
const oldAlias = {
...oldFacts,
data: { ...oldFacts.data, key: "verified-previous:light-fast:legacy" },
configuration: JSON.stringify({ processor: "pinyin", scope: "word", generation: "section", options: {} }),
};
const unknown = savedJob("unknown", "2026-10-05", [{ start: 4, end: 6 }], "unknown:scope");
assert.deepEqual(
selectSuccessfulAnnotationJobs([oldAlias, clearedFacts, unknown], accepts),
[clearedFacts],
"Verified revision aliases and old generation formatting describe the same semantic scope; unknown revisions cannot replace it",
);
const anotherRange = { ...oldFacts, data: { ...oldFacts.data, start: 6, end: 8 } },
anotherSource = { ...oldFacts, data: { ...oldFacts.data, sourceRevision: "doc@2" } },
anotherInput = {
...oldFacts,
configuration: JSON.stringify({ processor: "pinyin", scope: "word", options: { targetLanguage: "French" } }),
};
assert.equal(
selectSuccessfulAnnotationJobs([clearedFacts, anotherRange, anotherSource, anotherInput], accepts).length,
4,
"Different semantic inputs, ranges and sources retain independent results",
);
const canonical = mergeCachedAnnotationEntries([{ node: oldFacts }], [{ node: narrowedFacts }, { node: failedFacts }]);
assert.deepEqual(
canonical.map(({ node }) => node._camino.ref.object),
["old", "failed"],
"Indexed success takes precedence over newer attempt history for its exact key",
);
assert.deepEqual(
selectSuccessfulAnnotationJobs(
canonical.map(({ node }) => node),
accepts,
),
[oldFacts],
);
assert.deepEqual(
mergeCachedAnnotationEntries([{ node: clearedFacts }, { node: oldFacts }], []),
[{ node: clearedFacts }],
"Overlapping pagination snapshots retain one canonical pointer per key",
);
assert.deepEqual(
JSON.parse(oldFacts.data.output).items,
[
{ start: 0, end: 2 },
{ start: 2, end: 4 },
],
"Selection never mutates retained historical output",
);
console.log(
"PASS canonical cache pointers and whole-scope replacement, including empty/narrowed successes, aliases and failed retries",
);
+164
View File
@@ -0,0 +1,164 @@
# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!
{ lib, stdenv, nodejs, git, cacert, fetchurl, writeShellScript, writeShellScriptBin }:
{ src, overrideAttrs ? null, ... } @ args:
let
yarnBin = fetchurl {
url = "https://repo.yarnpkg.com/4.18.0/packages/yarnpkg-cli/bin/yarn.js";
hash = "sha512-/Lhxb+fNDuzhQf/Bi5IZOp35IEwbqDGJwoiDUiP8C75kr0c7qw1emSen2utcryuwfrJ4fMkzjKBA6hJfKh8vfg==";
};
cacheFolder = ".yarn/cache";
lockfile = ./yarn.lock;
# Call overrideAttrs on a derivation if a function is provided.
optionalOverride = fn: drv:
if fn == null then drv else drv.overrideAttrs fn;
# Simple stub that provides the global yarn command.
yarn = writeShellScriptBin "yarn" ''
exec '${nodejs}/bin/node' '${yarnBin}' "$@"
'';
# Common attributes between Yarn derivations.
drvCommon = {
# Make sure the build uses the right Node.js version everywhere.
buildInputs = [ nodejs yarn ];
# All dependencies should already be cached.
yarn_enable_network = "0";
# Tell node-gyp to use the provided Node.js headers for native code builds.
npm_config_nodedir = nodejs;
};
# Comman variables that we set in a Nix build, but not in a Nix shell.
buildVars = ''
# Make Yarn produce friendlier logging for automated builds.
export CI=1
# Tell node-pre-gyp to never fetch binaries / always build from source.
export npm_config_build_from_source=true
'';
# Create derivations for fetching dependencies.
cacheDrvs = let
in lib.mapAttrs (locator: { filename, hash }: stdenv.mkDerivation {
name = lib.strings.sanitizeDerivationName locator;
buildInputs = [ yarn git cacert ];
buildCommand = ''
cd '${src}'
${buildVars}
HOME="$TMP" yarn_enable_global_cache=false yarn_cache_folder="$TMP" \
yarn nixify fetch ${lib.escapeShellArg locator}
# Because we change the cache dir, Yarn may generate a different name.
mv "$TMP/$(sed 's/-[^-]*\.[^-]*$//' <<< "$outputFilename")"-* $out
'';
outputFilename = filename;
outputHash = hash;
}) cacheEntries;
# Create a shell snippet to copy dependencies from a list of derivations.
mkCacheBuilderForDrvs = drvs:
writeShellScript "collect-yarn-cache" (lib.concatMapStrings (drv: ''
cp --reflink=auto ${drv} '${drv.outputFilename}'
'') drvs);
# Main project derivation.
project = stdenv.mkDerivation (drvCommon // {
inherit src;
name = "@quixos-example/language-reader-presentation";
configurePhase = ''
${buildVars}
# Copy over the Yarn cache.
rm -fr '${cacheFolder}'
mkdir -p '${cacheFolder}'
pushd '${cacheFolder}' > /dev/null
source ${mkCacheBuilderForDrvs (lib.attrValues cacheDrvs)}
popd > /dev/null
# Yarn may need a writable home directory.
export yarn_global_folder="$TMP"
# Ensure global cache is disabled. Cache must be part of our output.
touch .yarnrc.yml
sed -i -e '/^enableGlobalCache/d' .yarnrc.yml
echo 'enableGlobalCache: false' >> .yarnrc.yml
# Some node-gyp calls may call out to npm, which could fail due to an
# read-only home dir.
export HOME="$TMP"
# running preConfigure after the cache is populated allows for
# preConfigure to contain substituteInPlace for dependencies as well as the
# main project. This is necessary for native bindings that maybe have
# hardcoded values.
runHook preConfigure
# Run normal Yarn install to complete dependency installation.
yarn install --immutable --immutable-cache
runHook postConfigure
'';
buildPhase = ''
runHook preBuild
runHook postBuild
'';
installPhase = ''
runHook preInstall
# Move the package contents to the output directory.
if grep -q '"workspaces"' package.json; then
# We can't use `yarn pack` in a workspace setup, because it only
# packages the outer workspace.
mkdir -p "$out/libexec"
mv $PWD "$out/libexec/$name"
else
# - If the package.json has a `files` field, only files matching those patterns are copied
# - Otherwise all files are copied.
yarn pack --out package.tgz
mkdir -p "$out/libexec/$name"
tar xzf package.tgz --directory "$out/libexec/$name" --strip-components=1
cp --reflink=auto .yarnrc* "$out/libexec/$name"
cp --reflink=auto ${lockfile} "$out/libexec/$name/yarn.lock"
cp --reflink=auto --recursive .yarn "$out/libexec/$name"
# Copy the Yarn linker output into the package.
cp --reflink=auto --recursive node_modules "$out/libexec/$name"
fi
cd "$out/libexec/$name"
# Invoke a plugin internal command to setup binaries.
mkdir -p "$out/bin"
yarn nixify install-bin $out/bin
# A package with node_modules doesn't need the cache
yarn cache clean
runHook postInstall
'';
passthru = {
inherit nodejs;
yarn-freestanding = yarn;
yarn = writeShellScriptBin "yarn" ''
exec '${yarn}/bin/yarn' --cwd '${overriddenProject}/libexec/${overriddenProject.name}' "$@"
'';
};
});
overriddenProject = optionalOverride overrideAttrs project;
cacheEntries = {
"@cto.af/linebreak@npm:4.0.3" = { filename = "@cto.af-linebreak-npm-4.0.3-1836c4374f-16e3aa986c.zip"; hash = "sha512-FuOqmGwU/Lv8yjZUL8tRQEbkIyhwtKwAa3xiJ/bpQUo/UlJwbQqvmywezvJa2/PIm0t7cjXQdSdCMDCV5OJTVA=="; };
"@cto.af/unicode-trie-runtime@npm:3.2.9" = { filename = "@cto.af-unicode-trie-runtime-npm-3.2.9-083d03e6f3-a72de3f240.zip"; hash = "sha512-py3j8kDjqlHhHiB4eLp5xNC9INPv+ejTCgSgrS4pVi3j3Ny/mJnSHbUY9c7fcvad1lR2dySnRwayEDKRcfccdw=="; };
"fflate@npm:0.8.3" = { filename = "fflate-npm-0.8.3-35acaff861-eab181ca37.zip"; hash = "sha512-6rGByjf1NIrnbUtvhA4AJuMCIOMxUyiayUIiLYuWOCN9SGUH28wJh41yQJW9NUmTou5Iu+6ZyPLGRA1ESHGapw=="; };
};
in overriddenProject
+39
View File
@@ -0,0 +1,39 @@
# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!
__metadata:
version: 10
cacheKey: 10c0
"@cto.af/linebreak@npm:4.0.3":
version: 4.0.3
resolution: "@cto.af/linebreak@npm:4.0.3"
dependencies:
"@cto.af/unicode-trie-runtime": "npm:3.2.9"
checksum: 10c0/16e3aa986c14fcbbfcca36542fcb514046e4232870b4ac006b7c6227f6e9414a3f5252706d0aaf9b2c1ecef25adbf3c89b4b7b7235d0752742303095e4e25354
languageName: node
linkType: hard
"@cto.af/unicode-trie-runtime@npm:3.2.9":
version: 3.2.9
resolution: "@cto.af/unicode-trie-runtime@npm:3.2.9"
dependencies:
fflate: "npm:^0.8.2"
checksum: 10c0/a72de3f240e3aa51e11e207878ba79c4d0bd20d3eff9e8d30a04a0ad2e29562de3dcdcbf9899d21db518f5cedf72f69dd654767724a74706b210329171f71c77
languageName: node
linkType: hard
"@quixos-example/language-reader-presentation@workspace:.":
version: 0.0.0-use.local
resolution: "@quixos-example/language-reader-presentation@workspace:."
dependencies:
"@cto.af/linebreak": "npm:4.0.3"
languageName: unknown
linkType: soft
"fflate@npm:^0.8.2":
version: 0.8.3
resolution: "fflate@npm:0.8.3"
checksum: 10c0/eab181ca37f5348ae76d4b6f840e0026e30220e33153289ac942222d8b9638237d486507dbcc09878d724095bd354993a2ee48bbee99c8f2c6440d4448719aa7
languageName: node
linkType: hard