Export template source from 122c862dccc4a1825f19cc8d4eb1095e5661f1ea:examples/local-first/packages/language-reader-presentation
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"monorepoCommit": "122c862dccc4a1825f19cc8d4eb1095e5661f1ea",
|
||||
"sourcePath": "examples/local-first/packages/language-reader-presentation"
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
+29
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"component:reading:reader" = {
|
||||
entry = "src/view.tsx";
|
||||
export = "Reader";
|
||||
};
|
||||
"component:reading:vocabulary" = {
|
||||
entry = "src/vocabulary.tsx";
|
||||
export = "Vocabulary";
|
||||
};
|
||||
}
|
||||
Generated
+108
@@ -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
|
||||
}
|
||||
@@ -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; };
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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} } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
query DocumentContent {
|
||||
root {
|
||||
library { _camino { ref } }
|
||||
reader { _camino { ref } }
|
||||
canonical { data { title } }
|
||||
adaptation
|
||||
data { title text sourceRevision source url attribution }
|
||||
}
|
||||
}
|
||||
@@ -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} }
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
@@ -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} } }
|
||||
} } }
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
} }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"backend": "execution",
|
||||
"queryFrontends": ["react"]
|
||||
}
|
||||
+22
@@ -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"; }
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)) };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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",
|
||||
);
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user