diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e81830c1..6240271c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,6 +127,32 @@ jobs: tests/ui/results retention-days: 7 + perf-typing: + # Phase 4's typing benchmark, in-page measure only (ARCHITECTURE.md section 24.5): every typing condition and + # the motion gestures, recorded as an artifact. Hosted runners are too noisy for the 16 ms gate itself, which + # the reference laptop's run checks with OPENNOTE_TYPING_GATE=1 (docs/perf/phase-4.md). + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Install tools + run: npm ci + - name: Typing benchmark + run: npm run perf:typing + - name: Keep the numbers + if: always() + uses: actions/upload-artifact@v4 + with: + name: perf-typing + path: tests/perf/typing/results + retention-days: 30 + windows-app: # Rust checks and a test build of the desktop app on Windows, the first target platform. runs-on: windows-latest diff --git a/.gitignore b/.gitignore index 527713b0d..901df2f98 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ tests/e2e/.drivers/ tests/e2e/logs/ tests/ui/report/ tests/ui/results/ +tests/perf/typing/results/ diff --git a/Cargo.lock b/Cargo.lock index ce5c5d5e6..fee6acbbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2764,6 +2764,7 @@ dependencies = [ "tauri-build", "tempfile", "ts-rs", + "ureq", "webview2-com", "windows", ] diff --git a/README.md b/README.md index 343d0bcdf..b62806b23 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,26 @@ OpenNote is an open-source note-taking app that combines your favorite writing, OpenNote is in early development. Each phase of the [development plan](docs/DEVELOPMENT.md#5-phases) gets its own branch and merges into `main` when it meets its exit gate. -| Phase | Focus | Status | -| --------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------- | -| 0 | Project foundation | Done and merged | -| 1 | Spikes that test ink, text, PDF export, and audio | Done and merged | -| 2 | [App shell and navigation](https://github.com/XrxcGH/OpenNote/pull/8) | Done and merged 2026-10-02 | -| 3 | [Document model and storage](https://github.com/XrxcGH/OpenNote/pull/9) | Done and merged 2026-10-03 | -| 4 | Typed notes (text, Markdown, tables, code) | In progress | -| [5 to 14](docs/DEVELOPMENT.md#5-phases) | Ink, export, charts, search, audio, math, import, intelligence, hardening, and release | Core in progress; beta build being assembled | +| Phase | Focus | Status | +| --------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| 0 | Project foundation | Done and merged | +| 1 | Spikes that test ink, text, PDF export, and audio | Done and merged | +| 2 | [App shell and navigation](https://github.com/XrxcGH/OpenNote/pull/8) | Done and merged 2026-10-02 | +| 3 | [Document model and storage](https://github.com/XrxcGH/OpenNote/pull/9) | Done and merged 2026-10-03 | +| 4 | [Typed notes (text, Markdown, tables, code)](https://github.com/XrxcGH/OpenNote/pull/15) | Done and merged 2026-10-03 | +| [5 to 14](docs/DEVELOPMENT.md#5-phases) | Ink, export, charts, search, audio, math, import, intelligence, hardening, and release | In progress, being wired into the app; a first beta build exists on branch `beta` | + +## What the app does today + +OpenNote runs as a Windows desktop app. You can organize notebooks, sections, and pages, and write typed notes on a page, either in a simple document flow or in text boxes you place anywhere. Pages save on their own and recover after a crash. Typed notes support: + +- Text boxes, headings, lists, checkboxes, bold and italic, links, quotes, and callouts +- Markdown shortcuts as you type (for example `# ` for a heading), and Markdown paste +- Images by paste, drag, or file picker, with resize and crop +- Tables, and code blocks with syntax highlighting +- Spell check through the Windows spelling service + +Ink, export, charts, search, audio, math, and import are still being wired into the app. ## Downloads @@ -39,13 +51,29 @@ cd OpenNote npm install && npm start ``` -That opens the OpenNote window in development mode. Run `npm run setup-hooks` once, so CHECKS runs before every commit. [CONTRIBUTING.md](CONTRIBUTING.md) has the full setup, the npm scripts, and the workflow for branches and pull requests. +That opens the OpenNote window in development mode. Run `npm run setup-hooks` once, so CHECKS runs before every commit. To test your changes, run: + +```sh +npm test # CHECKS, lint rule, and unit tests +npm run test:components # component tests in the installed Edge or Chrome +npm run test:ui # screen and accessibility tests in Playwright +npm run test:e2e # real-app tests, which need tauri-driver and msedgedriver +npm run perf:typing # the typing latency benchmark +cargo test --workspace # Rust tests +``` + +[CONTRIBUTING.md](CONTRIBUTING.md) has the full setup, every npm script, and the workflow for branches and pull requests. ## Repository map ```text -app/ The desktop app (Phase 2) +app/ The desktop app (Phases 2 and 4) src/ TypeScript and React interface + editor/ The typed notes editor: schema, Markdown, tables, and highlighting + features/ + page/ The page view: text boxes, images, paste, and spell check + services/ + pages/ The page service contract, with its Tauri and in-memory versions src-tauri/ Rust shell, built on Tauri 2 brand/ Design tokens, logo, and app icon checks/ The CHECKS quality gate @@ -53,14 +81,16 @@ crates/ Rust libraries (Phase 3) core/ Document model, file format, storage, undo, and search updater/ Self-updater (fetch, verify, install, rollback) docs/ Plans, specs, and guides - adr/ Architecture decision records + adr/ Architecture decision records, including Phase 4's 0020 to 0026 design/ Screen wireframes and the generator that draws them format/ Note file format specification and tools + perf/ Performance records, starting with Phase 4's typing numbers spikes/ Throwaway Phase 1 experiments and their results tests/ End-to-end, UI, performance, and crash tests (Phase 3) crash/ Crash safety tests that kill the app during saves - e2e/ Smoke set of real-app tests (keyboard, screen reader) + e2e/ Real-app tests: the smoke set (keyboard, screen reader) and typed notes perf/ Performance benchmarks for every budget + typing/ The typing latency benchmark for the 20-page note (Phase 4) ui/ Screenshot tests of every screen and size .github/ Issue and pull request templates, and the CI and release workflows ``` diff --git a/app/bundle-budget.json b/app/bundle-budget.json index 057a30079..7a64097b3 100644 --- a/app/bundle-budget.json +++ b/app/bundle-budget.json @@ -1,7 +1,16 @@ { - "startup": { "jsKb": 160, "cssKb": 15 }, + "startup": { "jsKb": 180, "cssKb": 15 }, + "after": { "within": "features/page/", "entry": "features/page/PageBody" }, "lazyKb": { - "features/page": 150, + "editor/markdown/worker": 45, + "editor/highlight": 20, + "highlight.js/lib/languages": 5, + "features/page/history": 25, + "features/page/readAloud": 10, + "features/page/styles": 15, + "features/page/readingOrder": 15, + "features/page/PageBody": 225, + "features/page": 40, "features/palette": 40, "features/settings": 40, "features/setup": 40, diff --git a/app/scripts/check-bundle.test.ts b/app/scripts/check-bundle.test.ts index 69fa74ed6..4ed5bef9b 100644 --- a/app/scripts/check-bundle.test.ts +++ b/app/scripts/check-bundle.test.ts @@ -35,6 +35,38 @@ describe('check-bundle', () => { ]); }); + it('counts a group that loads after another one without what that one loads', () => { + const manifest: Manifest = { + 'index.html': { file: 'assets/index.js', isEntry: true }, + 'src/features/page/PageBody.tsx': { + file: 'assets/page.js', + src: 'src/features/page/PageBody.tsx', + isDynamicEntry: true, + imports: ['_editor.js'], + }, + '_editor.js': { file: 'assets/editor.js' }, + 'src/features/page/history/chunk.ts': { + file: 'assets/history.js', + src: 'src/features/page/history/chunk.ts', + isDynamicEntry: true, + imports: ['_editor.js', '_diff.js'], + }, + '_diff.js': { file: 'assets/diff.js' }, + 'src/features/palette/Palette.tsx': { + file: 'assets/palette.js', + src: 'src/features/palette/Palette.tsx', + isDynamicEntry: true, + imports: ['_editor.js'], + }, + }; + const budget: Budget = { ...BUDGET, after: { within: 'features/page/', entry: 'features/page/PageBody' } }; + expect(groups(manifest, budget).slice(2)).toEqual([ + { name: 'src/features/page/PageBody.tsx', files: ['assets/page.js', 'assets/editor.js'], limitKb: 3 }, + { name: 'src/features/page/history/chunk.ts', files: ['assets/history.js', 'assets/diff.js'], limitKb: 3 }, + { name: 'src/features/palette/Palette.tsx', files: ['assets/palette.js', 'assets/editor.js'], limitKb: 2 }, + ]); + }); + it('measures gzipped sizes and reports groups over their limit', () => { const dist = mkdtempSync(join(tmpdir(), 'bundle-')); writeFileSync(join(dist, 'small.js'), 'export {};'); diff --git a/app/scripts/check-bundle.ts b/app/scripts/check-bundle.ts index 119dac9b0..bf740c98a 100644 --- a/app/scripts/check-bundle.ts +++ b/app/scripts/check-bundle.ts @@ -2,7 +2,8 @@ // gzips each group of files, and fails when a group passes its limit in app/bundle-budget.json. The groups are: // - start-up JavaScript: the entry and everything it imports statically // - start-up CSS: the style sheets of those chunks -// - one lazy group per dynamic import, without what start-up already loads +// - one lazy group per dynamic import, without what start-up already loads. A group that only ever loads after +// another one (the page's on-demand chunks load after the page) also leaves out what that one loads. // // Run after `npm run app:build`: // node app/scripts/check-bundle.ts [--dist app/dist] [--compare-with ] [--report ] @@ -29,6 +30,11 @@ export interface Budget { /** Limits for lazy groups whose source path contains the key, such as "features/palette". */ lazyKb: Record; lazyDefaultKb: number; + /** + * Lazy groups that load only after another one: a group whose source path contains `within` counts without the + * files of the group whose path contains `entry`, which is loaded by then. + */ + after?: { within: string; entry: string }; } export interface Group { @@ -68,8 +74,13 @@ export function groups(manifest: Manifest, budget: Budget): Group[] { limitKb: budget.startup.cssKb, }, ]; - for (const key of keys.filter((k) => manifest[k].isDynamicEntry)) { - const own = [...staticClosure(manifest, [key])].filter((k) => !startup.has(k)).map((k) => manifest[k]); + const lazyKeys = keys.filter((k) => manifest[k].isDynamicEntry); + const first = budget.after && lazyKeys.find((k) => k.includes(budget.after!.entry)); + const loadedFirst = first ? staticClosure(manifest, [first]) : new Set(); + for (const key of lazyKeys) { + const follows = first !== undefined && key !== first && key.includes(budget.after!.within); + const loaded = (k: string) => startup.has(k) || (follows && loadedFirst.has(k)); + const own = [...staticClosure(manifest, [key])].filter((k) => !loaded(k)).map((k) => manifest[k]); const limit = Object.entries(budget.lazyKb).find(([fragment]) => key.includes(fragment))?.[1]; const files = [...own.map((chunk) => chunk.file), ...new Set(own.flatMap((chunk) => chunk.css ?? []))]; result.push({ name: manifest[key].src ?? key, files, limitKb: limit ?? budget.lazyDefaultKb }); diff --git a/app/scripts/package-imports.test.ts b/app/scripts/package-imports.test.ts new file mode 100644 index 000000000..74e90ec2d --- /dev/null +++ b/app/scripts/package-imports.test.ts @@ -0,0 +1,49 @@ +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { importsIn, packageImports } from './package-imports.ts'; + +describe('importsIn', () => { + it('finds static imports, re-exports, and lazy imports of packages', () => { + const source = [ + "import { Editor } from '@tiptap/core';", + "import Moon, {\n type Icon,\n} from '@phosphor-icons/react/dist/csr/Moon';", + "export { lowlight } from 'lowlight';", + "const load = () => import('markdown-it');", + ].join('\n'); + expect(importsIn(source)).toEqual([ + '@tiptap/core', + '@phosphor-icons/react/dist/csr/Moon', + 'lowlight', + 'markdown-it', + ]); + }); + + it('leaves out the app’s own modules, types, styles, Node, and the test runner', () => { + const source = [ + "import { t } from '../strings/t';", + "import type { Root } from 'hast';", + "import type { Node } from '@tiptap/pm/model';", + "export type { Plugin } from 'vite';", + "import sheet from 'some-package/style.css';", + "import { readFileSync } from 'node:fs';", + "import { expect } from 'vitest';", + "import { page } from 'vitest/browser';", + ].join('\n'); + expect(importsIn(source)).toEqual([]); + }); +}); + +describe('packageImports', () => { + const found = packageImports(join(import.meta.dirname, '..', 'src')); + + it('lists what the app imports, once each and sorted', () => { + expect(found).toContain('react'); + expect(found).toContain('@tiptap/pm/state'); + expect(found).toEqual([...new Set(found)].sort()); + }); + + it('lists the modules lazy chunks import, which a late scan would meet mid-test', () => { + expect(found.some((name) => name.startsWith('@phosphor-icons/react/dist/csr/'))).toBe(true); + expect(found.some((name) => name.startsWith('highlight.js/lib/languages/'))).toBe(true); + }); +}); diff --git a/app/scripts/package-imports.ts b/app/scripts/package-imports.ts new file mode 100644 index 000000000..1a4acf258 --- /dev/null +++ b/app/scripts/package-imports.ts @@ -0,0 +1,43 @@ +// The package modules the app's source imports, for the component tests' dependency bundling (vitest.config.ts). +// +// Vite bundles a package the first time it meets it. One it meets after the tests start, in a chunk that loads +// lazily, reloads the page under a running test, and the test fails. Vite's own scan misses some of them, so on a +// machine with no cache (CI) the first run failed. Listing every import bundles them all before the first test. + +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** `import ... from 'x'`, `export ... from 'x'`, and `import('x')`, where x is a package and no type import. */ +const IMPORT = /\b(?:import|export)\s+(?!type\b)[^'"`;]*?\bfrom\s+'([^'./][^']*)'|\bimport\(\s*'([^'./][^']*)'\s*\)/g; + +/** Packages that are not bundled for the browser: Node's own, the test runner's, and type-only ones. */ +const SKIPPED = /^(node:|virtual:|vitest($|\/)|@vitest\/|hast$)/; + +const SOURCE = /\.tsx?$/; +const TEST = /\.test\.tsx?$/; + +/** The package modules imported in one module's text. */ +export function importsIn(source: string): string[] { + const found: string[] = []; + for (const match of source.matchAll(IMPORT)) { + const name = match[1] ?? match[2]; + if (name && !SKIPPED.test(name) && !/\.css$|\?/.test(name)) found.push(name); + } + return found; +} + +/** Every package module imported under `dir`, outside test files, sorted. */ +export function packageImports(dir: string): string[] { + const found = new Set(); + const walk = (folder: string): void => { + for (const entry of readdirSync(folder, { withFileTypes: true })) { + const path = join(folder, entry.name); + if (entry.isDirectory()) walk(path); + else if (SOURCE.test(entry.name) && !TEST.test(entry.name)) { + for (const name of importsIn(readFileSync(path, 'utf8'))) found.add(name); + } + } + }; + walk(dir); + return [...found].sort(); +} diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 5d103ccf5..76f5f525e 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -28,6 +28,8 @@ serde.workspace = true serde_json.workspace = true sha2.workspace = true tauri = { version = "2", features = [] } +# Web images for paste (Phase 4 ARCHITECTURE.md section 12.2), with the same TLS stack and version as the updater. +ureq = { version = "3.4.2", default-features = false, features = ["native-tls-no-default"] } [target.'cfg(windows)'.dependencies] webview2-com = "0.39.1" @@ -39,14 +41,18 @@ features = [ "Networking_Connectivity", "UI_ViewManagement", "Win32_Foundation", + "Win32_Globalization", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", + "Win32_Graphics_Imaging", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_DataExchange", "Win32_System_LibraryLoader", + "Win32_System_Memory", + "Win32_System_Ole", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_Variant", diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index e497ac6f2..35d3fe2eb 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -34,6 +34,29 @@ "allow-updater-unskip", "allow-updater-go-back", "allow-notes-snapshot-load", - "allow-notes-snapshot-save" + "allow-notes-snapshot-save", + "allow-page-open", + "allow-page-apply", + "allow-page-undo", + "allow-page-redo", + "allow-page-save-now", + "allow-page-close", + "allow-history-list", + "allow-history-open", + "allow-history-restore", + "allow-history-restore-blocks", + "allow-history-name", + "allow-clipboard-facts", + "allow-clipboard-read", + "allow-image-import", + "allow-image-import-url", + "allow-image-import-clip", + "allow-spell-languages", + "allow-spell-check", + "allow-spell-suggest", + "allow-spell-add-word", + "allow-spell-remove-word", + "allow-speech-voices", + "allow-speech-synthesize" ] } diff --git a/app/src-tauri/src/clipboard.rs b/app/src-tauri/src/clipboard.rs new file mode 100644 index 000000000..46e323bdf --- /dev/null +++ b/app/src-tauri/src/clipboard.rs @@ -0,0 +1,590 @@ +//! Clipboard facts for paste (Phase 4 ARCHITECTURE.md section 15.2): the clipboard sequence number, a hash of its +//! text, CF_HTML's source address, whether OneNote put its formats there, and file tokens for the temporary images +//! Word and OneNote leave in `%TEMP%\msohtmlclip*`. The browser's paste event can't see any of these. The page uses +//! the facts only when the text hash matches its own paste, so they provably belong to it, and it never names a +//! path: a token stands for one file whose path was resolved and checked here. + +use std::{ + collections::{hash_map::RandomState, HashMap}, + hash::{BuildHasher, Hasher}, + path::{Path, PathBuf}, + sync::{Mutex, PoisonError}, + time::{Duration, Instant}, +}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; +use tauri::State; + +use crate::ipc::{IpcError, IpcResult}; + +/// How long a file token stays valid. +const TOKEN_LIFE: Duration = Duration::from_secs(60); + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ClipboardFacts { + pub sequence: u32, + pub text_sha256: Option, + pub source_url: Option, + pub has_one_note: bool, + pub word_images: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ClipImage { + pub src: String, + pub token: String, +} + +/// Tokens that name files the shell found, so the interface never sends a path back. +pub struct ClipTokens { + tokens: Mutex>, + random: RandomState, + counter: Mutex, +} + +impl Default for ClipTokens { + fn default() -> Self { + Self { + tokens: Mutex::new(HashMap::new()), + random: RandomState::new(), + counter: Mutex::new(0), + } + } +} + +impl ClipTokens { + /// 128 bits from the process's random hash keys and a counter, so a token can't be guessed from another. + fn fresh(&self) -> String { + let mut counter = self.counter.lock().unwrap_or_else(PoisonError::into_inner); + *counter += 1; + let half = |salt: u64| { + let mut hasher = self.random.build_hasher(); + hasher.write_u64(*counter); + hasher.write_u64(salt); + hasher.finish() + }; + format!("clip-{:016x}{:016x}", half(0x6f70_656e), half(0x6e6f_7465)) + } + + pub fn issue(&self, path: PathBuf) -> String { + let token = self.fresh(); + let mut tokens = self.tokens.lock().unwrap_or_else(PoisonError::into_inner); + tokens.retain(|_, (_, at)| at.elapsed() < TOKEN_LIFE); + tokens.insert(token.clone(), (path, Instant::now())); + token + } + + pub fn resolve(&self, token: &str) -> Option { + let tokens = self.tokens.lock().unwrap_or_else(PoisonError::into_inner); + let (path, at) = tokens.get(token)?; + (at.elapsed() < TOKEN_LIFE).then(|| path.clone()) + } + + #[cfg(test)] + fn age(&self, token: &str, by: Duration) { + let mut tokens = self.tokens.lock().unwrap_or_else(PoisonError::into_inner); + if let Some((_, at)) = tokens.get_mut(token) { + *at = at.checked_sub(by).unwrap_or(*at); + } + } +} + +/// What one read of the clipboard found, before the checks. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Snapshot { + pub sequence: u32, + pub text: Option, + /// The `HTML Format` item: a CF_HTML header, then UTF-8 HTML. + pub html: Option>, + pub has_one_note: bool, + /// A `CF_DIB` item: a bitmap header and pixels, without the file header. + pub dib: Option>, +} + +/// SHA-256 of the text with CRLF turned into LF, as lowercase hexadecimal, which the page computes the same way. +pub fn text_hash(text: &str) -> String { + let normalized = text.replace("\r\n", "\n"); + let digest = Sha256::digest(normalized.as_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// The HTML of a CF_HTML item, from `StartHTML` to `EndHTML`, and its `SourceURL` when it is http or https. +pub fn cf_html(raw: &[u8]) -> (String, Option) { + let header_end = raw.iter().position(|byte| *byte == b'<').unwrap_or(raw.len()); + let header = String::from_utf8_lossy(&raw[..header_end]); + let field = |name: &str| { + header.lines().find_map(|line| { + let (key, value) = line.split_once(':')?; + (key.trim().eq_ignore_ascii_case(name)).then(|| value.trim().to_owned()) + }) + }; + let offset = |name: &str| field(name).and_then(|value| value.parse::().ok()); + let (start, end) = match (offset("StartHTML"), offset("EndHTML")) { + (Some(start), Some(end)) if start <= end && end <= raw.len() => (start, end), + _ => (header_end, raw.len()), + }; + let html = String::from_utf8_lossy(&raw[start..end]) + .trim_end_matches('\0') + .to_owned(); + let source = field("SourceURL").filter(|url| { + let lower = url.to_ascii_lowercase(); + (lower.starts_with("https://") || lower.starts_with("http://")) && !url.chars().any(char::is_whitespace) + }); + (html, source) +} + +/// Every distinct `file:` image source in the HTML, as written: Word's `` and ``. +pub fn file_image_sources(html: &str) -> Vec { + let mut found: Vec = Vec::new(); + let lower = html.to_ascii_lowercase(); + let mut from = 0; + while let Some(at) = lower[from..].find("src=") { + let start = from + at + 4; + from = start; + let Some(quote) = html[start..].chars().next().filter(|c| *c == '"' || *c == '\'') else { + continue; + }; + let Some(close) = html[start + 1..].find(quote) else { + break; + }; + let value = html_unescape(&html[start + 1..start + 1 + close]); + if value.to_ascii_lowercase().starts_with("file:") && !found.contains(&value) { + found.push(value); + } + from = start + 1 + close; + } + found +} + +fn html_unescape(text: &str) -> String { + text.replace("&", "&").replace(""", "\"").replace("'", "'") +} + +/// The path a `file:` URL names, on Windows' terms. +pub fn file_url_path(src: &str) -> Option { + let rest = src.get(5..)?.trim_start_matches('/'); + if rest.is_empty() { + return None; + } + let decoded = crate::images::import::percent_decode(rest); + Some(PathBuf::from(decoded.replace('/', "\\"))) +} + +const CLIP_IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "bmp", "webp", "tif", "tiff", "heic"]; + +/// The file, with links resolved, when it is an image inside a `msohtmlclip` folder of the temp folder. +pub fn checked_clip_path(path: &Path, temp: &Path) -> Option { + let resolved = std::fs::canonicalize(path).ok()?; + let temp = std::fs::canonicalize(temp).ok()?; + let inside = resolved.strip_prefix(&temp).ok()?; + let in_clip_folder = inside.parent().into_iter().flat_map(Path::components).any(|part| { + part.as_os_str() + .to_string_lossy() + .to_ascii_lowercase() + .starts_with("msohtmlclip") + }); + let extension = resolved.extension()?.to_string_lossy().to_ascii_lowercase(); + (in_clip_folder && CLIP_IMAGE_EXTENSIONS.contains(&extension.as_str()) && resolved.is_file()).then_some(resolved) +} + +/// A BMP file from a `CF_DIB` item: the 14-byte file header, then the item. +pub fn dib_to_bmp(dib: &[u8]) -> Option> { + let le32 = |at: usize| Some(u32::from_le_bytes(dib.get(at..at + 4)?.try_into().ok()?)); + let le16 = |at: usize| Some(u16::from_le_bytes(dib.get(at..at + 2)?.try_into().ok()?)); + let header = le32(0)?; + if header < 12 || header as usize > dib.len() { + return None; + } + let (bits, compression, used) = if header == 12 { + (le16(10)?, 0, 0) + } else { + (le16(14)?, le32(16)?, le32(32)?) + }; + let masks = if header == 40 && (compression == 3 || compression == 6) { + if compression == 6 { + 16 + } else { + 12 + } + } else { + 0 + }; + let entry = if header == 12 { 3 } else { 4 }; + let colors = if bits <= 8 { + if used == 0 { + 1u32 << bits + } else { + used + } + } else { + used + }; + let offset = 14 + header as usize + masks + colors as usize * entry; + let total = 14 + dib.len(); + let mut out = Vec::with_capacity(total); + out.extend_from_slice(b"BM"); + out.extend_from_slice(&u32::try_from(total).ok()?.to_le_bytes()); + out.extend_from_slice(&[0, 0, 0, 0]); + out.extend_from_slice(&u32::try_from(offset).ok()?.to_le_bytes()); + out.extend_from_slice(dib); + Some(out) +} + +/// The facts of a snapshot, with a token issued for each Word image that passes the path checks. +pub fn facts_of(snapshot: &Snapshot, tokens: &ClipTokens, temp: &Path) -> ClipboardFacts { + let (html, source_url) = snapshot.html.as_deref().map(cf_html).unwrap_or_default(); + let word_images = file_image_sources(&html) + .into_iter() + .filter_map(|src| { + let path = checked_clip_path(&file_url_path(&src)?, temp)?; + Some(ClipImage { + token: tokens.issue(path), + src, + }) + }) + .collect(); + ClipboardFacts { + sequence: snapshot.sequence, + text_sha256: snapshot.text.as_deref().map(text_hash), + source_url, + has_one_note: snapshot.has_one_note, + word_images, + } +} + +#[cfg(windows)] +mod win { + use windows::{ + core::w, + Win32::{ + Foundation::{HANDLE, HGLOBAL}, + System::{ + DataExchange::{ + CloseClipboard, EnumClipboardFormats, GetClipboardData, GetClipboardFormatNameW, + GetClipboardSequenceNumber, OpenClipboard, RegisterClipboardFormatW, + }, + Memory::{GlobalLock, GlobalSize, GlobalUnlock}, + Ole::{CF_DIB, CF_UNICODETEXT}, + }, + }, + }; + + use super::Snapshot; + + /// Copies a clipboard item's memory. + /// + /// # Safety + /// The clipboard must be open, and `handle` one of its items. + unsafe fn bytes_of(handle: HANDLE) -> Option> { + let memory = HGLOBAL(handle.0); + let size = GlobalSize(memory); + let start = GlobalLock(memory) as *const u8; + if start.is_null() { + return None; + } + let out = std::slice::from_raw_parts(start, size).to_vec(); + let _ = GlobalUnlock(memory); + Some(out) + } + + fn open() -> bool { + for attempt in 0..10 { + // SAFETY: no window owns the clipboard while it is open here, and it is closed by the caller. + if unsafe { OpenClipboard(None) }.is_ok() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(5 + attempt * 5)); + } + false + } + + fn read_open(content: bool) -> Snapshot { + let mut snapshot = Snapshot::default(); + // SAFETY: the clipboard is open for the whole read, and each item is copied before it closes. + unsafe { + snapshot.sequence = GetClipboardSequenceNumber(); + let html_format = RegisterClipboardFormatW(w!("HTML Format")); + if let Ok(handle) = GetClipboardData(u32::from(CF_UNICODETEXT.0)) { + snapshot.text = bytes_of(handle).map(|bytes| { + let units: Vec = bytes + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .collect(); + let end = units.iter().position(|unit| *unit == 0).unwrap_or(units.len()); + String::from_utf16_lossy(&units[..end]) + }); + } + if let Ok(handle) = GetClipboardData(html_format) { + snapshot.html = bytes_of(handle); + } + let mut format = EnumClipboardFormats(0); + while format != 0 { + let mut name = [0u16; 128]; + let length = GetClipboardFormatNameW(format, &mut name); + if length > 0 { + let name = String::from_utf16_lossy(&name[..length as usize]).to_ascii_lowercase(); + if name.contains("onenote") { + snapshot.has_one_note = true; + } + } + format = EnumClipboardFormats(format); + } + if content { + if let Ok(handle) = GetClipboardData(u32::from(CF_DIB.0)) { + snapshot.dib = bytes_of(handle); + } + } + } + snapshot + } + + /// One consistent read: the sequence number before and after must agree, or the read runs once more. + pub fn read(content: bool) -> Option { + let mut last = None; + for _ in 0..2 { + if !open() { + return None; + } + let snapshot = read_open(content); + // SAFETY: opened above. + let _ = unsafe { CloseClipboard() }; + // SAFETY: a plain query. + let after = unsafe { GetClipboardSequenceNumber() }; + let stable = after == snapshot.sequence; + last = Some(snapshot); + if stable { + break; + } + } + last + } +} + +#[cfg(windows)] +fn read_snapshot(content: bool) -> IpcResult { + win::read(content).ok_or_else(|| IpcError::new("busy", "Another app is using the clipboard. Try again.")) +} + +#[cfg(not(windows))] +fn read_snapshot(_content: bool) -> IpcResult { + Err(IpcError::not_implemented("clipboard_facts")) +} + +async fn snapshot_on_blocking(content: bool) -> IpcResult { + tauri::async_runtime::spawn_blocking(move || read_snapshot(content)) + .await + .map_err(|error| IpcError::new(crate::ipc::codes::INTERNAL, error.to_string()))? +} + +#[tauri::command] +pub async fn clipboard_facts(tokens: State<'_, ClipTokens>) -> IpcResult { + let snapshot = snapshot_on_blocking(false).await?; + Ok(facts_of(&snapshot, &tokens, &std::env::temp_dir())) +} + +/// The JSON of `clipboard_read`: the facts, the CF_HTML's HTML, and the text. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ContentJson { + #[serde(flatten)] + facts: ClipboardFacts, + html: Option, + text: Option, +} + +/// The JSON length as 4 bytes, the JSON (facts, html, text), then BMP bytes if the clipboard holds an image. +pub fn encode_content(snapshot: &Snapshot, facts: ClipboardFacts) -> Vec { + let html = snapshot.html.as_deref().map(|raw| cf_html(raw).0); + let json = serde_json::to_vec(&ContentJson { + facts, + html, + text: snapshot.text.as_ref().map(|text| text.replace("\r\n", "\n")), + }) + .unwrap_or_else(|_| b"{}".to_vec()); + let mut out = Vec::with_capacity(4 + json.len()); + out.extend_from_slice(&u32::try_from(json.len()).unwrap_or(0).to_le_bytes()); + out.extend_from_slice(&json); + if let Some(bmp) = snapshot.dib.as_deref().and_then(dib_to_bmp) { + out.extend_from_slice(&bmp); + } + out +} + +#[tauri::command] +pub async fn clipboard_read(tokens: State<'_, ClipTokens>) -> IpcResult { + let snapshot = snapshot_on_blocking(true).await?; + let facts = facts_of(&snapshot, &tokens, &std::env::temp_dir()); + Ok(tauri::ipc::Response::new(encode_content(&snapshot, facts))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tokens_name_their_files_and_expire() { + let tokens = ClipTokens::default(); + let token = tokens.issue(PathBuf::from("a.png")); + assert_eq!(tokens.resolve(&token), Some(PathBuf::from("a.png"))); + assert_eq!(tokens.resolve("clip-999"), None); + let other = tokens.issue(PathBuf::from("b.png")); + assert_ne!(other, token); + assert_eq!(token.len(), "clip-".len() + 32); + tokens.age(&token, TOKEN_LIFE + Duration::from_secs(1)); + assert_eq!(tokens.resolve(&token), None); + assert_eq!(tokens.resolve(&other), Some(PathBuf::from("b.png"))); + } + + #[test] + fn text_hashes_ignore_crlf() { + assert_eq!(text_hash("a\r\nb"), text_hash("a\nb")); + assert_eq!( + text_hash(""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + fn cf_html_item(html: &str, source: &str) -> Vec { + let head = |start: usize, end: usize| { + format!("Version:0.9\r\nStartHTML:{start:010}\r\nEndHTML:{end:010}\r\nSourceURL:{source}\r\n") + }; + let length = head(0, 0).len(); + let mut out = head(length, length + html.len()).into_bytes(); + out.extend_from_slice(html.as_bytes()); + out.push(0); + out + } + + #[test] + fn cf_html_gives_its_html_and_a_web_source() { + let html = "

Hi

"; + let (found, source) = cf_html(&cf_html_item(html, "https://en.wikipedia.org/wiki/Tea")); + assert_eq!(found, html); + assert_eq!(source.as_deref(), Some("https://en.wikipedia.org/wiki/Tea")); + assert_eq!(cf_html(&cf_html_item(html, "file:///C:/a.html")).1, None); + assert_eq!(cf_html(&cf_html_item(html, "javascript:alert(1)")).1, None); + assert_eq!(cf_html(b"

no header

").0, "

no header

"); + } + + #[test] + fn word_images_are_found_by_their_file_sources() { + let html = r#"x + + + "#; + assert_eq!( + file_image_sources(html), + [ + "file:///C:/Users/a/AppData/Local/Temp/msohtmlclip1/01/clip_image002.png", + "file:///C:/Users/a/AppData/Local/Temp/msohtmlclip1/01/clip_image001.png", + ] + ); + assert_eq!( + file_url_path("file:///C:/Temp/msohtmlclip1/01/clip%20image.png"), + Some(PathBuf::from(r"C:\Temp\msohtmlclip1\01\clip image.png")) + ); + } + + #[test] + fn only_images_in_msohtmlclip_folders_of_the_temp_folder_pass() { + let temp = tempfile::tempdir().unwrap(); + let clip = temp.path().join("msohtmlclip1").join("01"); + std::fs::create_dir_all(&clip).unwrap(); + let image = clip.join("clip_image001.png"); + std::fs::write(&image, b"png").unwrap(); + let list = clip.join("clip_filelist.xml"); + std::fs::write(&list, b"").unwrap(); + let outside = temp.path().join("other.png"); + std::fs::write(&outside, b"png").unwrap(); + assert!(checked_clip_path(&image, temp.path()).is_some()); + assert!(checked_clip_path(&list, temp.path()).is_none()); + assert!(checked_clip_path(&outside, temp.path()).is_none()); + assert!(checked_clip_path(&clip.join("..").join("..").join("other.png"), temp.path()).is_none()); + assert!(checked_clip_path(&clip.join("missing.png"), temp.path()).is_none()); + let elsewhere = tempfile::tempdir().unwrap(); + assert!(checked_clip_path(&image, elsewhere.path()).is_none()); + + let tokens = ClipTokens::default(); + let src = format!("file:///{}", image.display().to_string().replace('\\', "/")); + let html = format!("", outside.display()); + let snapshot = Snapshot { + sequence: 7, + text: Some("a\r\nb".into()), + html: Some(cf_html_item(&html, "")), + ..Snapshot::default() + }; + let facts = facts_of(&snapshot, &tokens, temp.path()); + assert_eq!(facts.sequence, 7); + assert_eq!(facts.text_sha256, Some(text_hash("a\nb"))); + assert_eq!(facts.word_images.len(), 1); + assert_eq!(facts.word_images[0].src, src); + let resolved = tokens.resolve(&facts.word_images[0].token).unwrap(); + assert_eq!(resolved, std::fs::canonicalize(&image).unwrap()); + } + + #[test] + fn a_dib_becomes_a_bmp_file() { + // A 2 by 1 picture, 24 bits per pixel, its row padded to 8 bytes. + let mut dib = Vec::new(); + for value in [40u32, 2, 1] { + dib.extend_from_slice(&value.to_le_bytes()); + } + dib.extend_from_slice(&1u16.to_le_bytes()); + dib.extend_from_slice(&24u16.to_le_bytes()); + dib.extend_from_slice(&[0; 24]); + dib.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0]); + let bmp = dib_to_bmp(&dib).unwrap(); + assert_eq!(&bmp[..2], b"BM"); + assert_eq!(u32::from_le_bytes(bmp[10..14].try_into().unwrap()), 54); + let probed = crate::images::probe(&bmp).unwrap(); + assert_eq!((probed.width, probed.height), (2, 1)); + } + + #[test] + fn content_is_its_json_length_then_json_then_the_bmp() { + let snapshot = Snapshot { + sequence: 1, + text: Some("x\r\ny".into()), + ..Snapshot::default() + }; + let facts = facts_of(&snapshot, &ClipTokens::default(), Path::new(".")); + let out = encode_content(&snapshot, facts); + let length = u32::from_le_bytes(out[..4].try_into().unwrap()) as usize; + let json: serde_json::Value = serde_json::from_slice(&out[4..4 + length]).unwrap(); + assert_eq!(json["text"], "x\ny"); + assert_eq!(json["sequence"], 1); + assert_eq!(json["hasOneNote"], false); + assert_eq!(out.len(), 4 + length); + } + + /// The real clipboard, through the clipset tool. It replaces what the clipboard holds, so it runs only when + /// OPENNOTE_CLIPBOARD_TESTS is set: `OPENNOTE_CLIPBOARD_TESTS=1 cargo test -p opennote clipboard`. + #[cfg(windows)] + #[test] + fn facts_from_the_real_clipboard() { + if std::env::var_os("OPENNOTE_CLIPBOARD_TESTS").is_none() { + return; + } + let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../../tests"); + let case = format!("{root}/fixtures/clipboard/web/wikipedia-table"); + let status = std::process::Command::new("powershell") + .args(["-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-File"]) + .arg(format!("{root}/e2e/tools/clipset/clipset.ps1")) + .arg(&case) + .status() + .unwrap(); + assert!(status.success()); + let snapshot = read_snapshot(true).unwrap(); + let facts = facts_of(&snapshot, &ClipTokens::default(), &std::env::temp_dir()); + let text = std::fs::read_to_string(format!("{case}/text.txt")).unwrap(); + assert_eq!(facts.text_sha256, Some(text_hash(&text))); + assert!(facts + .source_url + .is_some_and(|url| url.starts_with("https://en.wikipedia.org/"))); + assert!(!facts.has_one_note); + } +} diff --git a/app/src-tauri/src/command_list.rs b/app/src-tauri/src/command_list.rs index df1b2dc9b..109b7651e 100644 --- a/app/src-tauri/src/command_list.rs +++ b/app/src-tauri/src/command_list.rs @@ -17,6 +17,10 @@ pub const APP_COMMANDS: &[&str] = &[ "updater_status", "updater_check", "updater_download", "updater_restart_to_update", "updater_skip", "updater_unskip", "updater_go_back", "notes_snapshot_load", "notes_snapshot_save", + "page_open", "page_apply", "page_undo", "page_redo", "page_save_now", "page_close", + "history_list", "history_open", "history_restore", "history_restore_blocks", "history_name", + "clipboard_facts", "clipboard_read", "image_import", "image_import_url", "image_import_clip", + "spell_languages", "spell_check", "spell_suggest", "spell_add_word", "spell_remove_word", "speech_voices", "speech_synthesize", ]; #[cfg(test)] diff --git a/app/src-tauri/src/core_bridge.rs b/app/src-tauri/src/core_bridge.rs new file mode 100644 index 000000000..777849d21 --- /dev/null +++ b/app/src-tauri/src/core_bridge.rs @@ -0,0 +1,656 @@ +//! Phase 3's core behind the page commands (core plan 11.1; Phase 4 PLAN.md section 3.13). Phase 3 planned this +//! bridge for its notes service; WP0 of Phase 4 builds the part pages need, so typed text reaches the core and +//! survives a restart. +//! +//! Until the storage-backed notes service lands (`storage.core`), the interface's pages come from Phase 2's notes +//! snapshot, whose IDs the core doesn't know. The bridge keeps their content in one notebook of its own, under +//! `%LOCALAPPDATA%\OpenNote\phase4`, and a small map from each interface page ID to the core page that holds it. +//! The core starts the first time a page opens, so start-up doesn't wait for it. +//! +//! WP2 adds the history commands and turns the core's events into `core:*` events for the interface, with the +//! interface's page IDs. + +use std::{ + collections::{BTreeMap, HashMap}, + fs, io, + path::{Path, PathBuf}, + sync::{Arc, Mutex, OnceLock, PoisonError}, + time::Duration, +}; + +use opennote_core::{ + model::{history::VersionEntry, page::Rect}, + ops::{ + resolve::{Edit, TxnRequest}, + CoalesceKey, + }, + session::{ + core::{Core, CoreConfig}, + events::{CoreEvent, EventSink}, + notebook::{NodePlacement, NotebookHandle, ParentRef}, + page::{PageHandle, RestoreResult, TxnAck}, + }, + BlockId, ClientId, CoreError, PageId, RevisionId, SectionId, +}; +use serde_json::Value; +use tauri::{ipc::Response, AppHandle, Emitter, Manager, State}; + +use crate::{ + ipc::{codes, IpcError, IpcResult}, + paths::Paths, + settings::file::write_atomic, +}; + +/// How long the exit flush may take before the journals keep the rest for the next start (core plan 9.3). +const EXIT_FLUSH: Duration = Duration::from_secs(5); + +/// The longest page ID the commands take. The interface's page IDs are far shorter. +const MAX_PAGE_ID: usize = 128; + +/// What the interface hears when the core can't start. The cause, which can name local paths, goes to the log. +const NOT_STARTED: &str = "OpenNote couldn't open its pages. Try again in a moment."; + +/// Sends one event to the interface. +type Emit = Box; + +/// Where the core's page events go: the app, once a page has opened, under the interface's page IDs. It holds the +/// app behind a closure, so unit tests never link the windowing code an `AppHandle` drags in. +#[derive(Default)] +struct Relay { + emit: OnceLock, + /// Core page ID to interface page ID. + pages: Mutex>, +} + +/// The core's events, as `core:*` events for the interface (core plan 11.2). +struct AppEvents(Arc); + +impl EventSink for AppEvents { + fn emit(&self, event: CoreEvent) { + if let CoreEvent::SaveFailed { .. } = &event { + ::log::warn!("The core couldn't save a page: {event:?}"); + } + let (name, page) = match &event { + CoreEvent::TxnApplied { page, .. } => ("core:txn-applied", *page), + CoreEvent::ExternalChange { page, .. } => ("core:external-change", *page), + CoreEvent::ReadOnly { page, .. } => ("core:read-only", *page), + CoreEvent::Saved { page, .. } => ("core:saved", *page), + CoreEvent::SaveFailed { page, .. } => ("core:save-failed", *page), + _ => return, + }; + let Some(emit) = self.0.emit.get() else { + return; + }; + let pages = self.0.pages.lock().unwrap_or_else(PoisonError::into_inner); + let Some(ui) = pages.get(&page).cloned() else { + return; + }; + drop(pages); + let Ok(mut payload) = serde_json::to_value(&event) else { + return; + }; + payload["page"] = Value::String(ui); + emit(name, payload); + } +} + +/// The managed state behind the page commands. +pub struct CoreBridge { + root: PathBuf, + /// The started bridge. A start that failed leaves it empty, so the next command tries again. + state: Mutex>, + relay: Arc, +} + +struct Bridge { + core: Core, + notebook: NotebookHandle, + section: SectionId, + map_file: PathBuf, + /// Interface page ID to core page ID. + map: BTreeMap, + /// Open pages by core page and client. + open: HashMap<(PageId, ClientId), PageHandle>, + relay: Arc, +} + +fn internal(error: impl std::fmt::Display) -> IpcError { + IpcError::new(codes::INTERNAL, error.to_string()) +} + +fn invalid(field: &str, error: impl std::fmt::Display) -> IpcError { + IpcError::invalid(field, &error.to_string()) +} + +/// A core error with the code the page service reads: an edit's own code, `readOnly`, `notFound`, or internal. +fn core_error(error: CoreError) -> IpcError { + let code = match &error { + CoreError::Edit(edit) => edit.code(), + CoreError::ReadOnly(_) => "readOnly", + CoreError::NotFound(_) => "notFound", + _ => codes::INTERNAL, + }; + IpcError::new(code, error.to_string()) +} + +fn revision_id(text: &str) -> IpcResult { + RevisionId::parse(text).map_err(|error| invalid("revision", error)) +} + +/// Checks a page argument: a short, plain ID, as the interface's page IDs are, so nothing else becomes a key of the +/// page map. +fn check_page(page: &str) -> IpcResult<()> { + let plain = page + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'); + if page.is_empty() || page.len() > MAX_PAGE_ID || !plain { + return Err(IpcError::invalid("page", "isn't a page ID")); + } + Ok(()) +} + +/// The page map, or an empty one when there is no file yet. Any other failure stops the start, so a map that can't +/// be read now is never replaced by one without its pages. +fn read_map(file: &Path) -> Result, IpcError> { + match fs::read_to_string(file) { + Ok(text) => serde_json::from_str(&text) + .map_err(|error| internal(format!("The page map at {} can't be read: {error}", file.display()))), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(BTreeMap::new()), + Err(error) => Err(internal(format!( + "The page map at {} can't be read: {error}", + file.display() + ))), + } +} + +impl Bridge { + fn start(root: &Path, relay: Arc) -> Result { + // The map goes first, so a map that can't be read stops the start before the core holds the notebook. + let map_file = root.join("pages.json"); + let map = read_map(&map_file)?; + let config = + CoreConfig::production(root.join("core"), env!("CARGO_PKG_VERSION").to_owned()).map_err(internal)?; + let core = Core::start(config, Arc::new(AppEvents(relay.clone())), None).map_err(internal)?; + let dir = root.join("Pages"); + let notebook = if dir.join("notebook.json").exists() { + core.open_notebook(&dir) + } else { + core.create_notebook(root, "Pages") + } + .map_err(internal)?; + let section = match notebook.tree().sections.first() { + Some(section) => section.id, + None => { + let at = NodePlacement { + parent: ParentRef::Notebook, + before: None, + }; + notebook.create_section("Pages", at).map_err(internal)? + } + }; + Ok(Bridge { + core, + notebook, + section, + map_file, + map, + open: HashMap::new(), + relay, + }) + } + + /// Lets the core's events for a core page reach the interface under its page ID. + fn relay_page(&self, ui: &str, core: PageId) { + let mut pages = self.relay.pages.lock().unwrap_or_else(PoisonError::into_inner); + pages.entry(core).or_insert_with(|| ui.to_owned()); + } + + /// The core page that holds an interface page, made the first time it opens. The map holds a new page only once + /// the file does, so a page whose mapping wasn't written isn't typed into. + fn core_page(&mut self, page: &str) -> IpcResult { + check_page(page)?; + if let Some(id) = self.map.get(page) { + return PageId::parse(id).map_err(|error| invalid("page", error)); + } + let at = NodePlacement { + parent: ParentRef::Section(self.section), + before: None, + }; + let id = self.notebook.create_page(self.section, at).map_err(internal)?; + let mut next = self.map.clone(); + next.insert(page.to_owned(), id.to_string()); + let json = serde_json::to_vec_pretty(&next).map_err(internal)?; + write_atomic(&self.map_file, &json)?; + self.map = next; + Ok(id) + } + + /// The client's open session of a page, for every command but page_open: only page_open makes a page. + fn open_handle(&self, page: &str, client: &str) -> IpcResult { + check_page(page)?; + let not_open = || IpcError::new("notFound", "This page isn't open."); + let id = self + .map + .get(page) + .and_then(|id| PageId::parse(id).ok()) + .ok_or_else(not_open)?; + let client = ClientId::parse(client).map_err(|error| invalid("client", error))?; + self.open.get(&(id, client)).cloned().ok_or_else(not_open) + } + + /// The client's session of a page, opened and made first when needed (page_open). + fn handle(&mut self, page: &str, client: &str) -> IpcResult { + let id = self.core_page(page)?; + self.relay_page(page, id); + let client = ClientId::parse(client).map_err(|error| invalid("client", error))?; + if let Some(handle) = self.open.get(&(id, client.clone())) { + return Ok(handle.clone()); + } + let handle = self.notebook.open_page(id, client.clone()).map_err(internal)?; + self.open.insert((id, client), handle.clone()); + Ok(handle) + } + + fn open_handles(&self, page: &str) -> Vec { + let Some(id) = self.map.get(page).and_then(|id| PageId::parse(id).ok()) else { + return Vec::new(); + }; + self.open + .iter() + .filter(|((open, _), _)| *open == id) + .map(|(_, handle)| handle.clone()) + .collect() + } +} + +impl CoreBridge { + pub fn new(paths: &Paths) -> CoreBridge { + CoreBridge::at(paths.local.join("phase4")) + } + + fn at(root: PathBuf) -> CoreBridge { + CoreBridge { + root, + state: Mutex::new(None), + relay: Arc::default(), + } + } + + /// Runs `work` on the bridge, starting the core first if it hasn't started. A start that fails is logged and + /// tried again by the next command. + fn with(&self, work: impl FnOnce(&mut Bridge) -> IpcResult) -> IpcResult { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + if state.is_none() { + match Bridge::start(&self.root, self.relay.clone()) { + Ok(bridge) => *state = Some(bridge), + Err(error) => { + ::log::error!("The core couldn't start: {}", error.message); + return Err(IpcError::new(codes::INTERNAL, NOT_STARTED)); + } + } + } + match state.as_mut() { + Some(bridge) => work(bridge), + None => Err(IpcError::new(codes::INTERNAL, NOT_STARTED)), + } + } + + /// Saves every page and stops the core. The app calls it on exit. + pub fn shutdown(&self) { + let state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(bridge) = state.as_ref() { + if let Err(error) = bridge.core.flush_all(EXIT_FLUSH) { + ::log::error!("Couldn't save the open pages: {error}"); + } + bridge.core.shutdown(EXIT_FLUSH); + } + } +} + +/// An open page of the core, for Phase 4's image commands (P3-8). +pub fn page_handle(app: &AppHandle, page: PageId) -> Option { + let bridge = app.state::(); + let state = bridge.state.lock().unwrap_or_else(PoisonError::into_inner); + match state.as_ref() { + Some(bridge) => bridge + .open + .iter() + .find(|((id, _), _)| *id == page) + .map(|(_, handle)| handle.clone()), + _ => None, + } +} + +/// The core page that holds an interface page, if it was ever opened. +pub fn core_page_id(app: &AppHandle, page: &str) -> Option { + let bridge = app.state::(); + let state = bridge.state.lock().unwrap_or_else(PoisonError::into_inner); + match state.as_ref() { + Some(bridge) => bridge.map.get(page).and_then(|id| PageId::parse(id).ok()), + _ => None, + } +} + +fn edit_error(error: opennote_core::EditError) -> IpcError { + IpcError::new(error.code(), error.to_string()) +} + +/// The page envelope (core plan 11.3): session JSON, page.json, and the live strokes, in one buffer. +#[tauri::command] +pub async fn page_open( + app: AppHandle, + bridge: State<'_, CoreBridge>, + page: String, + client: String, + viewport: Option, +) -> IpcResult { + bridge.relay.emit.get_or_init(|| { + Box::new(move |name, payload| { + if let Err(error) = app.emit(name, payload) { + ::log::warn!("Couldn't send {name} to the interface: {error}"); + } + }) + }); + bridge.with(|bridge| { + let handle = bridge.handle(&page, &client)?; + let envelope = handle.envelope(viewport).map_err(internal)?; + Ok(Response::new(envelope.bytes)) + }) +} + +/// One transaction from the interface, in the client's sequence (core plan 11.4). +#[tauri::command] +pub async fn page_apply( + bridge: State<'_, CoreBridge>, + page: String, + client: String, + client_seq: u64, + coalesce: Option, + ui: Option, + edits: Vec, +) -> IpcResult { + bridge.with(|bridge| { + let handle = bridge.open_handle(&page, &client)?; + let request = TxnRequest { + page: handle.id(), + client: handle.client().clone(), + client_seq, + coalesce, + ui, + edits, + }; + handle.apply(request).map_err(edit_error) + }) +} + +/// The applied-changes frame of the undone step, or no bytes when there is nothing to undo. +#[tauri::command] +pub async fn page_undo(bridge: State<'_, CoreBridge>, page: String, client: String) -> IpcResult { + bridge.with(|bridge| { + let handle = bridge.open_handle(&page, &client)?; + let frame = handle.undo(handle.client()).map_err(edit_error)?; + Ok(Response::new(frame.map(|frame| frame.bytes).unwrap_or_default())) + }) +} + +#[tauri::command] +pub async fn page_redo(bridge: State<'_, CoreBridge>, page: String, client: String) -> IpcResult { + bridge.with(|bridge| { + let handle = bridge.open_handle(&page, &client)?; + let frame = handle.redo(handle.client()).map_err(edit_error)?; + Ok(Response::new(frame.map(|frame| frame.bytes).unwrap_or_default())) + }) +} + +/// Saves the page now, as Ctrl+S does. +#[tauri::command] +pub async fn page_save_now(bridge: State<'_, CoreBridge>, page: String) -> IpcResult<()> { + bridge.with(|bridge| { + for handle in bridge.open_handles(&page) { + handle.save_now().map_err(internal)?; + } + Ok(()) + }) +} + +/// Closes the client's session of the page. The core saves it in the background. +#[tauri::command] +pub async fn page_close(bridge: State<'_, CoreBridge>, page: String, client: String) -> IpcResult<()> { + bridge.with(|bridge| { + let Some(id) = bridge.map.get(&page).and_then(|id| PageId::parse(id).ok()) else { + return Ok(()); + }; + let client = ClientId::parse(&client).map_err(|error| invalid("client", error))?; + if let Some(handle) = bridge.open.remove(&(id, client.clone())) { + handle.close(&client).map_err(internal)?; + } + Ok(()) + }) +} + +/// The page's saved versions, newest first (core plan 8). +#[tauri::command] +pub async fn history_list(bridge: State<'_, CoreBridge>, page: String, client: String) -> IpcResult> { + bridge.with(|bridge| bridge.open_handle(&page, &client)?.history().map_err(core_error)) +} + +/// A saved version as a read-only page envelope. +#[tauri::command] +pub async fn history_open( + bridge: State<'_, CoreBridge>, + page: String, + client: String, + revision: String, +) -> IpcResult { + bridge.with(|bridge| { + let handle = bridge.open_handle(&page, &client)?; + let envelope = handle.open_version(revision_id(&revision)?).map_err(core_error)?; + Ok(Response::new(envelope.bytes)) + }) +} + +/// Restores a version in place, or as a new page. +#[tauri::command] +pub async fn history_restore( + bridge: State<'_, CoreBridge>, + page: String, + client: String, + revision: String, + as_copy: bool, +) -> IpcResult { + bridge.with(|bridge| { + let handle = bridge.open_handle(&page, &client)?; + handle + .restore_version(revision_id(&revision)?, as_copy) + .map_err(core_error) + }) +} + +/// Brings blocks back from a version as one transaction of the client (P3-7). +#[tauri::command] +pub async fn history_restore_blocks( + bridge: State<'_, CoreBridge>, + page: String, + client: String, + client_seq: u64, + revision: String, + blocks: Vec, +) -> IpcResult { + bridge.with(|bridge| { + let handle = bridge.open_handle(&page, &client)?; + handle + .restore_blocks(client_seq, revision_id(&revision)?, &blocks) + .map_err(edit_error) + }) +} + +/// Names a version, or marks it to keep forever. +#[tauri::command] +pub async fn history_name( + bridge: State<'_, CoreBridge>, + page: String, + client: String, + revision: String, + name: Option, + keep: bool, +) -> IpcResult<()> { + bridge.with(|bridge| { + let handle = bridge.open_handle(&page, &client)?; + handle + .name_version(revision_id(&revision)?, name, keep) + .map_err(core_error) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use opennote_core::ops::resolve::Edit; + + fn text_of(handle: &PageHandle) -> String { + let envelope = handle.envelope(None).expect("an envelope"); + let decoded = opennote_core::wire::envelope::decode(&envelope.bytes).expect("decodes"); + let page: Value = serde_json::from_slice(decoded.page_json).expect("page JSON"); + page["blocks"][0]["data"]["markdown"] + .as_str() + .unwrap_or_default() + .to_owned() + } + + fn insert(handle: &PageHandle, seq: u64, markdown: &str) { + let block: opennote_core::ops::resolve::NewBlock = serde_json::from_value(serde_json::json!({ + "id": "01k6f00000000000000000b001", "type": "text", "data": { "markdown": markdown } + })) + .expect("a new block"); + let request = TxnRequest { + page: handle.id(), + client: handle.client().clone(), + client_seq: seq, + coalesce: None, + ui: None, + edits: vec![Edit::InsertBlock { + block, + after: None, + before: None, + }], + }; + handle.apply(request).expect("applies"); + } + + #[test] + fn typed_text_survives_a_restart_of_the_core() { + let dir = tempfile::tempdir().expect("a temp folder"); + let bridge = CoreBridge::at(dir.path().to_owned()); + bridge + .with(|bridge| { + let handle = bridge.handle("p-mitosis", "main-1")?; + insert(&handle, 1, "Cells divide"); + Ok(()) + }) + .expect("the first run"); + bridge.shutdown(); + let again = CoreBridge::at(dir.path().to_owned()); + let text = again + .with(|bridge| Ok(text_of(&bridge.handle("p-mitosis", "main-1")?))) + .expect("the second run"); + again.shutdown(); + assert_eq!(text, "Cells divide"); + } + + #[test] + fn each_interface_page_gets_its_own_core_page() { + let dir = tempfile::tempdir().expect("a temp folder"); + let bridge = CoreBridge::at(dir.path().to_owned()); + let (one, two, again) = bridge + .with(|bridge| Ok((bridge.core_page("a")?, bridge.core_page("b")?, bridge.core_page("a")?))) + .expect("pages"); + bridge.shutdown(); + assert_ne!(one, two); + assert_eq!(one, again); + } + + #[test] + fn events_name_the_interface_page_and_history_keeps_closed_versions() { + let dir = tempfile::tempdir().expect("a temp folder"); + let bridge = CoreBridge::at(dir.path().to_owned()); + let (core, versions) = bridge + .with(|bridge| { + let handle = bridge.handle("p-history", "main-1")?; + insert(&handle, 1, "Kept when closed"); + let client = handle.client().clone(); + bridge.open.remove(&(handle.id(), client.clone())); + handle.close(&client).map_err(internal)?; + let again = bridge.handle("p-history", "main-1")?; + Ok((again.id(), again.history().map_err(core_error)?)) + }) + .expect("a saved page"); + let named = bridge.relay.pages.lock().expect("the relay").get(&core).cloned(); + bridge.shutdown(); + assert_eq!(named.as_deref(), Some("p-history")); + assert!(!versions.is_empty()); + assert_eq!(core_error(CoreError::NotFound("version".into())).code, "notFound"); + } + + #[test] + fn an_unreadable_page_map_stops_the_start_and_is_never_replaced() { + let dir = tempfile::tempdir().expect("a temp folder"); + let map_file = dir.path().join("pages.json"); + fs::write(&map_file, b"{ not json").expect("a damaged map"); + let bridge = CoreBridge::at(dir.path().to_owned()); + let error = bridge + .with(|bridge| bridge.handle("p-damaged", "main-1").map(|_| ())) + .expect_err("the start fails"); + assert_eq!(error.code, codes::INTERNAL); + assert!(!error.message.contains(&dir.path().display().to_string())); + assert_eq!(fs::read(&map_file).expect("the map"), b"{ not json"); + // A start that failed is tried again by the next command, once the file can be read. + fs::write(&map_file, br#"{ "p-kept": "01k6f00000000000000000p001" }"#).expect("a mended map"); + let kept = bridge + .with(|bridge| Ok(bridge.map.get("p-kept").cloned())) + .expect("the second start"); + bridge.shutdown(); + assert_eq!(kept.as_deref(), Some("01k6f00000000000000000p001")); + } + + #[test] + fn a_page_is_mapped_only_once_its_map_is_written() { + let dir = tempfile::tempdir().expect("a temp folder"); + let bridge = CoreBridge::at(dir.path().to_owned()); + let (first, second, mapped) = bridge + .with(|bridge| { + // A folder where the map goes makes every write fail. + bridge.map_file = dir.path().join("blocked"); + fs::create_dir(&bridge.map_file).map_err(internal)?; + let first = bridge.core_page("p-new").is_err(); + let second = bridge.core_page("p-new").is_err(); + Ok((first, second, bridge.map.contains_key("p-new"))) + }) + .expect("the bridge"); + bridge.shutdown(); + assert!(first && second); + assert!(!mapped); + } + + #[test] + fn only_page_open_makes_a_page_and_page_ids_are_checked() { + let dir = tempfile::tempdir().expect("a temp folder"); + let bridge = CoreBridge::at(dir.path().to_owned()); + let (unopened, opened) = bridge + .with(|bridge| { + let unopened = bridge.open_handle("p-never-opened", "main-1").map(|_| ()).unwrap_err(); + bridge.handle("p-opened", "main-1")?; + let opened = bridge.open_handle("p-opened", "main-1").is_ok(); + Ok((unopened, opened)) + }) + .expect("the bridge"); + let map = fs::read_to_string(dir.path().join("pages.json")).expect("the map"); + bridge.shutdown(); + assert_eq!(unopened.code, "notFound"); + assert!(opened); + assert!(!map.contains("p-never-opened")); + for bad in ["", "a/b", "..", "p 1", &"p".repeat(MAX_PAGE_ID + 1)] { + assert_eq!(check_page(bad).expect_err(bad).code, codes::INVALID); + } + assert!(check_page("01k6f00000000000000000p001").is_ok()); + } +} diff --git a/app/src-tauri/src/images/convert.rs b/app/src-tauri/src/images/convert.rs new file mode 100644 index 000000000..c30917522 --- /dev/null +++ b/app/src-tauri/src/images/convert.rs @@ -0,0 +1,230 @@ +//! HEIC and TIFF conversion at import, behind `page.heicImport` (Phase 4 ARCHITECTURE.md section 12.7). WebView2 +//! can't show either, so when Windows Imaging Component has a codec for the file, the shell decodes it once, turns +//! it upright, and stores a JPEG (opaque) or PNG (with transparency) instead. HEIC needs the HEIF extension from the +//! Microsoft Store; without it the import is refused as before. + +use super::probe::ImageFormat; + +/// The converted file and its upright size. +pub struct Converted { + pub format: ImageFormat, + pub width: u32, + pub height: u32, + pub bytes: Vec, +} + +/// Larger pictures aren't converted: the decoded pixels alone would take 400 MB. +const MAX_PIXELS: u64 = 100_000_000; + +#[cfg(not(windows))] +pub fn to_web_format(_bytes: &[u8], _orientation: u8) -> Option { + None +} + +#[cfg(windows)] +pub fn to_web_format(bytes: &[u8], orientation: u8) -> Option { + use windows::{ + core::Interface, + Win32::{ + Foundation::HGLOBAL, + Graphics::Imaging::{ + GUID_ContainerFormatJpeg, GUID_ContainerFormatPng, GUID_WICPixelFormat32bppBGRA, IWICBitmapSource, + WICBitmapDitherTypeNone, WICBitmapEncoderNoCache, WICBitmapPaletteTypeCustom, + WICDecodeMetadataCacheOnDemand, + }, + System::Com::{ + StructuredStorage::{CreateStreamOnHGlobal, GetHGlobalFromStream}, + STREAM_SEEK_SET, + }, + System::Memory::{GlobalLock, GlobalSize, GlobalUnlock}, + }, + }; + + super::wic::with_com(|| { + // SAFETY: COM is initialized by `with_com`. The input stream reads `bytes`, which outlive every object here, + // and the output memory is read while it is locked and released with its stream. + unsafe { + let factory = super::wic::factory()?; + let input = factory.CreateStream()?; + input.InitializeFromMemory(bytes)?; + let decoder = factory.CreateDecoderFromStream(&input, std::ptr::null(), WICDecodeMetadataCacheOnDemand)?; + let frame: IWICBitmapSource = decoder.GetFrame(0)?.cast()?; + let rotator = factory.CreateBitmapFlipRotator()?; + rotator.Initialize(&frame, transform(orientation))?; + let converter = factory.CreateFormatConverter()?; + converter.Initialize( + &rotator, + &GUID_WICPixelFormat32bppBGRA, + WICBitmapDitherTypeNone, + None, + 0.0, + WICBitmapPaletteTypeCustom, + )?; + let (mut width, mut height) = (0u32, 0u32); + converter.GetSize(&mut width, &mut height)?; + if width == 0 || height == 0 || u64::from(width) * u64::from(height) > MAX_PIXELS { + return Err(windows::core::Error::from_hresult( + windows::Win32::Foundation::E_INVALIDARG, + )); + } + let stride = width * 4; + let mut pixels = vec![0u8; stride as usize * height as usize]; + converter.CopyPixels(std::ptr::null(), stride, &mut pixels)?; + let opaque = pixels.as_chunks::<4>().0.iter().all(|pixel| pixel[3] == 255); + drop(pixels); + + let output = CreateStreamOnHGlobal(HGLOBAL::default(), true)?; + let container = if opaque { + GUID_ContainerFormatJpeg + } else { + GUID_ContainerFormatPng + }; + let encoder = factory.CreateEncoder(&container, std::ptr::null())?; + encoder.Initialize(&output, WICBitmapEncoderNoCache)?; + let mut target = None; + let mut options = None; + encoder.CreateNewFrame(&mut target, &mut options)?; + let target = target.ok_or_else(windows::core::Error::empty)?; + target.Initialize(options.as_ref())?; + target.SetSize(width, height)?; + let mut format = GUID_WICPixelFormat32bppBGRA; + target.SetPixelFormat(&mut format)?; + target.WriteSource(&converter, std::ptr::null())?; + target.Commit()?; + encoder.Commit()?; + + output.Seek(0, STREAM_SEEK_SET, None)?; + let memory = GetHGlobalFromStream(&output)?; + let size = GlobalSize(memory); + let start = GlobalLock(memory) as *const u8; + if start.is_null() { + return Err(windows::core::Error::from_thread()); + } + let encoded = std::slice::from_raw_parts(start, size).to_vec(); + let _ = GlobalUnlock(memory); + Ok(Converted { + format: if opaque { ImageFormat::Jpeg } else { ImageFormat::Png }, + width, + height, + bytes: encoded, + }) + } + }) +} + +/// The flip and rotation that turn a picture with this EXIF orientation upright. WIC rotates clockwise first, then +/// flips. +#[cfg(windows)] +fn transform(orientation: u8) -> windows::Win32::Graphics::Imaging::WICBitmapTransformOptions { + use windows::Win32::Graphics::Imaging::{ + WICBitmapTransformFlipHorizontal as FLIP_H, WICBitmapTransformFlipVertical as FLIP_V, + WICBitmapTransformOptions, WICBitmapTransformRotate0 as R0, WICBitmapTransformRotate180 as R180, + WICBitmapTransformRotate270 as R270, WICBitmapTransformRotate90 as R90, + }; + match orientation { + 2 => FLIP_H, + 3 => R180, + 4 => FLIP_V, + 5 => WICBitmapTransformOptions(R90.0 | FLIP_H.0), + 6 => R90, + 7 => WICBitmapTransformOptions(R270.0 | FLIP_H.0), + 8 => R270, + _ => R0, + } +} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + + const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/src/images/fixtures"); + + /// The blue pixels of a decoded picture, by position. + fn blue_pixels(bytes: &[u8]) -> (u32, u32, Vec<(u32, u32)>) { + use windows::Win32::Graphics::Imaging::{ + GUID_WICPixelFormat32bppBGRA, WICBitmapDitherTypeNone, WICBitmapPaletteTypeCustom, + WICDecodeMetadataCacheOnDemand, + }; + super::super::wic::with_com(|| unsafe { + let factory = super::super::wic::factory()?; + let stream = factory.CreateStream()?; + stream.InitializeFromMemory(bytes)?; + let decoder = factory.CreateDecoderFromStream(&stream, std::ptr::null(), WICDecodeMetadataCacheOnDemand)?; + let converter = factory.CreateFormatConverter()?; + converter.Initialize( + &decoder.GetFrame(0)?, + &GUID_WICPixelFormat32bppBGRA, + WICBitmapDitherTypeNone, + None, + 0.0, + WICBitmapPaletteTypeCustom, + )?; + let (mut w, mut h) = (0, 0); + converter.GetSize(&mut w, &mut h)?; + let mut pixels = vec![0u8; (w * h * 4) as usize]; + converter.CopyPixels(std::ptr::null(), w * 4, &mut pixels)?; + let blue = (0..h) + .flat_map(|y| (0..w).map(move |x| (x, y))) + .filter(|(x, y)| { + let at = ((y * w + x) * 4) as usize; + pixels[at] > 120 && pixels[at + 2] < 100 + }) + .collect(); + Ok((w, h, blue)) + }) + .unwrap() + } + + #[test] + fn every_orientation_comes_out_upright() { + // Each fixture stores the same 6 by 4 picture, whose top row starts with three blue pixels; its EXIF tag + // says how to turn it. Upright, orientation 1's picture turned by the tag's transform is what shows. + let (_, _, stored) = blue_pixels(&std::fs::read(format!("{FIXTURES}/jpeg-o1.jpg")).unwrap()); + for orientation in 1..=8u8 { + let bytes = std::fs::read(format!("{FIXTURES}/jpeg-o{orientation}.jpg")).unwrap(); + let converted = to_web_format(&bytes, orientation).expect("WIC converts JPEG"); + assert_eq!(converted.format, ImageFormat::Jpeg); + let (w, h, blue) = blue_pixels(&converted.bytes); + let turned = orientation >= 5; + assert_eq!( + (w, h), + if turned { (4, 6) } else { (6, 4) }, + "orientation {orientation}" + ); + let expected: Vec<(u32, u32)> = { + let mut out: Vec<(u32, u32)> = stored + .iter() + .map(|&(x, y)| display_position(orientation, x, y, 6, 4)) + .collect(); + out.sort_by_key(|&(x, y)| (y, x)); + out + }; + assert_eq!(blue, expected, "orientation {orientation}"); + } + } + + /// Where a stored pixel shows for an EXIF orientation (TIFF 6.0, tag 274). + fn display_position(orientation: u8, x: u32, y: u32, w: u32, h: u32) -> (u32, u32) { + let (mx, my) = (w - 1 - x, h - 1 - y); + match orientation { + 2 => (mx, y), + 3 => (mx, my), + 4 => (x, my), + 5 => (y, x), + 6 => (my, x), + 7 => (my, mx), + 8 => (y, mx), + _ => (x, y), + } + } + + #[test] + fn transparent_pictures_become_png() { + let bytes = std::fs::read(format!("{FIXTURES}/png-alpha.png")).unwrap(); + let converted = to_web_format(&bytes, 1).expect("WIC converts PNG"); + assert_eq!( + (converted.format, converted.width, converted.height), + (ImageFormat::Png, 6, 4) + ); + } +} diff --git a/app/src-tauri/src/images/fixtures/avif.avif b/app/src-tauri/src/images/fixtures/avif.avif new file mode 100644 index 000000000..e44264210 Binary files /dev/null and b/app/src-tauri/src/images/fixtures/avif.avif differ diff --git a/app/src-tauri/src/images/fixtures/bmp.bmp b/app/src-tauri/src/images/fixtures/bmp.bmp new file mode 100644 index 000000000..f2d2cba15 Binary files /dev/null and b/app/src-tauri/src/images/fixtures/bmp.bmp differ diff --git a/app/src-tauri/src/images/fixtures/gif.gif b/app/src-tauri/src/images/fixtures/gif.gif new file mode 100644 index 000000000..72b1e2a1a Binary files /dev/null and b/app/src-tauri/src/images/fixtures/gif.gif differ diff --git a/app/src-tauri/src/images/fixtures/heic-header.heic b/app/src-tauri/src/images/fixtures/heic-header.heic new file mode 100644 index 000000000..af6b22c6c Binary files /dev/null and b/app/src-tauri/src/images/fixtures/heic-header.heic differ diff --git a/app/src-tauri/src/images/fixtures/jpeg-o1.jpg b/app/src-tauri/src/images/fixtures/jpeg-o1.jpg new file mode 100644 index 000000000..39ca9fdb7 Binary files /dev/null and b/app/src-tauri/src/images/fixtures/jpeg-o1.jpg differ diff --git a/app/src-tauri/src/images/fixtures/jpeg-o2.jpg b/app/src-tauri/src/images/fixtures/jpeg-o2.jpg new file mode 100644 index 000000000..8a765adee Binary files /dev/null and b/app/src-tauri/src/images/fixtures/jpeg-o2.jpg differ diff --git a/app/src-tauri/src/images/fixtures/jpeg-o3.jpg b/app/src-tauri/src/images/fixtures/jpeg-o3.jpg new file mode 100644 index 000000000..76b39e910 Binary files /dev/null and b/app/src-tauri/src/images/fixtures/jpeg-o3.jpg differ diff --git a/app/src-tauri/src/images/fixtures/jpeg-o4.jpg b/app/src-tauri/src/images/fixtures/jpeg-o4.jpg new file mode 100644 index 000000000..843b9dba3 Binary files /dev/null and b/app/src-tauri/src/images/fixtures/jpeg-o4.jpg differ diff --git a/app/src-tauri/src/images/fixtures/jpeg-o5.jpg b/app/src-tauri/src/images/fixtures/jpeg-o5.jpg new file mode 100644 index 000000000..706df8293 Binary files /dev/null and b/app/src-tauri/src/images/fixtures/jpeg-o5.jpg differ diff --git a/app/src-tauri/src/images/fixtures/jpeg-o6.jpg b/app/src-tauri/src/images/fixtures/jpeg-o6.jpg new file mode 100644 index 000000000..59e6ea3ef Binary files /dev/null and b/app/src-tauri/src/images/fixtures/jpeg-o6.jpg differ diff --git a/app/src-tauri/src/images/fixtures/jpeg-o7.jpg b/app/src-tauri/src/images/fixtures/jpeg-o7.jpg new file mode 100644 index 000000000..31348212f Binary files /dev/null and b/app/src-tauri/src/images/fixtures/jpeg-o7.jpg differ diff --git a/app/src-tauri/src/images/fixtures/jpeg-o8.jpg b/app/src-tauri/src/images/fixtures/jpeg-o8.jpg new file mode 100644 index 000000000..894a85e1e Binary files /dev/null and b/app/src-tauri/src/images/fixtures/jpeg-o8.jpg differ diff --git a/app/src-tauri/src/images/fixtures/jpeg-plain.jpg b/app/src-tauri/src/images/fixtures/jpeg-plain.jpg new file mode 100644 index 000000000..daa5ecd79 Binary files /dev/null and b/app/src-tauri/src/images/fixtures/jpeg-plain.jpg differ diff --git a/app/src-tauri/src/images/fixtures/png-alpha.png b/app/src-tauri/src/images/fixtures/png-alpha.png new file mode 100644 index 000000000..aacdcf4ab Binary files /dev/null and b/app/src-tauri/src/images/fixtures/png-alpha.png differ diff --git a/app/src-tauri/src/images/fixtures/png.png b/app/src-tauri/src/images/fixtures/png.png new file mode 100644 index 000000000..88732c2ff Binary files /dev/null and b/app/src-tauri/src/images/fixtures/png.png differ diff --git a/app/src-tauri/src/images/fixtures/tiff-o6.tif b/app/src-tauri/src/images/fixtures/tiff-o6.tif new file mode 100644 index 000000000..c83ee7a56 Binary files /dev/null and b/app/src-tauri/src/images/fixtures/tiff-o6.tif differ diff --git a/app/src-tauri/src/images/fixtures/webp-exif.webp b/app/src-tauri/src/images/fixtures/webp-exif.webp new file mode 100644 index 000000000..72b25900d Binary files /dev/null and b/app/src-tauri/src/images/fixtures/webp-exif.webp differ diff --git a/app/src-tauri/src/images/fixtures/webp-lossless.webp b/app/src-tauri/src/images/fixtures/webp-lossless.webp new file mode 100644 index 000000000..ff3e98026 Binary files /dev/null and b/app/src-tauri/src/images/fixtures/webp-lossless.webp differ diff --git a/app/src-tauri/src/images/fixtures/webp-lossy.webp b/app/src-tauri/src/images/fixtures/webp-lossy.webp new file mode 100644 index 000000000..94bfaf9f7 Binary files /dev/null and b/app/src-tauri/src/images/fixtures/webp-lossy.webp differ diff --git a/app/src-tauri/src/images/header.rs b/app/src-tauri/src/images/header.rs new file mode 100644 index 000000000..2ec8d627c --- /dev/null +++ b/app/src-tauri/src/images/header.rs @@ -0,0 +1,418 @@ +//! Image sizes and orientations read from file headers, without decoding a pixel (Phase 4 ARCHITECTURE.md section +//! 12.2). Windows Imaging Component checks these on Windows (see `wic.rs`); this reader answers for the formats WIC +//! can't open without an optional codec, such as AVIF and HEIC, and on other systems. + +use super::probe::ImageFormat; + +/// A size as stored in the file, before the orientation is applied, and the EXIF orientation from 1 to 8. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RawSize { + pub width: u32, + pub height: u32, + pub orientation: u8, +} + +/// The format a file's first bytes announce. SVG is told by its text: an ` ImageFormat { + let starts = |magic: &[u8]| bytes.starts_with(magic); + if starts(b"\x89PNG\r\n\x1a\n") { + ImageFormat::Png + } else if starts(b"\xff\xd8\xff") { + ImageFormat::Jpeg + } else if starts(b"GIF87a") || starts(b"GIF89a") { + ImageFormat::Gif + } else if bytes.len() >= 12 && starts(b"RIFF") && &bytes[8..12] == b"WEBP" { + ImageFormat::Webp + } else if starts(b"BM") && bytes.len() >= 26 { + ImageFormat::Bmp + } else if starts(b"II*\0") || starts(b"MM\0*") { + ImageFormat::Tiff + } else if let Some(format) = isobmff_format(bytes) { + format + } else if looks_like_svg(bytes) { + ImageFormat::Svg + } else { + ImageFormat::Other + } +} + +fn looks_like_svg(bytes: &[u8]) -> bool { + let head = &bytes[..bytes.len().min(4096)]; + let text = String::from_utf8_lossy(head); + let trimmed = text.trim_start_matches('\u{feff}').trim_start(); + trimmed.starts_with('<') && text.contains(" Option { + if bytes.len() < 16 || &bytes[4..8] != b"ftyp" { + return None; + } + let size = (be32(bytes, 0)? as usize).min(bytes.len()); + let brands: Vec<&[u8]> = std::iter::once(&bytes[8..12]) + .chain( + bytes + .get(16..size) + .unwrap_or_default() + .as_chunks::<4>() + .0 + .iter() + .map(<[u8; 4]>::as_slice), + ) + .collect(); + let has = |names: &[&[u8]]| brands.iter().any(|brand| names.contains(brand)); + if has(&[b"avif", b"avis"]) { + Some(ImageFormat::Avif) + } else if has(&[b"heic", b"heix", b"hevc", b"hevx", b"heim", b"heis", b"mif1", b"msf1"]) { + Some(ImageFormat::Heic) + } else { + None + } +} + +/// The stored size and orientation of a raster image, or None when the header is cut short or broken. +pub fn read(format: ImageFormat, bytes: &[u8]) -> Option { + let plain = |(width, height): (u32, u32)| RawSize { + width, + height, + orientation: 1, + }; + let size = match format { + ImageFormat::Png => png(bytes), + ImageFormat::Jpeg => jpeg(bytes), + ImageFormat::Gif => Some(plain((le16(bytes, 6)?.into(), le16(bytes, 8)?.into()))), + ImageFormat::Bmp => bmp(bytes).map(plain), + ImageFormat::Webp => webp(bytes), + ImageFormat::Tiff => tiff(bytes, true), + ImageFormat::Avif | ImageFormat::Heic => isobmff(bytes), + ImageFormat::Svg | ImageFormat::Other => None, + }?; + (size.width > 0 && size.height > 0).then_some(size) +} + +fn be16(bytes: &[u8], at: usize) -> Option { + Some(u16::from_be_bytes(bytes.get(at..at + 2)?.try_into().ok()?)) +} +fn be32(bytes: &[u8], at: usize) -> Option { + Some(u32::from_be_bytes(bytes.get(at..at + 4)?.try_into().ok()?)) +} +fn le16(bytes: &[u8], at: usize) -> Option { + Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?)) +} +fn le24(bytes: &[u8], at: usize) -> Option { + let b = bytes.get(at..at + 3)?; + Some(u32::from(b[0]) | (u32::from(b[1]) << 8) | (u32::from(b[2]) << 16)) +} +fn le32(bytes: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?)) +} + +/// PNG: the IHDR chunk, and the orientation from an `eXIf` chunk before the image data. +fn png(bytes: &[u8]) -> Option { + if bytes.get(12..16)? != b"IHDR" { + return None; + } + let (width, height) = (be32(bytes, 16)?, be32(bytes, 20)?); + let mut orientation = 1; + let mut at = 8; + while let (Some(length), Some(kind)) = (be32(bytes, at), bytes.get(at + 4..at + 8)) { + let data = at + 8; + if kind == b"IDAT" || kind == b"IEND" { + break; + } + if kind == b"eXIf" { + let end = data.checked_add(length as usize)?; + orientation = bytes.get(data..end).and_then(exif_orientation).unwrap_or(1); + break; + } + at = data.checked_add(length as usize)?.checked_add(4)?; + } + Some(RawSize { + width, + height, + orientation, + }) +} + +/// JPEG: the start-of-frame segment, and the orientation from the EXIF segment (APP1). +fn jpeg(bytes: &[u8]) -> Option { + let mut orientation = 1; + let mut at = 2; + loop { + while bytes.get(at) == Some(&0xff) && bytes.get(at + 1) == Some(&0xff) { + at += 1; + } + if *bytes.get(at)? != 0xff { + return None; + } + let marker = *bytes.get(at + 1)?; + if marker == 0xd8 || (0xd0..=0xd7).contains(&marker) || marker == 0x01 { + at += 2; + continue; + } + if marker == 0xd9 || marker == 0xda { + return None; + } + let length = usize::from(be16(bytes, at + 2)?); + let body = bytes.get(at + 4..at + 2 + length)?; + if marker == 0xe1 && body.starts_with(b"Exif\0\0") { + orientation = exif_orientation(&body[6..]).unwrap_or(orientation); + } + // Start-of-frame markers, except DHT (C4), JPG (C8), and DAC (CC), hold the size. + if (0xc0..=0xcf).contains(&marker) && ![0xc4, 0xc8, 0xcc].contains(&marker) { + return Some(RawSize { + width: be16(body, 3)?.into(), + height: be16(body, 1)?.into(), + orientation, + }); + } + at += 2 + length; + } +} + +fn bmp(bytes: &[u8]) -> Option<(u32, u32)> { + let header = le32(bytes, 14)?; + if header == 12 { + return Some((le16(bytes, 18)?.into(), le16(bytes, 20)?.into())); + } + let width = le32(bytes, 18)? as i32; + let height = le32(bytes, 22)? as i32; + Some((width.unsigned_abs(), height.unsigned_abs())) +} + +/// WebP: the first chunk gives the size (VP8, VP8L, or VP8X); an EXIF chunk gives the orientation. +fn webp(bytes: &[u8]) -> Option { + let kind = bytes.get(12..16)?; + let plain = |width, height| RawSize { + width, + height, + orientation: 1, + }; + match kind { + b"VP8 " => { + if bytes.get(23..26)? != [0x9d, 0x01, 0x2a] { + return None; + } + Some(plain( + u32::from(le16(bytes, 26)? & 0x3fff), + u32::from(le16(bytes, 28)? & 0x3fff), + )) + } + b"VP8L" => { + if *bytes.get(20)? != 0x2f { + return None; + } + let bits = le32(bytes, 21)?; + Some(plain((bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1)) + } + b"VP8X" => { + let mut size = plain(le24(bytes, 24)? + 1, le24(bytes, 27)? + 1); + let mut at = 12; + while let (Some(kind), Some(length)) = (bytes.get(at..at + 4), le32(bytes, at + 4)) { + let data = at + 8; + let end = data.checked_add(length as usize)?; + if kind == b"EXIF" { + let exif = bytes.get(data..end.min(bytes.len()))?; + let exif = exif.strip_prefix(b"Exif\0\0").unwrap_or(exif); + size.orientation = exif_orientation(exif).unwrap_or(1); + break; + } + at = end + (length as usize & 1); + } + Some(size) + } + _ => None, + } +} + +/// A TIFF structure's byte order, and its first directory. +struct Tiff<'a> { + bytes: &'a [u8], + little: bool, +} + +impl Tiff<'_> { + fn u16(&self, at: usize) -> Option { + if self.little { + le16(self.bytes, at) + } else { + be16(self.bytes, at) + } + } + fn u32(&self, at: usize) -> Option { + if self.little { + le32(self.bytes, at) + } else { + be32(self.bytes, at) + } + } + /// The first directory's entries as (tag, value) for SHORT and LONG values. + fn entries(&self) -> Option> { + let ifd = self.u32(4)? as usize; + let count = usize::from(self.u16(ifd)?).min(512); + let mut out = Vec::with_capacity(count); + for i in 0..count { + let at = ifd + 2 + i * 12; + let (tag, kind) = (self.u16(at)?, self.u16(at + 2)?); + let value = match kind { + 3 => u32::from(self.u16(at + 8)?), + 4 => self.u32(at + 8)?, + _ => continue, + }; + out.push((tag, value)); + } + Some(out) + } +} + +fn tiff_of(bytes: &[u8]) -> Option> { + let little = match bytes.get(0..4)? { + b"II*\0" => true, + b"MM\0*" => false, + _ => return None, + }; + Some(Tiff { bytes, little }) +} + +fn tiff(bytes: &[u8], need_size: bool) -> Option { + let entries = tiff_of(bytes)?.entries()?; + let find = |tag: u16| entries.iter().find(|(found, _)| *found == tag).map(|(_, value)| *value); + let orientation = find(274) + .and_then(|value| u8::try_from(value).ok()) + .filter(|o| (1..=8).contains(o)); + let (width, height) = if need_size { (find(256)?, find(257)?) } else { (0, 0) }; + Some(RawSize { + width, + height, + orientation: orientation.unwrap_or(1), + }) +} + +/// The orientation tag of an EXIF block (a TIFF structure), if it has a valid one. +pub fn exif_orientation(exif: &[u8]) -> Option { + tiff(exif, false).map(|size| size.orientation).filter(|o| *o != 1) +} + +/// A box of the ISO base media file format: its type and its payload. +fn boxes(bytes: &[u8]) -> impl Iterator { + let mut at = 0usize; + std::iter::from_fn(move || { + let size = be32(bytes, at)? as usize; + let kind = bytes.get(at + 4..at + 8)?; + let (header, size) = match size { + 0 => (8, bytes.len() - at), + 1 => ( + 16, + usize::try_from(u64::from(be32(bytes, at + 8)?) << 32 | u64::from(be32(bytes, at + 12)?)).ok()?, + ), + size => (8, size), + }; + let end = at.checked_add(size)?.min(bytes.len()); + let payload = bytes.get(at + header..end)?; + at = end.max(at + header); + Some((kind, payload)) + }) +} + +fn child<'a>(bytes: &'a [u8], kind: &[u8]) -> Option<&'a [u8]> { + boxes(bytes) + .find(|(found, _)| *found == kind) + .map(|(_, payload)| payload) +} + +/// AVIF and HEIC: the primary item's `ispe` size, turned by its `irot` and `imir` properties. +fn isobmff(bytes: &[u8]) -> Option { + let meta = child(bytes, b"meta")?.get(4..)?; + let primary = child(meta, b"pitm").and_then(|pitm| be16(pitm, 4)).map(u32::from); + let iprp = child(meta, b"iprp")?; + let properties: Vec<(&[u8], &[u8])> = boxes(child(iprp, b"ipco")?).collect(); + let ipma = child(iprp, b"ipma")?; + let (version, flags) = (*ipma.first()?, be32(ipma, 0)? & 0xff_ffff); + let entries = be32(ipma, 4)?; + let mut at = 8; + let mut chosen: Option> = None; + for _ in 0..entries.min(4096) { + let item = if version < 1 { + u32::from(be16(ipma, at)?) + } else { + be32(ipma, at)? + }; + at += if version < 1 { 2 } else { 4 }; + let count = usize::from(*ipma.get(at)?); + at += 1; + let mut indexes = Vec::with_capacity(count); + for _ in 0..count { + let index = if flags & 1 == 1 { + let value = be16(ipma, at)?; + at += 2; + usize::from(value & 0x7fff) + } else { + let value = *ipma.get(at)?; + at += 1; + usize::from(value & 0x7f) + }; + indexes.push(index); + } + if chosen.is_none() || Some(item) == primary { + chosen = Some(indexes); + } + if Some(item) == primary { + break; + } + } + let mut size = None; + let mut matrix = IDENTITY; + for index in chosen? { + let Some((kind, payload)) = index.checked_sub(1).and_then(|i| properties.get(i)) else { + continue; + }; + match *kind { + b"ispe" => size = Some((be32(payload, 4)?, be32(payload, 8)?)), + b"irot" => { + for _ in 0..(payload.first()? & 3) { + matrix = multiply(ROTATE_CCW, matrix); + } + } + b"imir" => matrix = multiply(if payload.first()? & 1 == 0 { FLIP_H } else { FLIP_V }, matrix), + _ => {} + } + } + let (width, height) = size?; + Some(RawSize { + width, + height, + orientation: orientation_of(matrix), + }) +} + +/// A transform of the plane, with y pointing down, as a 2 by 2 matrix. +type Matrix = [[i8; 2]; 2]; +const IDENTITY: Matrix = [[1, 0], [0, 1]]; +const FLIP_H: Matrix = [[-1, 0], [0, 1]]; +const FLIP_V: Matrix = [[1, 0], [0, -1]]; +const ROTATE_CCW: Matrix = [[0, 1], [-1, 0]]; + +/// The EXIF orientations 1 to 8 as transforms. +const ORIENTATIONS: [Matrix; 8] = [ + IDENTITY, + FLIP_H, + [[-1, 0], [0, -1]], + FLIP_V, + [[0, 1], [1, 0]], + [[0, -1], [1, 0]], + [[0, -1], [-1, 0]], + ROTATE_CCW, +]; + +fn multiply(a: Matrix, b: Matrix) -> Matrix { + let cell = |r: usize, c: usize| a[r][0] * b[0][c] + a[r][1] * b[1][c]; + [[cell(0, 0), cell(0, 1)], [cell(1, 0), cell(1, 1)]] +} + +fn orientation_of(matrix: Matrix) -> u8 { + ORIENTATIONS + .iter() + .position(|found| *found == matrix) + .and_then(|i| u8::try_from(i + 1).ok()) + .unwrap_or(1) +} diff --git a/app/src-tauri/src/images/import.rs b/app/src-tauri/src/images/import.rs new file mode 100644 index 000000000..f1908ccd2 --- /dev/null +++ b/app/src-tauri/src/images/import.rs @@ -0,0 +1,345 @@ +//! Importing image bytes and Word's clipboard images into a page (Phase 4 ARCHITECTURE.md section 12.2). Each image +//! is probed from its header, checked against its declared type and against what WebView2 can show, and written into +//! the page's assets by the core, which returns only after the file is durable. The WebView never names a path. + +use std::path::Path; + +use opennote_core::{ + session::page::PageHandle, + store::assets::{AssetSource, ImageSize}, + CoreError, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tauri::{ipc::InvokeBody, AppHandle, Manager, State}; + +use super::{ + convert, + probe::{probe, ImageFormat, ProbeError}, +}; +use crate::{ + clipboard::ClipTokens, + core_bridge, + ipc::{codes, IpcError, IpcResult}, +}; + +/// Files over 50 MB are refused (ARCHITECTURE.md section 12.2). +pub const MAX_IMAGE_BYTES: usize = 50 * 1024 * 1024; + +/// The error codes the interface turns into messages. +pub mod errors { + pub const UNSUPPORTED_TYPE: &str = "unsupportedType"; + pub const TOO_LARGE: &str = "tooLarge"; + pub const NOT_FOUND: &str = "notFound"; + pub const DOWNLOAD_FAILED: &str = "downloadFailed"; + pub const STALE_TOKEN: &str = "staleToken"; +} + +#[derive(Debug, Clone, Serialize)] +pub struct ImportedAsset { + pub id: String, + pub asset: serde_json::Value, +} + +/// The `x-opennote-image` header. The name is percent-encoded, because a header holds ASCII only. +#[derive(Debug, Deserialize)] +struct ImportHeader { + page: String, + name: String, + #[serde(default)] + mime: String, +} + +/// An image that passed the checks, ready for the core. +#[derive(Debug, PartialEq, Eq)] +pub struct Checked { + pub name: String, + pub mime: &'static str, + pub width: u32, + pub height: u32, + pub bytes: Vec, +} + +pub fn unsupported() -> IpcError { + IpcError::new( + errors::UNSUPPORTED_TYPE, + "The file isn't an image type a page can show, or it doesn't match its declared type.", + ) +} + +pub fn too_large() -> IpcError { + IpcError::new(errors::TOO_LARGE, "Images over 50 MB can't be added.") +} + +/// Probes the bytes and checks them: at most 50 MB, a format WebView2 shows, and the declared type when one is +/// given. HEIC and TIFF are converted to PNG or JPEG when `convert` is on and Windows has a codec for them. +pub fn check(bytes: Vec, name: &str, declared: &str, convert: bool) -> Result { + if bytes.len() > MAX_IMAGE_BYTES { + return Err(too_large()); + } + let probed = probe(&bytes).map_err(|error| match error { + ProbeError::Unsupported | ProbeError::Malformed(_) => unsupported(), + })?; + let declared = declared.trim(); + let generic = declared.is_empty() || declared.eq_ignore_ascii_case("application/octet-stream"); + if !generic && ImageFormat::from_mime(declared) != Some(probed.format) { + return Err(unsupported()); + } + if probed.format.displayable() { + return Ok(Checked { + name: name_for(name, probed.format), + mime: probed.format.mime(), + width: probed.width, + height: probed.height, + bytes, + }); + } + if !convert || !matches!(probed.format, ImageFormat::Heic | ImageFormat::Tiff) { + return Err(unsupported()); + } + let converted = convert::to_web_format(&bytes, probed.orientation).ok_or_else(unsupported)?; + Ok(Checked { + name: name_for(name, converted.format), + mime: converted.format.mime(), + width: converted.width, + height: converted.height, + bytes: converted.bytes, + }) +} + +/// The file name with the extension of the format it holds, or a plain name when none was given. +fn name_for(name: &str, format: ImageFormat) -> String { + let base = Path::new(name.trim()) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_owned(); + let stem = match base.rsplit_once('.') { + Some((stem, _)) if !stem.is_empty() => stem.to_owned(), + _ if base.is_empty() => "image".to_owned(), + _ => base.clone(), + }; + let extension = format.extension(); + let same = |found: &str| match format { + ImageFormat::Jpeg => ["jpg", "jpeg", "jfif"].contains(&found), + ImageFormat::Tiff => ["tif", "tiff"].contains(&found), + _ => found == extension, + }; + match name.trim().rsplit_once('.') { + Some((_, found)) if same(&found.to_ascii_lowercase()) => base, + _ => format!("{stem}.{extension}"), + } +} + +/// The asset table entry as `page.json` holds it, which the page keeps until its next open. +pub fn asset_json(asset: &opennote_core::model::asset::Asset) -> Value { + let mut value = json!({ + "file": asset.file, + "mime": asset.mime, + "bytes": asset.bytes, + "sha256": asset.sha256_hex(), + "name": asset.name, + "created": asset.created, + }); + if let (Some(width), Some(height)) = (asset.width, asset.height) { + value["width"] = json!(width); + value["height"] = json!(height); + } + value +} + +/// The open page an interface page ID names, or `notFound`. +pub fn open_page(app: &AppHandle, page: &str) -> Result { + core_bridge::core_page_id(app, page) + .and_then(|id| core_bridge::page_handle(app, id)) + .ok_or_else(|| IpcError::new(errors::NOT_FOUND, "The page isn't open.")) +} + +fn core_error(error: CoreError) -> IpcError { + match error { + CoreError::NotFound(what) => IpcError::new(errors::NOT_FOUND, what), + CoreError::Edit(edit) => IpcError::new(edit.code(), edit.to_string()), + CoreError::ReadOnly(reason) => IpcError::new("readOnly", format!("{reason:?}")), + other => IpcError::new(codes::IO, other.to_string()), + } +} + +/// Stores a checked image in the page through the core, with its probed size, and answers with the table entry. +pub fn store(handle: &PageHandle, checked: Checked) -> Result { + let (width, height) = (checked.width, checked.height); + let asset = handle + .import_asset(AssetSource::Bytes { + name: checked.name, + mime: checked.mime.to_owned(), + bytes: checked.bytes, + image: Some(ImageSize { width, height }), + }) + .map_err(core_error)?; + let mut json = asset_json(&asset); + // The core reads PNG, GIF, JPEG, and BMP sizes from their headers itself, without the EXIF orientation. The + // page lays an image out at its displayed size, so it gets the probed, oriented one. + json["width"] = json!(width); + json["height"] = json!(height); + Ok(ImportedAsset { + id: asset.id.to_string(), + asset: json, + }) +} + +/// Whether HEIC and TIFF are converted at import: `page.heicImport`, off by default, which development and nightly +/// builds can turn on in `settings.experimental.flags`, as the interface's flags do. +pub fn convert_enabled(app: &AppHandle) -> bool { + let channel = crate::boot::channel_for(&app.package_info().version.to_string(), cfg!(debug_assertions)); + let allowed = matches!(channel, crate::boot::Channel::Dev | crate::boot::Channel::Nightly); + allowed + && app.try_state::().is_some_and(|store| { + store + .get() + .experimental + .flags + .get("page.heicImport") + .copied() + .unwrap_or(false) + }) +} + +/// Runs an import on a blocking thread, so the WebView's command thread never waits on a disk write. +pub async fn on_blocking(work: impl FnOnce() -> IpcResult + Send + 'static) -> IpcResult { + tauri::async_runtime::spawn_blocking(work) + .await + .map_err(|error| IpcError::new(codes::INTERNAL, error.to_string()))? +} + +pub(crate) fn percent_decode(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let hex = bytes.get(i + 1..i + 3).and_then(|pair| std::str::from_utf8(pair).ok()); + match (bytes[i], hex.and_then(|pair| u8::from_str_radix(pair, 16).ok())) { + (b'%', Some(byte)) => { + out.push(byte); + i += 3; + } + (byte, _) => { + out.push(byte); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// A raw body with the header `x-opennote-image: {"page","name","mime"}`. +#[tauri::command] +pub async fn image_import(app: AppHandle, request: tauri::ipc::Request<'_>) -> IpcResult { + let header = request + .headers() + .get("x-opennote-image") + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| IpcError::invalid("x-opennote-image", "The image header is missing."))?; + let header: ImportHeader = serde_json::from_str(header) + .map_err(|_| IpcError::invalid("x-opennote-image", "The image header isn't valid JSON."))?; + let bytes = match request.body() { + InvokeBody::Raw(bytes) => bytes.clone(), + InvokeBody::Json(_) => return Err(IpcError::invalid("body", "The image must come as raw bytes.")), + }; + let convert = convert_enabled(&app); + on_blocking(move || { + let handle = open_page(&app, &header.page)?; + let checked = check(bytes, &percent_decode(&header.name), &header.mime, convert)?; + store(&handle, checked) + }) + .await +} + +#[tauri::command] +pub async fn image_import_clip( + app: AppHandle, + tokens: State<'_, ClipTokens>, + page: String, + token: String, +) -> IpcResult { + let path = tokens.resolve(&token).ok_or_else(|| { + IpcError::new( + errors::STALE_TOKEN, + "The copied image is no longer on the clipboard. Copy it again.", + ) + })?; + let convert = convert_enabled(&app); + on_blocking(move || { + let handle = open_page(&app, &page)?; + let bytes = read_capped(&path)?; + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("image").to_owned(); + let checked = check(bytes, &name, "", convert)?; + store(&handle, checked) + }) + .await +} + +/// A file's bytes, refused when it is over the size limit. +fn read_capped(path: &Path) -> Result, IpcError> { + let length = std::fs::metadata(path) + .map_err(|_| IpcError::new(errors::STALE_TOKEN, "The copied image is gone. Copy it again."))? + .len(); + if length > MAX_IMAGE_BYTES as u64 { + return Err(too_large()); + } + std::fs::read(path).map_err(|_| IpcError::new(errors::STALE_TOKEN, "The copied image is gone. Copy it again.")) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/src/images/fixtures"); + + fn fixture(name: &str) -> Vec { + std::fs::read(format!("{FIXTURES}/{name}")).unwrap() + } + + #[test] + fn a_matching_image_passes_with_its_oriented_size() { + let checked = check(fixture("jpeg-o6.jpg"), "Photo.JPEG", "image/jpeg", false).unwrap(); + assert_eq!((checked.mime, checked.width, checked.height), ("image/jpeg", 4, 6)); + assert_eq!(checked.name, "Photo.JPEG"); + let checked = check(fixture("png.png"), "", "", false).unwrap(); + assert_eq!((checked.name.as_str(), checked.mime), ("image.png", "image/png")); + let checked = check( + fixture("webp-lossy.webp"), + "pasted.png", + "application/octet-stream", + false, + ) + .unwrap(); + assert_eq!((checked.name.as_str(), checked.mime), ("pasted.webp", "image/webp")); + } + + #[test] + fn a_type_mismatch_is_refused() { + let error = check(fixture("png.png"), "a.jpg", "image/jpeg", false).unwrap_err(); + assert_eq!(error.code, errors::UNSUPPORTED_TYPE); + let error = check(b"not an image".to_vec(), "a.png", "image/png", false).unwrap_err(); + assert_eq!(error.code, errors::UNSUPPORTED_TYPE); + } + + #[test] + fn heic_and_tiff_are_refused_without_conversion() { + for name in ["heic-header.heic", "tiff-o6.tif"] { + let error = check(fixture(name), name, "", false).unwrap_err(); + assert_eq!(error.code, errors::UNSUPPORTED_TYPE, "{name}"); + } + } + + #[test] + fn files_over_50_mb_are_refused() { + let error = check(vec![0; MAX_IMAGE_BYTES + 1], "big.png", "image/png", false).unwrap_err(); + assert_eq!(error.code, errors::TOO_LARGE); + } + + #[test] + fn names_are_percent_decoded() { + assert_eq!(percent_decode("Fot%C3%B3%20%25.png"), "Fotó %.png"); + assert_eq!(percent_decode("100%"), "100%"); + } +} diff --git a/app/src-tauri/src/images/mod.rs b/app/src-tauri/src/images/mod.rs new file mode 100644 index 000000000..8c4c7334f --- /dev/null +++ b/app/src-tauri/src/images/mod.rs @@ -0,0 +1,18 @@ +//! Image import (Phase 4 ARCHITECTURE.md section 12): each image is probed from its header, checked, and stored in +//! the page's assets through the core. + +mod convert; +mod header; +pub mod import; +pub mod probe; +mod protocol; +pub mod renditions; +mod svg; +pub mod web; +#[cfg(windows)] +mod wic; + +pub use import::{image_import, image_import_clip, ImportedAsset}; +pub use probe::{probe, ImageFormat, Probe, ProbeError}; +pub use renditions::register_renditions; +pub use web::image_import_url; diff --git a/app/src-tauri/src/images/probe.rs b/app/src-tauri/src/images/probe.rs new file mode 100644 index 000000000..555f27799 --- /dev/null +++ b/app/src-tauri/src/images/probe.rs @@ -0,0 +1,259 @@ +//! The header read at import: format, pixel size, and orientation (Phase 4 ARCHITECTURE.md section 12.2). The page +//! never decodes an image to learn its size. The format comes from the first bytes. The size and the EXIF orientation +//! come from the header. On Windows, the Windows Imaging Component confirms the size for each format it can decode. +//! SVG gets its size from its `width`, `height`, or `viewBox`, or 300 by 150. + +use super::{header, svg}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImageFormat { + Png, + Jpeg, + Gif, + Webp, + Bmp, + Avif, + Svg, + Heic, + Tiff, + Other, +} + +impl ImageFormat { + /// The media type the page's asset gets. + pub fn mime(self) -> &'static str { + match self { + ImageFormat::Png => "image/png", + ImageFormat::Jpeg => "image/jpeg", + ImageFormat::Gif => "image/gif", + ImageFormat::Webp => "image/webp", + ImageFormat::Bmp => "image/bmp", + ImageFormat::Avif => "image/avif", + ImageFormat::Svg => "image/svg+xml", + ImageFormat::Heic => "image/heic", + ImageFormat::Tiff => "image/tiff", + ImageFormat::Other => "application/octet-stream", + } + } + + /// The file name extension for that media type. + pub fn extension(self) -> &'static str { + match self { + ImageFormat::Png => "png", + ImageFormat::Jpeg => "jpg", + ImageFormat::Gif => "gif", + ImageFormat::Webp => "webp", + ImageFormat::Bmp => "bmp", + ImageFormat::Avif => "avif", + ImageFormat::Svg => "svg", + ImageFormat::Heic => "heic", + ImageFormat::Tiff => "tif", + ImageFormat::Other => "bin", + } + } + + /// The format a media type names, with the aliases browsers and Windows use. + pub fn from_mime(mime: &str) -> Option { + let mime = mime.split(';').next().unwrap_or("").trim().to_ascii_lowercase(); + Some(match mime.as_str() { + "image/png" | "image/apng" => ImageFormat::Png, + "image/jpeg" | "image/jpg" | "image/pjpeg" => ImageFormat::Jpeg, + "image/gif" => ImageFormat::Gif, + "image/webp" => ImageFormat::Webp, + "image/bmp" | "image/x-bmp" | "image/x-ms-bmp" => ImageFormat::Bmp, + "image/avif" => ImageFormat::Avif, + "image/svg+xml" => ImageFormat::Svg, + "image/heic" | "image/heif" | "image/heic-sequence" | "image/heif-sequence" => ImageFormat::Heic, + "image/tiff" | "image/tif" => ImageFormat::Tiff, + _ => return None, + }) + } + + /// Whether WebView2 shows the format in an ``. + pub fn displayable(self) -> bool { + matches!( + self, + ImageFormat::Png + | ImageFormat::Jpeg + | ImageFormat::Gif + | ImageFormat::Webp + | ImageFormat::Bmp + | ImageFormat::Avif + | ImageFormat::Svg + ) + } +} + +/// The oriented size: width and height after the EXIF orientation is applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Probe { + pub format: ImageFormat, + pub width: u32, + pub height: u32, + pub orientation: u8, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProbeError { + /// The bytes aren't an image this version reads. + Unsupported, + /// The bytes start like an image, but its header is cut short or broken. + Malformed(ImageFormat), +} + +/// The format alone, from the first bytes. +pub fn sniff(bytes: &[u8]) -> ImageFormat { + header::sniff(bytes) +} + +pub fn probe(bytes: &[u8]) -> Result { + let format = header::sniff(bytes); + match format { + ImageFormat::Other => Err(ProbeError::Unsupported), + ImageFormat::Svg => { + let (width, height) = svg::size(bytes).ok_or(ProbeError::Malformed(format))?; + Ok(Probe { + format, + width, + height, + orientation: 1, + }) + } + _ => { + let raw = header::read(format, bytes); + let checked = confirm(bytes, raw.map(|raw| (raw.width, raw.height))); + let (width, height) = checked.ok_or(ProbeError::Malformed(format))?; + let orientation = raw.map_or(1, |raw| raw.orientation); + let turned = orientation >= 5; + Ok(Probe { + format, + width: if turned { height } else { width }, + height: if turned { width } else { height }, + orientation, + }) + } + } +} + +/// The stored size as WIC reads it, when a codec is installed for the format, else the header's. +#[cfg(windows)] +fn confirm(bytes: &[u8], header: Option<(u32, u32)>) -> Option<(u32, u32)> { + super::wic::frame_size(bytes) + .filter(|(w, h)| *w > 0 && *h > 0) + .or(header) +} + +#[cfg(not(windows))] +fn confirm(_bytes: &[u8], header: Option<(u32, u32)>) -> Option<(u32, u32)> { + header +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/src/images/fixtures"); + + /// The fixtures are 6 by 4 pixels, made with Pillow 12: one per format, and a JPEG for each EXIF orientation. + fn fixture(name: &str) -> Vec { + std::fs::read(format!("{FIXTURES}/{name}")).unwrap_or_else(|error| panic!("{name}: {error}")) + } + + fn expect(name: &str, format: ImageFormat, size: (u32, u32), orientation: u8) { + let found = probe(&fixture(name)).unwrap_or_else(|error| panic!("{name}: {error:?}")); + assert_eq!( + (found.format, (found.width, found.height), found.orientation), + (format, size, orientation), + "{name}" + ); + } + + #[test] + fn every_format_reports_its_size() { + expect("png.png", ImageFormat::Png, (6, 4), 1); + expect("png-alpha.png", ImageFormat::Png, (6, 4), 1); + expect("jpeg-plain.jpg", ImageFormat::Jpeg, (6, 4), 1); + expect("gif.gif", ImageFormat::Gif, (6, 4), 1); + expect("webp-lossy.webp", ImageFormat::Webp, (6, 4), 1); + expect("webp-lossless.webp", ImageFormat::Webp, (6, 4), 1); + expect("bmp.bmp", ImageFormat::Bmp, (6, 4), 1); + expect("avif.avif", ImageFormat::Avif, (6, 4), 1); + } + + #[test] + fn orientations_5_to_8_swap_width_and_height() { + for orientation in 1..=8u8 { + let size = if orientation >= 5 { (4, 6) } else { (6, 4) }; + expect( + &format!("jpeg-o{orientation}.jpg"), + ImageFormat::Jpeg, + size, + orientation, + ); + } + expect("webp-exif.webp", ImageFormat::Webp, (4, 6), 6); + expect("tiff-o6.tif", ImageFormat::Tiff, (4, 6), 6); + } + + #[test] + fn heic_is_read_from_its_boxes_even_without_a_codec() { + // A header only: an `ispe` of 6 by 4 and an `irot` of a quarter turn counterclockwise. + expect("heic-header.heic", ImageFormat::Heic, (4, 6), 8); + } + + #[test] + fn svg_sizes_come_from_the_root_element() { + let found = probe(br#""#); + assert_eq!( + found.map(|p| (p.format, p.width, p.height)), + Ok((ImageFormat::Svg, 30, 20)) + ); + } + + #[test] + fn other_bytes_are_refused() { + assert_eq!(probe(b""), Err(ProbeError::Unsupported)); + assert_eq!(probe(b"just some text"), Err(ProbeError::Unsupported)); + assert_eq!(probe(b"\x01\x00\x00\x00 EMF"), Err(ProbeError::Unsupported)); + let png = fixture("png.png"); + assert_eq!(probe(&png[..14]), Err(ProbeError::Malformed(ImageFormat::Png))); + let jpeg = fixture("jpeg-o6.jpg"); + assert_eq!(probe(&jpeg[..40]), Err(ProbeError::Malformed(ImageFormat::Jpeg))); + } + + #[test] + fn media_types_name_their_formats() { + assert_eq!(ImageFormat::from_mime("image/JPG"), Some(ImageFormat::Jpeg)); + assert_eq!( + ImageFormat::from_mime("image/svg+xml; charset=utf-8"), + Some(ImageFormat::Svg) + ); + assert_eq!(ImageFormat::from_mime("image/x-emf"), None); + assert!(ImageFormat::Avif.displayable()); + assert!(!ImageFormat::Heic.displayable() && !ImageFormat::Tiff.displayable()); + } + + #[cfg(windows)] + #[test] + fn wic_agrees_with_the_header_reader() { + for name in [ + "png.png", + "jpeg-o6.jpg", + "gif.gif", + "bmp.bmp", + "webp-lossy.webp", + "webp-lossless.webp", + "tiff-o6.tif", + ] { + let bytes = fixture(name); + let raw = header::read(header::sniff(&bytes), &bytes).map(|raw| (raw.width, raw.height)); + let wic = super::super::wic::frame_size(&bytes); + // Windows ships the WebP decoder as an optional extension, which a CI runner may lack. No codec is no + // answer, not a disagreement, so only an answer is compared. + if wic.is_none() && name.ends_with(".webp") { + continue; + } + assert_eq!(wic, raw, "{name}"); + } + } +} diff --git a/app/src-tauri/src/images/protocol.rs b/app/src-tauri/src/images/protocol.rs new file mode 100644 index 000000000..e819fe924 --- /dev/null +++ b/app/src-tauri/src/images/protocol.rs @@ -0,0 +1,127 @@ +//! The `opennote-asset` scheme (Phase 4 ARCHITECTURE.md section 12.3): `/` serves an asset of an open +//! page through the core's `PageHandle::asset_bytes`, so the WebView never opens an asset file itself. On Windows, +//! WebView2 reaches it as `http://opennote-asset.localhost//`. Assets never change once written, so +//! the answers may be cached for good. + +use std::ops::Range; + +use opennote_core::AssetId; +use tauri::{ + http::{header, Request, Response, StatusCode}, + AppHandle, Builder, Runtime, UriSchemeContext, Wry, +}; + +pub const SCHEME: &str = "opennote-asset"; + +/// The page and asset a request path names: `//`. +fn parse_path(path: &str) -> Option<(String, AssetId)> { + let mut parts = path.trim_start_matches('/').split('/'); + let page = parts.next().filter(|page| !page.is_empty())?; + let asset = AssetId::parse(parts.next()?).ok()?; + parts.next().is_none().then(|| (page.to_owned(), asset)) +} + +/// A `Range: bytes=a-b` header as a byte range, when it names one range the asset has. +fn parse_range(value: &str) -> Option<(u64, Option)> { + let spec = value.trim().strip_prefix("bytes=")?; + if spec.contains(',') { + return None; + } + let (start, end) = spec.split_once('-')?; + let start: u64 = start.trim().parse().ok()?; + let end = match end.trim() { + "" => None, + end => Some(end.parse::().ok()?), + }; + Some((start, end)) +} + +fn plain(status: StatusCode) -> Response> { + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "text/plain") + .body(Vec::new()) + .unwrap_or_default() +} + +/// The answer to one request, on a blocking thread: the core reads the file. +fn answer(app: &AppHandle, request: &Request>) -> Response> { + let Some((page, asset)) = parse_path(request.uri().path()) else { + return plain(StatusCode::NOT_FOUND); + }; + let Ok(handle) = super::import::open_page(app, &page) else { + return plain(StatusCode::NOT_FOUND); + }; + let requested = request + .headers() + .get(header::RANGE) + .and_then(|value| value.to_str().ok()) + .and_then(parse_range); + let range = requested.map(|(start, end)| Range { + start, + end: end.map_or(u64::MAX, |end| end.saturating_add(1)), + }); + let bytes = match handle.asset_bytes(asset, range) { + Ok(bytes) => bytes, + Err(opennote_core::CoreError::NotFound(_)) => return plain(StatusCode::NOT_FOUND), + Err(error) => { + log::warn!("Couldn't read an asset for the page: {error}"); + return plain(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + let mut builder = Response::builder() + .header(header::CONTENT_TYPE, bytes.mime.as_str()) + .header(header::CACHE_CONTROL, "private, max-age=31536000, immutable") + .header(header::ACCEPT_RANGES, "bytes") + .header("X-Content-Type-Options", "nosniff") + .header( + header::CONTENT_SECURITY_POLICY, + "default-src 'none'; style-src 'unsafe-inline'; sandbox", + ); + builder = match &bytes.range { + Some(range) => builder.status(StatusCode::PARTIAL_CONTENT).header( + header::CONTENT_RANGE, + format!("bytes {}-{}/{}", range.start, range.end.saturating_sub(1), bytes.total), + ), + None => builder.status(StatusCode::OK), + }; + builder + .body(bytes.bytes) + .unwrap_or_else(|_| plain(StatusCode::INTERNAL_SERVER_ERROR)) +} + +pub fn register(builder: Builder) -> Builder { + builder.register_asynchronous_uri_scheme_protocol(SCHEME, |context, request, responder| { + let app = app_of(&context); + tauri::async_runtime::spawn_blocking(move || responder.respond(answer(&app, &request))); + }) +} + +fn app_of(context: &UriSchemeContext<'_, R>) -> AppHandle { + context.app_handle().clone() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_name_a_page_and_an_asset() { + let asset = "01m3sabc31y0rfa24eeh6j4ky4"; + assert_eq!( + parse_path(&format!("/page-1/{asset}")).map(|(page, id)| (page, id.to_string())), + Some(("page-1".to_owned(), asset.to_owned())) + ); + assert!(parse_path("/page-1").is_none()); + assert!(parse_path(&format!("/page-1/{asset}/more")).is_none()); + assert!(parse_path("/page-1/..%2f..%2fwin.ini").is_none()); + } + + #[test] + fn ranges_parse() { + assert_eq!(parse_range("bytes=0-99"), Some((0, Some(99)))); + assert_eq!(parse_range("bytes=100-"), Some((100, None))); + assert_eq!(parse_range("bytes=0-1,4-5"), None); + assert_eq!(parse_range("items=0-1"), None); + } +} diff --git a/app/src-tauri/src/images/renditions.rs b/app/src-tauri/src/images/renditions.rs new file mode 100644 index 000000000..6e6db2ba4 --- /dev/null +++ b/app/src-tauri/src/images/renditions.rs @@ -0,0 +1,9 @@ +//! The image schemes on the app's builder. `opennote-asset` serves page assets (section 12.3). Display-size +//! renditions behind `page.imageRenditions` (section 12.6) wait for spike S3: the flag is off in every channel, and +//! until the spike asks for them nothing more is registered. + +use tauri::{Builder, Wry}; + +pub fn register_renditions(builder: Builder) -> Builder { + super::protocol::register(builder) +} diff --git a/app/src-tauri/src/images/svg.rs b/app/src-tauri/src/images/svg.rs new file mode 100644 index 000000000..708f760a7 --- /dev/null +++ b/app/src-tauri/src/images/svg.rs @@ -0,0 +1,148 @@ +//! The size of an SVG image from its root element (Phase 4 ARCHITECTURE.md section 12.2): `width` and `height` in +//! absolute units, else the `viewBox`, else 300 by 150, as a browser sizes an `` of it. The file is never +//! rendered here, and the page shows it only through ``, which runs no script and loads nothing else. + +/// The default size of a replaced element without one (CSS 2, section 10.3.2). +const DEFAULT: (u32, u32) = (300, 150); + +/// The size the root `` element asks for, or None when the text holds no `` element. +pub fn size(bytes: &[u8]) -> Option<(u32, u32)> { + let text = String::from_utf8_lossy(&bytes[..bytes.len().min(64 * 1024)]); + let attributes = root_attributes(&text)?; + let get = |name: &str| { + attributes + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()) + }; + let width = get("width").and_then(length); + let height = get("height").and_then(length); + let view_box = get("viewBox").and_then(view_box); + let (w, h) = match (width, height, view_box) { + (Some(w), Some(h), _) => (w, h), + (Some(w), None, Some((vw, vh))) => (w, w * vh / vw), + (None, Some(h), Some((vw, vh))) => (h * vw / vh, h), + (None, None, Some((vw, vh))) => (vw, vh), + (Some(w), None, None) => (w, f64::from(DEFAULT.1)), + (None, Some(h), None) => (f64::from(DEFAULT.0), h), + (None, None, None) => return Some(DEFAULT), + }; + Some((pixels(w), pixels(h))) +} + +fn pixels(value: f64) -> u32 { + // Clamped to the core's largest side, so the cast never wraps. + value.round().clamp(1.0, 1_000_000.0) as u32 +} + +/// The attributes of the first ` Option> { + let start = find_tag(text, "svg")?; + let rest = &text[start + 4..]; + let end = rest.find('>')?; + Some(parse_attributes(&rest[..end])) +} + +fn find_tag(text: &str, name: &str) -> Option { + let mut from = 0; + while let Some(found) = text[from..].find('<') { + let at = from + found; + let rest = &text[at + 1..]; + if rest.starts_with("!--") { + from = at + 4 + rest.find("-->")?; + continue; + } + if rest.starts_with(name) + && rest[name.len()..].starts_with(|c: char| c.is_ascii_whitespace() || c == '>' || c == '/') + { + return Some(at); + } + from = at + 1; + } + None +} + +fn parse_attributes(tag: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + let mut rest = tag; + while let Some(eq) = rest.find('=') { + let key = rest[..eq] + .trim() + .rsplit(|c: char| c.is_ascii_whitespace()) + .next() + .unwrap_or(""); + let after = rest[eq + 1..].trim_start(); + let Some(quote) = after.chars().next().filter(|c| *c == '"' || *c == '\'') else { + break; + }; + let Some(close) = after[1..].find(quote) else { + break; + }; + out.push((key.to_owned(), after[1..1 + close].to_owned())); + rest = &after[close + 2..]; + } + out +} + +/// A CSS length in pixels, for the absolute units only. Percentages and font-relative units have no fixed size. +fn length(value: &str) -> Option { + let value = value.trim(); + let split = value + .find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E')) + .unwrap_or(value.len()); + let (number, unit) = value.split_at(split); + let number: f64 = number.parse().ok()?; + let scale = match unit.trim() { + "" | "px" => 1.0, + "in" => 96.0, + "cm" => 96.0 / 2.54, + "mm" => 96.0 / 25.4, + "pt" => 96.0 / 72.0, + "pc" => 16.0, + _ => return None, + }; + let pixels = number * scale; + (pixels.is_finite() && pixels > 0.0).then_some(pixels) +} + +fn view_box(value: &str) -> Option<(f64, f64)> { + let numbers: Vec = value + .split(|c: char| c.is_ascii_whitespace() || c == ',') + .filter(|part| !part.is_empty()) + .map(str::parse) + .collect::>() + .ok()?; + match numbers.as_slice() { + [_, _, w, h] if *w > 0.0 && *h > 0.0 && w.is_finite() && h.is_finite() => Some((*w, *h)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sizes_come_from_attributes_then_the_view_box_then_the_default() { + let cases: &[(&str, (u32, u32))] = &[ + (r#""#, (120, 80)), + ( + r#""#, + (30, 20), + ), + ( + r#""#, + (96, 64), + ), + (r#""#, (60, 40)), + (r#""#, (40, 10)), + (r#""#, DEFAULT), + (r#""#, DEFAULT), + ]; + for (text, expected) in cases { + assert_eq!(size(text.as_bytes()), Some(*expected), "{text}"); + } + assert_eq!(size(b"no image"), None); + assert_eq!(size(b""), None); + } +} diff --git a/app/src-tauri/src/images/web.rs b/app/src-tauri/src/images/web.rs new file mode 100644 index 000000000..4758e8802 --- /dev/null +++ b/app/src-tauri/src/images/web.rs @@ -0,0 +1,468 @@ +//! Downloading a web image for import (Phase 4 ARCHITECTURE.md sections 12.2 and 15.5): only on the person's paste, +//! with `ureq` and Windows' TLS stack, as the updater does. A download gets 20 seconds and 50 MB; only `image/*` +//! responses count; at most 3 redirects, never to another scheme; no cookies, no `Referer`, and no proxy. Loopback, +//! link-local, private, and other non-public addresses are refused when the name resolves, so a pasted page can't +//! make the app reach into the local network. + +use std::{ + io::Read, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + time::Duration, +}; + +use tauri::AppHandle; +use ureq::{ + http::Uri, + unversioned::{ + resolver::{DefaultResolver, ResolvedSocketAddrs, Resolver}, + transport::{DefaultConnector, NextTimeout}, + }, + Agent, +}; + +use super::{ + import::{check, convert_enabled, errors, on_blocking, open_page, store, too_large, Checked, MAX_IMAGE_BYTES}, + ImportedAsset, +}; +use crate::ipc::{IpcError, IpcResult}; + +const TIMEOUT: Duration = Duration::from_secs(20); +const MAX_REDIRECTS: usize = 3; + +/// Which addresses a download may reach. Only the tests allow loopback, for their local server. +#[derive(Debug, Clone, Copy)] +struct Policy { + allow_loopback: bool, +} + +/// Whether an address is on the public internet. Everything a home or office network keeps to itself is refused. +fn is_public(ip: IpAddr, policy: Policy) -> bool { + match ip { + IpAddr::V4(v4) => is_public_v4(v4, policy), + IpAddr::V6(v6) => match v6.to_ipv4_mapped() { + Some(v4) => is_public_v4(v4, policy), + None => is_public_v6(v6, policy), + }, + } +} + +fn is_public_v4(ip: Ipv4Addr, policy: Policy) -> bool { + let [a, b, ..] = ip.octets(); + if ip.is_loopback() { + return policy.allow_loopback; + } + !(ip.is_private() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_broadcast() + || ip.is_multicast() + || ip.is_documentation() + || a == 0 + || (a == 100 && (64..128).contains(&b)) + || (a == 192 && b == 0 && ip.octets()[2] == 0) + || (a == 198 && (18..20).contains(&b)) + || a >= 240) +} + +fn is_public_v6(ip: Ipv6Addr, policy: Policy) -> bool { + if ip.is_loopback() { + return policy.allow_loopback; + } + let first = ip.segments()[0]; + !(ip.is_unspecified() + || ip.is_multicast() + || (first & 0xfe00) == 0xfc00 + || (first & 0xffc0) == 0xfe80 + || (first & 0xffc0) == 0xfec0 + || first == 0x2001 && ip.segments()[1] == 0x0db8 + || first == 0x0064 && ip.segments()[1] == 0xff9b) +} + +/// The system resolver, keeping only the addresses the policy allows, so the connection can only go to them. +#[derive(Debug)] +struct GuardedResolver { + inner: DefaultResolver, + policy: Policy, +} + +impl Resolver for GuardedResolver { + fn resolve( + &self, + uri: &Uri, + config: &ureq::config::Config, + timeout: NextTimeout, + ) -> Result { + let found = self.inner.resolve(uri, config, timeout)?; + let mut kept = self.empty(); + for address in found.iter().filter(|address| is_public(address.ip(), self.policy)) { + kept.push(*address); + } + if kept.is_empty() { + return Err(ureq::Error::BadUri(format!( + "{} resolves only to addresses that aren't public", + uri.host().unwrap_or("") + ))); + } + Ok(kept) + } +} + +fn agent(policy: Policy) -> Agent { + use ureq::tls::{RootCerts, TlsConfig, TlsProvider}; + let tls = TlsConfig::builder() + .provider(TlsProvider::NativeTls) + .root_certs(RootCerts::PlatformVerifier) + .build(); + let config = Agent::config_builder() + .tls_config(tls) + .proxy(None) + .max_redirects(0) + .max_redirects_will_error(false) + .http_status_as_error(false) + .save_redirect_history(false) + .timeout_global(Some(TIMEOUT)) + .user_agent(concat!("OpenNote/", env!("CARGO_PKG_VERSION"))) + .build(); + Agent::with_parts( + config, + DefaultConnector::new(), + GuardedResolver { + inner: DefaultResolver::default(), + policy, + }, + ) +} + +fn failed(message: impl Into) -> IpcError { + IpcError::new(errors::DOWNLOAD_FAILED, message) +} + +/// An address the download may start from or be sent on to: http or https, with a host. +fn parse_url(text: &str) -> Result { + let uri: Uri = text + .trim() + .parse() + .map_err(|_| failed("The image address isn't valid."))?; + match uri.scheme_str() { + Some("http" | "https") if uri.host().is_some_and(|host| !host.is_empty()) => Ok(uri), + _ => Err(failed("Only http and https images can be saved.")), + } +} + +/// A redirect's target, resolved against the address it came from, on the same scheme. +fn redirect(from: &Uri, location: &str) -> Result { + let base = url_text(from); + let next = resolve_reference(&base, location).ok_or_else(|| failed("The image's redirect isn't valid."))?; + let next = parse_url(&next)?; + if next.scheme_str() != from.scheme_str() { + return Err(failed("The image's redirect changes the scheme.")); + } + Ok(next) +} + +fn url_text(uri: &Uri) -> String { + uri.to_string() +} + +/// A reference resolved against an absolute http(s) address: absolute, scheme-relative, root-relative, or relative. +fn resolve_reference(base: &str, reference: &str) -> Option { + let reference = reference.trim(); + if reference.contains("://") { + return Some(reference.to_owned()); + } + let (scheme, rest) = base.split_once("://")?; + if let Some(stripped) = reference.strip_prefix("//") { + return Some(format!("{scheme}://{stripped}")); + } + let authority_end = rest.find('/').unwrap_or(rest.len()); + let origin = format!("{scheme}://{}", &rest[..authority_end]); + if reference.starts_with('/') { + return Some(format!("{origin}{reference}")); + } + if reference.contains(':') { + // Another scheme, such as `file:` or `javascript:`. + return Some(reference.to_owned()); + } + let path = &rest[authority_end..]; + let path = path.split(['?', '#']).next().unwrap_or(""); + let directory = path.rfind('/').map_or("/", |end| &path[..=end]); + Some(format!("{origin}{directory}{reference}")) +} + +/// The bytes of a web image and its file name. +struct Download { + bytes: Vec, + name: String, +} + +fn download(url: &str, policy: Policy) -> Result { + let agent = agent(policy); + let mut current = parse_url(url)?; + for _ in 0..=MAX_REDIRECTS { + let response = agent + .get(&url_text(¤t)) + .call() + .map_err(|error| failed(format!("The image couldn't be downloaded: {error}")))?; + let status = response.status().as_u16(); + if (300..400).contains(&status) { + let location = response + .headers() + .get("location") + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| failed("The image's redirect has no address."))?; + current = redirect(¤t, location)?; + continue; + } + if status != 200 { + return Err(failed(format!("The image's server answered {status}."))); + } + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + if !content_type.trim_start().starts_with("image/") { + return Err(IpcError::new(errors::UNSUPPORTED_TYPE, "The address isn't an image.")); + } + if response + .body() + .content_length() + .is_some_and(|length| length > MAX_IMAGE_BYTES as u64) + { + return Err(too_large()); + } + let mut bytes = Vec::new(); + response + .into_body() + .into_reader() + .take(MAX_IMAGE_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| failed(format!("The image download stopped: {error}")))?; + if bytes.len() > MAX_IMAGE_BYTES { + return Err(too_large()); + } + return Ok(Download { + bytes, + name: name_from(¤t), + }); + } + Err(failed("The image's address redirects too many times.")) +} + +/// The last part of the address's path, as the asset's original name. +fn name_from(uri: &Uri) -> String { + let last = uri.path().rsplit('/').next().unwrap_or(""); + let decoded = super::import::percent_decode(last); + let safe: String = decoded + .chars() + .filter(|c| !c.is_control() && !"\\/:*?\"<>|".contains(*c)) + .collect(); + if safe.trim().is_empty() { + "image".to_owned() + } else { + safe.chars().take(120).collect() + } +} + +/// Downloads and checks a web image, without storing it. +fn fetch_checked(url: &str, policy: Policy, convert: bool) -> Result { + let download = download(url, policy)?; + // The server's type only has to say "image"; the bytes decide the format, as servers often mislabel them. + check(download.bytes, &download.name, "", convert) +} + +#[tauri::command] +pub async fn image_import_url(app: AppHandle, page: String, url: String) -> IpcResult { + let convert = convert_enabled(&app); + on_blocking(move || { + let handle = open_page(&app, &page)?; + let checked = fetch_checked(&url, Policy { allow_loopback: false }, convert)?; + store(&handle, checked) + }) + .await +} + +#[cfg(test)] +mod tests { + use std::{ + io::{BufRead, BufReader, Write}, + net::TcpListener, + sync::{Arc, Mutex}, + thread, + }; + + use super::*; + + const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/src/images/fixtures"); + const TEST: Policy = Policy { allow_loopback: true }; + + /// A local server that answers each request path with a canned response, and records the request headers. + struct Server { + base: String, + requests: Arc>>, + } + + fn serve(routes: Vec<(&'static str, Vec)>) -> Server { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let seen = requests.clone(); + thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { break }; + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut head = String::new(); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + head.push_str(&line); + } + let path = head.split_whitespace().nth(1).unwrap_or("/").to_owned(); + seen.lock().unwrap().push(head); + let response = routes + .iter() + .find(|(route, _)| *route == path) + .map(|(_, response)| response.clone()) + .unwrap_or_else(|| b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".to_vec()); + let _ = stream.write_all(&response); + } + }); + Server { base, requests } + } + + fn response(content_type: &str, body: &[u8]) -> Vec { + let mut out = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .into_bytes(); + out.extend_from_slice(body); + out + } + + fn redirect_to(location: &str) -> Vec { + format!("HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .into_bytes() + } + + fn png() -> Vec { + std::fs::read(format!("{FIXTURES}/png.png")).unwrap() + } + + #[test] + fn an_image_downloads_and_passes_the_probe() { + let server = serve(vec![ + ("/pic%20one.png", response("image/png", &png())), + ("/moved", redirect_to("/pic%20one.png")), + ]); + let checked = fetch_checked(&format!("{}/moved", server.base), TEST, false).unwrap(); + assert_eq!( + (checked.name.as_str(), checked.width, checked.height), + ("pic one.png", 6, 4) + ); + let requests = server.requests.lock().unwrap(); + assert!(requests.iter().all(|head| { + let lower = head.to_ascii_lowercase(); + !lower.contains("\ncookie:") && !lower.contains("\nreferer:") + })); + } + + #[test] + fn other_types_and_oversized_answers_are_refused() { + let mut big = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\n\r\n", + MAX_IMAGE_BYTES + 1 + ) + .into_bytes(); + big.extend_from_slice(&png()); + let server = serve(vec![ + ("/page", response("text/html", b"")), + ("/big", big), + ("/lie", response("image/png", b"not a picture")), + ]); + let code = |path: &str| { + fetch_checked(&format!("{}{path}", server.base), TEST, false) + .unwrap_err() + .code + }; + assert_eq!(code("/page"), errors::UNSUPPORTED_TYPE); + assert_eq!(code("/big"), errors::TOO_LARGE); + assert_eq!(code("/lie"), errors::UNSUPPORTED_TYPE); + assert_eq!(code("/missing"), errors::DOWNLOAD_FAILED); + } + + #[test] + fn redirects_to_other_schemes_or_private_addresses_are_refused() { + let server = serve(vec![ + ("/file", redirect_to("file:///C:/Windows/win.ini")), + ("/https", redirect_to("https://example.com/a.png")), + ("/private", redirect_to("http://10.0.0.1/a.png")), + ("/a", redirect_to("/b")), + ("/b", redirect_to("/c")), + ("/c", redirect_to("/d")), + ("/d", redirect_to("/e")), + ("/e", response("image/png", &png())), + ]); + for path in ["/file", "/https", "/private", "/a"] { + let error = fetch_checked(&format!("{}{path}", server.base), TEST, false).unwrap_err(); + assert_eq!(error.code, errors::DOWNLOAD_FAILED, "{path}: {}", error.message); + } + } + + #[test] + fn local_and_private_addresses_are_refused_without_a_request() { + let server = serve(vec![("/a.png", response("image/png", &png()))]); + let strict = Policy { allow_loopback: false }; + for url in [ + format!("{}/a.png", server.base), + "http://[::1]/a.png".to_owned(), + "http://192.168.1.1/a.png".to_owned(), + "http://169.254.169.254/latest".to_owned(), + "ftp://example.com/a.png".to_owned(), + "javascript:alert(1)".to_owned(), + ] { + let error = fetch_checked(&url, strict, false).unwrap_err(); + assert_eq!(error.code, errors::DOWNLOAD_FAILED, "{url}"); + } + assert!(server.requests.lock().unwrap().is_empty()); + } + + #[test] + fn address_classes() { + let strict = Policy { allow_loopback: false }; + for (ip, public) in [ + ("8.8.8.8", true), + ("2606:4700::1111", true), + ("127.0.0.1", false), + ("10.1.2.3", false), + ("172.16.0.1", false), + ("100.64.0.1", false), + ("::ffff:192.168.0.1", false), + ("fd00::1", false), + ("fe80::1", false), + ("0.0.0.0", false), + ] { + assert_eq!(is_public(ip.parse().unwrap(), strict), public, "{ip}"); + } + } + + #[test] + fn references_resolve_against_the_page() { + let base = "http://example.com/a/b.html?x=1"; + assert_eq!( + resolve_reference(base, "c.png").as_deref(), + Some("http://example.com/a/c.png") + ); + assert_eq!( + resolve_reference(base, "/c.png").as_deref(), + Some("http://example.com/c.png") + ); + assert_eq!( + resolve_reference(base, "//cdn.example/c.png").as_deref(), + Some("http://cdn.example/c.png") + ); + } +} diff --git a/app/src-tauri/src/images/wic.rs b/app/src-tauri/src/images/wic.rs new file mode 100644 index 000000000..5a4756489 --- /dev/null +++ b/app/src-tauri/src/images/wic.rs @@ -0,0 +1,46 @@ +//! Windows Imaging Component (WIC), which reads an image's header the way Windows and WebView2 will decode it. +//! Opening a decoder reads only the header; no pixel is decoded here. + +use windows::Win32::{ + Graphics::Imaging::{CLSID_WICImagingFactory, IWICImagingFactory, WICDecodeMetadataCacheOnDemand}, + System::Com::{CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED}, +}; + +/// Runs `body` with COM initialized on this thread, and releases COM after it if this call initialized it. +pub fn with_com(body: impl FnOnce() -> windows::core::Result) -> Option { + // SAFETY: COM is initialized for this call and released after it; every COM object `body` makes is dropped + // before it returns. + unsafe { + let apartment = CoInitializeEx(None, COINIT_MULTITHREADED); + let result = body(); + if apartment.is_ok() { + CoUninitialize(); + } + result.ok() + } +} + +/// The factory every WIC call starts from. +/// +/// # Safety +/// COM must be initialized on the calling thread. +pub unsafe fn factory() -> windows::core::Result { + CoCreateInstance(&CLSID_WICImagingFactory, None, CLSCTX_INPROC_SERVER) +} + +/// The pixel size of the first frame as stored, before any orientation, or None when no installed codec opens it. +pub fn frame_size(bytes: &[u8]) -> Option<(u32, u32)> { + with_com(|| { + // SAFETY: COM is initialized by `with_com`. The stream reads `bytes`, which outlive every object here. + unsafe { + let factory = factory()?; + let stream = factory.CreateStream()?; + stream.InitializeFromMemory(bytes)?; + let decoder = factory.CreateDecoderFromStream(&stream, std::ptr::null(), WICDecodeMetadataCacheOnDemand)?; + let frame = decoder.GetFrame(0)?; + let (mut width, mut height) = (0u32, 0u32); + frame.GetSize(&mut width, &mut height)?; + Ok((width, height)) + } + }) +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index f2a282f4b..a5e38f7e5 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -4,9 +4,12 @@ pub mod appearance; pub mod args; pub mod boot; +pub mod clipboard; pub mod command_list; +pub mod core_bridge; pub mod early; pub mod events; +pub mod images; pub mod install; pub mod instance; pub mod ipc; @@ -17,6 +20,8 @@ pub mod paths; pub mod perf; pub mod settings; pub mod shell; +pub mod speech; +pub mod spelling; pub mod state; pub mod theme_tokens; pub mod updater; @@ -71,7 +76,9 @@ pub fn run(context: EarlyContext) { flag_overrides: boot::flag_overrides(&std::env::var("OPENNOTE_FLAGS").unwrap_or_default()), }; let hooks = lifecycle::Hooks(updater::hooks(&paths)); - let app = tauri::Builder::default() + let pages = core_bridge::CoreBridge::new(&paths); + let builder = images::register_renditions(tauri::Builder::default()); + let app = builder .manage(loaded.store) .manage(state) .manage(hooks) @@ -79,6 +86,9 @@ pub fn run(context: EarlyContext) { .manage(launch) .manage(paths) .manage(ExitState::default()) + .manage(pages) + .manage(clipboard::ClipTokens::default()) + .manage(spelling::SpellService) // The instance guard holds the profile's lock, so it lives in managed state until the process exits. .manage(instance) .setup(|app| { @@ -95,6 +105,7 @@ pub fn run(context: EarlyContext) { app.run(|app, event| { if let tauri::RunEvent::Exit = event { flush_files(app); + app.state::().shutdown(); } }); } @@ -142,5 +153,28 @@ fn commands() -> impl Fn(Invoke) -> bool + Send + Sync + 'static { updater::updater_go_back, notes_snapshot::notes_snapshot_load, notes_snapshot::notes_snapshot_save, + core_bridge::page_open, + core_bridge::page_apply, + core_bridge::page_undo, + core_bridge::page_redo, + core_bridge::page_save_now, + core_bridge::page_close, + core_bridge::history_list, + core_bridge::history_open, + core_bridge::history_restore, + core_bridge::history_restore_blocks, + core_bridge::history_name, + clipboard::clipboard_facts, + clipboard::clipboard_read, + images::import::image_import, + images::web::image_import_url, + images::import::image_import_clip, + spelling::spell_languages, + spelling::spell_check, + spelling::spell_suggest, + spelling::spell_add_word, + spelling::spell_remove_word, + speech::speech_voices, + speech::speech_synthesize, ] } diff --git a/app/src-tauri/src/speech/mod.rs b/app/src-tauri/src/speech/mod.rs new file mode 100644 index 000000000..312387c46 --- /dev/null +++ b/app/src-tauri/src/speech/mod.rs @@ -0,0 +1,26 @@ +//! The read-aloud fallback over Windows' speech synthesis (Phase 4 ARCHITECTURE.md section 19.1; owner after WP0: +//! WP7). Read aloud uses the browser's Web Speech voices; these commands are built only if spike S2 shows WebView2 +//! lacks them, and until then answer notImplemented. + +use serde::Serialize; + +use crate::ipc::{IpcError, IpcResult}; + +#[derive(Debug, Clone, Serialize)] +pub struct Voice { + pub id: String, + pub name: String, + pub language: String, +} + +#[tauri::command] +pub async fn speech_voices() -> IpcResult> { + Err(IpcError::not_implemented("speech_voices")) +} + +/// The JSON length as 4 bytes, the word boundaries as JSON, then the WAV bytes. +#[tauri::command] +pub async fn speech_synthesize(text: String, voice: String) -> IpcResult { + let _ = (text, voice); + Err(IpcError::not_implemented("speech_synthesize")) +} diff --git a/app/src-tauri/src/spelling/mod.rs b/app/src-tauri/src/spelling/mod.rs new file mode 100644 index 000000000..0270b8541 --- /dev/null +++ b/app/src-tauri/src/spelling/mod.rs @@ -0,0 +1,242 @@ +//! Spell check through the Windows Spell Checking API (Phase 4 ARCHITECTURE.md section 16; ADR 0023). +//! Ranges are in UTF-16 units, which equal JavaScript string indices. One thread owns the COM objects +//! (`windows.rs`); this module holds the commands and the rules that don't need Windows: several languages at +//! once, the personal dictionary, and the words the settings skip. + +pub mod windows; + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tauri::{AppHandle, Emitter, Manager, State}; + +use crate::events; +use crate::ipc::{IpcError, IpcResult}; +use crate::settings::{editing::Spelling, SettingsChanged, SettingsStore}; + +pub use self::windows::SpellService; + +/// At most this many items in one `spell_check`. +pub const MAX_ITEMS: usize = 200; +/// At most this much text, in UTF-8 bytes, in one `spell_check`. +pub const MAX_BYTES: usize = 256 * 1024; +/// At most this many suggestions for a word, merged across languages. +pub const MAX_SUGGESTIONS: usize = 5; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SpellLanguage { + pub tag: String, + pub name: String, + pub is_default: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SpellItem { + pub id: String, + pub text: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SpellResult { + pub id: String, + pub errors: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct SpellError { + pub start: u32, + pub length: u32, +} + +impl SpellError { + fn end(self) -> u32 { + self.start + self.length + } + + fn overlaps(self, other: SpellError) -> bool { + self.start < other.end() && other.start < self.end() + } +} + +/// The words a check never flags: the personal dictionary, and by default words in all capitals or with digits. +#[derive(Debug, Clone, Default)] +pub struct Rules { + any_case: HashSet, + exact: HashSet, + ignore_uppercase: bool, + ignore_with_digits: bool, +} + +impl Rules { + pub fn new(spelling: &Spelling) -> Self { + let (lower, other): (Vec<&String>, Vec<&String>) = spelling + .personal_words + .iter() + .partition(|word| word.chars().all(|c| !c.is_uppercase())); + Self { + any_case: lower.into_iter().cloned().collect(), + exact: other.into_iter().cloned().collect(), + ignore_uppercase: spelling.ignore_uppercase, + ignore_with_digits: spelling.ignore_with_digits, + } + } + + /// An all-lowercase personal word matches any capitalization; any other matches exactly. + pub fn skips(&self, word: &str) -> bool { + if self.exact.contains(word) || self.any_case.contains(&word.to_lowercase()) { + return true; + } + let mut letters = word.chars().filter(|c| c.is_alphabetic()).peekable(); + let uppercase = letters.peek().is_some() && letters.all(char::is_uppercase); + (self.ignore_uppercase && uppercase) || (self.ignore_with_digits && word.chars().any(|c| c.is_numeric())) + } + + /// The errors whose words the rules don't skip. `units` is the checked text in UTF-16. + pub fn filter(&self, units: &[u16], errors: Vec) -> Vec { + errors + .into_iter() + .filter(|error| !self.skips(&word_at(units, *error))) + .collect() + } +} + +/// The text an error covers. +pub fn word_at(units: &[u16], error: SpellError) -> String { + let start = (error.start as usize).min(units.len()); + let end = (error.end() as usize).min(units.len()); + String::from_utf16_lossy(&units[start..end]) +} + +/// With several languages, a range is an error only when every language flags it: a range from the first list +/// stays when every other list has one that overlaps it. +pub fn common_errors(per_language: &[Vec]) -> Vec { + let Some((first, others)) = per_language.split_first() else { + return Vec::new(); + }; + first + .iter() + .copied() + .filter(|error| { + others + .iter() + .all(|list| list.iter().any(|other| error.overlaps(*other))) + }) + .collect() +} + +/// Takes suggestions from each language in turn, without repeats, up to `max`. +pub fn merge_suggestions(lists: Vec>, max: usize) -> Vec { + let mut merged: Vec = Vec::new(); + let longest = lists.iter().map(Vec::len).max().unwrap_or(0); + for index in 0..longest { + for list in &lists { + if let Some(word) = list.get(index) { + if merged.len() < max && !merged.contains(word) { + merged.push(word.clone()); + } + } + } + } + merged +} + +/// Rejects a request over the item or size limit. +pub fn check_limits(items: &[SpellItem]) -> IpcResult<()> { + if items.len() > MAX_ITEMS { + return Err(IpcError::invalid("items", "A spelling check takes at most 200 items.")); + } + if items.iter().map(|item| item.text.len()).sum::() > MAX_BYTES { + return Err(IpcError::invalid( + "items", + "A spelling check takes at most 256 KB of text.", + )); + } + Ok(()) +} + +/// The personal words with `word` added or removed, or None when nothing changes. +pub fn edit_personal_words(words: &[String], word: &str, add: bool) -> IpcResult>> { + let word = word.trim(); + if word.is_empty() || word.chars().count() > 64 || word.chars().any(char::is_whitespace) { + return Err(IpcError::invalid( + "word", + "A dictionary word is one word of up to 64 characters.", + )); + } + let present = words.iter().any(|known| known == word); + match (add, present) { + (true, true) | (false, false) => Ok(None), + (true, false) => Ok(Some(words.iter().cloned().chain([word.to_owned()]).collect())), + (false, true) => Ok(Some(words.iter().filter(|known| *known != word).cloned().collect())), + } +} + +#[tauri::command] +pub async fn spell_languages(svc: State<'_, SpellService>) -> IpcResult> { + let svc = *svc.inner(); + tauri::async_runtime::spawn_blocking(move || svc.languages()) + .await + .map_err(|error| IpcError::new(crate::ipc::codes::INTERNAL, error.to_string()))? +} + +/// At most 200 items and 256 KB of text in one call. +#[tauri::command] +pub async fn spell_check( + svc: State<'_, SpellService>, + store: State<'_, SettingsStore>, + items: Vec, + languages: Vec, +) -> IpcResult> { + check_limits(&items)?; + let rules = Rules::new(&store.get().editing.spelling); + let svc = *svc.inner(); + tauri::async_runtime::spawn_blocking(move || svc.check(items, languages, &rules)) + .await + .map_err(|error| IpcError::new(crate::ipc::codes::INTERNAL, error.to_string()))? +} + +#[tauri::command] +pub async fn spell_suggest( + svc: State<'_, SpellService>, + word: String, + languages: Vec, +) -> IpcResult> { + let svc = *svc.inner(); + tauri::async_runtime::spawn_blocking(move || svc.suggest(&word, &languages)) + .await + .map_err(|error| IpcError::new(crate::ipc::codes::INTERNAL, error.to_string()))? +} + +#[tauri::command] +pub async fn spell_add_word(app: AppHandle, word: String) -> IpcResult<()> { + change_personal_words(&app, &word, true) +} + +#[tauri::command] +pub async fn spell_remove_word(app: AppHandle, word: String) -> IpcResult<()> { + change_personal_words(&app, &word, false) +} + +/// Updates `settings.editing.spelling.personalWords` and tells every window, as `settings_update` does. +fn change_personal_words(app: &AppHandle, word: &str, add: bool) -> IpcResult<()> { + let store = app.state::(); + let words = store.get().editing.spelling.personal_words; + let Some(next) = edit_personal_words(&words, word, add)? else { + return Ok(()); + }; + let patch = json!({ "editing": { "spelling": { "personalWords": next } } }); + let settings = store.update(patch, "spelling")?; + let payload = SettingsChanged { + settings, + origin: "spelling".to_owned(), + }; + if let Err(error) = app.emit(events::SETTINGS_CHANGED, payload) { + log::warn!("Couldn't send {}: {error}", events::SETTINGS_CHANGED); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/app/src-tauri/src/spelling/tests.rs b/app/src-tauri/src/spelling/tests.rs new file mode 100644 index 000000000..f776ba9da --- /dev/null +++ b/app/src-tauri/src/spelling/tests.rs @@ -0,0 +1,139 @@ +use super::*; + +fn err(start: u32, length: u32) -> SpellError { + SpellError { start, length } +} + +fn rules(words: &[&str]) -> Rules { + Rules::new(&Spelling { + personal_words: words.iter().map(|word| (*word).to_owned()).collect(), + ..Spelling::default() + }) +} + +fn units(text: &str) -> Vec { + text.encode_utf16().collect() +} + +#[test] +fn an_error_needs_every_language_to_flag_it() { + let english = vec![err(0, 5), err(10, 4)]; + let german = vec![err(10, 4), err(20, 3)]; + assert_eq!(common_errors(&[english.clone(), german]), vec![err(10, 4)]); + assert_eq!(common_errors(std::slice::from_ref(&english)), english); + assert!(common_errors(&[]).is_empty()); +} + +#[test] +fn personal_words_match_by_case_as_the_architecture_says() { + let rules = rules(&["opennote", "McCoy"]); + assert!(rules.skips("OpenNote")); + assert!(rules.skips("opennote")); + assert!(rules.skips("McCoy")); + assert!(!rules.skips("mccoy")); + assert!(!rules.skips("teh")); +} + +#[test] +fn capitals_and_digits_are_skipped_only_when_the_settings_say_so() { + let on = rules(&[]); + assert!(on.skips("NASA")); + assert!(on.skips("abc123")); + assert!(!on.skips("Teh")); + let off = Rules::new(&Spelling { + ignore_uppercase: false, + ignore_with_digits: false, + ..Spelling::default() + }); + assert!(!off.skips("NASA")); + assert!(!off.skips("abc123")); +} + +#[test] +fn filtering_reads_words_in_utf16_units() { + let text = "😀 teh NASA"; + let found = vec![err(3, 3), err(7, 4)]; + assert_eq!(word_at(&units(text), err(3, 3)), "teh"); + assert_eq!(rules(&[]).filter(&units(text), found), vec![err(3, 3)]); +} + +#[test] +fn suggestions_alternate_languages_without_repeats() { + let merged = merge_suggestions( + vec![ + vec!["the".into(), "tea".into(), "ten".into()], + vec!["the".into(), "teh".into()], + ], + 4, + ); + assert_eq!(merged, ["the", "tea", "teh", "ten"]); +} + +#[test] +fn a_check_over_the_limits_is_refused() { + let item = |text: &str| SpellItem { + id: "a".into(), + text: text.into(), + }; + assert!(check_limits(&vec![item("x"); MAX_ITEMS]).is_ok()); + assert!(check_limits(&vec![item("x"); MAX_ITEMS + 1]).is_err()); + assert!(check_limits(&[item(&"x".repeat(MAX_BYTES + 1))]).is_err()); +} + +#[test] +fn personal_words_are_added_once_and_removed() { + let words = vec!["alpha".to_owned()]; + assert_eq!( + edit_personal_words(&words, "beta", true).unwrap(), + Some(vec!["alpha".to_owned(), "beta".to_owned()]) + ); + assert_eq!(edit_personal_words(&words, "alpha", true).unwrap(), None); + assert_eq!(edit_personal_words(&words, "alpha", false).unwrap(), Some(vec![])); + assert_eq!(edit_personal_words(&words, "gamma", false).unwrap(), None); + assert!(edit_personal_words(&words, "two words", true).is_err()); + assert!(edit_personal_words(&words, "", true).is_err()); +} + +/// The Windows checker with en-US. Machines without the language skip rather than fail. +#[test] +fn windows_flags_misspellings_in_english() { + let svc = SpellService; + let languages = svc.languages().unwrap_or_default(); + if !languages + .iter() + .any(|language| language.tag.eq_ignore_ascii_case("en-US")) + { + eprintln!("skipped: no en-US spell checker on this machine"); + return; + } + let english = vec!["en-US".to_owned()]; + let text = "I recieve teh 😀 letters from NASA and abc123."; + let items = vec![SpellItem { + id: "p1".into(), + text: text.into(), + }]; + let results = svc.check(items, english.clone(), &rules(&[])).expect("check"); + let words: Vec = results[0] + .errors + .iter() + .map(|error| word_at(&units(text), *error)) + .collect(); + assert_eq!(words, ["recieve", "teh"]); + + let personal = svc + .check( + vec![SpellItem { + id: "p2".into(), + text: "teh recieve".into(), + }], + english.clone(), + &rules(&["teh"]), + ) + .expect("check"); + assert_eq!(personal[0].errors, vec![err(4, 7)]); + + let suggestions = svc.suggest("recieve", &english).expect("suggest"); + assert!(suggestions.len() <= MAX_SUGGESTIONS); + assert!(suggestions.iter().any(|word| word == "receive"), "{suggestions:?}"); + assert!(svc.suggest("receive", &english).expect("suggest").is_empty()); +} diff --git a/app/src-tauri/src/spelling/windows.rs b/app/src-tauri/src/spelling/windows.rs new file mode 100644 index 000000000..af179d21d --- /dev/null +++ b/app/src-tauri/src/spelling/windows.rs @@ -0,0 +1,333 @@ +//! The spelling thread over `ISpellCheckerFactory` (ARCHITECTURE.md section 16.2). One thread initializes COM for +//! the multithreaded apartment, creates the factory and one `ISpellChecker` per language on first use, and serves +//! requests from a channel. The service the commands share is a handle to that thread, which starts on the first +//! request. + +use std::sync::mpsc; +use std::sync::OnceLock; + +use super::MAX_SUGGESTIONS; +use super::{common_errors, merge_suggestions, Rules, SpellError, SpellItem, SpellLanguage, SpellResult}; +use crate::ipc::{codes, IpcError, IpcResult}; + +/// The managed state the spelling commands share: a handle to the spelling thread. +#[derive(Debug, Default, Clone, Copy)] +pub struct SpellService; + +type Job = Box; + +static WORKER: OnceLock>> = OnceLock::new(); + +fn worker() -> IpcResult<&'static mpsc::Sender> { + WORKER + .get_or_init(|| { + let (sender, jobs) = mpsc::channel::(); + let spawned = std::thread::Builder::new().name("spelling".into()).spawn(move || { + let mut engine = engine::Engine::start(); + for job in jobs { + job(&mut engine); + } + }); + match spawned { + Ok(_) => Some(sender), + Err(error) => { + log::error!("Couldn't start the spelling thread: {error}"); + None + } + } + }) + .as_ref() + .ok_or_else(unavailable) +} + +fn unavailable() -> IpcError { + IpcError::new(codes::INTERNAL, "Spell check isn't available.") +} + +impl SpellService { + /// Runs `job` on the spelling thread and waits for its answer. Call it off the async runtime's threads. + fn run(self, job: impl FnOnce(&mut engine::Engine) -> T + Send + 'static) -> IpcResult { + let (reply, answer) = mpsc::channel(); + worker()? + .send(Box::new(move |engine| { + let _ = reply.send(job(engine)); + })) + .map_err(|_| unavailable())?; + answer.recv().map_err(|_| unavailable()) + } + + /// The installed spell-checking languages, marking those in the Windows language list as the defaults. + pub fn languages(self) -> IpcResult> { + self.run(|engine| engine.languages()) + } + + /// Each item checked with every enabled language; empty `languages` means the defaults. + pub fn check(self, items: Vec, languages: Vec, rules: &Rules) -> IpcResult> { + let rules = rules.clone(); + self.run(move |engine| { + let tags = engine.resolve(&languages); + items + .into_iter() + .map(|item| { + let units: Vec = item.text.encode_utf16().collect(); + let per_language: Vec> = + tags.iter().map(|tag| engine.check(tag, &item.text)).collect(); + SpellResult { + id: item.id, + errors: rules.filter(&units, common_errors(&per_language)), + } + }) + .collect() + }) + } + + /// Up to 5 suggestions, merged across the enabled languages that flag the word. + pub fn suggest(self, word: &str, languages: &[String]) -> IpcResult> { + let word = word.to_owned(); + let languages = languages.to_vec(); + self.run(move |engine| { + let tags = engine.resolve(&languages); + let lists = tags.iter().map(|tag| engine.suggest(tag, &word)).collect(); + merge_suggestions(lists, MAX_SUGGESTIONS) + }) + } +} + +#[cfg(windows)] +mod engine { + use std::collections::HashMap; + + use windows::core::{HSTRING, PWSTR}; + use windows::Win32::Globalization::{ + GetLocaleInfoEx, GetUserPreferredUILanguages, ISpellChecker, ISpellCheckerFactory, SpellCheckerFactory, + CORRECTIVE_ACTION_DELETE, CORRECTIVE_ACTION_NONE, LOCALE_SLOCALIZEDDISPLAYNAME, MUI_LANGUAGE_NAME, + }; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoTaskMemFree, IEnumString, CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, + }; + + use crate::spelling::{SpellError, SpellLanguage}; + + pub struct Engine { + factory: Option, + checkers: HashMap>, + } + + impl Engine { + pub fn start() -> Self { + // SAFETY: called once on the spelling thread, which owns the apartment for its lifetime. + let factory = unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + CoCreateInstance::<_, ISpellCheckerFactory>(&SpellCheckerFactory, None, CLSCTX_INPROC_SERVER) + }; + let factory = factory + .inspect_err(|error| log::warn!("Spell check is off: no spell checker factory ({error})")) + .ok(); + Self { + factory, + checkers: HashMap::new(), + } + } + + fn supported(&self) -> Vec { + let Some(factory) = &self.factory else { + return Vec::new(); + }; + // SAFETY: the factory lives on this thread; the enumerator's strings are freed by take_strings. + unsafe { + factory + .SupportedLanguages() + .map(|list| take_strings(&list)) + .unwrap_or_default() + } + } + + pub fn languages(&self) -> Vec { + let supported = self.supported(); + let defaults = default_tags(&supported); + supported + .into_iter() + .map(|tag| SpellLanguage { + is_default: defaults.iter().any(|d| d.eq_ignore_ascii_case(&tag)), + name: display_name(&tag), + tag, + }) + .collect() + } + + /// The installed tags among `wanted`, or the defaults when `wanted` is empty. + pub fn resolve(&self, wanted: &[String]) -> Vec { + let supported = self.supported(); + if wanted.is_empty() { + return default_tags(&supported); + } + wanted + .iter() + .filter_map(|tag| supported.iter().find(|known| known.eq_ignore_ascii_case(tag)).cloned()) + .collect() + } + + fn checker(&mut self, tag: &str) -> Option<&ISpellChecker> { + let factory = self.factory.as_ref()?; + self.checkers + .entry(tag.to_ascii_lowercase()) + .or_insert_with(|| { + // SAFETY: the factory lives on this thread. + unsafe { factory.CreateSpellChecker(&HSTRING::from(tag)) } + .inspect_err(|error| log::warn!("No spell checker for {tag}: {error}")) + .ok() + }) + .as_ref() + } + + /// Misspelled words, in UTF-16 units. Repeated words ("the the") aren't spelling errors here. + pub fn check(&mut self, tag: &str, text: &str) -> Vec { + let Some(checker) = self.checker(tag) else { + return Vec::new(); + }; + let mut errors = Vec::new(); + // SAFETY: the checker lives on this thread; each error is a COM object the loop releases. + unsafe { + let Ok(found) = checker.Check(&HSTRING::from(text)) else { + return errors; + }; + loop { + let mut next = None; + if found.Next(&mut next).is_err() { + break; + } + let Some(error) = next else { break }; + let action = error.CorrectiveAction().unwrap_or(CORRECTIVE_ACTION_NONE); + if action == CORRECTIVE_ACTION_DELETE || action == CORRECTIVE_ACTION_NONE { + continue; + } + if let (Ok(start), Ok(length)) = (error.StartIndex(), error.Length()) { + errors.push(SpellError { start, length }); + } + } + } + errors + } + + /// The checker's suggestions, or none when the word is spelled right in this language. + pub fn suggest(&mut self, tag: &str, word: &str) -> Vec { + if self.check(tag, word).is_empty() { + return Vec::new(); + } + let Some(checker) = self.checker(tag) else { + return Vec::new(); + }; + // SAFETY: as in check. + unsafe { + checker + .Suggest(&HSTRING::from(word)) + .map(|list| take_strings(&list)) + .unwrap_or_default() + } + } + } + + /// Reads an enumerator of task-allocated strings, freeing each. + unsafe fn take_strings(list: &IEnumString) -> Vec { + let mut out = Vec::new(); + loop { + let mut item = [PWSTR::null()]; + let mut fetched = 0u32; + // SAFETY: one slot, filled with a CoTaskMemAlloc string the loop frees. + let result = unsafe { list.Next(&mut item, Some(&mut fetched)) }; + if result.is_err() || fetched == 0 || item[0].is_null() { + break; + } + // SAFETY: the string is valid until it's freed just below. + unsafe { + if let Ok(text) = item[0].to_string() { + out.push(text); + } + CoTaskMemFree(Some(item[0].0 as *const _)); + } + } + out + } + + /// The installed tags that match the Windows language list, matching a neutral language to its first region. + fn default_tags(supported: &[String]) -> Vec { + let mut tags: Vec = Vec::new(); + for preferred in preferred_languages() { + let exact = supported.iter().find(|tag| tag.eq_ignore_ascii_case(&preferred)); + let primary = preferred.split('-').next().unwrap_or_default().to_ascii_lowercase(); + let near = || { + supported + .iter() + .find(|tag| tag.split('-').next().unwrap_or_default().eq_ignore_ascii_case(&primary)) + }; + if let Some(tag) = exact.or_else(near) { + if !tags.contains(tag) { + tags.push(tag.clone()); + } + } + } + tags + } + + fn preferred_languages() -> Vec { + let mut count = 0u32; + let mut length = 0u32; + // SAFETY: the first call sizes the buffer, the second fills it with a double-null-terminated list. + unsafe { + if GetUserPreferredUILanguages(MUI_LANGUAGE_NAME, &mut count, None, &mut length).is_err() { + return Vec::new(); + } + let mut buffer = vec![0u16; length as usize]; + let filled = GetUserPreferredUILanguages( + MUI_LANGUAGE_NAME, + &mut count, + Some(PWSTR(buffer.as_mut_ptr())), + &mut length, + ); + if filled.is_err() { + return Vec::new(); + } + buffer + .split(|unit| *unit == 0) + .filter(|part| !part.is_empty()) + .map(String::from_utf16_lossy) + .collect() + } + } + + fn display_name(tag: &str) -> String { + let mut buffer = [0u16; 256]; + // SAFETY: the buffer's length bounds the write. + let written = unsafe { GetLocaleInfoEx(&HSTRING::from(tag), LOCALE_SLOCALIZEDDISPLAYNAME, Some(&mut buffer)) }; + if written <= 1 { + return tag.to_owned(); + } + String::from_utf16_lossy(&buffer[..written as usize - 1]) + } +} + +#[cfg(not(windows))] +mod engine { + use crate::spelling::{SpellError, SpellLanguage}; + + /// Other systems have no spell checker yet: every word passes. + pub struct Engine; + + impl Engine { + pub fn start() -> Self { + Self + } + pub fn languages(&self) -> Vec { + Vec::new() + } + pub fn resolve(&self, _wanted: &[String]) -> Vec { + Vec::new() + } + pub fn check(&mut self, _tag: &str, _text: &str) -> Vec { + Vec::new() + } + pub fn suggest(&mut self, _tag: &str, _word: &str) -> Vec { + Vec::new() + } + } +} diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index da4b79cf2..7c8248f9f 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -24,7 +24,7 @@ } ], "security": { - "csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' opennote-asset: blob: data:; worker-src 'self'" + "csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' opennote-asset: http://opennote-asset.localhost blob: data:; worker-src 'self'" } }, "bundle": { diff --git a/app/src/app/flags.ts b/app/src/app/flags.ts index 2654e8dbb..244aa19ee 100644 --- a/app/src/app/flags.ts +++ b/app/src/app/flags.ts @@ -3,6 +3,7 @@ // nightly builds also take overrides from the boot payload and from settings.experimental.flags. import type { Channel } from '../platform/bindings/Channel'; +import { PAGE_FLAGS } from '../features/page/flags'; import { createStore, useStore } from '../state/store'; export type { Channel } from '../platform/bindings/Channel'; @@ -105,6 +106,7 @@ export const FLAGS: readonly FlagDef[] = [ flag('settings.privacyAndAi', 'The Privacy and smart features section of Settings.', off), flag('bottomBar.recent', 'Recent pages in the compact bottom bar.', off), flag('storage.core', "Phase 3's storage-backed notes service.", off), + ...PAGE_FLAGS, ]; interface FlagState { diff --git a/app/src/app/start.ts b/app/src/app/start.ts index d6d85a91e..2b52eae55 100644 --- a/app/src/app/start.ts +++ b/app/src/app/start.ts @@ -5,7 +5,7 @@ import { hasInjectedBoot, readBoot, readDevOptions } from '../boot/read'; import { installDispatcher } from '../commands/dispatcher'; import { configureCommands } from '../commands/registry'; import '../features'; -import { installPageZoom } from '../features/page'; +import { installPageZoom, installPages } from '../features/page'; import { installSetup } from '../features/setup'; import { installAppearance } from '../features/theme'; import { createPlatform } from '../platform'; @@ -54,6 +54,7 @@ export function installApp( initLayout(), installAppearance(), installPageZoom(), + installPages(platform), installAppContextMenu(), installSetup(platform, notes), ]; diff --git a/app/src/commands/fixtures/layouts.ts b/app/src/commands/fixtures/layouts.ts new file mode 100644 index 000000000..7a3a533fe --- /dev/null +++ b/app/src/commands/fixtures/layouts.ts @@ -0,0 +1,94 @@ +// Keyboard layout fixtures for the dispatcher (ARCHITECTURE.md section 22.3; spike S7; owner: WP4). Each entry is a +// key press as WebView2 on Windows reports it on that layout: the typed key, the physical code, the modifiers, and +// the AltGraph state. Windows reports AltGr, and Ctrl+Alt on European layouts, as Ctrl and Alt with AltGraph on. +// +// `expect` is the command the press runs in a text box, or null when it types text. spikes/phase4/s7-keyboard- +// layouts has the page that records these and the manual checks that confirm them on real keyboards. + +export type LayoutName = 'US' | 'Polish (Programmers)' | 'German' | 'French (AZERTY)'; + +export interface LayoutPress { + layout: LayoutName; + /** What the person pressed, as the keycaps read. */ + pressed: string; + key: string; + code: string; + ctrl?: boolean; + alt?: boolean; + shift?: boolean; + altGraph?: boolean; + /** The shortcut set the press is in; the default set when absent. */ + preset?: 'default' | 'onenote'; + expect: string | null; +} + +export const LAYOUT_PRESSES: readonly LayoutPress[] = [ + // US: Ctrl+Alt chords are shortcuts, and Shift chords on digits match by their physical key. + { layout: 'US', pressed: 'Ctrl+Alt+2', key: '2', code: 'Digit2', ctrl: true, alt: true, expect: 'block.heading2' }, + { layout: 'US', pressed: 'Ctrl+B', key: 'b', code: 'KeyB', ctrl: true, expect: 'format.bold' }, + { + layout: 'US', + pressed: 'Ctrl+Shift+7', + key: '&', + code: 'Digit7', + ctrl: true, + shift: true, + expect: 'block.orderedList', + }, + { layout: 'US', pressed: 'Ctrl+Shift+.', key: '>', code: 'Period', ctrl: true, shift: true, expect: 'format.larger' }, + { + layout: 'US', + pressed: 'Ctrl+Alt+H', + key: 'h', + code: 'KeyH', + ctrl: true, + alt: true, + preset: 'onenote', + expect: 'format.highlight', + }, + // Polish (Programmers): AltGr letters are text, never Ctrl+Alt shortcuts. + { layout: 'Polish (Programmers)', pressed: 'AltGr+C', key: 'ć', code: 'KeyC', altGraph: true, expect: null }, + { layout: 'Polish (Programmers)', pressed: 'AltGr+A', key: 'ą', code: 'KeyA', altGraph: true, expect: null }, + { layout: 'Polish (Programmers)', pressed: 'AltGr+L', key: 'ł', code: 'KeyL', altGraph: true, expect: null }, + { + layout: 'Polish (Programmers)', + pressed: 'AltGr+Shift+S', + key: 'Ś', + code: 'KeyS', + shift: true, + altGraph: true, + expect: null, + }, + { layout: 'Polish (Programmers)', pressed: 'Ctrl+B', key: 'b', code: 'KeyB', ctrl: true, expect: 'format.bold' }, + { + layout: 'Polish (Programmers)', + pressed: 'AltGr+H', + key: 'h', + code: 'KeyH', + altGraph: true, + preset: 'onenote', + expect: 'format.highlight', + }, + // German: AltGr+2 and AltGr+3 type ² and ³, so Heading 2 and 3 lose their keys there. Ctrl+Shift+7 types /, + // and the exact key beats the physical one. + { layout: 'German', pressed: 'AltGr+2', key: '²', code: 'Digit2', altGraph: true, expect: null }, + { layout: 'German', pressed: 'AltGr+3', key: '³', code: 'Digit3', altGraph: true, expect: null }, + { layout: 'German', pressed: 'AltGr+Q', key: '@', code: 'KeyQ', altGraph: true, expect: null }, + { layout: 'German', pressed: 'AltGr+E', key: '€', code: 'KeyE', altGraph: true, expect: null }, + { + layout: 'German', + pressed: 'Ctrl+Shift+7', + key: '/', + code: 'Digit7', + ctrl: true, + shift: true, + expect: 'app.shortcuts', + }, + // Ctrl+Z stays with the text box, whose keymap sends it to the page's undo by its letter. + { layout: 'German', pressed: 'Ctrl+Z', key: 'z', code: 'KeyY', ctrl: true, expect: null }, + // French (AZERTY): digits need Shift, so Ctrl+& is the 1 key; AltGr+é is a dead tilde. + { layout: 'French (AZERTY)', pressed: 'AltGr+é', key: 'Dead', code: 'Digit2', altGraph: true, expect: null }, + { layout: 'French (AZERTY)', pressed: 'AltGr+E', key: '€', code: 'KeyE', altGraph: true, expect: null }, + { layout: 'French (AZERTY)', pressed: 'Ctrl+&', key: '&', code: 'Digit1', ctrl: true, expect: null }, + { layout: 'French (AZERTY)', pressed: 'Ctrl+B', key: 'b', code: 'KeyB', ctrl: true, expect: 'format.bold' }, +]; diff --git a/app/src/core/client.ts b/app/src/core/client.ts new file mode 100644 index 000000000..03a4cf1e4 --- /dev/null +++ b/app/src/core/client.ts @@ -0,0 +1,112 @@ +// The interface's client for Phase 3's page commands (core plan 11.1 and 11.4). Phase 3 planned this file with the +// app bridge; WP0 of Phase 4 adds the part pages need. It takes `invoke` from platform/tauri (P3-8), so only that +// folder imports @tauri-apps/api, and it keeps one request in flight per page, so edits reach the core in order. +import { decodeEnvelope, decodeFrame } from './wire'; +import type { DecodedEnvelope, FrameInfo } from './wire'; + +export type { DecodedEnvelope, FrameInfo, SessionInfo } from './wire'; + +/** Calls a Tauri command. Binary answers arrive as an ArrayBuffer. */ +export type CoreInvoke = (command: string, args?: Record) => Promise; +/** Hears a `core:*` event. The returned function stops listening. */ +export type CoreListen = (event: string, handler: (payload: unknown) => void) => () => void; + +/** What restoring a version did (crates/core/src/session/page.rs, RestoreResult). */ +export type CoreRestoreResult = { kind: 'restored' } | { kind: 'copied'; page: string }; + +export interface CoreRestoreBlocksRequest { + page: string; + client: string; + clientSeq: number; + revision: string; + blocks: readonly string[]; +} + +export interface CoreTxnAck { + seq: number; + orderKeys: Record; + canUndo: boolean; + canRedo: boolean; +} + +/** page_apply's request, as core plan 11.4 names its fields. `edits` use the core's `Edit` shape. */ +export interface CoreTxnRequest { + page: string; + client: string; + clientSeq: number; + coalesce?: { kind: string; target: string } | null; + ui?: unknown; + edits: readonly unknown[]; +} + +export interface CoreClient { + pageOpen( + page: string, + client: string, + viewport: { x: number; y: number; w: number; h: number } | null, + ): Promise; + pageApply(request: CoreTxnRequest): Promise; + /** The frame of the undone step, or null when there is nothing to undo. */ + pageUndo(page: string, client: string): Promise; + pageRedo(page: string, client: string): Promise; + pageSaveNow(page: string): Promise; + pageClose(page: string, client: string): Promise; + /** The page's saved versions, newest first, as the core lists them. */ + historyList(page: string, client: string): Promise[]>; + historyOpen(page: string, client: string, revision: string): Promise; + historyRestore(page: string, client: string, revision: string, asCopy: boolean): Promise; + /** One transaction of the client, so it takes the client's next sequence number. */ + historyRestoreBlocks(request: CoreRestoreBlocksRequest): Promise; + historyName(page: string, client: string, revision: string, name: string | null, keep: boolean): Promise; + /** Hears a `core:*` event, or nothing where the client has no `listen`. */ + onEvent(event: string, handler: (payload: unknown) => void): () => void; +} + +function bytesOf(answer: unknown): ArrayBuffer { + if (answer instanceof ArrayBuffer) return answer; + if (ArrayBuffer.isView(answer)) + return new Uint8Array(answer.buffer, answer.byteOffset, answer.byteLength).slice().buffer; + if (Array.isArray(answer)) return new Uint8Array(answer as number[]).buffer; + throw new Error('The core answered with something other than bytes.'); +} + +export function createCoreClient(deps: { invoke: CoreInvoke; listen?: CoreListen }): CoreClient { + const queues = new Map>(); + /** Runs `call` after every earlier call for the same page has settled. */ + const ordered = (page: string, call: () => Promise): Promise => { + const before = queues.get(page) ?? Promise.resolve(); + const next = before.then(call, call); + const settled = next.catch(() => undefined); + queues.set(page, settled); + void settled.then(() => queues.get(page) === settled && queues.delete(page)); + return next; + }; + const frame = async (command: string, page: string, client: string) => { + const bytes = bytesOf(await deps.invoke(command, { page, client })); + return bytes.byteLength === 0 ? null : decodeFrame(bytes); + }; + return { + pageOpen: (page, client, viewport) => + ordered(page, async () => decodeEnvelope(bytesOf(await deps.invoke('page_open', { page, client, viewport })))), + pageApply: (request) => + ordered(request.page, () => deps.invoke('page_apply', { ...request }) as Promise), + pageUndo: (page, client) => ordered(page, () => frame('page_undo', page, client)), + pageRedo: (page, client) => ordered(page, () => frame('page_redo', page, client)), + pageSaveNow: (page) => ordered(page, async () => void (await deps.invoke('page_save_now', { page }))), + pageClose: (page, client) => ordered(page, async () => void (await deps.invoke('page_close', { page, client }))), + historyList: (page, client) => + ordered(page, () => deps.invoke('history_list', { page, client }) as Promise[]>), + historyOpen: (page, client, revision) => + ordered(page, async () => decodeEnvelope(bytesOf(await deps.invoke('history_open', { page, client, revision })))), + historyRestore: (page, client, revision, asCopy) => + ordered( + page, + () => deps.invoke('history_restore', { page, client, revision, asCopy }) as Promise, + ), + historyRestoreBlocks: (request) => + ordered(request.page, () => deps.invoke('history_restore_blocks', { ...request }) as Promise), + historyName: (page, client, revision, name, keep) => + ordered(page, async () => void (await deps.invoke('history_name', { page, client, revision, name, keep }))), + onEvent: (event, handler) => deps.listen?.(event, handler) ?? (() => undefined), + }; +} diff --git a/app/src/core/wire.ts b/app/src/core/wire.ts new file mode 100644 index 000000000..21614c123 --- /dev/null +++ b/app/src/core/wire.ts @@ -0,0 +1,74 @@ +// Phase 3's binary answers (crates/core/src/wire): the page envelope that page_open returns, and the applied-changes +// frame of undo and redo. Numbers are little-endian, as everywhere in the format. + +/** The session part of an envelope (crates/core/src/wire/envelope.rs, SessionInfo). */ +export interface SessionInfo { + page: string; + revision: string; + clientSeq: number; + readOnly: string | null; + canUndo: boolean; + canRedo: boolean; + saved: boolean; + conflicts: string[]; + damagedStrokes: number; + missingFiles: number; + strokesTotal: number; +} + +export interface DecodedEnvelope { + session: SessionInfo; + /** The page's page.json. */ + page: Record; + readOnly: boolean; + moreInk: boolean; + strokes: number; + /** The live stroke records, for Phase 5. */ + ink: Uint8Array; +} + +/** The JSON part of a frame (crates/core/src/wire/frames.rs, FrameInfo). */ +export interface FrameInfo { + seq: number; + changes: { pageFields: boolean; blocksChanged: string[]; blocksRemoved: string[]; assetsChanged: string[] }; + ui: unknown; + texts: Record; + blocks: Record[]; + title: string | null; + tags: string[] | null; + view: Record | null; + assets: (Record & { id: string })[]; + canUndo: boolean; + canRedo: boolean; + strokes: number; +} + +const HEADER = 24; +const decoder = new TextDecoder(); +const padded = (length: number) => Math.ceil(length / 8) * 8; + +export function decodeEnvelope(buffer: ArrayBuffer): DecodedEnvelope { + const view = new DataView(buffer); + const magic = decoder.decode(new Uint8Array(buffer, 0, 4)); + if (buffer.byteLength < HEADER || magic !== 'ONPE') throw new Error('The page envelope is damaged.'); + const flags = view.getUint16(6, true); + const sessionLength = view.getUint32(8, true); + const pageLength = view.getUint32(12, true); + const inkLength = view.getUint32(16, true); + const strokes = view.getUint32(20, true); + const pageAt = HEADER + padded(sessionLength); + const inkAt = pageAt + padded(pageLength); + return { + session: JSON.parse(decoder.decode(new Uint8Array(buffer, HEADER, sessionLength))) as SessionInfo, + page: JSON.parse(decoder.decode(new Uint8Array(buffer, pageAt, pageLength))) as Record, + readOnly: (flags & 1) !== 0, + moreInk: (flags & 2) !== 0, + strokes, + ink: new Uint8Array(buffer, inkAt, inkLength), + }; +} + +export function decodeFrame(buffer: ArrayBuffer): FrameInfo { + const length = new DataView(buffer).getUint32(0, true); + return JSON.parse(decoder.decode(new Uint8Array(buffer, 4, length))) as FrameInfo; +} diff --git a/app/src/editor/bench/typing.test.ts b/app/src/editor/bench/typing.test.ts new file mode 100644 index 000000000..4930cacac --- /dev/null +++ b/app/src/editor/bench/typing.test.ts @@ -0,0 +1,168 @@ +// @vitest-environment jsdom +// The typing path's microbenchmarks (Phase 4 ARCHITECTURE.md sections 24.1 and 26.4; owner: WP8). They run on the +// 20-page flat note and the 20-page outline: +// - Phase 4's plugins per transaction: a key's transaction applied to the editor's full state, minus the same +// transaction applied to a state with no plugins. Budget 1.5 ms at the 95th percentile. +// - Serializer throughput: cold, then with a typing session's warm cache. +// - Flush cost: serializing after a key, the splice against the last Markdown, and the request's JSON. +// - The range re-parse that applies undo and redo frames. +// +// Budgets are for the reference laptop. A fixed benchmark scales them on slower machines, as Phase 2's perf tests +// do, so a pull request fails only when the code got slower. +import { EditorState, TextSelection } from '@tiptap/pm/state'; +import type { Editor } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { afterAll, describe, expect, it } from 'vitest'; +// The page fixtures are test data; the editor library itself never reads them. +// eslint-disable-next-line opennote/editor-boundaries +import { pageFixture } from '../../services/pages/fixtures'; +import { createBlockEditor } from '../extensions/kit'; +import { testHost } from '../commands/testing'; +import { + createMarkdownCache, + diffMarkdown, + parseTextBlock, + reparseRange, + serializeTextBlock, + utf8Offset, +} from '../markdown'; + +const KEYS = 200; +/** The fixed benchmark's time on the reference laptop, in ms (tests/ui/perf/tree.perf.spec.ts). */ +const REFERENCE_BENCHMARK_MS = 60; +const PLUGINS_BUDGET_MS = 1.5; +const FLUSH_BUDGET_MS = 8; +/** Undo's whole feedback budget is 50 ms; the range re-parse gets less than half. */ +const UNDO_BUDGET_MS = 50; +const REPARSE_BUDGET_MS = 20; + +/** How much slower this machine is than the reference laptop: the best of 5 runs, from 1 to 4. */ +function calibration(): number { + let best = Infinity; + for (let run = 0; run < 5; run += 1) { + const start = performance.now(); + let total = 0; + for (let round = 0; round < 40; round += 1) { + const items = Array.from({ length: 20_000 }, (_, i) => (i * 7919) % 10_007); + items.sort((a, b) => a - b); + total += items[round]; + } + if (total >= 0) best = Math.min(best, performance.now() - start); + } + // A machine more than 4 times slower is too busy to measure; its budgets stop growing, so it fails loudly. + return Math.min(4, Math.max(1, best / REFERENCE_BENCHMARK_MS)); +} + +const scale = calibration(); + +function p95(values: readonly number[]): number { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.ceil(sorted.length * 0.95) - 1]; +} + +const markdownOf = (fixture: 'twentyPage' | 'twentyPageOutline') => + (pageFixture(fixture).page.blocks[0].data as { markdown: string }).markdown; + +/** A caret in the middle of the document, inside a textblock. */ +function middle(doc: PMNode): number { + let found = -1; + doc.descendants((node, pos) => { + if (found >= 0) return false; + if (node.isTextblock && pos >= doc.content.size / 2) found = pos + 1 + Math.floor(node.content.size / 2); + return !node.isTextblock; + }); + return found; +} + +/** Applies KEYS one-letter transactions in the middle and returns each one's time in ms. */ +function typeInto(start: EditorState): number[] { + let state = start.apply(start.tr.setSelection(TextSelection.create(start.doc, middle(start.doc)))); + const times: number[] = []; + for (let i = 0; i < KEYS; i += 1) { + const tr = state.tr.insertText('abc '[i % 4]); + const before = performance.now(); + state = state.apply(tr); + times.push(performance.now() - before); + } + return times; +} + +const editors: Editor[] = []; + +/** Prints one measure, with the machine's scale, for docs/perf/phase-4.md. */ +function record(name: string, value: string): void { + console.log(`[typing bench] ${name}: ${value} (machine scale ${scale.toFixed(2)})`); +} + +afterAll(() => editors.forEach((editor) => editor.destroy())); + +describe.each(['twentyPage', 'twentyPageOutline'] as const)('the typing path in %s', (fixture) => { + const markdown = markdownOf(fixture); + const doc = parseTextBlock(markdown); + + it('runs Phase 4’s plugins within 1.5 ms a key', () => { + const root = document.createElement('div'); + document.body.append(root); + const editor = createBlockEditor(root, doc, { kind: 'text', block: 'b1', host: testHost() }); + editors.push(editor); + const bare = EditorState.create({ doc: editor.state.doc, schema: editor.schema }); + // Warm both paths up first, so the JIT compiles them before they are timed. + typeInto(editor.state); + typeInto(bare); + const full = typeInto(editor.state); + const none = typeInto(bare); + const plugins = full.map((time, i) => Math.max(0, time - none[i])); + record(`${fixture} plugins p95`, `${p95(plugins).toFixed(3)} ms (${editor.state.plugins.length} plugins)`); + expect(p95(plugins)).toBeLessThanOrEqual(PLUGINS_BUDGET_MS * scale); + }); + + it('serializes, splices, and builds the flush request within budget', () => { + const cold = performance.now(); + const before = serializeTextBlock(doc, createMarkdownCache()); + const coldMs = performance.now() - cold; + record( + `${fixture} serialize cold`, + `${coldMs.toFixed(1)} ms (${((before.length / 1e6 / coldMs) * 1000).toFixed(1)} MB/s)`, + ); + const cache = createMarkdownCache(); + let state = EditorState.create({ doc }); + let last = serializeTextBlock(state.doc, cache); + state = state.apply(state.tr.setSelection(TextSelection.create(state.doc, middle(state.doc)))); + const flushes: number[] = []; + for (let i = 0; i < 50; i += 1) { + state = state.apply(state.tr.insertText('abc '[i % 4])); + const start = performance.now(); + const next = serializeTextBlock(state.doc, cache); + const splice = diffMarkdown(last, next)!; + JSON.stringify({ block: 'b1', at: utf8Offset(last, splice.at), del: splice.del.length, ins: splice.ins }); + flushes.push(performance.now() - start); + last = next; + } + record(`${fixture} flush p95`, `${p95(flushes.slice(10)).toFixed(2)} ms`); + expect(p95(flushes.slice(10))).toBeLessThanOrEqual(FLUSH_BUDGET_MS * scale); + }); + + it('re-parses the changed part of an undo frame within budget', () => { + const cache = createMarkdownCache(); + serializeTextBlock(doc, cache); + const at = Math.floor(markdown.length / 2); + const space = markdown.indexOf(' ', at); + const times: number[] = []; + let full = 0; + for (let i = 0; i < 20; i += 1) { + const next = `${markdown.slice(0, space)} **undone ${i}**${markdown.slice(space)}`; + const start = performance.now(); + // A change inside one long top-level node, such as the outline's one list, parses the whole text again. + if (reparseRange(doc, markdown, next, cache) === 'full') { + full += 1; + parseTextBlock(next); + } + times.push(performance.now() - start); + } + const kind = full ? 'full re-parse' : 'range re-parse'; + record(`${fixture} ${kind} p95`, `${p95(times.slice(5)).toFixed(2)} ms`); + if (fixture === 'twentyPage') expect(full).toBe(0); + // A full parse has the whole of undo's feedback budget; it leaves nothing for the rest, a known follow-up. + expect(p95(times.slice(5))).toBeLessThanOrEqual((full ? UNDO_BUDGET_MS : REPARSE_BUDGET_MS) * scale); + }); +}); diff --git a/app/src/editor/commands/blocks.ts b/app/src/editor/commands/blocks.ts new file mode 100644 index 000000000..1531da294 --- /dev/null +++ b/app/src/editor/commands/blocks.ts @@ -0,0 +1,81 @@ +// Block commands (owner: WP4): checking and cycling task items, and inserting a divider. Block types and lists +// go through turnInto.ts, which these build on. +import { Fragment } from '@tiptap/pm/model'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { TextSelection } from '@tiptap/pm/state'; +import type { Transaction } from '@tiptap/pm/state'; +import { fromChange, selectedTextblocks } from './command'; +import type { Command } from './command'; +import { listAround, turnIntoChange } from './turnInto'; + +/** The task items the selection touches: the list items whose first paragraph it reaches. */ +export function selectedTasks(tr: Transaction): { node: PMNode; pos: number }[] { + const items: { node: PMNode; pos: number }[] = []; + for (const { pos } of selectedTextblocks(tr)) { + const $pos = tr.doc.resolve(pos); + const item = $pos.parent; + if (item.type.name === 'listItem' && $pos.index() === 0 && item.attrs.checked !== null) { + items.push({ node: item, pos: $pos.before() }); + } + } + return items; +} + +/** + * Check or uncheck (Ctrl+Enter): flips the task item at the caret. Over several, it checks them all unless all + * are checked, which unchecks them: "Check all" and "Uncheck all" over a selected list. + */ +export function toggleCheck(): Command { + return fromChange((tr) => { + const tasks = selectedTasks(tr); + if (tasks.length === 0) return false; + const checked = !tasks.every(({ node }) => node.attrs.checked === true); + for (const { node, pos } of tasks) tr.setNodeMarkup(pos, undefined, { ...node.attrs, checked }); + return true; + }); +} + +/** Whether the selection is in a task item, for Check or uncheck's availability. */ +export function inTask(tr: Transaction): boolean { + return selectedTasks(tr).length > 0; +} + +/** OneNote's To Do tag cycle: text becomes an open task, an open task is checked, and a checked one loses its box. */ +export function cycleTodo(): Command { + return fromChange((tr) => { + const around = listAround(tr); + if (!around) return turnIntoChange('checklist')(tr); + const { item, itemPos } = around; + const next = item.attrs.checked === null ? false : item.attrs.checked === false ? true : null; + tr.setNodeMarkup(itemPos, undefined, { ...item.attrs, checked: next }); + return true; + }); +} + +/** Inserts a divider after the current block, or in place of an empty paragraph, with a paragraph to type in after. */ +export function insertDivider(): Command { + return fromChange((tr) => { + const schema = tr.doc.type.schema; + const { $from } = tr.selection; + if (!$from.parent.isTextblock || $from.parent.type.name === 'calloutTitle') return false; + const depth = $from.depth; + const container = $from.node(depth - 1); + const index = $from.index(depth - 1); + const rule = schema.nodes.horizontalRule.create(); + const paragraph = schema.nodes.paragraph.create(); + const empty = $from.parent.type.name === 'paragraph' && $from.parent.content.size === 0; + if (empty && container.canReplaceWith(index, index + 1, rule.type)) { + const start = $from.before(depth); + tr.replaceWith(start, $from.after(depth), Fragment.from([rule, paragraph])); + tr.setSelection(TextSelection.create(tr.doc, start + rule.nodeSize + 1)); + return true; + } + const after = $from.after(depth); + if (!container.canReplaceWith(index + 1, index + 1, rule.type)) return false; + const needsParagraph = index + 1 >= container.childCount; + tr.insert(after, needsParagraph ? Fragment.from([rule, paragraph]) : rule); + const next = after + rule.nodeSize; + tr.setSelection(TextSelection.near(tr.doc.resolve(Math.min(next + 1, tr.doc.content.size)))); + return true; + }); +} diff --git a/app/src/editor/commands/catalog.ts b/app/src/editor/commands/catalog.ts new file mode 100644 index 000000000..19dffdd9e --- /dev/null +++ b/app/src/editor/commands/catalog.ts @@ -0,0 +1,186 @@ +// The editor commands behind WP4's page commands (owner: WP4). Each entry turns a command ID and its arguments +// into a ProseMirror command, says whether it is on for the checked state, and names what to announce. The page's +// registrations, the formatting bar, the slash menu, and tests all run commands through here. +import type { Editor } from '@tiptap/core'; +import type { EditorState } from '@tiptap/pm/state'; +import { t } from '../../strings/t'; +import type { MessageKey } from '../../strings/t'; +import { hostOf } from '../extensions/keys'; +import type { HighlightColor, TextSizeName } from '../schema/specs'; +import { cycleTodo, insertDivider, toggleCheck } from './blocks'; +import { fromChange, runCommand } from './command'; +import type { Command } from './command'; +import { insertDateTime } from './insertDate'; +import { OUTLINE_IDS, runOutline } from './outlineRun'; +import { removeLink, setLink } from './links'; +import { + clearFormatting, + clearFormattingChange, + currentTextSize, + setHighlight, + setTextColor, + setTextSize, + stepTextSize, + toggleHighlight, + toggleMark, +} from './marks'; +import type { ToggledMark } from './marks'; +import { blockKindAt, isMarkActive, markAttrs } from './state'; +import { toggleKind, turnInto, turnIntoChange } from './turnInto'; +import type { BlockKind } from './turnInto'; + +/** What a command may take: a color, a size, a block kind, or a link. */ +export interface EditorCommandArgs { + color?: string | null; + size?: TextSizeName | null; + kind?: BlockKind; + href?: string; + text?: string; +} + +interface Entry { + /** The command, or null when it needs arguments it didn't get. */ + command(args: EditorCommandArgs): Command | null; + /** Whether the command is on at the selection, for toggles. */ + on?(state: EditorState): boolean; + /** The name to announce: "Bold on" for toggles, "Heading 2" for blocks. */ + name?: MessageKey; + announce?: 'toggle' | 'block'; +} + +const mark = (name: ToggledMark, title: MessageKey): Entry => ({ + command: () => toggleMark(name), + on: (state) => isMarkActive(state, name), + name: title, + announce: 'toggle', +}); + +const kind = (block: BlockKind, title: MessageKey): Entry => ({ + command: () => toggleKind(block), + on: (state) => blockKindAt(state) === block, + name: title, + announce: 'block', +}); + +const HEADINGS = [1, 2, 3, 4, 5, 6] as const; + +const ENTRIES: Record = { + 'format.bold': mark('bold', 'editor.commands.bold'), + 'format.italic': mark('italic', 'editor.commands.italic'), + 'format.underline': mark('underline', 'editor.commands.underline'), + 'format.strike': mark('strike', 'editor.commands.strike'), + 'format.code': mark('code', 'editor.commands.code'), + 'format.subscript': mark('subscript', 'editor.commands.subscript'), + 'format.superscript': mark('superscript', 'editor.commands.superscript'), + 'format.highlight': { + command: ({ color }) => + color === undefined ? toggleHighlight(null) : setHighlight(color as HighlightColor | null | 'none'), + on: (state) => isMarkActive(state, 'highlight'), + name: 'editor.commands.highlight', + announce: 'toggle', + }, + 'format.larger': { command: () => stepTextSize(1) }, + 'format.smaller': { command: () => stepTextSize(-1) }, + 'format.textColor': { command: ({ color }) => (color === undefined ? null : setTextColor(color)) }, + 'format.textSize': { command: ({ size }) => (size === undefined ? null : setTextSize(size)) }, + 'format.clear': { command: () => clearFormatting() }, + 'format.clearAll': { + command: () => fromChange((tr) => [clearFormattingChange(tr), turnIntoChange('paragraph')(tr)].some(Boolean)), + }, + 'format.link': { + command: ({ href, text }) => (href === undefined ? null : href === '' ? removeLink() : setLink(href, text)), + }, + 'block.normal': kind('paragraph', 'editor.commands.normal'), + ...Object.fromEntries( + HEADINGS.map((level) => [ + `block.heading${level}`, + kind(`heading${level}`, `editor.commands.heading${level}` as MessageKey), + ]), + ), + 'block.bulletList': kind('bulletList', 'editor.commands.bulletList'), + 'block.orderedList': kind('orderedList', 'editor.commands.orderedList'), + 'block.checklist': kind('checklist', 'editor.commands.checklist'), + 'block.quote': kind('quote', 'editor.commands.quote'), + 'block.callout': kind('callout', 'editor.commands.callout'), + 'block.codeBlock': { + command: () => turnInto('codeBlock'), + on: (state) => blockKindAt(state) === 'codeBlock', + name: 'editor.commands.codeBlock', + announce: 'block', + }, + 'block.todoCycle': { command: () => cycleTodo() }, + 'block.toggleCheck': { command: () => toggleCheck() }, + 'block.divider': { command: () => insertDivider() }, + 'block.turnInto': { command: ({ kind: to }) => (to ? turnInto(to) : null) }, + 'insert.date': { command: () => insertDateTime('date') }, + 'insert.time': { command: () => insertDateTime('time') }, + 'insert.dateTime': { command: () => insertDateTime('dateTime') }, +}; + +/** The IDs this catalog runs. */ +export const EDITOR_COMMAND_IDS: readonly string[] = Object.keys(ENTRIES); + +/** The command for an ID and its arguments, or null when there is none or it needs arguments. */ +export function editorCommand(id: string, args: EditorCommandArgs = {}): Command | null { + return ENTRIES[id]?.command(args) ?? null; +} + +/** Whether the command is on at the selection, or undefined for commands that aren't toggles. */ +export function commandOn(id: string, state: EditorState): boolean | undefined { + return ENTRIES[id]?.on?.(state); +} + +/** Whether the command would change the document now. */ +export function commandApplies(id: string, state: EditorState, args: EditorCommandArgs = {}): boolean { + const command = editorCommand(id, args); + return command !== null && command(state); +} + +function announcement(id: string, state: EditorState, args: EditorCommandArgs): string | null { + const entry = ENTRIES[id]; + if (id === 'block.toggleCheck') { + const checked = state.selection.$from.node(-1)?.attrs.checked; + return t(checked ? 'editor.announce.checked' : 'editor.announce.unchecked'); + } + if (id === 'format.larger' || id === 'format.smaller' || (id === 'format.textSize' && args.size !== undefined)) { + const size = currentTextSize(state); + return size ? t(`editor.sizes.${size}`) : t('editor.announce.sizeNormal'); + } + if (id === 'format.link' && args.href === '') return t('editor.announce.linkRemoved'); + if (!entry?.name || !entry.announce) return null; + const name = t(entry.name); + if (entry.announce === 'block') return t('editor.announce.block', { name }); + return t(entry.on?.(state) ? 'editor.announce.on' : 'editor.announce.off', { name }); +} + +/** + * Runs a command on the editor as one command transaction, then announces the result through the editor's host: + * "Bold on.", "Heading 2.", "Checked.". Returns whether the command applied. + */ +export function runEditorCommand( + editor: Editor, + id: string, + args: EditorCommandArgs = {}, + options: { focus?: boolean } = {}, +): boolean { + if (OUTLINE_IDS.includes(id)) return runOutline(editor, id); + const command = editorCommand(id, args); + if (!command) return false; + const ran = runCommand(editor, command, options); + const text = ran ? announcement(id, editor.state, args) : null; + if (text) hostOf(editor)?.announce(text); + return ran; +} + +/** The highlight color at the selection: a color name, null for Honey, or undefined without a highlight. */ +export function highlightAt(state: EditorState): HighlightColor | null | undefined { + const attrs = markAttrs(state, 'highlight'); + return attrs ? ((attrs.color as HighlightColor | null) ?? null) : undefined; +} + +/** The text color at the selection, or null. */ +export function textColorAt(state: EditorState): string | null { + return (markAttrs(state, 'textColor')?.color as string | undefined) ?? null; +} + +export { currentTextSize }; diff --git a/app/src/editor/commands/code.ts b/app/src/editor/commands/code.ts new file mode 100644 index 000000000..777d106d6 --- /dev/null +++ b/app/src/editor/commands/code.ts @@ -0,0 +1,158 @@ +// Code block commands (ARCHITECTURE.md sections 10.2, 14, and 22.4; owner: WP6), as ProseMirror commands. Tab and +// Shift+Tab indent and outdent the caret's line or the selected lines by the block's indent width. Enter keeps the +// line's indent. Three Enters at the end, Arrow Down at the end of the last block, or Ctrl+Enter leave the block. +import type { Node as PMNode } from '@tiptap/pm/model'; +import { TextSelection } from '@tiptap/pm/state'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; +import { findLanguage } from '../highlight/languages'; +import { META_COMMAND } from '../meta'; +import { LANGUAGE_PATTERN } from '../schema/constants'; + +type Dispatch = ((tr: Transaction) => void) | undefined; +export type Command = (state: EditorState, dispatch?: Dispatch, view?: EditorView) => boolean; + +/** The code block with the selection, and the position before it. Null outside code. */ +export function codeBlockAt(state: EditorState): { node: PMNode; pos: number } | null { + const { $from, $to } = state.selection; + if ($from.parent.type.name !== 'codeBlock' || !$from.sameParent($to)) return null; + return { node: $from.parent, pos: $from.before() }; +} + +/** + * The block's indent: a tab when a line starts with one, else the smallest indent its lines use (2 or 4 spaces), + * else its language's usual indent. + */ +export function indentUnit(text: string, language: string | null): string { + let smallest = Infinity; + for (const line of text.split('\n')) { + if (line.startsWith('\t')) return '\t'; + const spaces = /^ +(?=\S)/.exec(line)?.[0].length ?? 0; + if (spaces > 0) smallest = Math.min(smallest, spaces); + } + if (smallest === 2 || smallest === 3) return ' '; + if (smallest !== Infinity) return ' '; + return ' '.repeat(findLanguage(language)?.indent ?? 4); +} + +/** The start offsets, within the block's text, of the lines the selection touches. */ +function selectedLineStarts(text: string, from: number, to: number): number[] { + const starts: number[] = []; + let start = text.lastIndexOf('\n', from - 1) + 1; + for (;;) { + starts.push(start); + const next = text.indexOf('\n', start); + if (next === -1 || next + 1 > to || (next + 1 === to && to > from)) break; + start = next + 1; + } + return starts; +} + +/** Indents (1) or outdents (-1) the caret's line or the selected lines by the block's indent width. */ +export function indentCode(direction: 1 | -1): Command { + return (state, dispatch) => { + const at = codeBlockAt(state); + if (!at) return false; + const text = at.node.textContent; + const offset = at.pos + 1; + const unit = indentUnit(text, at.node.attrs.language as string | null); + const { from, to } = state.selection; + const starts = selectedLineStarts(text, from - offset, to - offset); + const tr = state.tr; + // Last line first, so earlier offsets stay right. + for (const start of [...starts].reverse()) { + if (direction > 0) { + if (text.indexOf('\n', start) !== start || starts.length === 1) tr.insertText(unit, offset + start); + continue; + } + const lead = /^[ \t]*/.exec(text.slice(start))?.[0] ?? ''; + const remove = lead.startsWith('\t') ? 1 : Math.min(lead.length, unit === '\t' ? 4 : unit.length); + if (remove > 0) tr.delete(offset + start, offset + start + remove); + } + if (!tr.docChanged) return direction < 0; + dispatch?.(tr.scrollIntoView()); + return true; + }; +} + +/** Enter: a new line with the same indent as the caret's line. */ +export const newlineKeepIndent: Command = (state, dispatch) => { + const at = codeBlockAt(state); + if (!at) return false; + const { $from } = state.selection; + const before = $from.parent.textBetween(0, $from.parentOffset); + const indent = /^[ \t]*/.exec(before.slice(before.lastIndexOf('\n') + 1))?.[0] ?? ''; + dispatch?.(state.tr.insertText(`\n${indent}`).scrollIntoView()); + return true; +}; + +/** Adds an empty textblock after the code block at `pos` and puts the caret in it. False where none fits. */ +function leaveAfter(tr: Transaction, pos: number): boolean { + const $pos = tr.doc.resolve(pos); + const parent = $pos.parent; + const index = $pos.index(); + const type = parent.contentMatchAt(index + 1).defaultType; + if (!type?.isTextblock || !parent.canReplaceWith(index + 1, index + 1, type)) return false; + const after = pos + parent.child(index).nodeSize; + tr.insert(after, type.create()); + tr.setSelection(TextSelection.create(tr.doc, after + 1)).scrollIntoView(); + return true; +} + +/** Leaves the code block for a new paragraph below it (Ctrl+Enter). */ +export const leaveCode: Command = (state, dispatch) => { + const at = codeBlockAt(state); + if (!at) return false; + const tr = state.tr.setMeta(META_COMMAND, true); + if (!leaveAfter(tr, at.pos)) return false; + dispatch?.(tr); + return true; +}; + +/** The third Enter at the end of a block whose last two lines are blank leaves the block and drops them. */ +export const tripleEnterLeave: Command = (state, dispatch) => { + const at = codeBlockAt(state); + if (!at || !state.selection.empty) return false; + const { $from } = state.selection; + if ($from.parentOffset !== $from.parent.content.size) return false; + const text = at.node.textContent; + const blank = /\n[ \t]*\n[ \t]*$/.exec(text); + if (!blank) return false; + const tr = state.tr.delete(at.pos + 1 + blank.index, at.pos + 1 + text.length); + if (!leaveAfter(tr, at.pos)) return false; + dispatch?.(tr); + return true; +}; + +/** Arrow Down on the last line of a code block that ends its text box: a new paragraph below to move into. */ +export const arrowDownLeave: Command = (state, dispatch, view) => { + const at = codeBlockAt(state); + if (!at || !state.selection.empty) return false; + if (at.pos + at.node.nodeSize !== state.doc.content.size) return false; + const lastLine = view + ? view.endOfTextblock('down') + : !at.node.textContent.slice(state.selection.$head.parentOffset).includes('\n'); + if (!lastLine) return false; + const tr = state.tr; + if (!leaveAfter(tr, at.pos)) return false; + dispatch?.(tr); + return true; +}; + +/** A tidy info string: what the picker chose, or null for plain text. */ +export function cleanLanguage(language: string | null): string | null { + const value = language?.trim() ?? ''; + return LANGUAGE_PATTERN.test(value) ? value : null; +} + +/** Sets the language of the code block at `pos`. */ +export function setCodeLanguage(pos: number, language: string | null): Command { + return (state, dispatch) => { + const node = state.doc.nodeAt(pos); + if (node?.type.name !== 'codeBlock') return false; + const next = cleanLanguage(language); + if ((node.attrs.language ?? null) === next) return false; + dispatch?.(state.tr.setNodeAttribute(pos, 'language', next).setMeta(META_COMMAND, true)); + return true; + }; +} diff --git a/app/src/editor/commands/command.ts b/app/src/editor/commands/command.ts new file mode 100644 index 000000000..c30130b72 --- /dev/null +++ b/app/src/editor/commands/command.ts @@ -0,0 +1,91 @@ +// What every editor command shares (owner: WP4): the ProseMirror command shape, and running one as its own undo +// step. It also builds changes on one transaction and finds the textblocks a selection covers. +import type { Editor } from '@tiptap/core'; +import type { Node as PMNode, ResolvedPos } from '@tiptap/pm/model'; +import { EditorState } from '@tiptap/pm/state'; +import type { Transaction } from '@tiptap/pm/state'; +import { META_COMMAND } from '../meta'; + +/** A ProseMirror command: it checks without `dispatch`, and changes the document with it. */ +export type Command = (state: EditorState, dispatch?: (tr: Transaction) => void) => boolean; + +/** A change made on a transaction in place. It returns false, and changes nothing, when it doesn't apply. */ +export type Change = (tr: Transaction) => boolean; + +/** A command from a change. Checking runs the change on a throwaway transaction. */ +export function fromChange(change: Change): Command { + return (state, dispatch) => { + const tr = state.tr; + if (!change(tr)) return false; + dispatch?.(tr); + return true; + }; +} + +/** A change from a ProseMirror command, run on the transaction's current document and selection. */ +export function viaCommand(command: Command): Change { + return (tr) => { + const state = EditorState.create({ doc: tr.doc, selection: tr.selection, storedMarks: tr.storedMarks }); + let made: Transaction | null = null; + const ran = command(state, (next) => { + made = next; + }); + if (!ran || !made) return false; + const next: Transaction = made; + next.steps.forEach((step) => tr.step(step)); + if (next.selectionSet) tr.setSelection(next.selection.map(tr.doc, tr.mapping.slice(tr.steps.length))); + if (next.storedMarksSet) tr.setStoredMarks(next.storedMarks); + return true; + }; +} + +/** + * Runs a command on an editor as a command transaction, which the sync sends as its own step after any pending + * typing. The editor keeps focus, so a command from the bar or the palette returns the caret to the text. + */ +export function runCommand(editor: Editor, command: Command, options: { focus?: boolean } = {}): boolean { + if (editor.isDestroyed || !editor.isEditable) return false; + let chain = editor.chain(); + if (options.focus ?? true) chain = chain.focus(undefined, { scrollIntoView: false }); + return chain + .command(({ state, dispatch }) => command(state, dispatch)) + .setMeta(META_COMMAND, true) + .run(); +} + +/** Whether a command can run on the editor now. */ +export function canRun(editor: Editor, command: Command): boolean { + return !editor.isDestroyed && editor.isEditable && command(editor.state); +} + +export interface Textblock { + node: PMNode; + pos: number; +} + +/** The textblocks from `from` to `to`, in document order. A caret touches the one it is in. */ +export function textblocksBetween(doc: PMNode, from: number, to: number): Textblock[] { + const found: Textblock[] = []; + doc.nodesBetween(from, to, (node, pos) => { + if (node.isTextblock) found.push({ node, pos }); + return !node.isTextblock; + }); + if (found.length === 0) { + const $from = doc.resolve(from); + if ($from.parent.isTextblock) found.push({ node: $from.parent, pos: $from.before() }); + } + return found; +} + +/** The textblocks the transaction's selection touches. */ +export function selectedTextblocks(tr: Transaction): Textblock[] { + return textblocksBetween(tr.doc, tr.selection.from, tr.selection.to); +} + +/** The depth of the innermost ancestor of `$pos` with one of the given types, or -1. */ +export function ancestorDepth($pos: ResolvedPos, names: readonly string[]): number { + for (let depth = $pos.depth; depth > 0; depth -= 1) { + if (names.includes($pos.node(depth).type.name)) return depth; + } + return -1; +} diff --git a/app/src/editor/commands/commands.test.ts b/app/src/editor/commands/commands.test.ts new file mode 100644 index 000000000..51c5ae09a --- /dev/null +++ b/app/src/editor/commands/commands.test.ts @@ -0,0 +1,133 @@ +// The editor commands on their own. Marks toggle over mixed selections, and Turn into keeps text between every +// kind. List shortcuts toggle, links normalize their address and refuse scripts, and checked tasks cycle. +import { describe, expect, it } from 'vitest'; +import { cycleTodo, insertDivider, toggleCheck } from './blocks'; +import { normalizeHref, removeLink, setLink } from './links'; +import { clearFormatting, setHighlight, stepTextSize, toggleMark } from './marks'; +import { TextSelection } from '@tiptap/pm/state'; +import { EditorState } from '@tiptap/pm/state'; +import type { Command } from './command'; +import { applyTo, marked, markdownOf } from './testing'; +import { blockKindAt, caretInLink, inTaskItem, isMarkActive, linkApplies } from './state'; +import { toggleKind, turnInto } from './turnInto'; + +/** Runs a command with all the document's text selected. */ +function applyToAll(source: string, command: Command): string | null { + const { doc } = marked(source).state; + const state = EditorState.create({ + doc, + selection: TextSelection.between(doc.resolve(1), doc.resolve(doc.content.size - 1)), + }); + let after: EditorState | null = null; + const ran = command(state, (tr) => (after = state.apply(tr))); + return ran && after ? markdownOf(after) : null; +} + +describe('marks', () => { + it('turn on over a partly marked selection, and off when all of it has the mark', () => { + expect(applyTo('[a **b**] c', toggleMark('bold'))).toBe('**a b** c'); + expect(applyTo('**[ab]** c', toggleMark('bold'))).toBe('ab c'); + }); + + it('step text size through small, normal, large, and extra large, and stop at the ends', () => { + expect(applyTo('[a]', stepTextSize(1))).toBe('a'); + expect(applyTo('[a]', stepTextSize(1))).toBeNull(); + expect(applyTo('[a]', stepTextSize(1))).toBe('a'); + }); + + it('highlight in a color and take it off', () => { + expect(applyTo('[a]', setHighlight('mint'))).toBe('a'); + expect(applyTo('==[a]==', setHighlight('none'))).toBe('a'); + }); + + it('clear every format but links', () => { + expect(applyToAll('**a** [b](https://x.org)', clearFormatting())).toBe('a [b](https://x.org)'); + }); + + it('never apply inside code blocks', () => { + expect(applyTo('```\n[code]\n```', toggleMark('bold'))).toBeNull(); + }); +}); + +describe('Turn into', () => { + const kinds = [ + ['paragraph', 'Words'], + ['heading2', '## Words'], + ['bulletList', '- Words'], + ['orderedList', '1. Words'], + ['checklist', '- [ ] Words'], + ['quote', '> Words'], + ['codeBlock', '```\nWords\n```'], + ] as const; + + it('converts between every kind and keeps the text', () => { + for (const [, from] of kinds) { + for (const [kind, to] of kinds) { + if (from === to) continue; + const source = from.includes('```') ? '```\n[Words]\n```' : from.replace('Words', '[Words]'); + expect(applyTo(source, turnInto(kind)), `${from} → ${kind}`).toBe(to); + } + } + }); + + it('drops marks going into code and keeps plain text coming out', () => { + expect(applyTo('**[bold]** text', turnInto('codeBlock'))).toBe('```\nbold text\n```'); + }); + + it('wraps in a callout with an empty title, and unwraps', () => { + expect(applyTo('[Words]', turnInto('callout'))).toBe('> [!note]\n> Words'); + expect(applyTo('> [!note]\n> [Words]', toggleKind('callout'))).toBe('Words'); + }); + + it('toggles a list kind the selection already is back to text', () => { + expect(applyTo('- [one]', toggleKind('bulletList'))).toBe('one'); + expect(applyTo('- [one]', toggleKind('orderedList'))).toBe('1. one'); + expect(applyTo('## [Title]', toggleKind('heading2'))).toBe('Title'); + }); +}); + +describe('tasks and dividers', () => { + it('check several tasks, then uncheck them all', () => { + expect(applyToAll('- [ ] one\n- [x] two', toggleCheck())).toBe('- [x] one\n- [x] two'); + expect(applyToAll('- [x] one\n- [x] two', toggleCheck())).toBe('- [ ] one\n- [ ] two'); + expect(applyTo('[plain]', toggleCheck())).toBeNull(); + }); + + it('cycle To Do: open, checked, then no box', () => { + expect(applyTo('[one]', cycleTodo())).toBe('- [ ] one'); + expect(applyTo('- [ ] [one]', cycleTodo())).toBe('- [x] one'); + expect(applyTo('- [x] [one]', cycleTodo())).toBe('- one'); + }); + + it('insert a divider after the block, or in place of an empty paragraph', () => { + expect(applyTo('one[]', insertDivider())).toBe('one\n\n---'); + }); +}); + +describe('links', () => { + it('normalize addresses as people type them', () => { + expect(normalizeHref('example.com')).toBe('https://example.com'); + expect(normalizeHref('ada@example.com')).toBe('mailto:ada@example.com'); + expect(normalizeHref('opennote:page/abc')).toBe('opennote:page/abc'); + expect(normalizeHref('javascript:alert(1)')).toBeNull(); + expect(normalizeHref('')).toBeNull(); + }); + + it('link the selection, insert the address at a caret, and remove a link', () => { + expect(applyTo('a [b] c', setLink('example.com'))).toBe('a [b](https://example.com) c'); + expect(applyTo('see []', setLink('example.com'))).toBe('see [https://example.com](https://example.com)'); + expect(applyToAll('[b](https://x.org)', removeLink())).toBe('b'); + }); +}); + +describe('selection state', () => { + it('reads marks, kinds, tasks, and links at the selection', () => { + expect(isMarkActive(marked('**[a]**').state, 'bold')).toBe(true); + expect(blockKindAt(marked('1. [a]').state)).toBe('orderedList'); + expect(blockKindAt(marked('- [ ] [a]').state)).toBe('checklist'); + expect(blockKindAt(marked('#### [a]').state)).toBe('heading4'); + expect(inTaskItem(marked('- [ ] [a]').state)).toBe(true); + expect(linkApplies(marked('a []b').state)).toBe(false); + expect(caretInLink(marked('[[a]](https://x.org)').state)).toBe(true); + }); +}); diff --git a/app/src/editor/commands/fold.ts b/app/src/editor/commands/fold.ts new file mode 100644 index 000000000..2f7a254c6 --- /dev/null +++ b/app/src/editor/commands/fold.ts @@ -0,0 +1,198 @@ +// Folding (ARCHITECTURE.md section 18.2; owner: WP4). A heading folds its section, up to the next heading of the +// same or a higher level among its siblings. A list item folds its nested lists. Heading and list folds are view +// state, kept as positions that edits map. The page saves them in pageViews as keys that survive reloads: the +// block, the text, and which occurrence of that text it is. +import type { Editor } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { PluginKey } from '@tiptap/pm/state'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; + +/** A fold that survives a reload. The same shape as Phase 2's device-state FoldKey. */ +export interface FoldKey { + block: string; + kind: 'heading' | 'item'; + text: string; + occurrence: number; +} + +export type FoldKind = FoldKey['kind']; + +/** The folded headings and list items, by position. */ +export interface FoldState { + folded: readonly number[]; +} + +export const foldKey = new PluginKey('opennoteFolds'); +/** A transaction's new fold positions, replacing the old ones. */ +export const META_FOLDS = 'opennote.folds'; + +/** The fold positions of each document a folding editor made, so foldKeysFor can read them from the doc. */ +const foldsByDoc = new WeakMap(); + +export function rememberFolds(doc: PMNode, folded: readonly number[]): void { + foldsByDoc.set(doc, folded); +} + +/** The positions after a heading's section ends: its last sibling before a heading of the same or higher level. */ +export function sectionEnd(doc: PMNode, headingPos: number): number { + const $pos = doc.resolve(headingPos); + const parent = $pos.parent; + const heading = $pos.nodeAfter; + if (!heading || heading.type.name !== 'heading') return headingPos; + const level = heading.attrs.level as number; + let end = headingPos + heading.nodeSize; + for (let index = $pos.index() + 1; index < parent.childCount; index += 1) { + const sibling = parent.child(index); + if (sibling.type.name === 'heading' && (sibling.attrs.level as number) <= level) break; + end += sibling.nodeSize; + } + return end; +} + +/** What folds at `pos`: a heading with something in its section, or a list item with nested content. */ +export function foldableAt(doc: PMNode, pos: number): FoldKind | null { + const node = doc.nodeAt(pos); + if (!node) return null; + if (node.type.name === 'heading') return sectionEnd(doc, pos) > pos + node.nodeSize ? 'heading' : null; + if (node.type.name === 'listItem') return node.childCount > 1 ? 'item' : null; + return null; +} + +/** The nodes a fold at `pos` hides, as [from, to] ranges of whole nodes. */ +export function hiddenRanges(doc: PMNode, pos: number): [number, number][] { + const node = doc.nodeAt(pos); + if (!node) return []; + const ranges: [number, number][] = []; + if (node.type.name === 'heading') { + let at = pos + node.nodeSize; + const end = sectionEnd(doc, pos); + while (at < end) { + const sibling = doc.nodeAt(at)!; + ranges.push([at, at + sibling.nodeSize]); + at += sibling.nodeSize; + } + } else if (node.type.name === 'listItem') { + let at = pos + 1; + node.forEach((child, _offset, index) => { + if (index > 0) ranges.push([at, at + child.nodeSize]); + at += child.nodeSize; + }); + } + return ranges; +} + +/** Every foldable heading and list item in document order, with its key's text. */ +export function foldables(doc: PMNode): { pos: number; kind: FoldKind; node: PMNode; text: string }[] { + const found: { pos: number; kind: FoldKind; node: PMNode; text: string }[] = []; + doc.descendants((node, pos) => { + const kind = foldableAt(doc, pos); + if (kind) { + const text = (kind === 'heading' ? node.textContent : (node.firstChild?.textContent ?? '')).trim(); + found.push({ pos, kind, node, text }); + } + return !node.isTextblock; + }); + return found; +} + +/** The keys of a block's folds, for pageViews. */ +export function foldKeysFor(doc: PMNode, block: string): FoldKey[] { + const folded = new Set(foldsByDoc.get(doc) ?? []); + const seen = new Map(); + const keys: FoldKey[] = []; + for (const { pos, kind, text } of foldables(doc)) { + const id = `${kind}\u0000${text}`; + const occurrence = seen.get(id) ?? 0; + seen.set(id, occurrence + 1); + if (folded.has(pos)) keys.push({ block, kind, text, occurrence }); + } + return keys; +} + +/** The positions that saved keys name in this document. Keys that no longer match are dropped. */ +export function positionsFor(doc: PMNode, keys: readonly FoldKey[]): number[] { + const seen = new Map(); + const wanted = new Set(keys.map((key) => `${key.kind}\u0000${key.text}\u0000${key.occurrence}`)); + const positions: number[] = []; + for (const { pos, kind, text } of foldables(doc)) { + const id = `${kind}\u0000${text}`; + const occurrence = seen.get(id) ?? 0; + seen.set(id, occurrence + 1); + if (wanted.has(`${id}\u0000${occurrence}`)) positions.push(pos); + } + return positions; +} + +export function foldsOf(state: EditorState): readonly number[] { + return foldKey.getState(state)?.folded ?? []; +} + +/** Sets the fold positions on a transaction. */ +export function setFolds(tr: Transaction, folded: readonly number[]): Transaction { + return tr.setMeta( + META_FOLDS, + [...new Set(folded)].sort((a, b) => a - b), + ); +} + +/** The folds that hide `pos`. */ +export function foldsHiding(state: EditorState, pos: number): number[] { + return foldsOf(state).filter((fold) => hiddenRanges(state.doc, fold).some(([from, to]) => pos >= from && pos < to)); +} + +/** + * Unfolds whatever hides `pos`, for F7, read aloud, undo, links, and Phase 8's search results. Returns whether + * anything unfolded. + */ +export function unfoldTo(editor: Editor, pos: number): boolean { + const hiding = foldsHiding(editor.state, pos); + if (hiding.length === 0) return false; + const remaining = foldsOf(editor.state).filter((fold) => !hiding.includes(fold)); + editor.view.dispatch(setFolds(editor.state.tr, remaining)); + return true; +} + +/** The foldable heading or list item the selection is in: the innermost item with children, else its heading. */ +export function foldTargetAt(state: EditorState): { pos: number; kind: FoldKind } | null { + const { $from } = state.selection; + for (let depth = $from.depth; depth > 0; depth -= 1) { + const pos = $from.before(depth); + const kind = foldableAt(state.doc, pos); + if (kind) return { pos, kind }; + } + // A paragraph in a section: the section's heading. + const parentDepth = Math.max(0, $from.depth - 1); + const container = $from.node(parentDepth); + const start = parentDepth === 0 ? 0 : $from.start(parentDepth); + let offset = start; + let target: { pos: number; kind: FoldKind } | null = null; + for (let index = 0; index < $from.index(parentDepth); index += 1) { + const child = container.child(index); + if (child.type.name === 'heading' && sectionEnd(state.doc, offset) > $from.pos) { + target = { pos: offset, kind: 'heading' }; + } + offset += child.nodeSize; + } + return target; +} + +/** The nesting depth of a list item: 1 for a top-level item. */ +export function itemDepth(state: EditorState, pos: number): number { + const $pos = state.doc.resolve(pos); + let depth = 0; + for (let d = $pos.depth; d >= 0; d -= 1) if ($pos.node(d).type.name.endsWith('List')) depth += 1; + return depth; +} + +/** + * Show levels 1 to `level`: folds every heading of that level and every list item nested that deep, and unfolds + * the levels above. `null` shows everything. + */ +export function foldsForLevel(state: EditorState, level: number | null): number[] { + if (level === null) return []; + return foldables(state.doc) + .filter(({ pos, kind, node }) => + kind === 'heading' ? (node.attrs.level as number) >= level : itemDepth(state, pos) >= level, + ) + .map(({ pos }) => pos); +} diff --git a/app/src/editor/commands/insertDate.ts b/app/src/editor/commands/insertDate.ts new file mode 100644 index 000000000..ddbfeef6a --- /dev/null +++ b/app/src/editor/commands/insertDate.ts @@ -0,0 +1,24 @@ +// Date and time (ARCHITECTURE.md section 18.3; owner: WP4). Alt+Shift+D, T, and F type the date ("Sep 30, 2026"), +// the time ("2:05 PM"), or both. They use Phase 2's formatters and type plain text in the current marks. +import { formatDate, formatTime } from '../../strings/format'; +import { fromChange } from './command'; +import type { Command } from './command'; + +export type DateTimeKind = 'date' | 'time' | 'dateTime'; + +/** The text a kind inserts for the moment `now`. */ +export function dateTimeText(kind: DateTimeKind, now: Date = new Date()): string { + const iso = now.toISOString(); + if (kind === 'date') return formatDate(iso); + if (kind === 'time') return formatTime(iso); + return `${formatDate(iso)} ${formatTime(iso)}`; +} + +export function insertDateTime(kind: DateTimeKind, now: Date = new Date()): Command { + return fromChange((tr) => { + const { $from } = tr.selection; + if (!$from.parent.isTextblock) return false; + tr.insertText(dateTimeText(kind, now), tr.selection.from, tr.selection.to); + return true; + }); +} diff --git a/app/src/editor/commands/links.ts b/app/src/editor/commands/links.ts new file mode 100644 index 000000000..e1a79e57d --- /dev/null +++ b/app/src/editor/commands/links.ts @@ -0,0 +1,99 @@ +// Links (owner: WP4): finding the link at the caret, adding or changing one, and removing it. Destinations follow +// SPEC 7.5: web, mail, OpenNote, and asset links are live, other schemes are kept and never opened, and script +// schemes never become links. +import { getMarkRange } from '@tiptap/core'; +import type { EditorState } from '@tiptap/pm/state'; +import { TextSelection } from '@tiptap/pm/state'; +import { isBlockedHref, linkKind, schemeOf } from '../schema/constants'; +import type { LinkKind } from '../schema/constants'; +import { fromChange } from './command'; +import type { Command } from './command'; + +export interface LinkAt { + from: number; + to: number; + href: string; + text: string; + kind: LinkKind; +} + +/** The link around the caret, or the one the selection lies inside. */ +export function linkAt(state: EditorState): LinkAt | null { + const type = state.schema.marks.link; + const { $from, from, to } = state.selection; + const range = getMarkRange($from, type) ?? (from === to ? null : getMarkRange(state.doc.resolve(from + 1), type)); + if (!range || range.from > from || range.to < to) return null; + const mark = state.doc + .resolve(range.from + 1) + .marks() + .find((candidate) => candidate.type === type); + const href = (mark?.attrs.href as string | undefined) ?? ''; + return { ...range, href, text: state.doc.textBetween(range.from, range.to), kind: linkKind(href) }; +} + +const EMAIL = /^[^\s@/]+@[^\s@/]+\.[^\s@/]+$/; +const BARE_HOST = /^(localhost|[\w-]+(\.[\w-]+)+)(:\d+)?([/?#].*)?$/; + +/** + * The destination as typed, made into a link: "example.com" gets https, "name@example.com" gets mailto, and + * script schemes are refused (null). + */ +export function normalizeHref(input: string): string | null { + const href = input.trim(); + if (href === '' || /\s/.test(href) || isBlockedHref(href)) return null; + if (schemeOf(href) !== null && !/^[\w-]+\.[\w.-]+:\d/.test(href)) return href; + if (EMAIL.test(href)) return `mailto:${href}`; + if (BARE_HOST.test(href)) return `https://${href}`; + return href.startsWith('/') || href.startsWith('#') ? null : `https://${href}`; +} + +/** + * Links the selection to `href`. With a caret in a link, it changes that link; with a bare caret, it inserts + * `text` (or the address itself) as a new link. Changing `text` on an existing link replaces its words. + */ +export function setLink(href: string, text?: string): Command { + return (state, dispatch) => { + const normalized = normalizeHref(href); + if (!normalized) return false; + const existing = linkAt(state); + return fromChange((tr) => { + const type = tr.doc.type.schema.marks.link; + const mark = type.create({ href: normalized }); + let { from, to } = tr.selection; + if (existing && from === to) ({ from, to } = existing); + const words = text?.trim(); + if (from === to || (words && words !== tr.doc.textBetween(from, to))) { + const marks = mark.addToSet(type.removeFromSet(tr.doc.resolve(from).marks())); + const node = tr.doc.type.schema.text(words || normalized, marks); + tr.replaceWith(from, to, node); + to = from + node.nodeSize; + } else { + tr.removeMark(from, to, type); + tr.addMark(from, to, mark); + } + tr.setSelection(TextSelection.create(tr.doc, to)); + tr.setStoredMarks(type.removeFromSet(tr.doc.resolve(to).marks())); + return true; + })(state, dispatch); + }; +} + +/** Removes the link at the caret, or every link in the selection, keeping the words. */ +export function removeLink(): Command { + return (state, dispatch) => { + const existing = linkAt(state); + const { from, to } = state.selection; + const range = from === to ? existing : { from, to }; + if (!range) return false; + let found = false; + state.doc.nodesBetween(range.from, range.to, (node) => { + if (node.marks.some((mark) => mark.type.name === 'link')) found = true; + return !found; + }); + if (!found) return false; + return fromChange((tr) => { + tr.removeMark(range.from, range.to, tr.doc.type.schema.marks.link); + return true; + })(state, dispatch); + }; +} diff --git a/app/src/editor/commands/marks.ts b/app/src/editor/commands/marks.ts new file mode 100644 index 000000000..e39bb7a32 --- /dev/null +++ b/app/src/editor/commands/marks.ts @@ -0,0 +1,122 @@ +// Character formatting (owner: WP4): the marks people toggle, highlight and text colors, text sizes, and clearing. +// Each function is a ProseMirror command, so the page's commands, the formatting bar, and tests share one body. +import type { MarkType } from '@tiptap/pm/model'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import type { HighlightColor, TextSizeName } from '../schema/specs'; +import { fromChange, selectedTextblocks } from './command'; +import type { Change, Command } from './command'; +import { isMarkActive, markAttrs } from './state'; + +export { isMarkActive, markAttrs } from './state'; + +export type ToggledMark = 'bold' | 'italic' | 'underline' | 'strike' | 'code' | 'subscript' | 'superscript'; + +/** Text sizes from small to extra large. Normal text has no mark, so it sits between small and large. */ +const SIZE_STEPS: readonly (TextSizeName | null)[] = ['small', null, 'large', 'xlarge']; + +/** Adds the mark to the selection, replacing one of the same type, or stores it for typing at a caret. */ +function addMark(tr: Transaction, type: MarkType, attrs: Record | null): boolean { + const { selection } = tr; + const mark = type.create(attrs); + if (selection.empty) { + const marks = tr.storedMarks ?? selection.$from.marks(); + if (!mark.isInSet(marks) && !type.isInSet(marks) && !canMark(tr, type)) return false; + tr.setStoredMarks(mark.addToSet(type.removeFromSet(marks))); + return true; + } + if (!canMark(tr, type)) return false; + for (const range of selection.ranges) { + tr.removeMark(range.$from.pos, range.$to.pos, type); + tr.addMark(range.$from.pos, range.$to.pos, mark); + } + return true; +} + +function removeMark(tr: Transaction, type: MarkType): boolean { + const { selection } = tr; + if (selection.empty) { + tr.setStoredMarks(type.removeFromSet(tr.storedMarks ?? selection.$from.marks())); + return true; + } + for (const range of selection.ranges) tr.removeMark(range.$from.pos, range.$to.pos, type); + return true; +} + +/** Whether some textblock in the selection allows the mark (code blocks allow none). */ +function canMark(tr: Transaction, type: MarkType): boolean { + return selectedTextblocks(tr).some(({ node }) => node.type.allowsMarkType(type)); +} + +/** Bold, italic, and the other marks without attributes: on unless the whole selection has it already. */ +export function toggleMark(name: ToggledMark): Command { + return (state, dispatch) => { + const type = state.schema.marks[name]; + const active = isMarkActive(state, name); + return fromChange((tr) => (active ? removeMark(tr, type) : addMark(tr, type, null)))(state, dispatch); + }; +} + +/** Highlight in a color; null is Honey. The same color again takes the highlight off. */ +export function toggleHighlight(color: HighlightColor | null = null): Command { + return (state, dispatch) => { + const type = state.schema.marks.highlight; + const active = isMarkActive(state, 'highlight', { color }); + return fromChange((tr) => (active ? removeMark(tr, type) : addMark(tr, type, { color })))(state, dispatch); + }; +} + +/** Sets the highlight color, or takes the highlight off with `'none'`. */ +export function setHighlight(color: HighlightColor | null | 'none'): Command { + return fromChange((tr) => { + const type = tr.doc.type.schema.marks.highlight; + return color === 'none' ? removeMark(tr, type) : addMark(tr, type, { color }); + }); +} + +/** A pen name or `#rrggbb`; null takes the color off. */ +export function setTextColor(color: string | null): Command { + return fromChange((tr) => { + const type = tr.doc.type.schema.marks.textColor; + return color === null ? removeMark(tr, type) : addMark(tr, type, { color }); + }); +} + +/** A text size; null is normal size. */ +export function setTextSize(size: TextSizeName | null): Command { + return fromChange((tr) => { + const type = tr.doc.type.schema.marks.textSize; + return size === null ? removeMark(tr, type) : addMark(tr, type, { size }); + }); +} + +/** The size of the selection's first character, or null for normal text. */ +export function currentTextSize(state: EditorState): TextSizeName | null { + return (markAttrs(state, 'textSize')?.size as TextSizeName | undefined) ?? null; +} + +/** One step larger (1) or smaller (-1), from small through normal to extra large. False at either end. */ +export function stepTextSize(direction: 1 | -1): Command { + return (state, dispatch) => { + const at = SIZE_STEPS.indexOf(currentTextSize(state)); + const next = at + direction; + if (next < 0 || next >= SIZE_STEPS.length) return false; + return setTextSize(SIZE_STEPS[next])(state, dispatch); + }; +} + +/** Takes off every character format but links, which are content rather than formatting. */ +export const clearFormattingChange: Change = (tr) => { + const marks = Object.values(tr.doc.type.schema.marks).filter((type) => type.name !== 'link'); + if (tr.selection.empty) { + tr.setStoredMarks((tr.storedMarks ?? tr.selection.$from.marks()).filter((mark) => mark.type.name === 'link')); + return true; + } + for (const range of tr.selection.ranges) { + for (const type of marks) tr.removeMark(range.$from.pos, range.$to.pos, type); + } + return true; +}; + +export function clearFormatting(): Command { + return fromChange(clearFormattingChange); +} diff --git a/app/src/editor/commands/outline.ts b/app/src/editor/commands/outline.ts new file mode 100644 index 000000000..149824e16 --- /dev/null +++ b/app/src/editor/commands/outline.ts @@ -0,0 +1,143 @@ +// Outline moves (ARCHITECTURE.md section 18.1; owner: WP4). The current item is the innermost list item holding +// the selection, or else the block. For a heading, it's the heading's section. +// +// - Move up and down swap the item with its previous or next sibling of the same kind, keeping the selection. +// - Promote and demote lift or sink a list item with its children, or shift a heading and its subheadings a +// level. Past heading 6 or above heading 1, nothing changes. +// Each returns what happened, for the announcement. +import type { Fragment, Node as PMNode } from '@tiptap/pm/model'; +import { liftListItem, sinkListItem } from '@tiptap/pm/schema-list'; +import { TextSelection } from '@tiptap/pm/state'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import { ancestorDepth } from './command'; +import { itemDepth, sectionEnd } from './fold'; + +export type OutlineResult = + | { kind: 'moved'; direction: 'up' | 'down' } + | { kind: 'edge'; direction: 'up' | 'down' } + | { kind: 'level'; level: number } + | { kind: 'maxLevel' } + | { kind: 'minLevel' } + | { kind: 'noLevel' }; + +type Dispatch = ((tr: Transaction) => void) | undefined; + +/** A run of sibling nodes in one parent: a list item, a top-level block, or a heading's section. */ +interface Unit { + from: number; + to: number; + heading: number | null; +} + +/** The unit the selection is in. */ +function currentUnit(state: EditorState): Unit { + const { $from } = state.selection; + const item = ancestorDepth($from, ['listItem']); + if (item > 0) return { from: $from.before(item), to: $from.after(item), heading: null }; + const from = $from.depth > 0 ? $from.before(1) : $from.pos; + const node = state.doc.nodeAt(from); + if (node?.type.name === 'heading') { + return { from, to: sectionEnd(state.doc, from), heading: node.attrs.level as number }; + } + return { from, to: from + (node?.nodeSize ?? 0), heading: null }; +} + +/** The sibling unit before or after: an item, a block, or a section of the same heading level. */ +function siblingUnit(state: EditorState, unit: Unit, direction: -1 | 1): { from: number; to: number } | null { + const $from = state.doc.resolve(unit.from); + const parent = $from.parent; + const index = $from.index(); + if (unit.heading === null) { + const isItem = state.doc.nodeAt(unit.from)?.type.name === 'listItem'; + if (direction < 0) { + if (index === 0) return null; + const before = parent.child(index - 1); + if (!isItem && before.type.name === 'heading') return null; + return { from: unit.from - before.nodeSize, to: unit.from }; + } + const $to = state.doc.resolve(unit.to); + const after = $to.nodeAfter; + if (!after || (!isItem && after.type.name === 'heading')) return null; + return { from: unit.to, to: unit.to + after.nodeSize }; + } + if (direction > 0) { + const next = state.doc.resolve(unit.to).nodeAfter; + if (!next || next.type.name !== 'heading' || next.attrs.level !== unit.heading) return null; + return { from: unit.to, to: sectionEnd(state.doc, unit.to) }; + } + // Up: the nearest earlier heading of the same level, if no higher heading comes between. + let offset = unit.from; + for (let at = index - 1; at >= 0; at -= 1) { + const child = parent.child(at); + offset -= child.nodeSize; + if (child.type.name !== 'heading') continue; + const level = child.attrs.level as number; + if (level < unit.heading) return null; + if (level === unit.heading) return { from: offset, to: unit.from }; + } + return null; +} + +/** Swaps the current unit with its neighbor, keeping the selection where it was in the text. */ +export function moveItem(direction: 'up' | 'down') { + return (state: EditorState, dispatch?: Dispatch): OutlineResult => { + const unit = currentUnit(state); + const sibling = siblingUnit(state, unit, direction === 'up' ? -1 : 1); + if (!sibling) return { kind: 'edge', direction }; + if (dispatch) { + const own = state.doc.slice(unit.from, unit.to).content; + const other = state.doc.slice(sibling.from, sibling.to).content; + const [from, to] = direction === 'up' ? [sibling.from, unit.to] : [unit.from, sibling.to]; + const swapped = direction === 'up' ? own.append(other) : other.append(own); + const tr = state.tr.replaceWith(from, to, swapped as Fragment); + const shift = direction === 'up' ? sibling.from - unit.from : sibling.to - unit.to; + const { anchor, head } = state.selection; + tr.setSelection(TextSelection.create(tr.doc, anchor + shift, head + shift)); + dispatch(tr.scrollIntoView()); + } + return { kind: 'moved', direction }; + }; +} + +/** The heading levels of a section: the heading and every subheading in it. */ +function sectionHeadings(state: EditorState, unit: Unit): { pos: number; node: PMNode }[] { + const found: { pos: number; node: PMNode }[] = []; + state.doc.nodesBetween(unit.from, unit.to, (node, pos) => { + if (node.type.name === 'heading' && pos >= unit.from) found.push({ pos, node }); + return !node.isTextblock; + }); + return found; +} + +/** Demote (1) or promote (-1): a list item sinks or lifts, a heading's section shifts a level. */ +export function changeLevel(direction: 1 | -1) { + return (state: EditorState, dispatch?: Dispatch): OutlineResult => { + const { $from } = state.selection; + const listItem = state.schema.nodes.listItem; + if (ancestorDepth($from, ['listItem']) > 0) { + const command = direction > 0 ? sinkListItem(listItem) : liftListItem(listItem); + let after = state; + if (!command(state, (tr) => (after = state.apply(tr)) && dispatch?.(tr))) { + return direction > 0 ? { kind: 'maxLevel' } : { kind: 'minLevel' }; + } + const item = ancestorDepth(after.selection.$from, ['listItem']); + return item > 0 + ? { kind: 'level', level: itemDepth(after, after.selection.$from.before(item)) } + : { kind: 'level', level: 0 }; + } + const unit = currentUnit(state); + if (unit.heading === null) return { kind: 'noLevel' }; + const headings = sectionHeadings(state, unit); + const levels = headings.map(({ node }) => (node.attrs.level as number) + direction); + if (levels.some((level) => level > 6)) return { kind: 'maxLevel' }; + if (levels[0] < 1) return { kind: 'minLevel' }; + if (dispatch) { + const tr = state.tr; + headings.forEach(({ pos, node }, index) => + tr.setNodeMarkup(pos, undefined, { ...node.attrs, level: Math.max(1, levels[index]) }), + ); + dispatch(tr); + } + return { kind: 'level', level: levels[0] }; + }; +} diff --git a/app/src/editor/commands/outlineRun.ts b/app/src/editor/commands/outlineRun.ts new file mode 100644 index 000000000..4064e666a --- /dev/null +++ b/app/src/editor/commands/outlineRun.ts @@ -0,0 +1,100 @@ +// Running the outline and fold commands on an editor, with their announcements (owner: WP4). Moves and levels +// change the text as one command; folds change only the view. +import type { Editor } from '@tiptap/core'; +import { t } from '../../strings/t'; +import { hostOf } from '../extensions/keys'; +import { runCommand } from './command'; +import { foldKey, foldTargetAt, foldsForLevel, foldsOf, hiddenRanges, setFolds } from './fold'; +import { changeLevel, moveItem } from './outline'; +import type { OutlineResult } from './outline'; + +const MOVES: Record> = { + 'outline.moveUp': moveItem('up'), + 'outline.moveDown': moveItem('down'), + 'outline.promote': changeLevel(-1), + 'outline.demote': changeLevel(1), +}; + +export const OUTLINE_IDS: readonly string[] = [ + ...Object.keys(MOVES), + 'outline.fold', + 'outline.unfold', + 'outline.showAll', + ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((level) => `outline.showLevel${level}`), +]; + +function outlineText(result: OutlineResult): string { + switch (result.kind) { + case 'moved': + return t(result.direction === 'up' ? 'editor.outline.movedUp' : 'editor.outline.movedDown'); + case 'edge': + return t(result.direction === 'up' ? 'editor.outline.top' : 'editor.outline.bottom'); + case 'level': + return result.level === 0 ? t('editor.tab.paragraph') : t('editor.outline.level', { level: result.level }); + case 'maxLevel': + return t('editor.outline.maxLevel'); + case 'minLevel': + return t('editor.outline.minLevel'); + default: + return t('editor.outline.noLevel'); + } +} + +/** Whether a fold command can act: folding plugins only run with the outline flag on. */ +const folding = (editor: Editor) => foldKey.getState(editor.state) !== undefined; + +function runFold(editor: Editor, id: string): boolean { + if (!folding(editor)) return false; + const host = hostOf(editor); + const { state } = editor; + const folds = foldsOf(state); + let next: readonly number[]; + let text: string; + if (id === 'outline.showAll') { + next = []; + text = t('editor.fold.all'); + } else if (id.startsWith('outline.showLevel')) { + const level = Number(id.slice('outline.showLevel'.length)); + next = foldsForLevel(state, level); + text = t('editor.fold.levels', { level }); + } else { + const target = foldTargetAt(state); + if (!target) { + host?.announce(t('editor.fold.nothing')); + return false; + } + const node = state.doc.nodeAt(target.pos)!; + const name = (target.kind === 'heading' ? node.textContent : (node.firstChild?.textContent ?? '')).trim(); + if (id === 'outline.fold') { + next = [...folds, target.pos]; + let count = 0; + for (const [from, to] of hiddenRanges(state.doc, target.pos)) { + state.doc.nodesBetween(from, to, (child) => { + if (child.isTextblock) count += 1; + return !child.isTextblock; + }); + } + text = t('editor.fold.folded', { name, count }); + } else { + next = folds.filter((pos) => pos !== target.pos); + text = t('editor.fold.expanded', { name }); + } + } + editor.view.dispatch(setFolds(state.tr, next)); + host?.announce(text); + return true; +} + +/** Runs an outline or fold command. Returns false for IDs that aren't outline commands. */ +export function runOutline(editor: Editor, id: string): boolean { + const move = MOVES[id]; + if (!move) return OUTLINE_IDS.includes(id) ? runFold(editor, id) : false; + const results: OutlineResult[] = []; + runCommand(editor, (state, dispatch) => { + results.push(move(state, dispatch)); + return true; + }); + const result = results.at(-1) ?? { kind: 'noLevel' }; + hostOf(editor)?.announce(outlineText(result)); + return result.kind === 'moved' || result.kind === 'level'; +} diff --git a/app/src/editor/commands/state.ts b/app/src/editor/commands/state.ts new file mode 100644 index 000000000..4ece2fde1 --- /dev/null +++ b/app/src/editor/commands/state.ts @@ -0,0 +1,138 @@ +// What the selection is now (owner: WP4): active marks, the block kind, and whether a link is under the caret. +// The page's commands read these to show checked states and to decide whether a key applies. The module imports +// types only, so the start-up registrations can use it without loading the editor. +import type { Mark, MarkType, Node as PMNode } from '@tiptap/pm/model'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import type { FoldKey } from './fold'; + +/** The marks at the caret, or of the selection's first character. */ +function marksAtSelection(state: EditorState | Transaction): readonly Mark[] { + const { selection } = state; + const stored = 'storedMarks' in state ? state.storedMarks : null; + if (selection.empty) return stored ?? selection.$from.marks(); + let found: readonly Mark[] | null = null; + state.doc.nodesBetween(selection.from, selection.to, (node) => { + if (found === null && node.isInline) found = node.marks; + return found === null; + }); + return found ?? selection.$from.marks(); +} + +function attrsMatch(mark: Mark, attrs: Record): boolean { + return Object.entries(attrs).every(([key, value]) => mark.attrs[key] === value); +} + +/** Whether every character in the selection has the mark, or the caret will type with it. */ +export function isMarkActive(state: EditorState, name: string, attrs?: Record): boolean { + const type = state.schema.marks[name] as MarkType | undefined; + if (!type) return false; + const matches = (mark: Mark) => mark.type === type && (!attrs || attrsMatch(mark, attrs)); + if (state.selection.empty) return marksAtSelection(state).some(matches); + let all = true; + let any = false; + state.doc.nodesBetween(state.selection.from, state.selection.to, (node: PMNode) => { + if (!node.isText) return true; + any = true; + if (!node.marks.some(matches)) all = false; + return false; + }); + return any && all; +} + +/** The attributes of the mark at the selection, or null. */ +export function markAttrs(state: EditorState, name: string): Record | null { + const mark = marksAtSelection(state).find((candidate) => candidate.type.name === name); + return mark ? mark.attrs : null; +} + +/** Whether the selection starts in a link. */ +export function caretInLink(state: EditorState): boolean { + const { $from } = state.selection; + const has = (marks: readonly Mark[]) => marks.some((mark) => mark.type.name === 'link'); + return has($from.marks()) || has($from.nodeAfter?.marks ?? []); +} + +/** Whether the selection has text in it, or the caret is in a link: when Ctrl+K edits a link. */ +export function linkApplies(state: EditorState): boolean { + return !state.selection.empty || caretInLink(state); +} + +export type BlockKindName = + | 'paragraph' + | 'heading1' + | 'heading2' + | 'heading3' + | 'heading4' + | 'heading5' + | 'heading6' + | 'bulletList' + | 'orderedList' + | 'checklist' + | 'quote' + | 'callout' + | 'codeBlock'; + +/** The kind of block the caret is in, innermost container first: a task, a list, a quote, a callout. */ +export function blockKindAt(state: EditorState): BlockKindName | null { + const { $from } = state.selection; + const parent = $from.parent; + if (parent.type.name === 'codeBlock') return 'codeBlock'; + for (let depth = $from.depth - 1; depth > 0; depth -= 1) { + const node = $from.node(depth); + const name = node.type.name; + if (name === 'listItem') { + if (node.attrs.checked !== null && node.attrs.checked !== undefined) return 'checklist'; + const list = $from.node(depth - 1).type.name; + return list === 'orderedList' ? 'orderedList' : 'bulletList'; + } + if (name === 'blockquote') return 'quote'; + if (name === 'callout') return 'callout'; + } + if (parent.type.name === 'heading') return `heading${parent.attrs.level as 1 | 2 | 3 | 4 | 5 | 6}`; + return parent.type.name === 'paragraph' ? 'paragraph' : null; +} + +/** Whether the caret's textblock allows marks: code blocks and math don't. */ +export function marksAllowed(state: EditorState): boolean { + const { $from } = state.selection; + return $from.parent.isTextblock && $from.parent.type.spec.code !== true; +} + +/** Whether the caret is in a task item's first paragraph, where Check or uncheck applies. */ +export function inTaskItem(state: EditorState): boolean { + const { $from, $to } = state.selection; + for (const $pos of [$from, $to]) { + const item = $pos.depth > 1 ? $pos.node(-1) : null; + if (item?.type.name === 'listItem' && item.attrs.checked !== null && $pos.index(-1) === 0) return true; + } + return false; +} + +/** The event an editor sends when Backspace at its very start should merge it into the text block above. */ +export const MERGE_BLOCKS_EVENT = 'opennote:mergeblocks'; + +export interface MergeBlocksDetail { + /** The block whose text moves up. */ + block: string; + /** The text block above, which keeps the merged text. */ + previous: string; +} + +/** The event an editor sends when "/" opens a slash menu session, with the session as its detail. */ +export const SLASH_MENU_EVENT = 'opennote:slashmenu'; + +/** The event an editor sends when its folds change, so the page can save them in pageViews. */ +export const FOLDS_EVENT = 'opennote:folds'; +/** The event an editor sends as it mounts, asking the page for the folds it saved. */ +export const FOLDS_REQUEST_EVENT = 'opennote:foldsrequest'; + +export interface FoldsDetail { + block: string; + keys: readonly FoldKey[]; +} + +export interface FoldsRequestDetail { + block: string; + /** The page calls this with the block's saved folds. */ + provide(keys: readonly FoldKey[]): void; +} diff --git a/app/src/editor/commands/tab.ts b/app/src/editor/commands/tab.ts new file mode 100644 index 000000000..60f5fe7d2 --- /dev/null +++ b/app/src/editor/commands/tab.ts @@ -0,0 +1,83 @@ +// Tab and Shift+Tab in text (ARCHITECTURE.md section 22.4; owner: WP4). Tab stays inside the editor, as in OneNote +// and Word, and Escape is the way out. +// +// - In a list item, Tab sinks it into the previous item, and Shift+Tab lifts it, to a paragraph at the top level. +// - A plain paragraph with the caret at its start, or several selected paragraphs, become a bulleted list. They +// join the list just above as new items. +// - Anywhere else, Tab types a tab character and Shift+Tab does nothing. +// Code blocks and table cells have their own Tab keys, so these commands don't apply there. +import { liftListItem, sinkListItem } from '@tiptap/pm/schema-list'; +import type { EditorState } from '@tiptap/pm/state'; +import { ancestorDepth, fromChange, selectedTextblocks, viaCommand } from './command'; +import type { Command } from './command'; +import { turnIntoChange } from './turnInto'; + +/** What Tab did, for the announcement. */ +export type TabResult = 'indented' | 'outdented' | 'bulleted' | 'numbered' | 'tab' | 'paragraph' | 'none'; + +/** Whether Tab belongs to someone else here: code blocks and table cells. */ +export function tabOwnedElsewhere(state: EditorState): boolean { + const { $from } = state.selection; + return $from.parent.type.spec.code === true || ancestorDepth($from, ['tableCell', 'tableHeader']) >= 0; +} + +function inListItem(state: EditorState): boolean { + return ancestorDepth(state.selection.$from, ['listItem']) >= 0; +} + +/** Whether Tab makes a list here: a paragraph with the caret at its start, or several paragraphs selected. */ +function startsList(state: EditorState): boolean { + const { selection } = state; + const { $from } = selection; + if ($from.parent.type.name !== 'paragraph' || inListItem(state)) return false; + if (selection.empty) return $from.parentOffset === 0; + const blocks = selectedTextblocks(state.tr); + return blocks.length > 1 && blocks.every(({ node }) => node.type.name === 'paragraph'); +} + +/** The kind of list just above the selection's first block, or null. */ +function listAbove(state: EditorState): 'bulletList' | 'orderedList' | null { + const { $from } = state.selection; + const index = $from.index($from.depth - 1); + if (index === 0) return null; + const before = $from.node($from.depth - 1).child(index - 1).type.name; + return before === 'bulletList' || before === 'orderedList' ? before : null; +} + +/** What Tab will do at the selection. */ +export function tabResult(state: EditorState, shift: boolean): TabResult { + if (tabOwnedElsewhere(state)) return 'none'; + if (inListItem(state)) return shift ? 'outdented' : 'indented'; + if (shift) return 'none'; + if (startsList(state)) return listAbove(state) === 'orderedList' ? 'numbered' : 'bulleted'; + return 'tab'; +} + +/** Tab: indents a list item, starts or joins a list, or types a tab character. */ +export function indent(): Command { + return (state, dispatch) => { + const result = tabResult(state, false); + if (result === 'none') return false; + const listItem = state.schema.nodes.listItem; + if (result === 'indented') { + // The first item can't sink; Tab still stays in the editor. + sinkListItem(listItem)(state, dispatch); + return true; + } + if (result === 'tab') { + dispatch?.(state.tr.insertText('\t').scrollIntoView()); + return true; + } + const kind = result === 'numbered' ? 'orderedList' : 'bulletList'; + return fromChange(turnIntoChange(kind))(state, dispatch); + }; +} + +/** Shift+Tab: lifts a list item a level, to a paragraph at the top. Elsewhere, nothing. */ +export function outdent(): Command { + return (state, dispatch) => { + const result = tabResult(state, true); + if (result === 'none') return !tabOwnedElsewhere(state); + return fromChange(viaCommand(liftListItem(state.schema.nodes.listItem)))(state, dispatch) || true; + }; +} diff --git a/app/src/editor/commands/tables.ts b/app/src/editor/commands/tables.ts new file mode 100644 index 000000000..76853bb80 --- /dev/null +++ b/app/src/editor/commands/tables.ts @@ -0,0 +1,149 @@ +// The table commands as changes to a table's data (ARCHITECTURE.md section 13; owner: WP6). Each takes the data +// and the cell the caret is in, and returns the next data and the cell for the caret, or null when the command +// doesn't apply. New rows and columns get fresh IDs; a moved row keeps its own, so later merges can match rows. +import type { EditorState } from '@tiptap/pm/state'; +import { CellSelection } from '@tiptap/pm/tables'; +import { clampColumnWidth, newColumn, newRow, normalizeTableData } from '../table/mapping'; +import type { TableData } from '../schema/specs'; + +/** A cell by row and column index. */ +export interface CellAt { + row: number; + column: number; +} + +export interface TableChange { + data: TableData; + /** Where the caret goes, or null when the table is gone. */ + caret: CellAt | null; +} + +export type TableOp = + | { op: 'rowAbove' } + | { op: 'rowBelow' } + | { op: 'columnLeft' } + | { op: 'columnRight' } + | { op: 'deleteRow' } + | { op: 'deleteColumn' } + | { op: 'headerRow'; on?: boolean } + | { op: 'moveRowUp' } + | { op: 'moveRowDown' } + | { op: 'columnWidth'; width: number }; + +/** The cell holding the selection's head, or null outside a table. */ +export function cellAt(state: EditorState): CellAt | null { + const selection = state.selection; + const $pos = selection instanceof CellSelection ? selection.$headCell : selection.$head; + for (let depth = $pos.depth; depth > 0; depth--) { + const role = $pos.node(depth).type.spec.tableRole as string | undefined; + if (role === 'row') return { row: $pos.index(depth - 1), column: $pos.index(depth) }; + if (role === 'cell' || role === 'header_cell') { + return { row: $pos.index(depth - 2), column: $pos.index(depth - 1) }; + } + } + if ($pos.depth === 0 && $pos.nodeAfter?.type.spec.tableRole === 'row') return { row: $pos.index(0), column: 0 }; + return null; +} + +/** The position inside the paragraph of a cell, at its start or end, in a table document. */ +export function cellTextPos(doc: EditorState['doc'], at: CellAt, end = false): number | null { + const table = doc.firstChild; + if (!table || at.row >= table.childCount) return null; + const row = table.child(at.row); + if (at.column >= row.childCount) return null; + let pos = 1; // inside the table + for (let r = 0; r < at.row; r++) pos += table.child(r).nodeSize; + pos += 1; // inside the row + for (let c = 0; c < at.column; c++) pos += row.child(c).nodeSize; + const cell = row.child(at.column); + const paragraph = cell.firstChild; + // Inside the cell, then inside its paragraph. + return pos + 2 + (end && paragraph ? paragraph.content.size : 0); +} + +const clampCell = (data: TableData, at: CellAt): CellAt => ({ + row: Math.min(Math.max(0, at.row), data.rows.length - 1), + column: Math.min(Math.max(0, at.column), data.columns.length - 1), +}); + +function withRows(data: TableData, rows: TableData['rows']): TableData { + return { ...data, rows }; +} + +/** Whether the op can run on this table with the caret in `at`. */ +export function canApply(data: TableData, at: CellAt, op: TableOp): boolean { + return applyTableOp(data, at, op) !== null; +} + +/** The data after `op`, or null when it doesn't apply (moving the top row up, deleting the last column). */ +export function applyTableOp(input: TableData, at: CellAt, op: TableOp): TableChange | null { + const data = normalizeTableData(input); + const { row, column } = clampCell(data, at); + const firstBody = data.header ? 1 : 0; + switch (op.op) { + case 'rowAbove': { + // Above the header row means a new first body row, so the header stays on top. + const index = Math.max(row, firstBody); + const rows = [...data.rows]; + rows.splice(index, 0, newRow(data.columns)); + return { data: withRows(data, rows), caret: { row: index, column } }; + } + case 'rowBelow': { + const rows = [...data.rows]; + rows.splice(row + 1, 0, newRow(data.columns)); + return { data: withRows(data, rows), caret: { row: row + 1, column } }; + } + case 'columnLeft': + case 'columnRight': { + const index = op.op === 'columnLeft' ? column : column + 1; + const added = newColumn(data.columns[column].width); + const columns = [...data.columns]; + columns.splice(index, 0, added); + // Each row's cells follow the columns' order, which is the order page.md writes them in. + const rows = data.rows.map((one) => ({ + ...one, + cells: Object.fromEntries(columns.map((c) => [c.id, one.cells[c.id] ?? { markdown: '' }])), + })); + return { data: { ...data, columns, rows }, caret: { row, column: index } }; + } + case 'deleteRow': { + if (data.rows.length <= 1) return null; + const rows = data.rows.filter((_, i) => i !== row); + // Deleting the header row makes the next row the first; it stays a body row. + const header = data.header && row !== 0; + return { data: { ...data, header, rows }, caret: { row: Math.min(row, rows.length - 1), column } }; + } + case 'deleteColumn': { + if (data.columns.length <= 1) return null; + const gone = data.columns[column].id; + const columns = data.columns.filter((_, i) => i !== column); + const rows = data.rows.map((one) => { + const cells = { ...one.cells }; + delete cells[gone]; + return { ...one, cells }; + }); + return { data: { ...data, columns, rows }, caret: { row, column: Math.min(column, columns.length - 1) } }; + } + case 'headerRow': { + const header = op.on ?? !data.header; + if (header === data.header) return null; + return { data: { ...data, header }, caret: { row, column } }; + } + case 'moveRowUp': + case 'moveRowDown': { + const to = op.op === 'moveRowUp' ? row - 1 : row + 1; + // The header row stays first, and no body row moves above it. + if (row < firstBody || to < firstBody || to >= data.rows.length) return null; + const rows = [...data.rows]; + const [moved] = rows.splice(row, 1); + rows.splice(to, 0, moved); + return { data: withRows(data, rows), caret: { row: to, column } }; + } + case 'columnWidth': { + const width = clampColumnWidth(op.width); + if (width === data.columns[column].width) return null; + const columns = data.columns.map((one, i) => (i === column ? { ...one, width } : one)); + return { data: { ...data, columns }, caret: { row, column } }; + } + } +} diff --git a/app/src/editor/commands/testing.ts b/app/src/editor/commands/testing.ts new file mode 100644 index 000000000..f4ad42373 --- /dev/null +++ b/app/src/editor/commands/testing.ts @@ -0,0 +1,127 @@ +// Test helpers for WP4's editor tests (owner: WP4): a host that records what it was asked, and an editor over +// Markdown. Brackets mark the selection as in the page helpers' `md`: "a [b] c" selects "b", "a [] c" is a caret. +import type { Editor } from '@tiptap/core'; +import { EditorState, TextSelection } from '@tiptap/pm/state'; +import { createBlockEditor } from '../extensions/kit'; +import { DEFAULT_EDITING_VIEW } from '../host'; +import type { EditingSettingsView, EditorHost } from '../host'; +import { createMarkdownCache, parseTextBlock, serializeTextBlock } from '../markdown'; +import type { Command } from './command'; + +export interface TestHost extends EditorHost { + readonly announced: string[]; + readonly selected: { blocks: readonly string[]; reason: string }[]; + menuAnswer: string | null; + editing: EditingSettingsView; +} + +export function testHost(overrides: Partial = {}): TestHost { + const host: TestHost = { + announced: [], + selected: [], + menuAnswer: null, + editing: { ...DEFAULT_EDITING_VIEW }, + settings: () => host.editing, + flag: () => true, + announce: (text) => void host.announced.push(text), + openMenu: () => Promise.resolve(host.menuAnswer), + screenReader: () => false, + spelling: () => null, + selectBlocks: (blocks, reason) => void host.selected.push({ blocks, reason }), + ...overrides, + }; + return host; +} + +const ANCHOR = ''; +const HEAD = ''; + +/** Markdown with "[" and "]" marking the selection, parsed, with the selection's positions. */ +export function marked(source: string): { state: EditorState } { + const markers = [...source.matchAll(/\[/g)] + .map((match) => ({ open: match.index, close: source.indexOf(']', match.index) })) + .find(({ open, close }) => close !== -1 && !/^\[[ xX!]/.test(source.slice(open)) && source[close + 1] !== '('); + const { open, close } = markers ?? { open: undefined, close: -1 }; + if (open === undefined || close === -1) { + const doc = parseTextBlock(source); + return { state: EditorState.create({ doc, selection: TextSelection.atStart(doc) }) }; + } + const text = source.slice(0, open) + ANCHOR + source.slice(open + 1, close) + HEAD + source.slice(close + 1); + const parsed = parseTextBlock(text); + const at: Record = {}; + parsed.descendants((node, pos) => { + if (!node.isText) return; + for (const mark of [ANCHOR, HEAD]) { + const index = node.text?.indexOf(mark) ?? -1; + if (index >= 0) at[mark] = pos + index; + } + }); + const tr = EditorState.create({ doc: parsed }).tr; + tr.delete(at[HEAD], at[HEAD] + 1).delete(at[ANCHOR], at[ANCHOR] + 1); + const selection = TextSelection.create(tr.doc, at[ANCHOR], at[HEAD] - 1); + return { state: EditorState.create({ doc: tr.doc, selection }) }; +} + +const cache = createMarkdownCache(); + +/** The Markdown of a state's document. */ +export function markdownOf(state: { doc: EditorState['doc'] }): string { + return serializeTextBlock(state.doc, cache); +} + +/** Runs a command on marked Markdown and returns the Markdown after it, or null when it doesn't apply. */ +export function applyTo(source: string, command: Command): string | null { + const { state } = marked(source); + let after: EditorState | null = null; + const ran = command(state, (tr) => { + after = state.apply(tr); + }); + return ran && after ? markdownOf(after) : null; +} + +export interface TestEditor { + readonly editor: Editor; + readonly host: TestHost; + readonly root: HTMLElement; + markdown(): string; + destroy(): void; +} + +/** A mounted text editor over marked Markdown. */ +export function mountEditor(source: string, host: TestHost = testHost()): TestEditor { + const { state } = marked(source); + const root = document.body.appendChild(document.createElement('div')); + const editor = createBlockEditor(root, state.doc, { kind: 'text', block: 'b1', host }); + editor.commands.setTextSelection({ from: state.selection.anchor, to: state.selection.head }); + return { + editor, + host, + root, + markdown: () => markdownOf(editor.state), + destroy() { + editor.destroy(); + root.remove(); + }, + }; +} + +/** Types text one character at a time, as the browser reports it: through handleTextInput, else inserted. */ +export function typeInto(editor: Editor, text: string): void { + for (const char of text) { + const { view } = editor; + const { from, to } = view.state.selection; + const handled = view.someProp('handleTextInput', (handle) => handle(view, from, to, char, () => view.state.tr)); + if (!handled) view.dispatch(view.state.tr.insertText(char, from, to)); + } +} + +/** jsdom has no layout: ranges get empty rectangles, so ProseMirror can scroll and measure without throwing. */ +export function stubLayout(): void { + if (typeof Range === 'undefined' || 'getClientRects' in Range.prototype) return; + const empty = () => ({ left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0 }) as DOMRect; + Object.assign(Range.prototype, { + getClientRects: () => Object.assign([], { item: () => null }) as unknown as DOMRectList, + getBoundingClientRect: empty, + }); +} +stubLayout(); diff --git a/app/src/editor/commands/turnInto.ts b/app/src/editor/commands/turnInto.ts new file mode 100644 index 000000000..432c136a4 --- /dev/null +++ b/app/src/editor/commands/turnInto.ts @@ -0,0 +1,294 @@ +// Turn into (ARCHITECTURE.md section 17.2; owner: WP4): converts the current textblock, or the selected ones, +// between Text, Heading 1 to 6, the three list kinds, Quote, Callout, and Code, keeping the text. Converting to Code +// drops marks; converting out of Code keeps plain text. The block commands and the slash menu use these too. +import { Fragment } from '@tiptap/pm/model'; +import type { NodeRange, NodeType, Node as PMNode } from '@tiptap/pm/model'; +import { liftListItem, wrapInList } from '@tiptap/pm/schema-list'; +import { TextSelection } from '@tiptap/pm/state'; +import type { Transaction } from '@tiptap/pm/state'; +import { findWrapping, liftTarget } from '@tiptap/pm/transform'; +import { ancestorDepth, fromChange, selectedTextblocks, viaCommand } from './command'; +import type { Change, Command } from './command'; + +export type BlockKind = + | 'paragraph' + | 'heading1' + | 'heading2' + | 'heading3' + | 'heading4' + | 'heading5' + | 'heading6' + | 'bulletList' + | 'orderedList' + | 'checklist' + | 'quote' + | 'callout' + | 'codeBlock'; + +export const BLOCK_KINDS: readonly BlockKind[] = [ + 'paragraph', + 'heading1', + 'heading2', + 'heading3', + 'heading4', + 'heading5', + 'heading6', + 'bulletList', + 'orderedList', + 'checklist', + 'quote', + 'callout', + 'codeBlock', +]; + +const LISTS = ['bulletList', 'orderedList']; + +/** Lifts the selection out of every list around it, so its textblocks can change type. */ +export const liftOutOfLists: Change = (tr) => { + let lifted = false; + for (let guard = 0; guard < 16; guard += 1) { + if (ancestorDepth(tr.selection.$from, ['listItem']) < 0) break; + if (!viaCommand(liftListItem(tr.doc.type.schema.nodes.listItem))(tr)) break; + lifted = true; + } + return lifted; +}; + +/** Sets every selected textblock but callout titles to the type, keeping text (and marks where allowed). */ +function setTextblocks(type: NodeType, attrs: Record | null): Change { + return (tr) => { + let changed = false; + for (const { node, pos } of selectedTextblocks(tr)) { + if (node.type.name === 'calloutTitle') continue; + if (node.type === type && (!attrs || Object.entries(attrs).every(([k, v]) => node.attrs[k] === v))) continue; + const at = tr.mapping.map(pos); + tr.setBlockType(at, at + 1, type, { ...keptAttrs(node, type), ...attrs }); + changed = true; + } + return changed; + }; +} + +/** Element metadata travels with the text (SPEC 6.6). */ +function keptAttrs(node: PMNode, type: NodeType): Record { + const kept: Record = {}; + for (const name of ['id', 'tags', 'style', 'tagChecked']) { + if (name in node.attrs && type.spec.attrs && name in type.spec.attrs) kept[name] = node.attrs[name]; + } + return kept; +} + +function textblockKind(kind: BlockKind): { type: string; attrs: Record | null } | null { + if (kind === 'paragraph') return { type: 'paragraph', attrs: null }; + if (kind === 'codeBlock') return { type: 'codeBlock', attrs: null }; + const level = /^heading([1-6])$/.exec(kind)?.[1]; + return level ? { type: 'heading', attrs: { level: Number(level) } } : null; +} + +/** The innermost list item holding the selection's start, with the list around it. */ +export function listAround(tr: Transaction): { list: PMNode; listPos: number; item: PMNode; itemPos: number } | null { + const $from = tr.selection.$from; + const itemDepth = ancestorDepth($from, ['listItem']); + if (itemDepth < 1) return null; + return { + list: $from.node(itemDepth - 1), + listPos: $from.before(itemDepth - 1), + item: $from.node(itemDepth), + itemPos: $from.before(itemDepth), + }; +} + +/** The list items the selection touches inside the innermost list around its start. */ +function selectedItems(tr: Transaction): { node: PMNode; pos: number }[] { + const around = listAround(tr); + if (!around) return []; + const items: { node: PMNode; pos: number }[] = []; + const { from, to } = tr.selection; + around.list.forEach((item, offset) => { + const pos = around.listPos + 1 + offset; + if (pos + item.nodeSize > from && pos < to + 1) items.push({ node: item, pos }); + }); + return items.length ? items : [{ node: around.item, pos: around.itemPos }]; +} + +function setChecked(tr: Transaction, items: readonly { node: PMNode; pos: number }[], checked: boolean | null) { + for (const { node, pos } of items) { + if (node.attrs.checked !== checked) tr.setNodeMarkup(pos, undefined, { ...node.attrs, checked }); + } +} + +/** Whether the selection is already in a list of this kind: the list's type, and task items for a checklist. */ +export function inListKind(tr: Transaction, kind: 'bulletList' | 'orderedList' | 'checklist'): boolean { + const around = listAround(tr); + if (!around) return false; + const items = selectedItems(tr); + const tasks = items.every(({ node }) => node.attrs.checked !== null); + if (kind === 'checklist') return tasks; + return around.list.type.name === kind && !tasks; +} + +/** Puts the selected textblocks in a list of this kind, or changes the list they are in. */ +function toList(kind: 'bulletList' | 'orderedList' | 'checklist'): Change { + return (tr) => { + const schema = tr.doc.type.schema; + const around = listAround(tr); + if (around) { + const items = selectedItems(tr); + // A checklist is a bulleted list of tasks. + setChecked(tr, items, kind === 'checklist' ? false : null); + const listType = kind === 'checklist' ? 'bulletList' : kind; + if (around.list.type.name !== listType) tr.setNodeMarkup(around.listPos, schema.nodes[listType]); + return tr.docChanged; + } + setTextblocks(schema.nodes.paragraph, null)(tr); + const listType = schema.nodes[kind === 'checklist' ? 'bulletList' : kind]; + if (!viaCommand(wrapInList(listType))(tr)) return wrapWholeList(tr, listType); + if (kind === 'checklist') { + const after = listAround(tr); + if (after) + after.list.forEach((item, offset) => setChecked(tr, [{ node: item, pos: after.listPos + 1 + offset }], false)); + } + return true; + }; +} + +/** Wraps the selection's block range, widened to whole list items when it starts inside one. */ +function wrapRange(tr: Transaction, wrapper: NodeType, attrs: Record | null = null): NodeRange | null { + const { $from, $to } = tr.selection; + let range = $from.blockRange($to); + if (range && range.parent.type.name === 'listItem' && range.startIndex === 0) { + const listDepth = ancestorDepth($from, LISTS); + range = $from.blockRange($to, (node) => node === $from.node(listDepth - 1)) ?? range; + } + if (!range) return null; + const wrapping = findWrapping(range, wrapper, attrs); + if (!wrapping) return null; + tr.wrap(range, wrapping); + return range; +} + +function wrapWholeList(tr: Transaction, listType: NodeType): boolean { + return wrapRange(tr, listType) !== null; +} + +/** Lifts the selection out of the innermost wrapper of this type. */ +function liftOutOf(name: string): Change { + return (tr) => { + const { $from, $to } = tr.selection; + const depth = ancestorDepth($from, [name]); + if (depth < 0) return false; + if (name === 'callout') return unwrapCallout(tr, $from.before(depth)); + const range = $from.blockRange($to, (node) => node.type.name === name); + const target = range && liftTarget(range); + if (!range || target === null || target === undefined) return false; + tr.lift(range, target); + return true; + }; +} + +/** Replaces a callout with its title as a paragraph, then its body. */ +function unwrapCallout(tr: Transaction, pos: number): boolean { + const callout = tr.doc.nodeAt(pos); + if (!callout) return false; + const schema = tr.doc.type.schema; + const title = callout.firstChild; + const blocks: PMNode[] = []; + if (title && title.content.size > 0) blocks.push(schema.nodes.paragraph.create(null, title.content)); + callout.forEach((child, _offset, index) => { + if (index > 0) blocks.push(child); + }); + if (blocks.length === 0) blocks.push(schema.nodes.paragraph.create()); + const keptTitle = Boolean(title && title.content.size > 0); + const shift = keptTitle ? 1 : 3; + const caret = Math.max(pos + 1, tr.selection.from - shift); + tr.replaceWith(pos, pos + callout.nodeSize, Fragment.from(blocks)); + tr.setSelection(TextSelection.near(tr.doc.resolve(Math.min(caret, tr.doc.content.size)))); + return true; +} + +/** Wraps the selected blocks in a callout of the type, with an empty title. */ +export function wrapInCallout(type = 'note'): Change { + return (tr) => { + if (ancestorDepth(tr.selection.$from, ['calloutTitle']) >= 0) return false; + const schema = tr.doc.type.schema; + const { $from, $to } = tr.selection; + let range = $from.blockRange($to); + if (range && range.parent.type.name === 'listItem' && range.startIndex === 0) { + const listDepth = ancestorDepth($from, LISTS); + range = $from.blockRange($to, (node) => node === $from.node(listDepth - 1)); + } + if (!range || !range.parent.canReplaceWith(range.startIndex, range.endIndex, schema.nodes.callout)) return false; + const blocks: PMNode[] = []; + for (let index = range.startIndex; index < range.endIndex; index += 1) blocks.push(range.parent.child(index)); + const callout = schema.nodes.callout.create({ type, fold: '' }, [schema.nodes.calloutTitle.create(), ...blocks]); + const offset = tr.selection.from - range.start; + tr.replaceWith(range.start, range.end, callout); + const titleSize = 2; + tr.setSelection(TextSelection.near(tr.doc.resolve(range.start + 1 + titleSize + offset))); + return true; + }; +} + +/** Converts the selection to the kind, keeping the text. A kind it already is changes nothing. */ +export function turnIntoChange(kind: BlockKind): Change { + return (tr) => { + const schema = tr.doc.type.schema; + const textblock = textblockKind(kind); + if (textblock) { + // Quote is a kind, so other kinds leave it; a callout is a container, so its blocks stay inside. + const lifted = [liftOutOfLists(tr), liftOutOf('blockquote')(tr)].some(Boolean); + const set = setTextblocks(schema.nodes[textblock.type], textblock.attrs)(tr); + return lifted || set; + } + if (kind === 'bulletList' || kind === 'orderedList' || kind === 'checklist') { + if (inListKind(tr, kind)) return false; + liftOutOf('blockquote')(tr); + return toList(kind)(tr); + } + // A quote or callout holds plain paragraphs, so the result is exactly the kind chosen. + const wrapper = kind === 'quote' ? 'blockquote' : 'callout'; + if (ancestorDepth(tr.selection.$from, [wrapper]) >= 0) return false; + liftOutOfLists(tr); + setTextblocks(schema.nodes.paragraph, null)(tr); + return kind === 'quote' ? wrapQuote(tr) : wrapInCallout()(tr); + }; +} + +export function turnInto(kind: BlockKind): Command { + return fromChange(turnIntoChange(kind)); +} + +function wrapQuote(tr: Transaction): boolean { + if (ancestorDepth(tr.selection.$from, ['calloutTitle']) >= 0) return false; + return wrapRange(tr, tr.doc.type.schema.nodes.blockquote) !== null; +} + +/** + * The shortcut form: a list, quote, or heading kind the selection already is goes back to text; anything else + * turns into the kind. + */ +export function toggleKind(kind: BlockKind): Command { + return fromChange((tr) => { + if (kind === 'bulletList' || kind === 'orderedList' || kind === 'checklist') { + if (!inListKind(tr, kind)) return toList(kind)(tr); + return viaCommand(liftListItem(tr.doc.type.schema.nodes.listItem))(tr); + } + if (kind === 'quote') { + return ancestorDepth(tr.selection.$from, ['blockquote']) >= 0 ? liftOutOf('blockquote')(tr) : wrapQuote(tr); + } + if (kind === 'callout') { + return ancestorDepth(tr.selection.$from, ['callout']) >= 0 ? liftOutOf('callout')(tr) : wrapInCallout()(tr); + } + const textblock = textblockKind(kind); + if (!textblock) return false; + const blocks = selectedTextblocks(tr).filter(({ node }) => node.type.name !== 'calloutTitle'); + const already = + blocks.length > 0 && + blocks.every( + ({ node }) => + node.type.name === textblock.type && (!textblock.attrs || node.attrs.level === textblock.attrs.level), + ); + if (already && kind !== 'paragraph') return setTextblocks(tr.doc.type.schema.nodes.paragraph, null)(tr); + return turnIntoChange(kind)(tr); + }); +} diff --git a/app/src/editor/diff/myers.ts b/app/src/editor/diff/myers.ts new file mode 100644 index 000000000..44dfe00e1 --- /dev/null +++ b/app/src/editor/diff/myers.ts @@ -0,0 +1,91 @@ +// Myers' O(ND) difference algorithm (ARCHITECTURE.md section 21; owner: WP7), over any two lists with an equality +// test. Compare versions uses it for a text block's paragraphs and for the words inside a changed paragraph. + +export type DiffOp = + { kind: 'equal'; a: number; b: number } | { kind: 'delete'; a: number } | { kind: 'insert'; b: number }; + +/** The furthest x reached on each diagonal k, for each number of edits d. */ +function forwardTrace(a: readonly T[], b: readonly T[], same: (x: T, y: T) => boolean): number[][] { + const max = a.length + b.length; + const offset = max + 1; + const v = new Array(2 * max + 3).fill(0); + const trace: number[][] = []; + for (let d = 0; d <= max; d += 1) { + trace.push(v.slice()); + for (let k = -d; k <= d; k += 2) { + const down = k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1]); + let x = down ? v[offset + k + 1] : v[offset + k - 1] + 1; + let y = x - k; + while (x < a.length && y < b.length && same(a[x], b[y])) { + x += 1; + y += 1; + } + v[offset + k] = x; + if (x >= a.length && y >= b.length) return trace; + } + } + return trace; +} + +/** The shortest edit script from `a` to `b`, in order: equal runs, deletions from `a`, and insertions from `b`. */ +export function myersDiff(a: readonly T[], b: readonly T[], same: (x: T, y: T) => boolean = Object.is): DiffOp[] { + const trace = forwardTrace(a, b, same); + const offset = a.length + b.length + 1; + const ops: DiffOp[] = []; + let x = a.length; + let y = b.length; + for (let d = trace.length - 1; d >= 0; d -= 1) { + const v = trace[d]; + const k = x - y; + const down = k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1]); + const prevK = down ? k + 1 : k - 1; + const prevX = v[offset + prevK]; + const prevY = prevX - prevK; + while (x > prevX && y > prevY) { + x -= 1; + y -= 1; + ops.push({ kind: 'equal', a: x, b: y }); + } + if (d > 0) { + if (down) ops.push({ kind: 'insert', b: (y -= 1) }); + else ops.push({ kind: 'delete', a: (x -= 1) }); + } + } + return ops.reverse(); +} + +/** Words and the spaces between them, so joining the pieces gives the text back. */ +export function wordPieces(text: string): string[] { + return text.match(/\s+|[\p{L}\p{N}'’_-]+|[^\s\p{L}\p{N}'’_-]/gu) ?? []; +} + +export type WordPart = { kind: 'same' | 'added' | 'removed'; text: string }; + +/** A word-level diff of two texts, with neighboring parts of the same kind joined. */ +export function diffWords(before: string, after: string): WordPart[] { + const a = wordPieces(before); + const b = wordPieces(after); + const parts: WordPart[] = []; + const push = (kind: WordPart['kind'], text: string) => { + const last = parts.at(-1); + if (last?.kind === kind) last.text += text; + else parts.push({ kind, text }); + }; + for (const op of myersDiff(a, b)) { + if (op.kind === 'equal') push('same', a[op.a]); + else if (op.kind === 'delete') push('removed', a[op.a]); + else push('added', b[op.b]); + } + return parts; +} + +/** The share of words two texts have in common, from 0 to 1, against the longer one. */ +export function wordSimilarity(before: string, after: string): number { + const words = (text: string) => wordPieces(text).filter((piece) => /[\p{L}\p{N}]/u.test(piece)); + const a = words(before.toLowerCase()); + const b = words(after.toLowerCase()); + const longest = Math.max(a.length, b.length); + if (!longest) return 1; + const common = myersDiff(a, b).filter((op) => op.kind === 'equal').length; + return common / longest; +} diff --git a/app/src/editor/diff/pageDiff.test.ts b/app/src/editor/diff/pageDiff.test.ts new file mode 100644 index 000000000..e67358b41 --- /dev/null +++ b/app/src/editor/diff/pageDiff.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { diffWords, myersDiff, wordSimilarity } from './myers'; +import { diffPages, diffText } from './pageDiff'; +import type { DiffBlock } from './pageDiff'; + +const text = (id: string, order: string, markdown: string, frame?: DiffBlock['frame']): DiffBlock => ({ + id, + type: 'text', + order, + frame, + data: { markdown }, +}); + +describe('myers', () => { + it('finds a shortest edit script that turns one list into the other', () => { + const a = [...'abcabba']; + const b = [...'cbabac']; + const ops = myersDiff(a, b); + expect(ops.filter((op) => op.kind !== 'equal')).toHaveLength(5); + const rebuilt = ops.flatMap((op) => (op.kind === 'equal' ? [a[op.a]] : op.kind === 'insert' ? [b[op.b]] : [])); + expect(rebuilt.join('')).toBe('cbabac'); + expect(myersDiff([], [])).toEqual([]); + }); + + it('diffs words and measures how many match', () => { + expect(diffWords('the quick fox', 'the slow fox')).toEqual([ + { kind: 'same', text: 'the ' }, + { kind: 'removed', text: 'quick' }, + { kind: 'added', text: 'slow' }, + { kind: 'same', text: ' fox' }, + ]); + expect(wordSimilarity('one two three four', 'one two three five')).toBe(0.75); + expect(wordSimilarity('one two', 'three four')).toBe(0); + }); +}); + +describe('page diff', () => { + it('pairs paragraphs whose words mostly match, and keeps the rest as added or removed', () => { + const changes = diffText( + 'One two three four.\n\nKeep this.\n\nGone entirely now.', + 'One two three five.\n\nKeep this.\n\nBrand new words here.', + ); + expect(changes.map((change) => change.status)).toEqual(['changed', 'same', 'removed', 'added']); + expect(changes[0].words?.filter((part) => part.kind !== 'same').map((part) => part.text)).toEqual(['four', 'five']); + expect(changes[3].after?.index).toBe(2); + }); + + it('aligns blocks by ID, keeps removed blocks in place, and notes moves', () => { + const before = { blocks: [text('a', 'a0', 'First.'), text('b', 'a1', 'Second.'), text('c', 'a2', 'Third.')] }; + const after = { + blocks: [text('c', 'a0', 'Third.'), text('a', 'a1', 'First, edited.'), text('d', 'a3', 'New.')], + }; + const diff = diffPages(before, after); + expect(diff.blocks.map((block) => [block.id, block.status, block.moved])).toEqual([ + ['c', 'same', false], + ['a', 'changed', true], + ['b', 'removed', false], + ['d', 'added', false], + ]); + expect(diff.counts).toEqual({ added: 1, removed: 1, changed: 1, moved: 1 }); + }); + + it('compares tables cell by cell and images by crop, size, and alt text', () => { + const table = (cells: Record, rows = ['r1', 'r2']): DiffBlock => ({ + id: 't', + type: 'table', + order: 'a0', + data: { + columns: [{ id: 'c1' }, { id: 'c2' }], + rows: rows.map((id) => ({ + id, + cells: Object.fromEntries( + ['c1', 'c2'].map((column) => [column, { markdown: cells[`${id}.${column}`] ?? '' }]), + ), + })), + }, + }); + const image = (alt: string, w: number): DiffBlock => ({ + id: 'i', + type: 'image', + order: 'a1', + frame: { w }, + data: { alt }, + }); + const diff = diffPages( + { blocks: [table({ 'r1.c1': 'x' }), image('A leaf', 100)] }, + { blocks: [table({ 'r1.c1': 'y' }, ['r1', 'r2', 'r3']), image('A green leaf', 200)] }, + ); + const [tableChange, imageChange] = diff.blocks; + expect(tableChange.kind === 'table' && tableChange.cells.map((cell) => [cell.row, cell.status])).toEqual([ + ['r1', 'changed'], + ['r3', 'added'], + ['r3', 'added'], + ]); + expect(tableChange.kind === 'table' && tableChange.rows).toEqual({ added: 1, removed: 0 }); + expect(imageChange.kind === 'image' && imageChange.notes).toEqual(['size', 'alt']); + }); +}); diff --git a/app/src/editor/diff/pageDiff.ts b/app/src/editor/diff/pageDiff.ts new file mode 100644 index 000000000..2d2d87ed2 --- /dev/null +++ b/app/src/editor/diff/pageDiff.ts @@ -0,0 +1,246 @@ +// Compares two versions of a page (ARCHITECTURE.md section 21; owner: WP7). Blocks match by ID. In a text block, the +// top-level blocks (paragraphs, headings, lists, quotes) are compared with Myers' diff over their Markdown; adjacent +// removed and added runs pair up as changed paragraphs when at least half their words match, with a word diff +// inside. Tables compare by row and column ID, cell by cell; images by crop, size, and alt text. Moves are noted. +import type { Node as PMNode } from '@tiptap/pm/model'; +import { createMarkdownCache, parseTextBlock, serializeTextBlock } from '../markdown'; +import { diffWords, myersDiff, wordSimilarity } from './myers'; +import type { WordPart } from './myers'; + +/** The parts of a page's JSON the comparison reads; PageJson fits. */ +export interface DiffBlock { + readonly id: string; + readonly type: string; + readonly order: string; + readonly frame?: { x?: number; y?: number; w?: number; h?: number; rotate?: number }; + readonly data: Record; +} + +export interface DiffPage { + readonly blocks: readonly DiffBlock[]; + readonly [key: string]: unknown; +} + +export type Status = 'same' | 'added' | 'removed' | 'changed'; + +/** A top-level block of a text block. Indexes are into each version's list of top-level blocks. */ +export interface ParagraphChange { + status: Status; + before: { index: number; markdown: string; node: PMNode } | null; + after: { index: number; markdown: string; node: PMNode } | null; + /** For a changed paragraph, the word diff of its text. */ + words: WordPart[] | null; +} + +export interface CellChange { + row: string; + column: string; + status: Status; + before: string | null; + after: string | null; +} + +export type BlockChange = + | { kind: 'text'; id: string; status: Status; moved: boolean; paragraphs: ParagraphChange[] } + | { kind: 'table'; id: string; status: Status; moved: boolean; cells: CellChange[]; rows: TableRows } + | { kind: 'image'; id: string; status: Status; moved: boolean; notes: ('crop' | 'size' | 'alt')[]; alt: string } + | { kind: 'other'; id: string; type: string; status: Status; moved: boolean }; + +export interface TableRows { + added: number; + removed: number; +} + +export interface PageDiff { + blocks: BlockChange[]; + counts: { added: number; removed: number; changed: number; moved: number }; + /** Strokes added and removed, when the page has ink. */ + ink: { added: number; removed: number } | null; +} + +const PAIRING = 0.5; +const cache = createMarkdownCache(); + +/** A text block's top-level blocks, each with its own Markdown. */ +export function topLevelBlocks(markdown: string): { markdown: string; node: PMNode }[] { + const doc = parseTextBlock(markdown); + const parts: { markdown: string; node: PMNode }[] = []; + doc.forEach((node) => { + parts.push({ markdown: serializeTextBlock(doc.type.create(null, [node]), cache).trim(), node }); + }); + return parts; +} + +const markdownOf = (block: DiffBlock | undefined) => { + const value = block?.data.markdown; + return typeof value === 'string' ? value : ''; +}; + +/** Pairs a run of removed paragraphs with the run of added ones after it, in order, when their words match. */ +function pairRuns(removed: ParagraphChange[], added: ParagraphChange[]): ParagraphChange[] { + const out: ParagraphChange[] = []; + let next = 0; + for (const gone of removed) { + const match = added.findIndex( + (item, index) => + index >= next && wordSimilarity(gone.before!.node.textContent, item.after!.node.textContent) >= PAIRING, + ); + if (match < 0) { + out.push(gone); + continue; + } + out.push(...added.slice(next, match)); + const pair = added[match]; + const words = diffWords(gone.before!.node.textContent, pair.after!.node.textContent); + out.push({ status: 'changed', before: gone.before, after: pair.after, words }); + next = match + 1; + } + return [...out, ...added.slice(next)]; +} + +export function diffText(before: string, after: string): ParagraphChange[] { + const a = topLevelBlocks(before); + const b = topLevelBlocks(after); + const ops = myersDiff(a, b, (x, y) => x.markdown === y.markdown); + const out: ParagraphChange[] = []; + let removed: ParagraphChange[] = []; + let added: ParagraphChange[] = []; + const flush = () => { + out.push(...pairRuns(removed, added)); + removed = []; + added = []; + }; + for (const op of ops) { + if (op.kind === 'delete') { + removed.push({ status: 'removed', before: { index: op.a, ...a[op.a] }, after: null, words: null }); + } else if (op.kind === 'insert') { + added.push({ status: 'added', before: null, after: { index: op.b, ...b[op.b] }, words: null }); + } else { + flush(); + out.push({ + status: 'same', + before: { index: op.a, ...a[op.a] }, + after: { index: op.b, ...b[op.b] }, + words: null, + }); + } + } + flush(); + return out; +} + +interface TableJson { + columns?: { id: string }[]; + rows?: { id: string; cells?: Record }[]; +} + +function diffTable( + before: DiffBlock | undefined, + after: DiffBlock | undefined, +): { cells: CellChange[]; rows: TableRows } { + const a = (before?.data ?? {}) as TableJson; + const b = (after?.data ?? {}) as TableJson; + const cell = (table: TableJson, row: string, column: string) => + table.rows?.find((r) => r.id === row)?.cells?.[column]?.markdown ?? null; + const rowIds = (table: TableJson) => (table.rows ?? []).map((row) => row.id); + const columnIds = (table: TableJson) => (table.columns ?? []).map((column) => column.id); + const rows = [...new Set([...rowIds(a), ...rowIds(b)])]; + const columns = [...new Set([...columnIds(a), ...columnIds(b)])]; + const cells: CellChange[] = []; + for (const row of rows) { + for (const column of columns) { + const was = cell(a, row, column); + const now = cell(b, row, column); + if (was === now) continue; + const status: Status = was === null ? 'added' : now === null ? 'removed' : 'changed'; + cells.push({ row, column, status, before: was, after: now }); + } + } + const counted = (from: TableJson, to: TableJson) => rowIds(from).filter((id) => !rowIds(to).includes(id)).length; + return { cells, rows: { added: counted(b, a), removed: counted(a, b) } }; +} + +function imageNotes(before: DiffBlock, after: DiffBlock): ('crop' | 'size' | 'alt')[] { + const notes: ('crop' | 'size' | 'alt')[] = []; + if (JSON.stringify(before.data.crop ?? null) !== JSON.stringify(after.data.crop ?? null)) notes.push('crop'); + if (before.frame?.w !== after.frame?.w || before.frame?.h !== after.frame?.h) notes.push('size'); + if ((before.data.alt ?? '') !== (after.data.alt ?? '')) notes.push('alt'); + return notes; +} + +/** Blocks whose place among the blocks both versions have changed, or whose position on the page changed. */ +function movedBlocks(before: DiffPage, after: DiffPage): Set { + const ids = (page: DiffPage, other: DiffPage) => + [...page.blocks] + .filter((block) => other.blocks.some((o) => o.id === block.id)) + .sort((x, y) => (x.order < y.order ? -1 : x.order > y.order ? 1 : 0)) + .map((block) => block.id); + const moved = new Set( + myersDiff(ids(before, after), ids(after, before)) + .filter((op) => op.kind === 'delete') + .map((op) => ids(before, after)[(op as { a: number }).a]), + ); + for (const block of after.blocks) { + const old = before.blocks.find((b) => b.id === block.id); + if (old && (old.frame?.x !== block.frame?.x || old.frame?.y !== block.frame?.y)) moved.add(block.id); + } + return moved; +} + +function compareBlock(before: DiffBlock | undefined, after: DiffBlock | undefined, moved: boolean): BlockChange { + const block = (after ?? before)!; + const presence: Status = !before ? 'added' : !after ? 'removed' : 'same'; + const settle = (changed: boolean): Status => (presence === 'same' && changed ? 'changed' : presence); + if (block.type === 'text') { + const paragraphs = diffText(markdownOf(before), markdownOf(after)); + return { + kind: 'text', + id: block.id, + moved, + paragraphs, + status: settle(paragraphs.some((p) => p.status !== 'same')), + }; + } + if (block.type === 'table') { + const { cells, rows } = diffTable(before, after); + return { kind: 'table', id: block.id, moved, cells, rows, status: settle(cells.length > 0) }; + } + if (block.type === 'image') { + const notes = before && after ? imageNotes(before, after) : []; + const alt = typeof block.data.alt === 'string' ? block.data.alt : ''; + return { kind: 'image', id: block.id, moved, notes, alt, status: settle(notes.length > 0) }; + } + const changed = JSON.stringify(before?.data) !== JSON.stringify(after?.data); + return { kind: 'other', id: block.id, type: block.type, moved, status: settle(changed) }; +} + +function strokeIds(page: DiffPage): string[] { + const ink = page.ink as { strokes?: { id?: string }[] } | undefined; + return (ink?.strokes ?? []).map((stroke, index) => stroke.id ?? String(index)); +} + +/** Compares `before` (an older version) with `after`, in `after`'s block order with removed blocks in place. */ +export function diffPages(before: DiffPage, after: DiffPage): PageDiff { + const moved = movedBlocks(before, after); + const byOrder = (x: DiffBlock, y: DiffBlock) => (x.order < y.order ? -1 : x.order > y.order ? 1 : 0); + const ordered = [...after.blocks, ...before.blocks.filter((b) => !after.blocks.some((a) => a.id === b.id))].sort( + byOrder, + ); + const blocks = ordered.map((block) => + compareBlock( + before.blocks.find((b) => b.id === block.id), + after.blocks.find((a) => a.id === block.id), + moved.has(block.id), + ), + ); + const count = (status: Status) => blocks.filter((block) => block.status === status).length; + const strokesBefore = strokeIds(before); + const strokesAfter = strokeIds(after); + const added = strokesAfter.filter((id) => !strokesBefore.includes(id)).length; + const removed = strokesBefore.filter((id) => !strokesAfter.includes(id)).length; + return { + blocks, + counts: { added: count('added'), removed: count('removed'), changed: count('changed'), moved: moved.size }, + ink: added || removed ? { added, removed } : null, + }; +} diff --git a/app/src/editor/extensions/autoChange.ts b/app/src/editor/extensions/autoChange.ts new file mode 100644 index 000000000..a1b3cb91a --- /dev/null +++ b/app/src/editor/extensions/autoChange.ts @@ -0,0 +1,95 @@ +// Automatic changes (ARCHITECTURE.md sections 8.3 and 10.3; owner: WP4). Markdown shortcuts and AutoCorrect change +// text the person typed. Each change is its own undo step, so one Ctrl+Z leaves the typed characters. +// +// The typed character goes in first, as typing. The change follows in its own transaction marked with +// META_AUTO_CHANGE, which the sync sends as its own step. Backspace right after the change, before anything else +// happens, reverts it the same way. +import { Extension } from '@tiptap/core'; +import type { Editor, Extensions } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import type { Step } from '@tiptap/pm/transform'; +import type { EditorView } from '@tiptap/pm/view'; +import type { EditorHost } from '../host'; +import { META_AUTO_CHANGE } from '../meta'; +import type { AutoChangeMeta } from '../meta'; +import { markdownShortcutsPlugin } from './markdownShortcuts'; + +/** Marks a transaction as an automatic change of `from` to `to` into `text`: its own undo step. */ +export function markAutoChange(tr: Transaction, change: AutoChangeMeta): Transaction { + return tr.setMeta(META_AUTO_CHANGE, { from: change.from, to: change.to, text: change.text }); +} + +interface Revertible { + steps: readonly Step[]; + docs: readonly PMNode[]; +} + +const REVERT = 'opennote.autoChangeRevert'; +export const autoChangeKey = new PluginKey('opennoteAutoChange'); + +/** The last automatic change after `tr`: its own steps, or nothing once anything else changes. */ +function remember(tr: Transaction, value: Revertible | null): Revertible | null { + if (tr.getMeta(META_AUTO_CHANGE) && tr.docChanged && !tr.getMeta(REVERT)) { + return { steps: tr.steps.slice(), docs: tr.docs.slice() }; + } + return tr.docChanged || tr.selectionSet ? null : value; +} + +/** Remembers the last automatic change until anything else changes the document or the selection. */ +function revertPlugin(): Plugin { + return new Plugin({ key: autoChangeKey, state: { init: () => null, apply: remember } }); +} + +/** Undoes the last automatic change, if nothing happened since. Returns whether it did. */ +export function revertAutoChange(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + const last = autoChangeKey.getState(state); + if (!last) return false; + if (dispatch) { + const tr = state.tr; + for (let index = last.steps.length - 1; index >= 0; index -= 1) { + tr.step(last.steps[index].invert(last.docs[index])); + } + const at = tr.mapping.map(state.selection.from); + dispatch(markAutoChange(tr, { from: at, to: at, text: '' }).setMeta(REVERT, true).scrollIntoView()); + } + return true; +} + +/** Types `text` over `from` to `to` as ordinary typing, then dispatches `change` of the result, if any, on its own. */ +export function typeThenChange( + view: EditorView, + typed: { from: number; to: number; text: string }, + change: (state: EditorState) => { tr: Transaction; meta: AutoChangeMeta } | null, +): void { + view.dispatch(view.state.tr.insertText(typed.text, typed.from, typed.to)); + const made = change(view.state); + if (made) view.dispatch(markAutoChange(made.tr, made.meta).scrollIntoView()); +} + +/** Whether automatic changes may run at the position: not in code, links, or math, and not while composing. */ +export function canAutoChange(view: EditorView, pos: number): boolean { + if (view.composing) return false; + const $pos = view.state.doc.resolve(pos); + if (!$pos.parent.isTextblock || $pos.parent.type.spec.code) return false; + const marks = [...$pos.marks(), ...(view.state.storedMarks ?? [])]; + return !marks.some((mark) => mark.type.name === 'link' || mark.type.spec.code); +} + +export function autoChangeExtensions(host: EditorHost): Extensions { + return [ + Extension.create({ + name: 'opennoteAutoChange', + // Before the list and callout keys, so Backspace right after "- " reverts the list rather than lifting it. + priority: 1000, + addProseMirrorPlugins: () => [revertPlugin(), markdownShortcutsPlugin(host)], + addKeyboardShortcuts() { + const editor: Editor = this.editor; + return { + Backspace: () => editor.state.selection.empty && revertAutoChange(editor.state, editor.view.dispatch), + }; + }, + }), + ]; +} diff --git a/app/src/editor/extensions/autocorrect.test.ts b/app/src/editor/extensions/autocorrect.test.ts new file mode 100644 index 000000000..144847d06 --- /dev/null +++ b/app/src/editor/extensions/autocorrect.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +// AutoCorrect in a mounted editor: replacements when a word ends, capitals kept, each one its own change that +// Backspace reverts, nothing in code, and the person's list replacing the built-in one. +import type { Editor } from '@tiptap/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { META_AUTO_CHANGE } from '../meta'; +import { mountEditor, testHost, typeInto } from '../commands/testing'; +import type { TestEditor } from '../commands/testing'; +import { DEFAULT_REPLACEMENTS, correctionFor } from './autocorrect'; + +let mounted: TestEditor | null = null; +afterEach(() => { + mounted?.destroy(); + mounted = null; +}); + +function mount(source: string, host = testHost()): TestEditor { + mounted = mountEditor(source, host); + return mounted; +} + +function press(editor: Editor, key: string): boolean { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + return editor.view.someProp('handleKeyDown', (handle) => handle(editor.view, event)) ?? false; +} + +describe('AutoCorrect', () => { + it('knows the built-in replacements and keeps capitals', () => { + expect(correctionFor('teh', DEFAULT_REPLACEMENTS)).toBe('the'); + expect(correctionFor('Teh', DEFAULT_REPLACEMENTS)).toBe('The'); + expect(correctionFor('TEH', DEFAULT_REPLACEMENTS)).toBe('THE'); + expect(correctionFor('(tm)', DEFAULT_REPLACEMENTS)).toBe('™'); + expect(correctionFor('the', DEFAULT_REPLACEMENTS)).toBeNull(); + }); + + it('replaces a word when it ends, as its own change after the typed space', () => { + const page = mount('[]'); + const auto: boolean[] = []; + page.editor.on('transaction', ({ transaction }) => { + if (transaction.docChanged) auto.push(Boolean(transaction.getMeta(META_AUTO_CHANGE))); + }); + typeInto(page.editor, 'Teh cat -> dog (c) '); + expect(page.editor.state.doc.textContent).toBe('The cat → dog © '); + expect(auto.filter(Boolean)).toHaveLength(3); + }); + + it('reverts on Backspace right after', () => { + const page = mount('[]'); + typeInto(page.editor, 'teh '); + expect(press(page.editor, 'Backspace')).toBe(true); + expect(page.editor.state.doc.textContent).toBe('teh '); + }); + + it('corrects before Enter splits the line', () => { + const page = mount('[]'); + typeInto(page.editor, 'teh'); + press(page.editor, 'Enter'); + expect(page.editor.state.doc.firstChild?.textContent).toBe('the'); + }); + + it('leaves code alone, and stops when turned off', () => { + const code = mount('```\n[]\n```'); + typeInto(code.editor, 'teh '); + expect(code.editor.state.doc.textContent).toBe('teh '); + code.destroy(); + const host = testHost(); + host.editing = { ...host.editing, autocorrect: { enabled: false, entries: [] } }; + const off = mount('[]', host); + typeInto(off.editor, 'teh '); + expect(off.editor.state.doc.textContent).toBe('teh '); + }); + + it('uses the person’s own list in place of the built-in one', () => { + const host = testHost(); + host.editing = { ...host.editing, autocorrect: { enabled: true, entries: [{ from: 'omw', to: 'on my way' }] } }; + const page = mount('[]', host); + typeInto(page.editor, 'omw teh '); + expect(page.editor.state.doc.textContent).toBe('on my way teh '); + }); +}); diff --git a/app/src/editor/extensions/autocorrect.ts b/app/src/editor/extensions/autocorrect.ts new file mode 100644 index 000000000..80af4962c --- /dev/null +++ b/app/src/editor/extensions/autocorrect.ts @@ -0,0 +1,152 @@ +// AutoCorrect (ARCHITECTURE.md section 18.3; owner: WP4). When a word ends, with a space, punctuation, or Enter, +// the word before is replaced from the person's list, or the built-in one when theirs is empty. Arrows, symbols, +// dashes, and about thirty common typos are built in. +// +// Each correction is an automatic change, its own undo step, and Backspace right after it reverts it. Nothing +// changes in code, links, or math, or while composing. +import { Extension } from '@tiptap/core'; +import type { Extensions } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import type { EditorState } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; +import type { EditorHost } from '../host'; +import { canAutoChange, markAutoChange, typeThenChange } from './autoChange'; + +export interface Replacement { + from: string; + to: string; +} + +const TYPOS: readonly [string, string][] = [ + ['teh', 'the'], + ['adn', 'and'], + ['taht', 'that'], + ['thier', 'their'], + ['recieve', 'receive'], + ['recieved', 'received'], + ['beleive', 'believe'], + ['seperate', 'separate'], + ['definately', 'definitely'], + ['occured', 'occurred'], + ['occurence', 'occurrence'], + ['untill', 'until'], + ['wich', 'which'], + ['becuase', 'because'], + ['beacuse', 'because'], + ['acheive', 'achieve'], + ['accomodate', 'accommodate'], + ['adress', 'address'], + ['begining', 'beginning'], + ['calender', 'calendar'], + ['commited', 'committed'], + ['enviroment', 'environment'], + ['existance', 'existence'], + ['goverment', 'government'], + ['independant', 'independent'], + ['neccessary', 'necessary'], + ['noticable', 'noticeable'], + ['publically', 'publicly'], + ['tommorow', 'tomorrow'], + ['truely', 'truly'], + ['wierd', 'weird'], + ['youre', 'you’re'], +]; + +/** The built-in list, which a person's own list replaces once they edit it. */ +export const DEFAULT_REPLACEMENTS: readonly Replacement[] = [ + { from: '->', to: '→' }, + { from: '<-', to: '←' }, + { from: '=>', to: '⇒' }, + { from: '<=>', to: '⇔' }, + { from: '(c)', to: '©' }, + { from: '(r)', to: '®' }, + { from: '(tm)', to: '™' }, + { from: '--', to: '–' }, + { from: '...', to: '…' }, + ...TYPOS.map(([from, to]) => ({ from, to })), +]; + +/** The characters that end a word. */ +const ENDS = /^[\s.,;:!?)\]}"”’]$/; + +/** The replacement for the token before a word end, keeping a capital for typos: "Teh" becomes "The". */ +export function correctionFor(token: string, list: readonly Replacement[]): string | null { + const exact = list.find((entry) => entry.from === token); + if (exact) return exact.to; + const lower = token.toLowerCase(); + const folded = list.find((entry) => /^[a-z]+$/.test(entry.from) && entry.from === lower); + if (!folded) return null; + if (token === token.toUpperCase() && token.length > 1) return folded.to.toUpperCase(); + return token[0] === token[0].toUpperCase() ? folded.to[0].toUpperCase() + folded.to.slice(1) : folded.to; +} + +function listOf(host: EditorHost): readonly Replacement[] | null { + const { autocorrect } = host.settings(); + if (!autocorrect.enabled || !host.flag('page.typingHelpers')) return null; + return autocorrect.entries.length > 0 ? autocorrect.entries : DEFAULT_REPLACEMENTS; +} + +/** The token that ends at `pos`: back to the last space in the textblock. */ +function tokenBefore(state: EditorState, pos: number): { from: number; text: string } | null { + const $pos = state.doc.resolve(pos); + if (!$pos.parent.isTextblock) return null; + const before = $pos.parent.textBetween(Math.max(0, $pos.parentOffset - 40), $pos.parentOffset, undefined, ''); + const text = /(\S+)$/.exec(before)?.[1]; + return text && !text.includes('') ? { from: pos - text.length, text } : null; +} + +/** The correction for the word ending at `pos`, as an automatic change, or null. */ +function correctionAt(state: EditorState, pos: number, list: readonly Replacement[]) { + const token = tokenBefore(state, pos); + if (!token) return null; + // The longest suffix of the token that has a correction: "(c)" in "x(c)", "teh" in "(teh". + for (let start = 0; start < token.text.length; start += 1) { + const piece = token.text.slice(start); + const to = correctionFor(piece, list); + if (to === null || (start > 0 && /\w/.test(token.text[start - 1]) && /^\w/.test(piece))) continue; + const from = token.from + start; + const tr = state.tr.insertText(to, from, pos); + return { tr, meta: { from, to: pos, text: to } }; + } + return null; +} + +function onWordEnd(view: EditorView, host: EditorHost, from: number, to: number, text: string): boolean { + if (!ENDS.test(text) || from !== to || !canAutoChange(view, from)) return false; + const list = listOf(host); + if (!list || !correctionAt(view.state, from, list)) return false; + typeThenChange(view, { from, to, text }, (state) => correctionAt(state, from, list)); + return true; +} + +/** Enter ends a word too: correct first, as its own change, and let Enter go on. */ +function onEnter(view: EditorView, host: EditorHost): boolean { + const { from, empty } = view.state.selection; + const list = listOf(host); + if (!empty || !list || !canAutoChange(view, from)) return false; + const made = correctionAt(view.state, from, list); + if (made) view.dispatch(markAutoChange(made.tr, made.meta)); + return false; +} + +export const autocorrectKey = new PluginKey('opennoteAutocorrect'); + +export function autocorrectExtensions(host: EditorHost): Extensions { + return [ + Extension.create({ + name: 'opennoteAutocorrect', + // After the Markdown shortcuts, which see the same keys first, and before Enter splits the block. + priority: 950, + addProseMirrorPlugins: () => [ + new Plugin({ + key: autocorrectKey, + props: { + handleTextInput: (view, from, to, text) => onWordEnd(view, host, from, to, text), + handleKeyDown: (view, event) => + event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.altKey && onEnter(view, host), + }, + }), + ], + }), + ]; +} diff --git a/app/src/editor/extensions/callout.ts b/app/src/editor/extensions/callout.ts new file mode 100644 index 000000000..64087bbbf --- /dev/null +++ b/app/src/editor/extensions/callout.ts @@ -0,0 +1,165 @@ +// Callouts (ARCHITECTURE.md section 8.2; owner: WP4): a note named by its type and title, a type menu, and the +// fold button of a foldable callout. A callout's fold is content (SPEC 7.2's `[!type]-`), so the button changes the +// Markdown in one undo step and folds it for everyone. Backspace at the start of the title unwraps the callout. +import type { Editor, Extensions } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { TextSelection } from '@tiptap/pm/state'; +import type { NodeView, ViewMutationRecord } from '@tiptap/pm/view'; +import { t } from '../../strings/t'; +import type { MessageKey } from '../../strings/t'; +import { fromChange, runCommand } from '../commands/command'; +import type { EditorHost } from '../host'; +import { META_COMMAND } from '../meta'; +import { CALLOUT_TYPES } from '../schema/constants'; +import { Callout } from '../schema/nodes'; +import styles from './content.module.css'; + +/** The type's name in words; an unknown type reads as a note (SPEC 7.2). */ +export function calloutTypeName(type: string): string { + const known = (CALLOUT_TYPES as readonly string[]).includes(type) ? type : 'note'; + return t(`editor.callout.types.${known}` as MessageKey); +} + +/** "Warning: Mind the gap", or the type alone when the title is empty. */ +export function calloutName(node: PMNode): string { + const type = calloutTypeName(node.attrs.type as string); + const title = node.firstChild?.textContent.trim() ?? ''; + return title ? t('editor.callout.named', { type, title }) : type; +} + +function button(className: string, onPress: () => void): HTMLButtonElement { + const element = document.createElement('button'); + element.type = 'button'; + element.tabIndex = -1; + element.className = className; + element.addEventListener('mousedown', (event) => event.preventDefault()); + element.addEventListener('click', onPress); + return element; +} + +class CalloutView implements NodeView { + dom: HTMLDivElement; + contentDOM: HTMLDivElement; + private typeButton: HTMLButtonElement; + private foldButton: HTMLButtonElement; + + constructor( + private node: PMNode, + private editor: Editor, + private getPos: () => number | undefined, + private host: EditorHost, + ) { + this.dom = document.createElement('div'); + this.dom.setAttribute('role', 'note'); + this.dom.className = styles.callout; + const controls = document.createElement('span'); + controls.contentEditable = 'false'; + controls.className = styles.calloutControls; + this.typeButton = button(styles.calloutType, () => void this.chooseType()); + this.typeButton.setAttribute('aria-haspopup', 'menu'); + this.foldButton = button(styles.foldButton, () => this.toggleFold()); + controls.append(this.typeButton, this.foldButton); + this.contentDOM = document.createElement('div'); + this.contentDOM.className = styles.calloutBody; + this.dom.append(controls, this.contentDOM); + this.render(); + } + + private render(): void { + const { type, fold } = this.node.attrs as { type: string; fold: string }; + const name = calloutName(this.node); + this.dom.dataset.callout = type; + if (fold) this.dom.dataset.fold = fold; + else delete this.dom.dataset.fold; + this.dom.setAttribute('aria-label', name); + this.typeButton.setAttribute('aria-label', t('editor.callout.typeButton', { type: calloutTypeName(type) })); + this.typeButton.hidden = !this.editor.isEditable; + this.foldButton.hidden = fold === ''; + this.foldButton.setAttribute('aria-expanded', String(fold !== '-')); + const title = this.node.firstChild?.textContent.trim() || calloutTypeName(type); + this.foldButton.setAttribute( + 'aria-label', + t(fold === '-' ? 'editor.fold.expand' : 'editor.fold.collapse', { name: title }), + ); + } + + private setAttrs(attrs: Record): void { + const pos = this.getPos(); + if (pos === undefined || !this.editor.isEditable) return; + const tr = this.editor.state.tr.setNodeMarkup(pos, undefined, { ...this.node.attrs, ...attrs }); + this.editor.view.dispatch(tr.setMeta(META_COMMAND, true)); + } + + private toggleFold(): void { + const folded = this.node.attrs.fold === '-'; + this.setAttrs({ fold: folded ? '+' : '-' }); + this.host.announce( + t(folded ? 'editor.fold.expanded' : 'editor.fold.collapsedCallout', { name: calloutName(this.node) }), + ); + } + + private async chooseType(): Promise { + const current = this.node.attrs.type as string; + const items = CALLOUT_TYPES.map((type) => ({ + id: type, + label: calloutTypeName(type), + kind: 'radio' as const, + checked: type === current, + })); + const chosen = await this.host.openMenu({ label: t('editor.callout.typeMenu'), items, anchor: this.typeButton }); + if (chosen && chosen !== current) this.setAttrs({ type: chosen }); + } + + update(node: PMNode): boolean { + if (node.type !== this.node.type) return false; + this.node = node; + this.render(); + return true; + } + + stopEvent(event: Event): boolean { + return ( + event.target instanceof Element && event.target.closest('button') !== null && this.dom.contains(event.target) + ); + } + + ignoreMutation(mutation: ViewMutationRecord): boolean { + return mutation.type !== 'selection' && !this.contentDOM.contains(mutation.target); + } +} + +/** Backspace at the very start of a callout's title turns the callout back into text. */ +function unwrapAtTitleStart(editor: Editor): boolean { + const { $from, empty } = editor.state.selection; + if (!empty || $from.parentOffset !== 0 || $from.parent.type.name !== 'calloutTitle') return false; + const pos = $from.before(-1); + return runCommand( + editor, + fromChange((tr) => { + const callout = tr.doc.nodeAt(pos); + if (!callout) return false; + const schema = tr.doc.type.schema; + const title = callout.firstChild; + const blocks: PMNode[] = [schema.nodes.paragraph.create(null, title?.content)]; + callout.forEach((child, _offset, index) => { + if (index > 0) blocks.push(child); + }); + tr.replaceWith(pos, pos + callout.nodeSize, blocks); + tr.setSelection(TextSelection.create(tr.doc, pos + 1)); + return true; + }), + ); +} + +export function calloutExtensions(host: EditorHost): Extensions { + return [ + Callout.extend({ + addNodeView() { + return ({ node, editor, getPos }) => new CalloutView(node, editor, getPos, host); + }, + addKeyboardShortcuts() { + return { Backspace: () => unwrapAtTitleStart(this.editor) }; + }, + }), + ]; +} diff --git a/app/src/editor/extensions/codeBlock.test.ts b/app/src/editor/extensions/codeBlock.test.ts new file mode 100644 index 000000000..44b87cb60 --- /dev/null +++ b/app/src/editor/extensions/codeBlock.test.ts @@ -0,0 +1,150 @@ +// @vitest-environment jsdom +// Code blocks in a text editor (PLAN.md section 10.5). The language button is named for its language, and Tab +// indents by the block's indent width. Enter keeps the indent. Three Enters, Arrow Down at the end, and Ctrl+Enter +// leave the block, and the caret's place puts the editor in the "editor.code" key scope. +import type { Editor } from '@tiptap/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { indentUnit } from '../commands/code'; +import { setLanguagePicker } from '../highlight/picker'; +import { DEFAULT_EDITING_VIEW } from '../host'; +import type { EditorHost } from '../host'; +import { createMarkdownCache, parseTextBlock, serializeTextBlock } from '../markdown'; +import { createBlockEditor } from './kit'; + +// jsdom has no layout, and ProseMirror measures a Range when it scrolls the caret into view. +Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList; +Range.prototype.getBoundingClientRect ??= () => new DOMRect(); + +const host: EditorHost = { + settings: () => DEFAULT_EDITING_VIEW, + flag: () => false, + announce: vi.fn(), + openMenu: () => Promise.resolve(null), + screenReader: () => false, + spelling: () => null, + selectBlocks: () => {}, +}; + +const editors: Editor[] = []; +afterEach(() => { + editors.splice(0).forEach((editor) => editor.destroy()); + document.body.replaceChildren(); + setLanguagePicker(null); +}); + +/** An editor holding `markdown`, with the caret at the end of the code block's text (or `at` into it). */ +function codeEditor(markdown: string, at?: number): Editor { + const root = document.body.appendChild(document.createElement('div')); + const editor = createBlockEditor(root, parseTextBlock(markdown), { kind: 'text', block: 'b1', host }); + editors.push(editor); + let start = -1; + editor.state.doc.descendants((node, pos) => { + if (start === -1 && node.type.name === 'codeBlock') start = pos + 1; + return start === -1; + }); + const code = editor.state.doc.nodeAt(start - 1)!; + editor.commands.setTextSelection(start + (at ?? code.content.size)); + return editor; +} + +function press(editor: Editor, key: string, options: KeyboardEventInit = {}): boolean { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...options }); + return editor.view.someProp('handleKeyDown', (handle) => handle(editor.view, event)) ?? false; +} + +const markdownOf = (editor: Editor) => serializeTextBlock(editor.state.doc, createMarkdownCache()); + +describe('the language button', () => { + it('is named for the language, out of the text, and opens the picker', () => { + const editor = codeEditor('```py\nprint(1)\n```'); + const button = editor.view.dom.querySelector('button')!; + expect(button.getAttribute('aria-label')).toBe('Code language: Python'); + expect(button.textContent).toBe('Python'); + expect(button.getAttribute('contenteditable')).toBe('false'); + expect(editor.view.dom.querySelector('code')!.getAttribute('spellcheck')).toBe('false'); + const picker = vi.fn(); + setLanguagePicker(picker); + button.click(); + expect(picker).toHaveBeenCalledWith(expect.objectContaining({ pos: 0, language: 'py', anchor: button })); + }); + + it('says Plain text without a language', () => { + const editor = codeEditor('```\nplain\n```'); + expect(editor.view.dom.querySelector('button')!.getAttribute('aria-label')).toBe('Code language: Plain text'); + }); +}); + +describe('code keys', () => { + it('finds the block’s indent width from its lines, then its language', () => { + expect(indentUnit('a\n b\n c', null)).toBe(' '); + expect(indentUnit('a\n b', 'javascript')).toBe(' '); + expect(indentUnit('a\n\tb', null)).toBe('\t'); + expect(indentUnit('a', 'typescript')).toBe(' '); + expect(indentUnit('a', 'python')).toBe(' '); + }); + + it('indents and outdents the caret’s line with Tab and Shift+Tab', () => { + const editor = codeEditor('```python\nif x:\nreturn 1\n```'); + expect(press(editor, 'Tab')).toBe(true); + expect(markdownOf(editor)).toBe('```python\nif x:\n return 1\n```'); + press(editor, 'Tab', { shiftKey: true }); + expect(markdownOf(editor)).toBe('```python\nif x:\nreturn 1\n```'); + }); + + it('indents every selected line', () => { + const editor = codeEditor('```js\na\nb\nc\n```'); + const start = 1; + editor.commands.setTextSelection({ from: start, to: start + 'a\nb\nc'.length }); + press(editor, 'Tab'); + expect(markdownOf(editor)).toBe('```js\n a\n b\n c\n```'); + press(editor, 'Tab', { shiftKey: true }); + expect(markdownOf(editor)).toBe('```js\na\nb\nc\n```'); + }); + + it('keeps the line’s indent on Enter, and leaves on the third Enter at the end', () => { + const editor = codeEditor('```js\n a\n```'); + press(editor, 'Enter'); + expect(editor.state.doc.firstChild!.textContent).toBe(' a\n '); + press(editor, 'Enter'); + press(editor, 'Enter'); + expect(editor.state.doc.firstChild!.textContent).toBe(' a'); + expect(editor.state.selection.$head.parent.type.name).toBe('paragraph'); + expect(editor.state.doc.childCount).toBe(2); + }); + + it('leaves with Ctrl+Enter from anywhere in the block', () => { + const editor = codeEditor('```js\none\ntwo\n```\n\nafter', 1); + expect(press(editor, 'Enter', { ctrlKey: true })).toBe(true); + expect(editor.state.selection.$head.parent.type.name).toBe('paragraph'); + expect(editor.state.doc.child(1).content.size).toBe(0); + expect(editor.state.doc.child(0).textContent).toBe('one\ntwo'); + }); + + it('leaves with Arrow Down at the end of a block that ends the text box', () => { + const editor = codeEditor('```js\nlast\n```'); + expect(press(editor, 'ArrowDown')).toBe(true); + expect(editor.state.doc.childCount).toBe(2); + expect(editor.state.selection.$head.parent.type.name).toBe('paragraph'); + }); + + it('puts the editor in the editor.code scope while the caret is in code', () => { + const editor = codeEditor('```js\nx\n```\n\nafter'); + expect(editor.view.dom.getAttribute('data-scope')).toBe('editor.code'); + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + expect(editor.view.dom.getAttribute('data-scope')).toBeNull(); + }); + + it('makes a code block from three backticks and a language name', async () => { + const editor = codeEditor('```js\nx\n```\n\nafter'); + editor.commands.setTextSelection(editor.state.doc.content.size - 'after'.length - 1); + for (const char of '```ts ') { + editor.commands.insertContent(char, { applyInputRules: true }); + // Tiptap runs input rules for inserted content in a task of their own. + await new Promise((resolve) => setTimeout(resolve, 0)); + } + const made = editor.state.doc.child(1); + expect(made.type.name).toBe('codeBlock'); + expect(made.attrs.language).toBe('ts'); + expect(made.textContent).toBe('after'); + }); +}); diff --git a/app/src/editor/extensions/codeBlock.ts b/app/src/editor/extensions/codeBlock.ts new file mode 100644 index 000000000..5fe3a0a6d --- /dev/null +++ b/app/src/editor/extensions/codeBlock.ts @@ -0,0 +1,165 @@ +// Code blocks (ARCHITECTURE.md sections 10.2 and 14; owner: WP6). The node keeps the schema's definition and adds a +// view with a language button, the keys of section 22.4, and the "editor.code" scope while the caret is in code. +// The highlight plugin comes along while page.codeHighlight is on; its highlighter loads when code needs colors. +import { Extension, InputRule } from '@tiptap/core'; +import type { Editor, Extensions, NodeViewRenderer } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import type { EditorState } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; +import { t } from '../../strings/t'; +import { + arrowDownLeave, + cleanLanguage, + codeBlockAt, + indentCode, + leaveCode, + newlineKeepIndent, + setCodeLanguage, + tripleEnterLeave, +} from '../commands/code'; +import type { Command } from '../commands/code'; +import styles from '../highlight/code.module.css'; +import { CODE_LANGUAGES, languageName } from '../highlight/languages'; +import { languagePicker } from '../highlight/picker'; +import { highlightPlugin } from '../highlight/plugin'; +import type { EditorHost } from '../host'; +import { CodeBlock } from '../schema/nodes'; + +/** Opens the language picker for the code block at `pos`, and sets the language chosen. */ +export function pickCodeLanguage(view: EditorView, pos: number, anchor: HTMLElement, host: EditorHost): void { + const language = (view.state.doc.nodeAt(pos)?.attrs.language as string | null) ?? null; + const picker = languagePicker(); + if (picker) return picker({ view, pos, anchor, language }); + const items = [ + { id: '', label: t('code.language.plain'), kind: 'radio' as const, checked: !language }, + ...CODE_LANGUAGES.map((one) => ({ + id: one.id, + label: one.name, + kind: 'radio' as const, + checked: one.id === language, + })), + ]; + void host.openMenu({ label: t('code.language.picker'), items, anchor }).then((chosen) => { + if (chosen === null || view.isDestroyed) return; + applyLanguage(view, pos, chosen || null, host); + }); +} + +/** Sets a code block's language and says so. */ +export function applyLanguage( + view: EditorView, + pos: number, + language: string | null, + host: Pick, +): boolean { + const done = setCodeLanguage(pos, language)(view.state, view.dispatch); + if (done) + host.announce(t('code.language.set', { name: languageName(cleanLanguage(language)) ?? t('code.language.plain') })); + view.focus(); + return done; +} + +const buttonLabel = (language: string | null) => languageName(language) ?? t('code.language.plain'); + +function codeBlockView(host: EditorHost): NodeViewRenderer { + return ({ node, getPos, editor }) => { + let current = node; + const dom = document.createElement('pre'); + dom.className = styles.block; + const button = dom.appendChild(document.createElement('button')); + button.type = 'button'; + button.setAttribute('contenteditable', 'false'); + button.className = styles.language; + button.dataset.codeLanguage = ''; + const code = dom.appendChild(document.createElement('code')); + code.setAttribute('spellcheck', 'false'); + const show = (shown: PMNode) => { + const language = (shown.attrs.language as string | null) ?? null; + const name = buttonLabel(language); + button.textContent = name; + button.setAttribute('aria-label', t('code.language.button', { name })); + code.className = language ? `language-${language}` : ''; + }; + show(node); + // A press keeps the caret where it is; the click opens the picker. + button.addEventListener('mousedown', (event) => event.preventDefault()); + button.addEventListener('click', (event) => { + event.preventDefault(); + const pos = getPos(); + if (typeof pos === 'number') pickCodeLanguage(editor.view, pos, button, host); + }); + return { + dom, + contentDOM: code, + update(next) { + if (next.type !== current.type) return false; + if (next.attrs.language !== current.attrs.language) show(next); + current = next; + return true; + }, + stopEvent: (event) => button.contains(event.target as Node), + ignoreMutation: (mutation) => + mutation.type === 'attributes' || (mutation.type !== 'selection' && button.contains(mutation.target)), + }; + }; +} + +const scopeKey = new PluginKey('opennote.codeScope'); + +/** The "editor.code" key scope while the selection is in code, so Ctrl+Enter leaves the block there. */ +function scopePlugin(): Plugin { + return new Plugin({ + key: scopeKey, + props: { + attributes: (state: EditorState): Record => + codeBlockAt(state) ? { 'data-scope': 'editor.code' } : {}, + }, + }); +} + +/** Three backticks and a language name at a line start make a code block in that language. */ +const FENCE = /^```([A-Za-z0-9_+#.-]{1,32})?[\s]$/; + +function key(editor: Editor, command: Command): () => boolean { + return () => command(editor.state, editor.view.dispatch, editor.view); +} + +export function codeBlockExtensions(host: EditorHost): Extensions { + const Code = CodeBlock.extend({ + addNodeView: () => codeBlockView(host), + addInputRules() { + const type = this.type; + return [ + new InputRule({ + find: FENCE, + handler: ({ state, range, match }) => { + if (!host.settings().markdownShortcuts) return null; + const $start = state.doc.resolve(range.from); + if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), type)) return null; + state.tr.delete(range.from, range.to).setBlockType(range.from, range.from, type, { + language: cleanLanguage(match[1] ?? null), + }); + }, + }), + ]; + }, + }); + const Keys = Extension.create({ + name: 'opennoteCodeKeys', + // Ahead of the text editor's own Tab and Enter handling, which applies outside code. + priority: 1200, + addKeyboardShortcuts() { + const { editor } = this; + return { + Tab: key(editor, indentCode(1)), + 'Shift-Tab': key(editor, indentCode(-1)), + Enter: () => key(editor, tripleEnterLeave)() || key(editor, newlineKeepIndent)(), + ArrowDown: key(editor, arrowDownLeave), + 'Mod-Enter': key(editor, leaveCode), + }; + }, + addProseMirrorPlugins: () => [scopePlugin(), ...(host.flag('page.codeHighlight') ? [highlightPlugin(host)] : [])], + }); + return [Code, Keys]; +} diff --git a/app/src/editor/extensions/content.module.css b/app/src/editor/extensions/content.module.css new file mode 100644 index 000000000..d87618069 --- /dev/null +++ b/app/src/editor/extensions/content.module.css @@ -0,0 +1,223 @@ +/* + * What WP4's node views and decorations draw inside a text editor: task checkboxes, callout controls, fold + * buttons, math source, and image chips. Text colors follow BRAND.md's ink rule: a custom color is lightened in + * the dark theme until it reads at 4.5:1 on the page. + */ +@layer components { + .task { + position: relative; + list-style: none; + } + + .taskBox { + position: absolute; + inset-inline-start: -1.5em; + inset-block-start: var(--space-1); + display: inline-flex; + align-items: center; + block-size: 1lh; + user-select: none; + } + + .taskBox input { + inline-size: 1em; + block-size: 1em; + margin: 0; + accent-color: var(--color-accent-primary); + cursor: pointer; + } + + .taskBox input:focus-visible { + outline: var(--size-focus-ring-width) solid var(--color-focus-ring); + outline-offset: var(--size-focus-ring-offset); + } + + .taskBody > p { + margin-block: var(--space-1); + } + + .task[data-checked='true'] > .taskBody > p { + color: var(--color-text-secondary); + } + + .callout { + position: relative; + } + + .calloutBody > p { + margin-block: var(--space-1) 0; + } + + .callout[data-fold='-'] > .calloutBody > :not([data-callout-title]), + :global([data-scope~='editor']) [data-callout][data-fold='-']:not(.callout) > :not([data-callout-title]) { + display: none; + } + + .calloutControls { + position: absolute; + inset-block-start: var(--space-1); + inset-inline-end: var(--space-1); + display: flex; + gap: var(--space-1); + user-select: none; + } + + .calloutType, + .foldButton { + display: inline-grid; + place-items: center; + min-inline-size: var(--size-chrome-hit); + min-block-size: var(--size-chrome-hit); + padding: 0; + border: 0; + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + background: transparent; + font: inherit; + cursor: pointer; + } + + .calloutType::before { + content: ''; + inline-size: 0.6em; + block-size: 0.6em; + border-radius: 50%; + background: var(--callout, var(--color-border-control)); + } + + .foldButton::before { + content: '\25BE'; + transition: rotate var(--motion-duration-fast) var(--motion-easing-standard); + } + + .foldButton[aria-expanded='false']::before { + rotate: -90deg; + } + + .calloutType:hover, + .foldButton:hover { + background: var(--color-surface-hover); + } + + .calloutType:focus-visible, + .foldButton:focus-visible { + outline: var(--size-focus-ring-width) solid var(--color-focus-ring); + outline-offset: calc(-1 * var(--size-focus-ring-width)); + } + + .mathInline, + .mathBlock { + font-family: var(--font-mono); + color: var(--color-text-secondary); + } + + .mathBlock { + display: block; + margin-block: var(--space-3); + white-space: pre-wrap; + } + + .mathField { + font: inherit; + color: var(--color-text-primary); + background: var(--color-surface-raised); + border: 1px solid var(--color-border-control); + border-radius: var(--radius-sm); + } + + textarea.mathField { + display: block; + inline-size: 100%; + min-block-size: 3lh; + } + + .imageChip { + display: inline-flex; + align-items: baseline; + gap: var(--space-1); + padding-inline: var(--space-2); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-pill); + color: var(--color-text-secondary); + background: var(--color-surface-sunken); + font-size: var(--type-small-size); + } + + /* A custom text color reads its value from the attribute, and the dark theme lightens it to stay legible. */ + :global(.ProseMirror) span[data-color^='#'], + :global([data-scope~='editor']) span[data-color^='#'] { + color: attr(data-color type(), currentcolor); + } + + :global(:is([data-theme='dark'], [data-theme-scope='dark'])) + :is(:global(.ProseMirror), :global([data-scope~='editor'])) + span[data-color^='#'] { + /* checks-disable-next-line brand-consistency: lightens the person's own color for dark pages; no token can */ + color: oklch(from attr(data-color type(), currentcolor) max(l, 0.74) c h); + } + + /* Fold buttons sit in the gutter before a heading or inside a list item's marker space. They show on hover, on + the folded state, and to anyone who reaches them by name; their hit area follows the density. */ + :global(.ProseMirror) { + position: relative; + } + + :global(.ProseMirror) li { + position: relative; + } + + .foldButton { + position: absolute; + inset-inline-start: calc(-1 * var(--size-chrome-hit)); + inline-size: var(--size-chrome-hit); + block-size: var(--size-chrome-hit); + margin-block-start: calc((1lh - var(--size-chrome-hit)) / 2); + padding: 0; + border: 0; + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + background: transparent; + opacity: 0; + cursor: pointer; + } + + .foldButton::before { + content: ''; + display: inline-block; + inline-size: 0.5em; + block-size: 0.5em; + border-block-end: 2px solid currentcolor; + border-inline-end: 2px solid currentcolor; + transform: rotate(45deg); + } + + .foldButton[aria-expanded='false']::before { + transform: rotate(-45deg); + } + + .foldButton:hover, + .foldButton:focus-visible, + .foldButton[aria-expanded='false'], + .foldButton:has(+ :hover), + li:hover > .foldButton { + opacity: 1; + } + + .foldButton:hover { + background: var(--color-surface-hover); + } + + .foldButton:focus-visible { + outline: var(--size-focus-ring-width) solid var(--color-focus-ring); + } + + @media (forced-colors: active) { + .calloutType::before { + background: CanvasText; + } + + :global(.ProseMirror) span[data-color^='#'] { + color: CanvasText; + } + } +} diff --git a/app/src/editor/extensions/crossBlock.ts b/app/src/editor/extensions/crossBlock.ts new file mode 100644 index 000000000..2b5f68f43 --- /dev/null +++ b/app/src/editor/extensions/crossBlock.ts @@ -0,0 +1,195 @@ +// Keys between blocks (ARCHITECTURE.md section 8.4; owner: WP4). Each block is its own editing host, so crossing +// blocks is app code here. +// +// - Arrow Up and Down on a flowing block's first or last line move to the flowing neighbor, near the same x. An +// image neighbor is selected as an object. Arrows in a floating text box stay inside it. +// - Shift+Arrow past a flowing block's edge selects both blocks as objects, through host.selectBlocks. +// - Backspace at the very start merges into the text block above, or selects the image or table there. +// - Ctrl+A selects the text, and a second Ctrl+A selects every block. Escape selects this block as an object. +// - Every editor is described by one hidden hint: "Press Escape, then Tab, to leave the text box." +import { Extension } from '@tiptap/core'; +import type { Extensions } from '@tiptap/core'; +import { Plugin, PluginKey, Selection } from '@tiptap/pm/state'; +import type { EditorState } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; +import { t } from '../../strings/t'; +import { MERGE_BLOCKS_EVENT } from '../commands/state'; +import type { MergeBlocksDetail } from '../commands/state'; +import type { EditorHost } from '../host'; + +export const ESCAPE_HINT_ID = 'opennote-escape-hint'; + +/** The page's one hidden Escape hint, made on first use. */ +function ensureHint(doc: Document): void { + if (doc.getElementById(ESCAPE_HINT_ID)) return; + const hint = doc.createElement('div'); + hint.id = ESCAPE_HINT_ID; + hint.hidden = true; + hint.textContent = t('editor.escapeHint'); + doc.body.append(hint); +} + +const blockOf = (view: EditorView): string | null => view.dom.getAttribute('data-block'); +const wrapperOf = (view: EditorView): HTMLElement | null => view.dom.closest('[data-block-id]'); + +/** Floating blocks sit at their frame; flowing ones stack in the column. */ +export function isFloating(wrapper: HTMLElement): boolean { + const position = wrapper.ownerDocument.defaultView?.getComputedStyle(wrapper).position; + return position === 'absolute' || position === 'fixed' || (wrapper.style.left !== '' && wrapper.style.top !== ''); +} + +/** The page's flowing blocks in reading order. */ +function flowingBlocks(wrapper: HTMLElement): HTMLElement[] { + const page = wrapper.closest('[data-scope~="page"]') ?? wrapper.ownerDocument; + return [...page.querySelectorAll('[data-block-id]')].filter((block) => !isFloating(block)); +} + +function neighbor(wrapper: HTMLElement, direction: -1 | 1): HTMLElement | null { + const blocks = flowingBlocks(wrapper); + return blocks[blocks.indexOf(wrapper) + direction] ?? null; +} + +// A block's editing root is editable only while its editor is mounted; before that it is the same element with the +// textbox role, and focusing it mounts the editor. +const editableIn = (wrapper: HTMLElement) => + wrapper.querySelector('[contenteditable="true"], [role="textbox"][data-scope="editor"]'); +const isTextBlock = (wrapper: HTMLElement) => + editableIn(wrapper)?.getAttribute('role') === 'textbox' && !wrapper.querySelector('table'); + +function atDocEdge(state: EditorState, direction: -1 | 1): boolean { + const { $head } = state.selection; + const edge = direction < 0 ? Selection.atStart(state.doc) : Selection.atEnd(state.doc); + return edge.$head.parent === $head.parent && edge.$head.start() === $head.start(); +} + +/** Puts the caret in another block's editable at `x`, on its first line going down or last line going up. */ +function placeCaret(target: HTMLElement, x: number, direction: -1 | 1): void { + target.focus({ preventScroll: false }); + const rect = target.getBoundingClientRect(); + const y = direction > 0 ? rect.top + 2 : rect.bottom - 2; + const doc = target.ownerDocument; + const selection = doc.getSelection(); + const fromPoint = ( + doc as Document & { caretPositionFromPoint?(x: number, y: number): CaretPosition | null } + ).caretPositionFromPoint?.(x, y); + if (!selection) return; + if (fromPoint && target.contains(fromPoint.offsetNode)) { + selection.collapse(fromPoint.offsetNode, fromPoint.offset); + return; + } + selection.selectAllChildren(target); + if (direction > 0) selection.collapseToStart(); + else selection.collapseToEnd(); +} + +function caretX(view: EditorView): number { + try { + return view.coordsAtPos(view.state.selection.head).left; + } catch { + return view.dom.getBoundingClientRect().left; + } +} + +/** Whether the caret is on the textblock's first line going up, or its last going down. */ +function onEdgeLine(view: EditorView, direction: -1 | 1): boolean { + try { + return view.endOfTextblock(direction < 0 ? 'up' : 'down'); + } catch { + // Without layout (in tests), the textblock's edge counts. + return true; + } +} + +/** Arrow Up or Down at a flowing block's edge: on to the neighbor, or select it when it isn't text. */ +function arrow(view: EditorView, host: EditorHost, direction: -1 | 1, extend: boolean): boolean { + const wrapper = wrapperOf(view); + const block = blockOf(view); + if (!wrapper || !block || isFloating(wrapper)) return false; + if ((!extend && !view.state.selection.empty) || !atDocEdge(view.state, direction)) return false; + if (!onEdgeLine(view, direction)) return false; + const next = neighbor(wrapper, direction); + const nextId = next?.dataset.blockId; + if (!next || !nextId) return false; + if (extend) { + host.selectBlocks(direction < 0 ? [nextId, block] : [block, nextId], 'escalate'); + return true; + } + const target = editableIn(next); + if (target) placeCaret(target, caretX(view), direction); + else host.selectBlocks([nextId], 'escape'); + return true; +} + +/** Backspace at the very start of a flowing block: merge into the text block above, or select what's there. */ +function backspace(view: EditorView, host: EditorHost): boolean { + const { selection } = view.state; + const wrapper = wrapperOf(view); + const block = blockOf(view); + if (!selection.empty || !wrapper || !block || isFloating(wrapper)) return false; + if (selection.from !== Selection.atStart(view.state.doc).from) return false; + // A list item or quote at the start lifts first, as their own Backspace does. + if (selection.$from.depth > 1) return false; + const previous = neighbor(wrapper, -1); + const previousId = previous?.dataset.blockId; + if (!previous || !previousId) return false; + if (!isTextBlock(previous)) { + host.selectBlocks([previousId], 'escape'); + return true; + } + const detail: MergeBlocksDetail = { block, previous: previousId }; + view.dom.dispatchEvent(new CustomEvent(MERGE_BLOCKS_EVENT, { bubbles: true, detail })); + return true; +} + +/** Ctrl+A: the text first; when all of it is selected already, every block on the page. */ +function selectAll(view: EditorView, host: EditorHost): boolean { + const { selection, doc } = view.state; + const all = selection.from <= Selection.atStart(doc).from && selection.to >= Selection.atEnd(doc).to; + const wrapper = wrapperOf(view); + if (!all || !wrapper) return false; + const page = wrapper.closest('[data-scope~="page"]') ?? wrapper.ownerDocument; + const blocks = [...page.querySelectorAll('[data-block-id]')] + .map((element) => element.dataset.blockId) + .filter((id): id is string => Boolean(id)); + host.selectBlocks(blocks, 'selectAll'); + return true; +} + +function escape(view: EditorView, host: EditorHost): boolean { + const block = blockOf(view); + if (!block) return false; + host.selectBlocks([block], 'escape'); + return true; +} + +export function crossBlockExtensions(host: EditorHost): Extensions { + return [ + Extension.create({ + name: 'opennoteCrossBlock', + // Before Tiptap's own Ctrl+A, so a second Ctrl+A reaches every block. + priority: 900, + addKeyboardShortcuts() { + const view = () => this.editor.view; + return { + ArrowUp: () => arrow(view(), host, -1, false), + ArrowDown: () => arrow(view(), host, 1, false), + 'Shift-ArrowUp': () => arrow(view(), host, -1, true), + 'Shift-ArrowDown': () => arrow(view(), host, 1, true), + Backspace: () => backspace(view(), host), + 'Mod-a': () => selectAll(view(), host), + Escape: () => escape(view(), host), + }; + }, + addProseMirrorPlugins: () => [ + new Plugin({ + key: new PluginKey('opennoteEscapeHint'), + view: (view) => { + ensureHint(view.dom.ownerDocument); + return {}; + }, + props: { attributes: { 'aria-describedby': ESCAPE_HINT_ID } }, + }), + ], + }), + ]; +} diff --git a/app/src/editor/extensions/folding.ts b/app/src/editor/extensions/folding.ts new file mode 100644 index 000000000..9bb13446a --- /dev/null +++ b/app/src/editor/extensions/folding.ts @@ -0,0 +1,304 @@ +// Fold buttons and folded content (ARCHITECTURE.md section 18.2; owner: WP4). Headings and list items with +// something to fold get a real button with aria-expanded and a name ("Collapse Light reactions"), outside the Tab +// order. Folded content gets the `hidden` attribute, so it leaves the accessibility tree too. +// +// Folds are positions that edits map. Typing inside a textblock only maps the decorations; a structural change +// rebuilds them. The page saves folds through FOLDS_EVENT and restores them through FOLDS_REQUEST_EVENT. +import { Extension } from '@tiptap/core'; +import type { Extensions } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { Plugin } from '@tiptap/pm/state'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import type { EditorView } from '@tiptap/pm/view'; +import { t } from '../../strings/t'; +import { FOLDS_EVENT, FOLDS_REQUEST_EVENT } from '../commands/state'; +import type { FoldsDetail, FoldsRequestDetail } from '../commands/state'; +import { + META_FOLDS, + foldKey, + foldKeysFor, + foldableAt, + foldables, + hiddenRanges, + positionsFor, + rememberFolds, + setFolds, +} from '../commands/fold'; +import type { FoldKind } from '../commands/fold'; +import type { EditorHost } from '../host'; +import styles from './content.module.css'; + +interface PluginValue { + folded: readonly number[]; + decorations: DecorationSet; + /** Fold nodes the last change touched, whose buttons may need their names updated. */ + touched: readonly number[]; +} + +/** The name a fold button and its announcements use: the heading's or item's own words. */ +function foldName(node: PMNode, kind: FoldKind): string { + const text = (kind === 'heading' ? node.textContent : (node.firstChild?.textContent ?? '')).trim(); + return text || t('editor.list.emptyTask'); +} + +function paragraphsIn(doc: PMNode, ranges: readonly [number, number][]): number { + let count = 0; + for (const [from, to] of ranges) { + doc.nodesBetween(from, to, (node) => { + if (node.isTextblock) count += 1; + return !node.isTextblock; + }); + } + return count; +} + +function toggle(view: EditorView, host: EditorHost, pos: number): void { + const { state } = view; + const node = state.doc.nodeAt(pos); + const kind = foldableAt(state.doc, pos); + if (!node || !kind) return; + const folded = foldKey.getState(state)?.folded ?? []; + const isFolded = folded.includes(pos); + const next = isFolded ? folded.filter((at) => at !== pos) : [...folded, pos]; + view.dispatch(setFolds(state.tr, next)); + const name = foldName(node, kind); + const count = paragraphsIn(state.doc, hiddenRanges(state.doc, pos)); + host.announce(isFolded ? t('editor.fold.expanded', { name }) : t('editor.fold.folded', { name, count })); +} + +const buttonLabel = (node: PMNode, kind: FoldKind, folded: boolean) => + t(folded ? 'editor.fold.expand' : 'editor.fold.collapse', { name: foldName(node, kind) }); + +function button(host: EditorHost, node: PMNode, kind: FoldKind, folded: boolean) { + return (view: EditorView, getPos: () => number | undefined): HTMLElement => { + const element = document.createElement('button'); + element.type = 'button'; + element.className = styles.foldButton; + element.contentEditable = 'false'; + element.tabIndex = -1; + element.setAttribute('aria-expanded', String(!folded)); + element.setAttribute('aria-label', buttonLabel(node, kind, folded)); + element.dataset.folded = String(folded); + element.addEventListener('pointerdown', (event) => event.preventDefault()); + element.addEventListener('click', () => { + const at = getPos(); + if (at === undefined) return; + // A heading's button sits just before it; a list item's sits inside it, before its first paragraph. + toggle(view, host, kind === 'heading' ? at : at - 1); + }); + return element; + }; +} + +function widgetFor(host: EditorHost, doc: PMNode, pos: number, folded: ReadonlySet): Decoration | null { + const kind = foldableAt(doc, pos); + const node = doc.nodeAt(pos); + if (!kind || !node) return null; + const on = folded.has(pos); + return Decoration.widget(kind === 'heading' ? pos : pos + 1, button(host, node, kind, on), { + side: -1, + // No name in the key: typing in a heading or item keeps its button, and relabel() renames it. A new button on + // every key restyled and relaid the whole note, several ms a key in a long outline. + key: `fold:${kind}:${on}`, + ignoreSelection: true, + stopEvent: () => true, + fold: true, + }); +} + +/** The `hidden` attribute on what each fold hides, and the folded mark on the folded node. */ +function hiddenFor(doc: PMNode, folded: readonly number[]): Decoration[] { + const decorations: Decoration[] = []; + for (const pos of folded) { + const node = doc.nodeAt(pos); + if (!node) continue; + decorations.push(Decoration.node(pos, pos + node.nodeSize, { 'data-folded': 'true' }, { hidden: true })); + for (const [from, to] of hiddenRanges(doc, pos)) { + decorations.push(Decoration.node(from, to, { hidden: 'hidden' }, { hidden: true })); + } + } + return decorations; +} + +/** Every fold button and every hidden range, from scratch: when an editor starts. */ +function build(host: EditorHost, doc: PMNode, folded: readonly number[]): DecorationSet { + const isFolded = new Set(folded); + const widgets = foldables(doc) + .map(({ pos }) => widgetFor(host, doc, pos, isFolded)) + .filter((decoration): decoration is Decoration => decoration !== null); + return DecorationSet.create(doc, [...widgets, ...hiddenFor(doc, folded)]); +} + +const isFoldNode = (node: PMNode) => node.type.name === 'listItem' || node.type.name === 'heading'; + +/** The headings whose sections can change with an edit at `$pos`: the nearest earlier ones, one per level. */ +function headingsBefore(doc: PMNode, pos: number, add: (pos: number) => void): void { + const $pos = doc.resolve(pos); + for (let depth = $pos.depth; depth >= 0; depth -= 1) { + const container = $pos.node(depth); + if (container.type.name.endsWith('List') || container.type.name === 'listItem' || container.isTextblock) continue; + let offset = depth === 0 ? 0 : $pos.start(depth); + const index = $pos.index(depth); + const starts: number[] = []; + for (let at = 0; at < Math.min(index + 1, container.childCount); at += 1) { + starts.push(offset); + offset += container.child(at).nodeSize; + } + let lowest = 7; + for (let at = starts.length - 1; at >= 0 && lowest > 1; at -= 1) { + const child = container.child(at); + if (child.type.name !== 'heading') continue; + const level = child.attrs.level as number; + if (level < lowest) { + lowest = level; + add(starts[at]); + } + } + } +} + +/** + * The fold buttons an edit can change: headings and items it touched, their ancestors (whose children or text + * changed), and the headings whose sections it can reach. Everything else only maps. + */ +function touched(tr: Transaction): Set { + const doc = tr.doc; + const found = new Set(); + const add = (pos: number) => void found.add(pos); + tr.mapping.maps.forEach((map, index) => { + const rest = tr.mapping.slice(index + 1); + map.forEach((_oldStart, _oldEnd, newStart, newEnd) => { + const from = Math.max(0, Math.min(doc.content.size, rest.map(newStart, -1))); + const to = Math.max(from, Math.min(doc.content.size, rest.map(newEnd, 1))); + for (const at of [from, to]) { + const $at = doc.resolve(at); + for (let depth = $at.depth; depth > 0; depth -= 1) if (isFoldNode($at.node(depth))) add($at.before(depth)); + headingsBefore(doc, at, add); + } + doc.nodesBetween(from, to, (node, pos) => { + if (isFoldNode(node)) add(pos); + return !node.isTextblock; + }); + }); + }); + return found; +} + +/** Maps the decorations, then rebuilds the buttons the change touched and every hidden range. */ +function update( + host: EditorHost, + tr: Transaction, + previous: DecorationSet, + folded: readonly number[], + extra: Iterable = [], +): Omit { + const doc = tr.doc; + let set = previous.map(tr.mapping, doc); + const targets = tr.docChanged ? touched(tr) : new Set(); + for (const pos of extra) targets.add(pos); + const isFolded = new Set(folded); + const stale: Decoration[] = set.find(undefined, undefined, (spec) => spec.hidden === true); + const fresh: Decoration[] = hiddenFor(doc, folded); + for (const pos of targets) { + const node = doc.nodeAt(pos); + if (!node) continue; + const at = node.type.name === 'heading' ? pos : pos + 1; + stale.push(...set.find(at, at, (spec) => spec.fold === true)); + const widget = widgetFor(host, doc, pos, isFolded); + if (widget) fresh.push(widget); + } + set = set.remove(stale); + return { decorations: set.add(doc, fresh), touched: [...targets] }; +} + +/** Renames the buttons of the fold nodes a change touched, in place, when their words changed. */ +function relabel(view: EditorView, positions: readonly number[]): void { + const folded = foldKey.getState(view.state)?.folded ?? []; + for (const pos of positions) { + const node = view.state.doc.nodeAt(pos); + const kind = foldableAt(view.state.doc, pos); + const dom = view.nodeDOM(pos); + if (!node || !kind || !(dom instanceof HTMLElement)) continue; + // A heading's button sits just before it; a list item's sits inside it, first. + const element = kind === 'heading' ? dom.previousElementSibling : dom.firstElementChild; + if (!element?.classList.contains(styles.foldButton)) continue; + const label = buttonLabel(node, kind, folded.includes(pos)); + if (element.getAttribute('aria-label') !== label) element.setAttribute('aria-label', label); + } +} + +function apply(host: EditorHost, tr: Transaction, value: PluginValue, state: EditorState): PluginValue { + const set = tr.getMeta(META_FOLDS) as readonly number[] | undefined; + if (set) { + rememberFolds(state.doc, set); + // The buttons whose state flipped get new names and aria-expanded. + const flipped = [ + ...set.filter((pos) => !value.folded.includes(pos)), + ...value.folded.filter((pos) => !set.includes(pos)), + ]; + return { folded: set, ...update(host, tr, value.decorations, set, flipped) }; + } + if (!tr.docChanged) { + rememberFolds(state.doc, value.folded); + return value.touched.length ? { ...value, touched: [] } : value; + } + const folded = value.folded + .map((pos) => tr.mapping.mapResult(pos, 1)) + .filter((result) => !result.deleted) + .map((result) => result.pos) + .filter((pos) => foldableAt(state.doc, pos) !== null); + rememberFolds(state.doc, folded); + return { folded, ...update(host, tr, value.decorations, folded) }; +} + +function foldingPlugin(host: EditorHost): Plugin { + return new Plugin({ + key: foldKey as never, + state: { + init: (_config, state) => { + rememberFolds(state.doc, []); + return { folded: [], decorations: build(host, state.doc, []), touched: [] }; + }, + apply: (tr, value, _old, state) => apply(host, tr, value, state), + }, + props: { + decorations: (state) => (foldKey.getState(state) as unknown as PluginValue | undefined)?.decorations, + }, + view(view) { + const block = view.dom.getAttribute('data-block') ?? ''; + // The page answers with the folds it saved for this block, if any. + const request: FoldsRequestDetail = { + block, + provide: (keys) => + queueMicrotask(() => { + if (view.isDestroyed) return; + const positions = positionsFor(view.state.doc, keys); + if (positions.length > 0) view.dispatch(setFolds(view.state.tr, positions)); + }), + }; + view.dom.dispatchEvent(new CustomEvent(FOLDS_REQUEST_EVENT, { bubbles: true, detail: request })); + return { + update(_view, previous) { + const value = foldKey.getState(view.state) as unknown as PluginValue | undefined; + if (value && value !== (foldKey.getState(previous) as unknown)) relabel(view, value.touched); + const before = foldKey.getState(previous)?.folded; + const now = foldKey.getState(view.state)?.folded; + if (before === now || !now) return; + if (before && before.length === now.length && before.every((pos, index) => pos === now[index])) return; + const detail: FoldsDetail = { block, keys: foldKeysFor(view.state.doc, block) }; + view.dom.dispatchEvent(new CustomEvent(FOLDS_EVENT, { bubbles: true, detail })); + }, + }; + }, + }); +} + +export function foldingExtensions(host: EditorHost): Extensions { + return [ + Extension.create({ + name: 'opennoteFolding', + addProseMirrorPlugins: () => (host.flag('page.outline') ? [foldingPlugin(host)] : []), + }), + ]; +} diff --git a/app/src/editor/extensions/highlight.ts b/app/src/editor/extensions/highlight.ts new file mode 100644 index 000000000..feaaf4fdb --- /dev/null +++ b/app/src/editor/extensions/highlight.ts @@ -0,0 +1,9 @@ +// The highlight mark and its colors (owner: WP4). +// WP0's factory returns the schema's own definition. Its owner replaces the body, keeping the signature. +import type { Extensions } from '@tiptap/core'; +import type { EditorHost } from '../host'; +import { Highlight } from '../schema/marks'; + +export function highlightExtensions(_host: EditorHost): Extensions { + return [Highlight]; +} diff --git a/app/src/editor/extensions/image.ts b/app/src/editor/extensions/image.ts new file mode 100644 index 000000000..0c64dfdce --- /dev/null +++ b/app/src/editor/extensions/image.ts @@ -0,0 +1,53 @@ +// Inline images inside text (ARCHITECTURE.md section 8.2; owner: WP4). The atom keeps what it parsed, and the +// editor never loads its source. An image from outside the page shows as a chip with its description (SPEC 7.3). +// An `asset:` image shows the same chip, marked with its asset ID, until the page resolves assets for it. +import type { Extensions } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import type { NodeView } from '@tiptap/pm/view'; +import { t } from '../../strings/t'; +import type { EditorHost } from '../host'; +import { ImageNode } from '../schema/nodes'; +import styles from './content.module.css'; + +class ImageChipView implements NodeView { + dom: HTMLSpanElement; + + constructor(private node: PMNode) { + this.dom = document.createElement('span'); + this.dom.className = styles.imageChip; + this.dom.setAttribute('role', 'img'); + this.render(); + } + + private render(): void { + const { src, alt } = this.node.attrs as { src: string; alt: string }; + const label = alt ? t('editor.image.chip', { alt }) : t('editor.image.chipUnnamed'); + this.dom.setAttribute('aria-label', label); + this.dom.textContent = alt || t('editor.image.chipText'); + if (src.startsWith('asset:')) this.dom.dataset.asset = src.slice('asset:'.length); + else delete this.dom.dataset.asset; + this.dom.title = src.startsWith('asset:') ? label : t('editor.image.notLoaded', { src }); + } + + update(node: PMNode): boolean { + if (node.type !== this.node.type) return false; + this.node = node; + this.render(); + return true; + } + + ignoreMutation(): boolean { + return true; + } +} + +export function imageExtensions(_host: EditorHost): Extensions { + return [ + ImageNode.extend({ + addNodeView: + () => + ({ node }) => + new ImageChipView(node), + }), + ]; +} diff --git a/app/src/editor/extensions/keys.ts b/app/src/editor/extensions/keys.ts new file mode 100644 index 000000000..5cdd35cb8 --- /dev/null +++ b/app/src/editor/extensions/keys.ts @@ -0,0 +1,71 @@ +// The editor keymap (owner: WP4): the keys ProseMirror owns inside a text box. Formatting and block keys are page +// commands, which Phase 2's dispatcher runs before the editor sees them. +// +// What stays here are the fixed keys. Shift+Enter types a line break. Ctrl+Z, Ctrl+Y, and Ctrl+Shift+Z go to the +// page's undo, because text fields keep them from the dispatcher and the core owns undo. +// +// It also keeps the page's EditorHost in the editor's storage, so commands reach announcements and menus. +import { Extension } from '@tiptap/core'; +import type { Editor, Extensions } from '@tiptap/core'; +import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'; +import { executeCommand } from '../../commands/registry'; +import type { EditorHost } from '../host'; + +const NAME = 'opennoteHost'; + +interface HostStorage { + host: EditorHost | null; +} + +/** The host of the page the editor belongs to. */ +export function hostOf(editor: Editor): EditorHost | null { + const storage = (editor.storage as unknown as Record)[NAME]; + return storage?.host ?? null; +} + +/** Inserts a line break, or a newline inside code. */ +function lineBreak(editor: Editor): boolean { + const { state } = editor; + const { $from } = state.selection; + if (!$from.parent.isTextblock) return false; + if ($from.parent.type.spec.code) return editor.commands.insertContent('\n'); + const hardBreak = state.schema.nodes.hardBreak; + const marks = state.storedMarks ?? $from.marks(); + const tr = state.tr.replaceSelectionWith(hardBreak.create(), false); + tr.setSelection(TextSelection.near(tr.doc.resolve(tr.mapping.map(state.selection.to)))); + tr.setStoredMarks(marks); + editor.view.dispatch(tr.scrollIntoView()); + return true; +} + +function pageHistory(editor: Editor, id: 'page.undo' | 'page.redo'): boolean { + if (editor.view.composing) return false; + void executeCommand(id, undefined, 'keyboard'); + return true; +} + +/** The page's text menu, with formatting and spelling, instead of the plain edit menu. */ +function textMenuPlugin(): Plugin { + return new Plugin({ + key: new PluginKey('opennoteTextMenu'), + props: { attributes: { 'data-app-menu': 'page.text' } }, + }); +} + +export function keysExtensions(host: EditorHost): Extensions { + return [ + Extension.create, HostStorage>({ + name: NAME, + addStorage: () => ({ host }), + addProseMirrorPlugins: () => [textMenuPlugin()], + addKeyboardShortcuts() { + return { + 'Shift-Enter': () => lineBreak(this.editor), + 'Mod-z': () => pageHistory(this.editor, 'page.undo'), + 'Mod-y': () => pageHistory(this.editor, 'page.redo'), + 'Mod-Shift-z': () => pageHistory(this.editor, 'page.redo'), + }; + }, + }), + ]; +} diff --git a/app/src/editor/extensions/kit.test.ts b/app/src/editor/extensions/kit.test.ts new file mode 100644 index 000000000..2c407edaf --- /dev/null +++ b/app/src/editor/extensions/kit.test.ts @@ -0,0 +1,78 @@ +// @vitest-environment jsdom +import { Extension, getSchema } from '@tiptap/core'; +import type { Schema } from '@tiptap/pm/model'; +import { afterEach, describe, expect, it } from 'vitest'; +import { DEFAULT_EDITING_VIEW } from '../host'; +import type { EditorHost } from '../host'; +import { createMarkdownCache, parseTextBlock, serializeTextBlock } from '../markdown'; +import { tableSchema, textSchema } from '../schema/schema'; +import { buildKit, createBlockEditor, editorExtensions } from './kit'; + +const host: EditorHost = { + settings: () => DEFAULT_EDITING_VIEW, + flag: () => true, + announce: () => {}, + openMenu: () => Promise.resolve(null), + screenReader: () => false, + spelling: () => null, + selectBlocks: () => {}, +}; + +/** What makes two schemas the same: each node's and mark's name, content, groups, attributes, and exclusions. */ +function outline(schema: Schema) { + const attrs = (spec: { attrs?: Record }) => + Object.entries(spec.attrs ?? {}).map(([name, attr]) => [name, attr.default]); + return { + nodes: Object.values(schema.nodes).map(({ name, spec }) => ({ + name, + content: spec.content ?? '', + group: spec.group ?? '', + inline: !!spec.inline, + atom: !!spec.atom, + marks: spec.marks, + attrs: attrs(spec), + })), + marks: Object.values(schema.marks).map(({ name, spec }) => ({ name, excludes: spec.excludes, attrs: attrs(spec) })), + }; +} + +describe('the editor kit', () => { + it('builds the text schema', () => { + expect(outline(getSchema(buildKit({ kind: 'text', block: 'b1', host })))).toEqual(outline(textSchema)); + }); + + it('builds the table schema', () => { + expect(outline(getSchema(buildKit({ kind: 'table', block: 'b1', host })))).toEqual(outline(tableSchema)); + }); + + it('adds registered extensions of its kind whose flags are on, in order', () => { + const stops = [ + editorExtensions.register({ id: 'b', order: 2, kinds: ['text'], create: () => Extension.create({ name: 'b' }) }), + editorExtensions.register({ id: 'a', order: 1, kinds: ['text'], create: () => Extension.create({ name: 'a' }) }), + editorExtensions.register({ id: 't', order: 0, kinds: ['table'], create: () => Extension.create({ name: 't' }) }), + ]; + const names = buildKit({ kind: 'text', block: 'b1', host }).map((extension) => extension.name); + expect(names.slice(-2)).toEqual(['a', 'b']); + expect(names).not.toContain('t'); + stops.forEach((stop) => stop()); + }); +}); + +describe('createBlockEditor', () => { + let root: HTMLElement; + afterEach(() => root.remove()); + + it('mounts in place of the static text and edits the same document', () => { + root = document.createElement('div'); + root.textContent = 'static'; + document.body.append(root); + const doc = parseTextBlock('# Title\n\nSome **bold** text'); + const editor = createBlockEditor(root, doc, { kind: 'text', block: 'b1', host }); + expect(root.textContent).not.toContain('static'); + expect(root.getAttribute('contenteditable')).toBe('true'); + expect(root.getAttribute('data-block')).toBe('b1'); + editor.commands.insertContentAt(editor.state.doc.content.size - 1, '!'); + expect(serializeTextBlock(editor.state.doc, createMarkdownCache())).toBe('# Title\n\nSome **bold** text!'); + editor.destroy(); + }); +}); diff --git a/app/src/editor/extensions/kit.ts b/app/src/editor/extensions/kit.ts new file mode 100644 index 000000000..cf4cc0695 --- /dev/null +++ b/app/src/editor/extensions/kit.ts @@ -0,0 +1,151 @@ +// The editor kit (PLAN.md section 3.8, owned by WP0): the extension list of a text or table editor. It names one +// factory per extension file, so each owner changes only their own file, and later phases add extensions through +// the editorExtensions registry. The kit builds the same schema as editor/schema, which a test checks. +import { Editor } from '@tiptap/core'; +import type { AnyExtension, Extensions } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import type { FlagId } from '../../app/flags'; +import { createRegistry } from '../../registries/registry'; +import type { Registry } from '../../registries/registry'; +import type { BlockId, EditorHost } from '../host'; +import * as marks from '../schema/marks'; +import * as nodes from '../schema/nodes'; +import { OneParagraphCell, OneParagraphHeader, TableDoc } from '../schema/schema'; +import { Table, TableRow } from '@tiptap/extension-table'; +import { tableKitExtensions } from '../table/tableKit'; +import { autoChangeExtensions } from './autoChange'; +import { autocorrectExtensions } from './autocorrect'; +import { calloutExtensions } from './callout'; +import { codeBlockExtensions } from './codeBlock'; +import { crossBlockExtensions } from './crossBlock'; +import { foldingExtensions } from './folding'; +import { highlightExtensions } from './highlight'; +import { imageExtensions } from './image'; +import { keysExtensions } from './keys'; +import { listItemExtensions } from './listItem'; +import { mathBlockExtensions, mathInlineExtensions } from './math'; +import { outlineExtensions } from './outline'; +import { slashExtensions } from './slash'; +import { spellingRangesExtensions } from './spellingRanges'; +import { tabKeysExtensions } from './tabKeys'; +import { textColorExtensions } from './textColor'; +import { textSizeExtensions } from './textSize'; + +export type EditorKind = 'text' | 'table'; + +export interface KitOptions { + kind: EditorKind; + block: BlockId; + host: EditorHost; +} + +/** An extension a later package or phase adds to every editor of the given kinds, such as Phase 5's ink anchors. */ +export interface EditorExtensionDef { + id: string; + order: number; + flag?: FlagId; + kinds: readonly EditorKind[]; + create(host: EditorHost): AnyExtension; +} + +export const editorExtensions: Registry = createRegistry('editor extensions'); + +/** The marks in SPEC 7.7 nesting order, which is also their order in the schema. */ +function markKit(host: EditorHost): Extensions { + return [ + marks.Link, + marks.Bold, + marks.Italic, + marks.Strike, + marks.Underline, + ...highlightExtensions(host), + ...textColorExtensions(host), + ...textSizeExtensions(host), + marks.Subscript, + marks.Superscript, + marks.Code, + ]; +} + +/** Behavior that adds no nodes or marks, shared by both kinds. */ +function behaviorKit(host: EditorHost): Extensions { + return [ + ...keysExtensions(host), + ...autoChangeExtensions(host), + ...autocorrectExtensions(host), + ...spellingRangesExtensions(host), + ]; +} + +function textKit(host: EditorHost): Extensions { + return [ + nodes.Doc, + nodes.TextNode, + nodes.Paragraph, + nodes.Heading, + nodes.HardBreak, + nodes.HorizontalRule, + nodes.Blockquote, + ...listItemExtensions(host), + nodes.BulletList, + nodes.OrderedList, + nodes.CalloutTitle, + ...calloutExtensions(host), + ...codeBlockExtensions(host), + ...mathBlockExtensions(host), + ...mathInlineExtensions(host), + ...imageExtensions(host), + ...markKit(host), + ...behaviorKit(host), + ...crossBlockExtensions(host), + ...outlineExtensions(host), + ...foldingExtensions(host), + ...slashExtensions(host), + ...tabKeysExtensions(host), + ]; +} + +function tableKit(host: EditorHost): Extensions { + return [ + TableDoc, + Table.configure({ resizable: false }), + TableRow, + OneParagraphHeader, + OneParagraphCell, + nodes.Paragraph, + nodes.TextNode, + nodes.HardBreak, + ...mathInlineExtensions(host), + ...imageExtensions(host), + ...markKit(host), + ...behaviorKit(host), + ...tableKitExtensions(host), + ]; +} + +/** The extensions of one editor: its kind's kit, then the registered extensions whose flags are on, in order. */ +export function buildKit(options: KitOptions): AnyExtension[] { + const { kind, host } = options; + const own = kind === 'text' ? textKit(host) : tableKit(host); + const added = editorExtensions + .list() + .filter((def) => def.kinds.includes(kind) && (!def.flag || host.flag(def.flag))) + .sort((a, b) => a.order - b.order) + .map((def) => def.create(host)); + return [...own, ...added]; +} + +/** + * Mounts an editor in place of `root`'s static content, so the text doesn't move. The root keeps its element, so + * the block's wrapper, its accessible name, and the caret's place on the page stay the same. + */ +export function createBlockEditor(root: HTMLElement, doc: PMNode, options: KitOptions): Editor { + return new Editor({ + // The mount form: `root` itself becomes the editable, and its static children are replaced in the same task. + element: { mount: root }, + extensions: buildKit(options), + content: doc.toJSON(), + injectCSS: false, + editorProps: { attributes: { 'data-app-menu': '', 'data-block': options.block } }, + }); +} diff --git a/app/src/editor/extensions/listItem.ts b/app/src/editor/extensions/listItem.ts new file mode 100644 index 000000000..24035903e --- /dev/null +++ b/app/src/editor/extensions/listItem.ts @@ -0,0 +1,179 @@ +// List items (ARCHITECTURE.md section 8.2; owner: WP4): Enter and Backspace in lists, and task items with a real +// checkbox. A normalizer joins adjacent lists of one kind, which canonical Markdown can't keep apart. +// +// A task item's node view puts a native checkbox in the item's marker box, named by the item's text, and leaves +// the item's content in a block of its own. It looks like the static `toDOM` rendering, whose marker draws the same +// box, so mounting an editor moves nothing. +import type { Editor, Extensions } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { liftListItem, splitListItem } from '@tiptap/pm/schema-list'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import type { NodeView, ViewMutationRecord } from '@tiptap/pm/view'; +import { t } from '../../strings/t'; +import type { EditorHost } from '../host'; +import { META_COMMAND, META_REMOTE } from '../meta'; +import { ListItem } from '../schema/nodes'; +import styles from './content.module.css'; + +const LISTS = new Set(['bulletList', 'orderedList']); + +/** Attributes a new item never copies from the one it was split from: element IDs and tags stay unique. */ +const FRESH = { id: null, tags: [], style: null, tagChecked: false }; + +/** Enter: a new item after this one. A new task starts unchecked; an empty item leaves the list. */ +function splitItem(editor: Editor): boolean { + const { state } = editor; + const { $from } = state.selection; + const item = $from.node(-1); + if (item?.type.name !== 'listItem' || $from.index(-1) !== 0) return false; + const checked = item.attrs.checked === null ? null : false; + return splitListItem(state.schema.nodes.listItem, { ...FRESH, checked })(state, editor.view.dispatch); +} + +/** Backspace at the start of an item's first paragraph lifts the item out one level. */ +function liftAtStart(editor: Editor): boolean { + const { state } = editor; + const { $from, empty } = state.selection; + if (!empty || $from.parentOffset !== 0) return false; + const item = $from.node(-1); + if (item?.type.name !== 'listItem' || $from.index(-1) !== 0) return false; + if (item.attrs.checked !== null) { + editor.view.dispatch(state.tr.setNodeMarkup($from.before(-1), undefined, { ...item.attrs, checked: null })); + return true; + } + return liftListItem(state.schema.nodes.listItem)(state, editor.view.dispatch); +} + +/** The positions after lists whose next sibling is a list of the same kind, inside the changed ranges. */ +function adjacentLists(tr: Transaction): number[] { + const joins: number[] = []; + const doc = tr.doc; + const ranges: [number, number][] = []; + tr.mapping.maps.forEach((map, index) => { + const rest = tr.mapping.slice(index + 1); + map.forEach((_oldStart, _oldEnd, newStart, newEnd) => { + ranges.push([rest.map(newStart, -1), rest.map(newEnd, 1)]); + }); + }); + for (const [from, to] of ranges) { + const start = Math.max(0, from - 1); + const end = Math.min(doc.content.size, to + 1); + doc.nodesBetween(start, end, (node, pos) => { + if (!LISTS.has(node.type.name)) return true; + const after = pos + node.nodeSize; + const next = after < doc.content.size ? doc.resolve(after).nodeAfter : null; + if (next?.type === node.type && !joins.includes(after)) joins.push(after); + return true; + }); + } + return joins.sort((a, b) => b - a); +} + +/** Joins adjacent lists of one kind after each local change (ARCHITECTURE.md section 8.1). */ +function joinListsPlugin(): Plugin { + return new Plugin({ + key: new PluginKey('opennoteJoinLists'), + appendTransaction(transactions: readonly Transaction[], _old: EditorState, state: EditorState) { + const changed = transactions.filter((tr) => tr.docChanged && !tr.getMeta(META_REMOTE)); + if (changed.length === 0) return null; + const joins = changed.flatMap(adjacentLists); + if (joins.length === 0) return null; + const tr = state.tr; + for (const pos of [...new Set(joins)].sort((a, b) => b - a)) { + const $pos = tr.doc.resolve(pos); + if ($pos.nodeBefore && $pos.nodeAfter && $pos.nodeBefore.type === $pos.nodeAfter.type) tr.join(pos); + } + return tr.docChanged ? tr.setMeta(META_COMMAND, true) : null; + }, + }); +} + +/** The words of an item, for its checkbox's name. */ +function itemText(node: PMNode): string { + return node.firstChild?.textContent.trim() || t('editor.list.emptyTask'); +} + +class TaskItemView implements NodeView { + dom: HTMLLIElement; + contentDOM: HTMLElement; + private box: HTMLInputElement; + + constructor( + private node: PMNode, + private editor: Editor, + private getPos: () => number | undefined, + ) { + this.dom = document.createElement('li'); + this.dom.className = styles.task; + const marker = document.createElement('span'); + marker.className = styles.taskBox; + marker.contentEditable = 'false'; + this.box = document.createElement('input'); + this.box.type = 'checkbox'; + this.box.tabIndex = -1; + this.box.addEventListener('change', () => this.toggle()); + marker.append(this.box); + this.contentDOM = document.createElement('div'); + this.contentDOM.className = styles.taskBody; + this.dom.append(marker, this.contentDOM); + this.render(); + } + + private render(): void { + const checked = this.node.attrs.checked === true; + this.dom.dataset.checked = String(checked); + this.box.checked = checked; + this.box.setAttribute('aria-label', itemText(this.node)); + this.box.disabled = !this.editor.isEditable; + } + + private toggle(): void { + const pos = this.getPos(); + if (pos === undefined || !this.editor.isEditable) return this.render(); + const checked = this.node.attrs.checked !== true; + const tr = this.editor.state.tr.setNodeMarkup(pos, undefined, { ...this.node.attrs, checked }); + this.editor.view.dispatch(tr.setMeta(META_COMMAND, true)); + } + + update(node: PMNode): boolean { + if (node.type !== this.node.type || node.attrs.checked === null) return false; + this.node = node; + this.render(); + return true; + } + + stopEvent(event: Event): boolean { + return event.target === this.box; + } + + ignoreMutation(mutation: ViewMutationRecord): boolean { + return mutation.type !== 'selection' && !this.contentDOM.contains(mutation.target); + } +} + +/** A plain item: an `li` that turns into a task view when it gains a checkbox. */ +function plainItemView(node: PMNode): NodeView { + const dom = document.createElement('li'); + return { dom, contentDOM: dom, update: (next: PMNode) => next.type === node.type && next.attrs.checked === null }; +} + +export function listItemExtensions(_host: EditorHost): Extensions { + return [ + ListItem.extend({ + addKeyboardShortcuts() { + return { + Enter: () => splitItem(this.editor), + Backspace: () => liftAtStart(this.editor), + }; + }, + addNodeView() { + return ({ node, editor, getPos }) => + node.attrs.checked === null ? plainItemView(node) : new TaskItemView(node, editor, getPos); + }, + addProseMirrorPlugins() { + return [joinListsPlugin()]; + }, + }), + ]; +} diff --git a/app/src/editor/extensions/markdownShortcuts.test.ts b/app/src/editor/extensions/markdownShortcuts.test.ts new file mode 100644 index 000000000..6b82ce8c4 --- /dev/null +++ b/app/src/editor/extensions/markdownShortcuts.test.ts @@ -0,0 +1,126 @@ +// @vitest-environment jsdom +// Markdown shortcuts in a mounted editor. Each conversion is its own transaction after the typed character, and +// Backspace right after reverts it. Nothing converts while composing, in code, in links, or with the setting off. +import type { Editor } from '@tiptap/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { META_AUTO_CHANGE } from '../meta'; +import { mountEditor, testHost, typeInto } from '../commands/testing'; +import type { TestEditor } from '../commands/testing'; + +let mounted: TestEditor | null = null; +afterEach(() => { + mounted?.destroy(); + mounted = null; +}); + +function mount(source: string, host = testHost()): TestEditor { + mounted = mountEditor(source, host); + return mounted; +} + +function press(editor: Editor, key: string): boolean { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + return editor.view.someProp('handleKeyDown', (handle) => handle(editor.view, event)) ?? false; +} + +const CONVERSIONS: readonly [string, string][] = [ + ['# Title', '# Title'], + ['### Title', '### Title'], + ['> Quote', '> Quote'], + ['- Item', '- Item'], + ['* Item', '- Item'], + ['3. Item', '3. Item'], + ['[ ] Task', '- [ ] Task'], + ['[x] Done', '- [x] Done'], + ['> [!tip] Title', '> [!tip] Title'], + ['```js ', '```js\n```'], + ['---', '---'], + ['a **bold** b', 'a **bold** b'], + ['a __bold__ b', 'a **bold** b'], + ['a *it* b', 'a *it* b'], + ['a _it_ b', 'a *it* b'], + ['a ~~gone~~ b', 'a ~~gone~~ b'], + ['a ==lit== b', 'a ==lit== b'], + ['a `code` b', 'a `code` b'], +]; + +describe('Markdown shortcuts', () => { + for (const [typed, markdown] of CONVERSIONS) { + it(`convert “${typed}”`, () => { + const page = mount('[]'); + typeInto(page.editor, typed); + expect(page.markdown()).toBe(markdown); + }); + } + + it('make the conversion its own transaction after the typed character', () => { + const page = mount('[]'); + const steps: { auto: boolean; text: string }[] = []; + page.editor.on('transaction', ({ transaction }) => { + if (transaction.docChanged) { + steps.push({ auto: Boolean(transaction.getMeta(META_AUTO_CHANGE)), text: transaction.doc.textContent }); + } + }); + typeInto(page.editor, '# '); + expect(steps).toEqual([ + { auto: false, text: '#' }, + { auto: false, text: '# ' }, + { auto: true, text: '' }, + ]); + }); + + it('revert on Backspace right after, leaving the typed characters', () => { + const page = mount('[]'); + typeInto(page.editor, '- '); + expect(page.markdown()).toBe('-'); + expect(press(page.editor, 'Backspace')).toBe(true); + expect(page.editor.state.doc.firstChild?.type.name).toBe('paragraph'); + expect(page.editor.state.doc.textContent).toBe('- '); + }); + + it('keep the conversion when something else happened before Backspace', () => { + const page = mount('[]'); + typeInto(page.editor, '# T'); + press(page.editor, 'Backspace'); + expect(page.editor.state.doc.firstChild?.type.name).toBe('heading'); + }); + + it('check a list item that starts with a box', () => { + const page = mount('- []'); + typeInto(page.editor, '[ ] Task'); + expect(page.markdown()).toBe('- [ ] Task'); + }); + + it('make a code block from a fence on Enter', () => { + const page = mount('[]'); + typeInto(page.editor, '```py'); + expect(press(page.editor, 'Enter')).toBe(true); + expect(page.markdown()).toBe('```py\n```'); + }); + + it('do nothing in code, in links, or while composing', () => { + const code = mount('```\n[]\n```'); + typeInto(code.editor, '**b** '); + expect(code.editor.state.doc.textContent).toBe('**b** '); + code.destroy(); + + const link = mount('[go](https://x.org)'); + link.editor.commands.setTextSelection(2); + typeInto(link.editor, '**b**'); + expect(link.editor.state.doc.textContent).toContain('**b**'); + link.destroy(); + + const composing = mount('[]'); + (composing.editor.view as unknown as { input: { composing: boolean } }).input.composing = true; + typeInto(composing.editor, '# '); + expect(composing.editor.state.doc.firstChild?.type.name).toBe('paragraph'); + }); + + it('do nothing when Settings turns them off', () => { + const host = testHost(); + host.editing = { ...host.editing, markdownShortcuts: false }; + const page = mount('[]', host); + typeInto(page.editor, '# **b**'); + expect(page.editor.state.doc.textContent).toBe('# **b**'); + }); +}); diff --git a/app/src/editor/extensions/markdownShortcuts.ts b/app/src/editor/extensions/markdownShortcuts.ts new file mode 100644 index 000000000..39170b974 --- /dev/null +++ b/app/src/editor/extensions/markdownShortcuts.ts @@ -0,0 +1,227 @@ +// Markdown shortcuts while typing (ARCHITECTURE.md section 8.3; owner: WP4). Typing "# " makes a heading, "- " a +// bulleted list, "**words**" bold text, and so on. Ours add "[ ] " and "[x] " for tasks, "> [!note] " for callouts, +// and "==words==" for highlights. +// +// Each conversion is an automatic change: the typed character goes in as typing, and the conversion is its own +// undo step. Nothing converts while composing, in code, in links, or when Settings turns the shortcuts off. +import type { MarkType, Node as PMNode, NodeType } from '@tiptap/pm/model'; +import { Fragment } from '@tiptap/pm/model'; +import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import { findWrapping } from '@tiptap/pm/transform'; +import type { EditorView } from '@tiptap/pm/view'; +import type { EditorHost } from '../host'; +import type { AutoChangeMeta } from '../meta'; +import { canAutoChange, markAutoChange, typeThenChange } from './autoChange'; + +/** How far back a rule looks, as prosemirror-inputrules does. */ +const MAX_MATCH = 500; +const OBJECT = ''; + +type Made = { tr: Transaction; meta: AutoChangeMeta } | null; + +interface Rule { + match: RegExp; + /** Whether it applies only to a whole paragraph's text: block rules. */ + block?: boolean; + /** The change, on the state after typing. `start` and `end` bound the matched text; `end` is the caret. */ + apply(state: EditorState, match: RegExpExecArray, start: number, end: number): Transaction | null; +} + +/** The paragraph at `pos` can become `type`, or be wrapped in it. */ +function textblockCan(state: EditorState, pos: number, type: NodeType | undefined): type is NodeType { + if (!type) return false; + const $pos = state.doc.resolve(pos); + if ($pos.parent.type.name !== 'paragraph') return false; + const index = $pos.index(-1); + return $pos.node(-1).canReplaceWith(index, index + 1, type); +} + +function setBlock(type: string, attrs: Record = {}): Rule['apply'] { + return (state, _match, start, end) => { + const nodeType = state.schema.nodes[type]; + if (!textblockCan(state, start, nodeType)) return null; + const tr = state.tr.delete(start, end); + const at = tr.mapping.map(start); + return tr.setBlockType(at, at, nodeType, attrs); + }; +} + +function wrap( + type: string, + attrs: (match: RegExpExecArray) => Record | null = () => null, +): Rule['apply'] { + return (state, match, start, end) => { + const wrapper = state.schema.nodes[type] as NodeType | undefined; + if (!wrapper || state.doc.resolve(start).parent.type.name !== 'paragraph') return null; + const tr = state.tr.delete(start, end); + const $start = tr.doc.resolve(tr.mapping.map(start)); + const range = $start.blockRange(); + const wrapping = range && findWrapping(range, wrapper, attrs(match)); + if (!range || !wrapping) return null; + return tr.wrap(range, wrapping); + }; +} + +/** "[ ] " or "[x] ": checks the list item it starts, or makes a checklist of the paragraph. */ +const task: Rule['apply'] = (state, match, start, end) => { + const checked = match[1] !== ' '; + const $start = state.doc.resolve(start); + const item = $start.depth > 1 ? $start.node(-1) : null; + if (item?.type.name === 'listItem' && $start.index(-1) === 0) { + if (item.attrs.checked !== null) return null; + return state.tr.delete(start, end).setNodeMarkup($start.before(-1), undefined, { ...item.attrs, checked }); + } + const tr = wrap('bulletList')(state, match, start, end); + if (!tr) return null; + const $item = tr.doc.resolve(tr.mapping.map(start)); + const itemPos = $item.before(-1); + return tr.setNodeMarkup(itemPos, undefined, { ...$item.node(-1).attrs, checked }); +}; + +/** "[!type] " in a quote's first paragraph turns the quote into a callout of that type, with an empty title. */ +const callout: Rule['apply'] = (state, match, start, end) => { + const $start = state.doc.resolve(start); + const schema = state.schema; + if (!schema.nodes.callout || $start.depth < 2 || $start.node(-1).type.name !== 'blockquote') return null; + if ($start.index(-1) !== 0) return null; + const quote = $start.node(-1); + const quotePos = $start.before(-1); + const rest: PMNode[] = []; + const first = quote.firstChild!; + const titleContent = first.content.cut(end - start); + quote.forEach((child, _offset, index) => void (index > 0 && rest.push(child))); + const node = schema.nodes.callout.create({ type: match[1].toLowerCase(), fold: match[2] ?? '' }, [ + schema.nodes.calloutTitle.create(null, titleContent), + ...rest, + ]); + const tr = state.tr.replaceWith(quotePos, quotePos + quote.nodeSize, node); + return tr.setSelection(TextSelection.create(tr.doc, quotePos + 2)); +}; + +/** "```lang " replaces the paragraph with a code block in that language. */ +const fence: Rule['apply'] = (state, match, start, end) => { + const type = state.schema.nodes.codeBlock; + if (!textblockCan(state, start, type) || end - start !== state.doc.resolve(start).parent.content.size) return null; + const tr = state.tr.delete(start, end); + const at = tr.mapping.map(start); + return tr.setBlockType(at, at, type, { language: match[1] || null }); +}; + +/** "---" replaces the paragraph with a divider and a paragraph to type in after it. */ +const divider: Rule['apply'] = (state, _match, start, end) => { + const rule = state.schema.nodes.horizontalRule; + const $start = state.doc.resolve(start); + if (!textblockCan(state, start, rule) || end - start !== $start.parent.content.size) return null; + const from = $start.before(); + const tr = state.tr.replaceWith( + from, + $start.after(), + Fragment.from([rule.create(), state.schema.nodes.paragraph.create()]), + ); + return tr.setSelection(TextSelection.create(tr.doc, from + 2)); +}; + +const escape = (text: string) => text.replace(/[*^$|?+.()[\]{}\\]/g, '\\$&'); + +/** A mark from text between delimiters: the delimiters go, and the text inside gets the mark. */ +function markRule(open: string, name: string): Rule { + const d = escape(open); + return { + match: new RegExp(`(?:^|[\\s(])(${d}(?!\\s)([^${escape(open[0])}]+)${d})$`), + apply(state, match, start, end) { + const type = state.schema.marks[name] as MarkType | undefined; + if (!type || !state.doc.resolve(end).parent.type.allowsMarkType(type) || /\s$/.test(match[2])) return null; + const tr = state.tr.delete(end - open.length, end).delete(start, start + open.length); + tr.addMark(start, start + match[2].length, type.create()); + return tr.removeStoredMark(type); + }, + }; +} + +export const MARKDOWN_RULES: readonly Rule[] = [ + { + match: /^(#{1,6})\s$/, + block: true, + apply: (state, match, start, end) => setBlock('heading', { level: match[1].length })(state, match, start, end), + }, + { match: /^\[!([A-Za-z]+)\]([+-]?)\s$/, block: true, apply: callout }, + { match: /^>\s$/, block: true, apply: wrap('blockquote') }, + { match: /^\[([ xX])\]\s$/, block: true, apply: task }, + { match: /^[-+*]\s$/, block: true, apply: wrap('bulletList') }, + { match: /^(\d{1,9})[.)]\s$/, block: true, apply: wrap('orderedList', (match) => ({ start: Number(match[1]) })) }, + { match: /^```([\w+#-]*)\s$/, block: true, apply: fence }, + { match: /^(?:---|—-|___\s|\*\*\*\s)$/, block: true, apply: divider }, + markRule('**', 'bold'), + markRule('__', 'bold'), + markRule('~~', 'strike'), + markRule('==', 'highlight'), + markRule('*', 'italic'), + markRule('_', 'italic'), + markRule('`', 'code'), +]; + +/** The rule that typing `text` at `from` triggers, with its match and the range it covers after typing. */ +export function findRule( + state: EditorState, + from: number, + to: number, + text: string, +): { rule: Rule; match: RegExpExecArray; start: number; end: number } | null { + const $from = state.doc.resolve(from); + if (from !== to && $from.parent !== state.doc.resolve(to).parent) return null; + const offset = $from.parentOffset; + const before = $from.parent.textBetween(Math.max(0, offset - MAX_MATCH), offset, undefined, OBJECT); + const end = from + text.length; + for (const rule of MARKDOWN_RULES) { + if (rule.block && before.length !== offset) continue; + const match = rule.match.exec(before + text); + if (!match) continue; + const start = rule.block ? $from.start() : end - match[1].length; + return { rule, match, start, end }; + } + return null; +} + +/** Types `text` and applies the rule it triggers, if any. Returns whether a rule ran. */ +export function applyMarkdownShortcut(view: EditorView, from: number, to: number, text: string): boolean { + const found = findRule(view.state, from, to, text); + if (!found || !canAutoChange(view, from)) return false; + const { rule, match, start, end } = found; + typeThenChange(view, { from, to, text }, (state): Made => { + const tr = rule.apply(state, match, start, end); + return tr ? { tr, meta: { from: start, to: end, text: '' } } : null; + }); + return true; +} + +/** Enter after "```lang" makes a code block too, as in most Markdown editors. */ +export function fenceOnEnter(view: EditorView): boolean { + const { $from, empty } = view.state.selection; + const fenced = /^```([\w+#-]*)$/.exec($from.parent.textContent); + if (!empty || !fenced || $from.parentOffset !== $from.parent.content.size || !canAutoChange(view, $from.pos)) { + return false; + } + const tr = fence(view.state, fenced, $from.start(), $from.pos); + if (!tr) return false; + view.dispatch(markAutoChange(tr, { from: $from.start(), to: $from.pos, text: '' }).scrollIntoView()); + return true; +} + +export const markdownShortcutsKey = new PluginKey('opennoteMarkdownShortcuts'); + +export function markdownShortcutsPlugin(host: EditorHost): Plugin { + return new Plugin({ + key: markdownShortcutsKey, + props: { + handleTextInput(view, from, to, text) { + if (!host.settings().markdownShortcuts) return false; + return applyMarkdownShortcut(view, from, to, text); + }, + handleKeyDown(view, event) { + if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return false; + return host.settings().markdownShortcuts && fenceOnEnter(view); + }, + }, + }); +} diff --git a/app/src/editor/extensions/math.ts b/app/src/editor/extensions/math.ts new file mode 100644 index 000000000..266d1944f --- /dev/null +++ b/app/src/editor/extensions/math.ts @@ -0,0 +1,151 @@ +// Math kept as source until Phase 10 renders it (ARCHITECTURE.md section 8.2; owner: WP4). Each atom shows its +// source as written, `$...$` inline and `$$` fences for display math, and opens a small source field on Enter or +// a double-click. Enter or leaving the field saves it as one undo step, Shift+Enter starts a new line of display +// math, and Escape puts the old source back. +import type { Editor, Extensions } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { NodeSelection, TextSelection } from '@tiptap/pm/state'; +import type { NodeView } from '@tiptap/pm/view'; +import { t } from '../../strings/t'; +import type { EditorHost } from '../host'; +import { META_COMMAND } from '../meta'; +import { MathBlock, MathInline } from '../schema/nodes'; +import styles from './content.module.css'; + +const views = new WeakMap(); + +class MathView implements NodeView { + dom: HTMLElement; + private shown: HTMLElement; + private field: HTMLInputElement | HTMLTextAreaElement | null = null; + + constructor( + private node: PMNode, + private editor: Editor, + private getPos: () => number | undefined, + private display: boolean, + ) { + this.dom = document.createElement(display ? 'div' : 'span'); + this.dom.className = display ? styles.mathBlock : styles.mathInline; + this.dom.setAttribute(display ? 'data-math-block' : 'data-math', ''); + this.shown = document.createElement('code'); + this.dom.append(this.shown); + this.dom.addEventListener('dblclick', () => this.open()); + views.set(this.dom, this); + this.render(); + } + + private source(): string { + return this.node.attrs.source as string; + } + + private render(): void { + const source = this.source(); + this.shown.textContent = this.display ? `$$\n${source}\n$$` : `$${source}$`; + this.dom.setAttribute(this.display ? 'data-math-block' : 'data-math', source); + this.dom.setAttribute('aria-label', t(this.display ? 'editor.math.block' : 'editor.math.inline', { source })); + } + + /** Opens the source field. */ + open(): void { + if (this.field || !this.editor.isEditable) return; + const field = document.createElement(this.display ? 'textarea' : 'input'); + field.className = styles.mathField; + field.value = this.source(); + field.setAttribute('aria-label', t('editor.math.field')); + field.addEventListener('keydown', (event) => this.onKey(event as KeyboardEvent)); + field.addEventListener('blur', () => this.close(true)); + this.field = field; + this.shown.hidden = true; + this.dom.append(field); + field.focus(); + field.select(); + } + + private onKey(event: KeyboardEvent): void { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + this.close(false); + } else if (event.key === 'Enter' && !(this.display && event.shiftKey)) { + event.preventDefault(); + this.close(true); + } + } + + private close(save: boolean): void { + const field = this.field; + if (!field) return; + this.field = null; + const source = field.value; + field.remove(); + this.shown.hidden = false; + const pos = this.getPos(); + if (pos === undefined) return; + const { state } = this.editor; + const tr = state.tr; + if (save && source !== this.source()) { + tr.setNodeMarkup(pos, undefined, { ...this.node.attrs, source }).setMeta(META_COMMAND, true); + } + tr.setSelection(TextSelection.near(tr.doc.resolve(pos + this.node.nodeSize))); + this.editor.view.dispatch(tr); + this.editor.view.focus(); + } + + update(node: PMNode): boolean { + if (node.type !== this.node.type) return false; + this.node = node; + if (!this.field) this.render(); + return true; + } + + stopEvent(event: Event): boolean { + return this.field !== null && event.target === this.field; + } + + ignoreMutation(): boolean { + return true; + } + + destroy(): void { + views.delete(this.dom); + } +} + +/** Enter on a selected math atom opens its source field. */ +function openSelected(editor: Editor): boolean { + const { selection } = editor.state; + if (!(selection instanceof NodeSelection) || !selection.node.type.name.startsWith('math')) return false; + const dom = editor.view.nodeDOM(selection.from); + const view = dom instanceof Element ? views.get(dom) : undefined; + view?.open(); + return Boolean(view); +} + +export function mathBlockExtensions(_host: EditorHost): Extensions { + return [ + MathBlock.extend({ + addNodeView: + () => + ({ node, editor, getPos }) => + new MathView(node, editor, getPos, true), + addKeyboardShortcuts() { + return { Enter: () => openSelected(this.editor) }; + }, + }), + ]; +} + +export function mathInlineExtensions(_host: EditorHost): Extensions { + return [ + MathInline.extend({ + addNodeView: + () => + ({ node, editor, getPos }) => + new MathView(node, editor, getPos, false), + addKeyboardShortcuts() { + return { Enter: () => openSelected(this.editor) }; + }, + }), + ]; +} diff --git a/app/src/editor/extensions/nodes.test.ts b/app/src/editor/extensions/nodes.test.ts new file mode 100644 index 000000000..b218bcf48 --- /dev/null +++ b/app/src/editor/extensions/nodes.test.ts @@ -0,0 +1,160 @@ +// @vitest-environment jsdom +// WP4's nodes in a mounted editor. Task items have real checkboxes, and Enter and Backspace work in lists. +// Adjacent lists join. Callouts are named by type and title, with a type menu and a fold button. Math has a +// source field, and image chips never load outside sources. +import { NodeSelection, TextSelection } from '@tiptap/pm/state'; +import { afterEach, describe, expect, it } from 'vitest'; +import { META_COMMAND } from '../meta'; +import { mountEditor, testHost } from '../commands/testing'; +import type { TestEditor } from '../commands/testing'; + +let mounted: TestEditor | null = null; +afterEach(() => { + mounted?.destroy(); + mounted = null; +}); + +function mount(source: string, host = testHost()): TestEditor { + mounted = mountEditor(source, host); + return mounted; +} + +function press(editor: TestEditor['editor'], key: string, init: KeyboardEventInit = {}): void { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }); + editor.view.someProp('handleKeyDown', (handle) => handle(editor.view, event)); +} + +describe('task items', () => { + it('draw a native checkbox named by the item’s text', () => { + const { root } = mount('- [ ] Water the plants\n- [x] Buy soil\n- plain'); + const boxes = [...root.querySelectorAll('input[type="checkbox"]')]; + expect(boxes.map((box) => [box.getAttribute('aria-label'), box.checked])).toEqual([ + ['Water the plants', false], + ['Buy soil', true], + ]); + }); + + it('check and uncheck from the checkbox as one command', () => { + const page = mount('- [ ] Water the plants'); + const metas: unknown[] = []; + page.editor.on('transaction', ({ transaction }) => metas.push(transaction.getMeta(META_COMMAND))); + const box = page.root.querySelector('input[type="checkbox"]')!; + box.click(); + expect(page.markdown()).toBe('- [x] Water the plants'); + expect(metas).toContain(true); + }); + + it('start a new unchecked task on Enter, and leave the list from an empty one', () => { + const page = mount('- [x] Done[]'); + press(page.editor, 'Enter'); + page.editor.commands.insertContent('Next'); + expect(page.markdown()).toBe('- [x] Done\n- [ ] Next'); + press(page.editor, 'Enter'); + press(page.editor, 'Enter'); + expect(page.markdown()).toBe('- [x] Done\n- [ ] Next'); + expect(page.editor.state.doc.lastChild?.type.name).toBe('paragraph'); + }); + + it('lose the checkbox, then the list, on Backspace at the start', () => { + const page = mount('- [ ] []Task'); + press(page.editor, 'Backspace'); + expect(page.markdown()).toBe('- Task'); + press(page.editor, 'Backspace'); + expect(page.markdown()).toBe('Task'); + }); +}); + +describe('adjacent lists', () => { + it('join when an edit puts two lists of one kind side by side', () => { + const page = mount('- one\n\nbetween[]\n\n- two'); + const { state } = page.editor; + const $at = state.selection.$from; + page.editor.view.dispatch(state.tr.delete($at.before(), $at.after())); + expect(page.markdown()).toBe('- one\n- two'); + }); + + it('stay apart when they are different kinds', () => { + const page = mount('- one\n\nbetween[]\n\n1. two'); + const { state } = page.editor; + const $at = state.selection.$from; + page.editor.view.dispatch(state.tr.delete($at.before(), $at.after())); + expect(page.markdown()).toBe('- one\n\n1. two'); + }); +}); + +describe('callouts', () => { + it('are notes named by their type and title', () => { + const { root } = mount('> [!warning] Mind the gap\n> Text'); + const note = root.querySelector('[role="note"]')!; + expect(note.getAttribute('aria-label')).toBe('Warning: Mind the gap'); + expect(root.querySelector('button[aria-label="Callout type: Warning"]')).not.toBeNull(); + }); + + it('change type from the type menu', async () => { + const host = testHost(); + host.menuAnswer = 'tip'; + const page = mount('> [!note] Title\n> Text', host); + page.root.querySelector('button[aria-haspopup="menu"]')!.click(); + await Promise.resolve(); + await Promise.resolve(); + expect(page.markdown()).toBe('> [!tip] Title\n> Text'); + }); + + it('fold with a real button that changes the Markdown', () => { + const page = mount('> [!note]+ Title\n> Body'); + const fold = page.root.querySelector('button[aria-expanded]')!; + expect(fold.getAttribute('aria-label')).toBe('Collapse Title'); + expect(fold.getAttribute('aria-expanded')).toBe('true'); + fold.click(); + expect(page.markdown()).toBe('> [!note]- Title\n> Body'); + const after = page.root.querySelector('button[aria-expanded]')!; + expect(after.getAttribute('aria-expanded')).toBe('false'); + expect(after.getAttribute('aria-label')).toBe('Expand Title'); + expect(page.host.announced).toEqual(['Folded Note: Title.']); + }); + + it('show no fold button unless the callout folds', () => { + const { root } = mount('> [!note] Title\n> Body'); + expect(root.querySelector('button[aria-expanded]')?.hidden).toBe(true); + }); + + it('turn back into text on Backspace at the start of the title', () => { + const page = mount('> [!note] []Title\n> Body'); + press(page.editor, 'Backspace'); + expect(page.markdown()).toBe('Title\n\nBody'); + }); +}); + +describe('math', () => { + it('shows its source and edits it in a field opened with Enter', () => { + const page = mount('Area $\\pi r^2$ here'); + const atom = page.root.querySelector('[data-math]')!; + expect(atom.textContent).toBe('$\\pi r^2$'); + const pos = page.editor.state.doc.firstChild!.child(0).nodeSize + 1; + page.editor.view.dispatch(page.editor.state.tr.setSelection(NodeSelection.create(page.editor.state.doc, pos))); + press(page.editor, 'Enter'); + const field = page.root.querySelector('input[aria-label="Math source"]')!; + field.value = '\\tau r'; + field.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(page.markdown()).toBe('Area $\\tau r$ here'); + expect(page.editor.state.selection).toBeInstanceOf(TextSelection); + }); + + it('puts the old source back on Escape', () => { + const page = mount('$x$'); + page.root.querySelector('[data-math]')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + const field = page.root.querySelector('input[aria-label="Math source"]')!; + field.value = 'y'; + field.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(page.markdown()).toBe('$x$'); + }); +}); + +describe('inline images', () => { + it('show a chip and never load an outside source', () => { + const { root } = mount('See ![A leaf](https://example.com/leaf.png) here'); + const chip = root.querySelector('[role="img"]')!; + expect(chip.getAttribute('aria-label')).toBe('Image: A leaf'); + expect(root.querySelector('img')).toBeNull(); + }); +}); diff --git a/app/src/editor/extensions/outline.test.ts b/app/src/editor/extensions/outline.test.ts new file mode 100644 index 000000000..c8d9d59ed --- /dev/null +++ b/app/src/editor/extensions/outline.test.ts @@ -0,0 +1,145 @@ +// @vitest-environment jsdom +// Outline moves, levels, and folding in a mounted editor. Moves swap items and heading sections and stop at the +// edges. Levels refuse past heading 1 and 6. Fold buttons are real buttons with aria-expanded, and folded content +// gets `hidden`. Fold keys survive a reload, and unfoldTo uncovers a position. +import { afterEach, describe, expect, it } from 'vitest'; +import { foldKeysFor, foldsOf, positionsFor, setFolds, unfoldTo } from '../commands/fold'; +import { runOutline } from '../commands/outlineRun'; +import { mountEditor } from '../commands/testing'; +import type { TestEditor } from '../commands/testing'; + +let mounted: TestEditor | null = null; +afterEach(() => { + mounted?.destroy(); + mounted = null; +}); + +function mount(source: string): TestEditor { + mounted = mountEditor(source); + return mounted; +} + +describe('outline moves', () => { + it('swap list items and keep the caret in the moved one', () => { + const page = mount('- one\n- t[]wo\n- three'); + expect(runOutline(page.editor, 'outline.moveUp')).toBe(true); + expect(page.markdown()).toBe('- two\n- one\n- three'); + expect(page.editor.state.selection.$from.parent.textContent).toBe('two'); + expect(page.host.announced).toEqual(['Moved up.']); + }); + + it('stop at the edge with an announcement', () => { + const page = mount('- o[]ne\n- two'); + expect(runOutline(page.editor, 'outline.moveUp')).toBe(false); + expect(page.markdown()).toBe('- one\n- two'); + expect(page.host.announced).toEqual(['Top of the text box.']); + }); + + it('move a heading with its section past the next section', () => { + const page = mount('## A[]\n\na text\n\n## B\n\nb text'); + runOutline(page.editor, 'outline.moveDown'); + expect(page.markdown()).toBe('## B\n\nb text\n\n## A\n\na text'); + }); +}); + +describe('outline levels', () => { + it('demote and promote a list item with its children', () => { + const page = mount('- one\n- t[]wo\n - child'); + runOutline(page.editor, 'outline.demote'); + expect(page.markdown()).toBe('- one\n\n - two\n\n - child'); + expect(page.host.announced.at(-1)).toBe('Level 2.'); + }); + + it('shift a heading and its subheadings, and refuse past heading 6', () => { + const page = mount('## A[]\n\n### Sub\n\n## B'); + runOutline(page.editor, 'outline.demote'); + expect(page.markdown()).toBe('### A\n\n#### Sub\n\n## B'); + const deep = mount('##### A[]\n\n###### Sub'); + expect(runOutline(deep.editor, 'outline.demote')).toBe(false); + expect(deep.host.announced).toEqual(['Heading 6 is the deepest level.']); + }); + + it('say that a paragraph has no level', () => { + const page = mount('plain[]'); + runOutline(page.editor, 'outline.promote'); + expect(page.host.announced).toEqual(['Only list items and headings change level.']); + }); +}); + +describe('folding', () => { + const buttons = (page: TestEditor) => [...page.root.querySelectorAll('button[aria-expanded]')]; + + it('gives headings and list items with children a named fold button, outside the Tab order', () => { + const page = mount('## Light reactions\n\nText\n\n- item\n - child\n- leaf'); + const found = buttons(page); + expect(found.map((button) => [button.getAttribute('aria-label'), button.getAttribute('aria-expanded')])).toEqual([ + ['Collapse Light reactions', 'true'], + ['Collapse item', 'true'], + ]); + expect(found.every((button) => button.tabIndex === -1)).toBe(true); + expect(page.root.querySelector('h2')?.hasAttribute('aria-expanded')).toBe(false); + expect(page.root.querySelector('li')?.hasAttribute('aria-expanded')).toBe(false); + }); + + it('keeps each button while its heading or item is typed in, and renames it', () => { + const page = mount('## Light[]\n\nText\n\n- item\n - child'); + const [heading, item] = buttons(page); + page.editor.commands.insertContent(' reactions'); + let itemAt = -1; + page.editor.state.doc.descendants((node, pos) => { + if (itemAt < 0 && node.type.name === 'listItem') itemAt = pos; + }); + // The end of "item": the item, its paragraph, then four letters. + page.editor.commands.setTextSelection(itemAt + 6); + page.editor.commands.insertContent('s'); + expect(buttons(page)[0]).toBe(heading); + expect(buttons(page)[1]).toBe(item); + expect(buttons(page).map((button) => button.getAttribute('aria-label'))).toEqual([ + 'Collapse Light reactions', + 'Collapse items', + ]); + }); + + it('hides the section when folded, announces it, and shows it again', () => { + const page = mount('## Light reactions\n\nOne\n\nTwo\n\n## Next'); + buttons(page)[0].click(); + const hidden = [...page.root.querySelectorAll('p')].map((p) => p.hidden); + expect(hidden).toEqual([true, true]); + expect(buttons(page)[0].getAttribute('aria-expanded')).toBe('false'); + expect(buttons(page)[0].getAttribute('aria-label')).toBe('Expand Light reactions'); + expect(page.host.announced).toEqual(['Folded Light reactions, 2 paragraphs hidden.']); + buttons(page)[0].click(); + expect([...page.root.querySelectorAll('p')].some((p) => p.hidden)).toBe(false); + }); + + it('folds with the commands, shows levels, and shows everything', () => { + const page = mount('# A[]\n\n## B\n\nText\n\n## C\n\nMore'); + runOutline(page.editor, 'outline.showLevel2'); + expect(foldsOf(page.editor.state)).toHaveLength(2); + runOutline(page.editor, 'outline.showAll'); + expect(foldsOf(page.editor.state)).toEqual([]); + runOutline(page.editor, 'outline.fold'); + expect(foldsOf(page.editor.state)).toEqual([0]); + runOutline(page.editor, 'outline.unfold'); + expect(foldsOf(page.editor.state)).toEqual([]); + }); + + it('keeps folds through typing, saves them as keys, and finds them again', () => { + const page = mount('## A\n\nText\n\n## A\n\nMore'); + const second = page.editor.state.doc.child(0).nodeSize + page.editor.state.doc.child(1).nodeSize; + page.editor.view.dispatch(setFolds(page.editor.state.tr, [second])); + page.editor.view.dispatch(page.editor.state.tr.insertText('x', page.editor.state.doc.child(0).nodeSize + 2)); + const keys = foldKeysFor(page.editor.state.doc, 'b1'); + expect(keys).toEqual([{ block: 'b1', kind: 'heading', text: 'A', occurrence: 1 }]); + expect(positionsFor(page.editor.state.doc, keys)).toEqual(foldsOf(page.editor.state)); + }); + + it('unfolds whatever hides a position', () => { + const page = mount('## A\n\nHidden text'); + page.editor.view.dispatch(setFolds(page.editor.state.tr, [0])); + const inside = page.editor.state.doc.child(0).nodeSize + 2; + expect(unfoldTo(page.editor, inside)).toBe(true); + expect(foldsOf(page.editor.state)).toEqual([]); + expect(unfoldTo(page.editor, inside)).toBe(false); + }); +}); diff --git a/app/src/editor/extensions/outline.ts b/app/src/editor/extensions/outline.ts new file mode 100644 index 000000000..a5c6a8b76 --- /dev/null +++ b/app/src/editor/extensions/outline.ts @@ -0,0 +1,8 @@ +// Outline moves: move up, move down, promote, and demote (owner: WP4). +// WP0's factory adds nothing. Its owner replaces the body, keeping the signature that kit.ts calls. +import type { Extensions } from '@tiptap/core'; +import type { EditorHost } from '../host'; + +export function outlineExtensions(_host: EditorHost): Extensions { + return []; +} diff --git a/app/src/editor/extensions/slash.ts b/app/src/editor/extensions/slash.ts new file mode 100644 index 000000000..1040e3d82 --- /dev/null +++ b/app/src/editor/extensions/slash.ts @@ -0,0 +1,149 @@ +// The slash menu's trigger (ARCHITECTURE.md section 17.1; owner: WP4). Typing "/" at the start of a line opens a +// session that follows the filter typed after it. The menu itself is the page's: the editor announces the session +// with SLASH_MENU_EVENT, and the page attaches its listbox to it. +// +// While a menu is attached, Up, Down, Enter, Tab, and Escape go to it, and the editor keeps focus. The trigger +// never opens in links, code, math, or table cells, while composing, or when the setting or flag is off. +import { Extension } from '@tiptap/core'; +import type { Extensions } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; +import { ancestorDepth } from '../commands/command'; +import type { EditorHost } from '../host'; + +export { SLASH_MENU_EVENT } from '../commands/state'; +import { SLASH_MENU_EVENT } from '../commands/state'; + +/** What the page's menu does with a session's keys and changes. */ +export interface SlashMenuUi { + update(): void; + /** Handles Up, Down, Enter, Tab, and Escape. Returns whether it used the key. */ + key(event: KeyboardEvent): boolean; + close(): void; +} + +/** One open slash menu: the "/" at `from` and the filter typed after it. */ +export interface SlashSession { + readonly view: EditorView; + readonly from: number; + readonly query: string; + /** Where the "/" is on screen, for placing the menu. */ + rect(): { left: number; top: number; bottom: number }; + /** The page's menu attaches here when it opens. */ + attach(ui: SlashMenuUi): void; + /** Deletes the "/" and the filter, before the chosen item runs. */ + remove(): void; + /** Closes the session, keeping the text. */ + close(): void; +} + +interface SlashState { + from: number; + query: string; +} + +export const slashKey = new PluginKey('opennoteSlash'); +const OPEN = 'opennote.slashOpen'; +const CLOSE = 'opennote.slashClose'; + +/** Whether "/" may open the menu at the selection. */ +export function slashAllowed(state: EditorState, host: EditorHost): boolean { + const { $from, empty } = state.selection; + if (!empty || !$from.parent.isTextblock || $from.parent.type.spec.code || $from.parentOffset !== 0) return false; + if (!host.settings().slashMenu || !host.flag('page.slashMenu')) return false; + if (ancestorDepth($from, ['tableCell', 'tableHeader', 'calloutTitle']) >= 0) return false; + return !$from.marks().some((mark) => mark.type.name === 'link' || mark.type.spec.code); +} + +/** The session's state after `tr`: the "/" mapped, and the filter between it and the caret, or closed. */ +function follow(tr: Transaction, value: SlashState | null): SlashState | null { + const opened = tr.getMeta(OPEN) as number | undefined; + if (opened !== undefined) return { from: opened, query: '' }; + if (!value || tr.getMeta(CLOSE)) return null; + const from = tr.mapping.map(value.from); + const { $head, empty } = tr.selection; + if (!empty || $head.pos <= from || $head.parent !== tr.doc.resolve(from).parent) return null; + const typed = tr.doc.textBetween(from, $head.pos, '\n', '\n'); + if (!typed.startsWith('/') || typed.includes('\n') || typed.length > 40 || /\s\s/.test(typed)) return null; + return { from, query: typed.slice(1) }; +} + +function makeSession(view: EditorView, state: SlashState, owner: { ui: SlashMenuUi | null }): SlashSession { + return { + view, + from: state.from, + query: state.query, + rect() { + try { + const at = view.coordsAtPos(state.from); + return { left: at.left, top: at.top, bottom: at.bottom }; + } catch { + const box = view.dom.getBoundingClientRect(); + return { left: box.left, top: box.top, bottom: box.top }; + } + }, + attach: (ui) => void (owner.ui = ui), + remove() { + const current = slashKey.getState(view.state); + if (!current) return; + const to = view.state.selection.head; + view.dispatch(view.state.tr.delete(current.from, to).setMeta(CLOSE, true)); + }, + close: () => view.dispatch(view.state.tr.setMeta(CLOSE, true)), + }; +} + +function slashPlugin(host: EditorHost): Plugin { + const owner: { ui: SlashMenuUi | null } = { ui: null }; + return new Plugin({ + key: slashKey, + state: { init: () => null, apply: follow }, + props: { + handleTextInput(view, from, to, text) { + if (text !== '/' || view.composing || !slashAllowed(view.state, host) || from !== to) return false; + view.dispatch(view.state.tr.insertText('/', from, to).setMeta(OPEN, from)); + return true; + }, + handleKeyDown(_view, event) { + return owner.ui !== null && owner.ui.key(event); + }, + }, + view: (view) => ({ + update(_view, previous) { + const before = slashKey.getState(previous); + const now = slashKey.getState(view.state); + if (before === now) return; + if (!now) { + owner.ui?.close(); + owner.ui = null; + return; + } + if (before) { + // The page's menu reads the filter as it attaches, so a session it hasn't reached yet waits. + owner.ui?.update(); + return; + } + const session = makeSession(view, now, owner); + view.dom.dispatchEvent(new CustomEvent(SLASH_MENU_EVENT, { bubbles: true, detail: session })); + }, + destroy: () => owner.ui?.close(), + }), + }); +} + +/** The open session in a view, for the page's menu to read the current filter. */ +export function slashSessionOf(view: EditorView): { from: number; query: string } | null { + return slashKey.getState(view.state) ?? null; +} + +export function slashExtensions(host: EditorHost): Extensions { + return [ + Extension.create({ + name: 'opennoteSlash', + // Before the editor's own Enter, Tab, and Escape, so the open menu gets them first. + priority: 1100, + addProseMirrorPlugins: () => [slashPlugin(host)], + }), + ]; +} diff --git a/app/src/editor/extensions/spellingRanges.test.ts b/app/src/editor/extensions/spellingRanges.test.ts new file mode 100644 index 000000000..0f3f4c49a --- /dev/null +++ b/app/src/editor/extensions/spellingRanges.test.ts @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import type { SpellingService } from '../host'; +import { rangeOver, SpellingHighlights, textblockText } from './spellingRanges'; + +function paragraph(html: string): HTMLElement { + const element = document.createElement('p'); + element.innerHTML = html; + document.body.append(element); + return element; +} + +const service = ( + results: Record, +): SpellingService & { asked: string[] } => { + const asked: string[] = []; + return { + asked, + errorsFor: (text) => results[text] ?? null, + requestCheck: (_key, text) => void asked.push(text), + }; +}; + +describe('textblock text', () => { + it('masks code, math, images, and addresses, keeping offsets in line', () => { + const element = paragraph( + 'See teh and x at https://teh.example or a@teh.io
end', + ); + const { text } = textblockText(element); + expect(text).toBe(`See ${' '.repeat(3)} and ${' '.repeat(2)} at ${' '.repeat(19)} or ${' '.repeat(8)} end`); + }); + + it("skips ProseMirror's own separators, so static and mounted text are the same", () => { + const mounted = paragraph('teh
'); + expect(textblockText(mounted).text).toBe('teh'); + }); + + it('builds ranges across formatting', () => { + const element = paragraph('a recieve b'); + const range = rangeOver(textblockText(element), 2, 7); + expect(range?.toString()).toBe('recieve'); + }); +}); + +describe('spelling highlights', () => { + it('shows cached errors and asks for unknown text', () => { + const highlights = new SpellingHighlights(); + const element = paragraph('I recieve teh'); + const known = service({ 'I recieve teh': [{ start: 2, length: 7 }] }); + expect(highlights.refresh(element, known, true)).toBeNull(); + expect(highlights.ranges(element).map(String)).toEqual(['recieve']); + const unknown = service({}); + expect(highlights.refresh(paragraph('other'), unknown, true, 'k')).toBe('other'); + expect(unknown.asked).toEqual(['other']); + }); + + it('carries errors before and after an edit until the new text is checked', () => { + const highlights = new SpellingHighlights(); + const element = paragraph('teh cat recieve'); + highlights.refresh( + element, + service({ + 'teh cat recieve': [ + { start: 0, length: 3 }, + { start: 8, length: 7 }, + ], + }), + false, + ); + element.firstChild!.textContent = 'teh big cat recieve'; + highlights.refresh(element, service({}), false); + expect(highlights.entry(element)?.errors).toEqual([ + { start: 0, length: 3 }, + { start: 12, length: 7 }, + ]); + expect(highlights.ranges(element).map(String)).toEqual(['teh', 'recieve']); + element.firstChild!.textContent = 'tehx big cat recieve'; + highlights.refresh(element, service({}), false); + expect(highlights.ranges(element).map(String)).toEqual(['recieve']); + }); + + it('keeps the ranges that typing moved with the text, so the page is not repainted', () => { + const highlights = new SpellingHighlights(); + const element = paragraph('a teh cat'); + highlights.refresh(element, service({ 'a teh cat': [{ start: 2, length: 3 }] }), false); + const [before] = highlights.ranges(element); + (element.firstChild as Text).insertData(0, 'big '); + highlights.refresh(element, service({}), false); + expect(highlights.ranges(element)).toEqual([before]); + expect(highlights.ranges(element)[0]).toBe(before); + expect(String(before)).toBe('teh'); + }); + + it('leaves the word at the caret alone until the caret leaves it', () => { + const highlights = new SpellingHighlights(); + const element = paragraph('a teh'); + const known = service({ 'a teh': [{ start: 2, length: 3 }] }); + highlights.setGuard(element, 5); + highlights.refresh(element, known, false); + expect(highlights.ranges(element)).toHaveLength(0); + highlights.setGuard(null); + highlights.refresh(element, known, false); + expect(highlights.ranges(element).map(String)).toEqual(['teh']); + }); + + it('lists elements in document order and forgets removed ones', () => { + const highlights = new SpellingHighlights(); + const first = paragraph('one'); + const second = paragraph('two'); + const none = service({ one: [], two: [] }); + highlights.refresh(second, none, false); + highlights.refresh(first, none, false); + expect(highlights.shownIn(document.body)).toEqual([first, second]); + second.remove(); + highlights.resolve(none); + expect(highlights.entry(second)).toBeNull(); + }); +}); diff --git a/app/src/editor/extensions/spellingRanges.ts b/app/src/editor/extensions/spellingRanges.ts new file mode 100644 index 000000000..5ac6c00ea --- /dev/null +++ b/app/src/editor/extensions/spellingRanges.ts @@ -0,0 +1,130 @@ +// Spelling squiggles (ARCHITECTURE.md section 16.3; owner: WP7). One CSS Custom Highlight named spelling-error holds +// DOM ranges over the misspelled words, so squiggles change no DOM and work on static blocks before an editor +// mounts. Text comes from the DOM the same way for static and mounted blocks, so mounting and demotion never check a +// textblock twice. In a mounted editor this plugin notices changed textblocks after the keystroke's task, keeps +// their squiggles in step until the new text is checked, and asks the page's spelling service to check it. +import { Extension } from '@tiptap/core'; +import type { Extensions } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; +import type { EditorHost } from '../host'; +import { isCheckable, spellingHighlights } from './spellingText'; + +export { + HIGHLIGHT_NAME, + isCheckable, + rangeOver, + SpellingHighlights, + spellingHighlights, + TEXTBLOCK_SELECTOR, + textblockText, +} from './spellingText'; +export type { SpellRange, TextblockText } from './spellingText'; + +/** Where `next` differs from `prev`, in `next`'s positions. */ +function changedRange(prev: PMNode, next: PMNode): { from: number; to: number } | null { + const start = next.content.findDiffStart(prev.content); + if (start === null) return null; + const end = next.content.findDiffEnd(prev.content); + const to = Math.max(end ? end.a : start, start); + return { from: start, to: Math.min(next.content.size, Math.max(to, start + 1)) }; +} + +/** Calls `visit` for each checkable textblock between `from` and `to`, with its element. */ +function eachTextblock( + view: EditorView, + from: number, + to: number, + visit: (element: Element, pos: number, node: PMNode) => void, +): void { + view.state.doc.nodesBetween(from, to, (node, pos) => { + if (node.type.spec.code) return false; + if (!node.isTextblock) return true; + const element = view.nodeDOM(pos); + if (element instanceof Element && isCheckable(element)) visit(element, pos, node); + return false; + }); +} + +const spellingKey = new PluginKey('opennote.spelling'); + +function spellingPlugin(host: EditorHost): Plugin { + return new Plugin({ + key: spellingKey, + // WebView2's own checker knows one language and no dictionary, and would squiggle a second time (ADR 0023). + props: { attributes: { spellcheck: 'false' } }, + view(view) { + let base: PMNode | null = null; + let all = true; + let typed = false; + let timer: ReturnType | null = null; + const enabled = () => host.settings().spelling.enabled && host.flag('editor.spelling'); + const run = () => { + timer = null; + const service = host.spelling(); + if (!service || !enabled() || view.isDestroyed) return; + const { doc, selection } = view.state; + const range = all ? { from: 0, to: doc.content.size } : base && changedRange(base, doc); + const head = selection.empty ? selection.head : -1; + const block = view.dom.getAttribute('data-block') ?? ''; + if (!typed) spellingHighlights.setGuard(null); + // Mounting in place replaced the static text's elements; their squiggles go before the new ones show. + if (all) spellingHighlights.prune(); + if (range) { + eachTextblock(view, range.from, range.to, (element, pos, node) => { + if (typed && head > pos && head < pos + node.nodeSize) spellingHighlights.setGuard(element, head - pos - 1); + spellingHighlights.refresh(element, service, true, `${block}:${pos}`); + }); + } + base = null; + all = false; + typed = false; + }; + const schedule = () => { + timer ??= setTimeout(run, 0); + }; + /** The caret left the guarded word: show it. */ + const caretMoved = () => { + const guard = spellingHighlights.guarded(); + const service = host.spelling(); + if (!guard || !service || !view.dom.contains(guard.element)) return; + const { selection } = view.state; + const at = selection.empty ? view.posAtDOM(guard.element, 0) : -1; + const offset = selection.head - at; + const entry = spellingHighlights.entry(guard.element); + const word = entry?.errors.find( + (error) => error.start <= guard.offset && guard.offset <= error.start + error.length, + ); + if (at >= 0 && word && offset >= word.start && offset <= word.start + word.length) return; + spellingHighlights.setGuard(null); + spellingHighlights.refresh(guard.element, service, false); + }; + schedule(); + return { + update(next, prev) { + if (next.state.doc !== prev.doc) { + base ??= prev.doc; + typed = true; + schedule(); + } else if (next.state.selection !== prev.selection && spellingHighlights.guarded()) { + setTimeout(caretMoved, 0); + } + }, + destroy() { + if (timer) clearTimeout(timer); + spellingHighlights.clear(view.dom); + }, + }; + }, + }); +} + +export function spellingRangesExtensions(host: EditorHost): Extensions { + return [ + Extension.create({ + name: 'spellingRanges', + addProseMirrorPlugins: () => [spellingPlugin(host)], + }), + ]; +} diff --git a/app/src/editor/extensions/spellingText.ts b/app/src/editor/extensions/spellingText.ts new file mode 100644 index 000000000..17e51d845 --- /dev/null +++ b/app/src/editor/extensions/spellingText.ts @@ -0,0 +1,248 @@ +// The DOM side of spelling squiggles (ARCHITECTURE.md section 16.3; owner: WP7): textblock text, ranges over it, and +// the spelling-error CSS Custom Highlight. It imports nothing from the editor, so static pages and read aloud use it +// without loading Tiptap. +import type { SpellingService } from '../host'; + +/** UTF-16 offsets into a textblock's checked text. */ +export interface SpellRange { + readonly start: number; + readonly length: number; +} + +/** A textblock's text as the checker sees it, and the DOM text nodes it came from. */ +export interface TextblockText { + readonly text: string; + readonly nodes: readonly { node: Text; at: number }[]; +} + +/** The elements whose text is checked: paragraphs, headings, and callout titles. Code blocks never are. */ +export const TEXTBLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, [data-callout-title]'; +export const HIGHLIGHT_NAME = 'spelling-error'; + +const SKIPPED = ['ProseMirror-separator', 'ProseMirror-trailingBreak', 'ProseMirror-widget']; +const ADDRESS = /\b(?:[a-z][a-z0-9+.-]*:\/\/|www\.)[^\s<>"]+|[^\s@<>"()]+@[^\s@<>"()]+\.[^\s@<>"()]+/gi; + +const isAtom = (element: Element) => + element.localName === 'br' || + element.localName === 'img' || + element.hasAttribute('data-math') || + element.getAttribute('contenteditable') === 'false'; + +/** + * The text of a textblock element, with inline code, math, images, web addresses, and email addresses replaced by + * spaces, so offsets still line up with the words around them. Each inline atom counts as one character. Read aloud + * passes `mask` false to keep code and addresses. + */ +export function textblockText(element: Element, mask = true): TextblockText { + let text = ''; + const nodes: { node: Text; at: number }[] = []; + const walk = (parent: Node, masked: boolean) => { + for (let child = parent.firstChild; child; child = child.nextSibling) { + if (child.nodeType === Node.TEXT_NODE) { + const data = (child as Text).data; + nodes.push({ node: child as Text, at: text.length }); + text += masked ? ' '.repeat(data.length) : data; + } else if (child instanceof Element) { + if (SKIPPED.some((name) => child.classList.contains(name))) continue; + if (isAtom(child)) text += ' '; + else walk(child, masked || (mask && child.localName === 'code')); + } + } + }; + walk(element, false); + return { text: mask ? text.replace(ADDRESS, (match) => ' '.repeat(match.length)) : text, nodes }; +} + +/** Whether a textblock element is checked: not in a code block or a widget. */ +export function isCheckable(element: Element): boolean { + return !element.closest('pre, [contenteditable="false"]'); +} + +/** The DOM range over `[start, start + length)` of a textblock's text, or null past its end. */ +export function rangeOver(extracted: TextblockText, start: number, length: number): Range | null { + /** A start prefers the node that begins at a boundary, an end the node that finishes there. */ + const point = (offset: number, end: boolean) => { + let found: { node: Text; offset: number } | null = null; + for (const { node, at } of extracted.nodes) { + const stop = at + node.data.length; + if (offset >= at && offset <= stop) { + found = { node, offset: offset - at }; + if (end || offset < stop) return found; + } + } + return found; + }; + const from = point(start, false); + const to = point(start + length, true); + if (!from || !to) return null; + const range = document.createRange(); + range.setStart(from.node, from.offset); + range.setEnd(to.node, to.offset); + return range; +} + +interface Entry { + text: string; + errors: readonly SpellRange[]; + ranges: Range[]; + waiting: boolean; +} + +function supportsHighlights(): boolean { + return typeof CSS !== 'undefined' && 'highlights' in CSS && typeof Highlight !== 'undefined'; +} + +const sameRanges = (a: readonly Range[], b: readonly Range[]) => + a.length === b.length && + a.every( + (range, i) => + range.startContainer === b[i].startContainer && + range.startOffset === b[i].startOffset && + range.endContainer === b[i].endContainer && + range.endOffset === b[i].endOffset, + ); + +/** The page's squiggles, by textblock element. */ +export class SpellingHighlights { + private readonly entries = new Map(); + private highlight: Highlight | null = null; + /** The word at the caret isn't flagged until the caret leaves it. */ + private guard: { element: Element; offset: number } | null = null; + + private registry(): Highlight | null { + if (!supportsHighlights()) return null; + if (!this.highlight || CSS.highlights.get(HIGHLIGHT_NAME) !== this.highlight) { + this.highlight = CSS.highlights.get(HIGHLIGHT_NAME) ?? new Highlight(); + this.highlight.type = 'spelling-error'; + CSS.highlights.set(HIGHLIGHT_NAME, this.highlight); + } + return this.highlight; + } + + /** Shows `errors` over the element's text, skipping the guarded word. */ + show(element: Element, extracted: TextblockText, errors: readonly SpellRange[], waiting = false): void { + const guard = this.guard?.element === element ? this.guard.offset : -1; + const shown = errors.filter((error) => guard < error.start || guard > error.start + error.length); + const ranges = shown.flatMap((error) => rangeOver(extracted, error.start, error.length) ?? []); + // Typing moves the live ranges with the text, so they usually match already. Changing a highlight's ranges + // repaints the whole page in Chromium, 17 ms a key with 200 squiggles in view, so unchanged ones stay put. + const old = this.entries.get(element); + if (old && sameRanges(old.ranges, ranges)) { + this.entries.set(element, { text: extracted.text, errors, ranges: old.ranges, waiting }); + return; + } + this.drop(element); + const highlight = this.registry(); + ranges.forEach((range) => highlight?.add(range)); + this.entries.set(element, { text: extracted.text, errors, ranges, waiting }); + } + + /** + * Keeps an element's squiggles in step with an edit until its new text is checked: errors before and after the + * changed part stay, shifted, and errors in it go. + */ + carry(element: Element, extracted: TextblockText): void { + const old = this.entries.get(element); + if (!old) return this.show(element, extracted, [], true); + const before = old.text; + const after = extracted.text; + let prefix = 0; + while (prefix < before.length && prefix < after.length && before[prefix] === after[prefix]) prefix += 1; + let suffix = 0; + while ( + suffix < before.length - prefix && + suffix < after.length - prefix && + before[before.length - 1 - suffix] === after[after.length - 1 - suffix] + ) { + suffix += 1; + } + const delta = after.length - before.length; + const changedEnd = before.length - suffix; + // A word that touches the change changed too. + const mapped = old.errors.flatMap((error) => { + if (error.start + error.length < prefix) return [error]; + if (error.start > changedEnd) return [{ start: error.start + delta, length: error.length }]; + return []; + }); + this.show(element, extracted, mapped, true); + } + + /** Re-reads every waiting element whose text now has results. */ + resolve(service: SpellingService): void { + for (const [element, entry] of [...this.entries]) { + if (!element.isConnected) this.drop(element, true); + else if (entry.waiting) this.refresh(element, service, false); + } + } + + /** Re-reads every element, as after a change to the personal dictionary or the ignored words. */ + refreshAll(service: SpellingService): void { + for (const element of [...this.entries.keys()]) { + if (!element.isConnected) this.drop(element, true); + else this.refresh(element, service, false); + } + } + + /** Shows the element's cached errors, or carries its old ones and asks for a check. */ + refresh(element: Element, service: SpellingService, request: boolean, key?: string): string | null { + const extracted = textblockText(element); + const errors = service.errorsFor(extracted.text); + if (errors) { + this.show(element, extracted, errors); + return null; + } + const entry = this.entries.get(element); + if (entry?.text === extracted.text) entry.waiting = true; + else this.carry(element, extracted); + if (request) service.requestCheck(key ?? extracted.text, extracted.text); + return extracted.text; + } + + setGuard(element: Element | null, offset = -1): void { + this.guard = element ? { element, offset } : null; + } + + guarded(): { element: Element; offset: number } | null { + return this.guard; + } + + /** The element's text and errors, as last shown. */ + entry(element: Element): { text: string; errors: readonly SpellRange[] } | null { + return this.entries.get(element) ?? null; + } + + /** The elements with squiggles inside `root`, in document order. */ + shownIn(root: Element): Element[] { + return [...this.entries.keys()] + .filter((element) => element.isConnected && root.contains(element)) + .sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1)); + } + + /** The shown ranges, for tests and the context menu. */ + ranges(element: Element): readonly Range[] { + return this.entries.get(element)?.ranges ?? []; + } + + /** Forgets elements that left the document, such as the static text an editor replaced when it mounted. */ + prune(): void { + for (const element of [...this.entries.keys()]) if (!element.isConnected) this.drop(element, true); + } + + /** Forgets every element inside `root`, or every element. */ + clear(root?: Element): void { + for (const element of [...this.entries.keys()]) { + if (!root || root.contains(element) || !element.isConnected) this.drop(element, true); + } + } + + private drop(element: Element, forget = false): void { + const entry = this.entries.get(element); + if (!entry) return; + entry.ranges.forEach((range) => this.highlight?.delete(range)); + entry.ranges = []; + if (forget) this.entries.delete(element); + } +} + +/** The page's squiggles. There is one page view per window, so one set. */ +export const spellingHighlights = new SpellingHighlights(); diff --git a/app/src/editor/extensions/tabKeys.test.ts b/app/src/editor/extensions/tabKeys.test.ts new file mode 100644 index 000000000..2bcd02652 --- /dev/null +++ b/app/src/editor/extensions/tabKeys.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment jsdom +// Tab and Shift+Tab, row by row of ARCHITECTURE.md section 22.4: list items indent and outdent, a paragraph at its +// start becomes or joins a bulleted list, elsewhere Tab types a tab, and code keeps its own Tab. +import type { Editor } from '@tiptap/core'; +import { TextSelection } from '@tiptap/pm/state'; +import { afterEach, describe, expect, it } from 'vitest'; +import { mountEditor } from '../commands/testing'; +import type { TestEditor } from '../commands/testing'; + +let mounted: TestEditor | null = null; +afterEach(() => { + mounted?.destroy(); + mounted = null; +}); + +function mount(source: string): TestEditor { + mounted = mountEditor(source); + return mounted; +} + +function press(editor: Editor, key: string, shiftKey = false): boolean { + const event = new KeyboardEvent('keydown', { key, shiftKey, bubbles: true, cancelable: true }); + return editor.view.someProp('handleKeyDown', (handle) => handle(editor.view, event)) ?? false; +} + +describe('Tab', () => { + it('indents a list item into the one above, and Shift+Tab brings it back', () => { + const page = mount('- one\n- []two'); + expect(press(page.editor, 'Tab')).toBe(true); + expect(page.markdown()).toBe('- one\n\n - two'); + expect(press(page.editor, 'Tab', true)).toBe(true); + expect(page.markdown()).toBe('- one\n- two'); + }); + + it('keeps the first item where it is, and still stays in the editor', () => { + const page = mount('- []one'); + expect(press(page.editor, 'Tab')).toBe(true); + expect(page.markdown()).toBe('- one'); + }); + + it('outdents a top-level item to a paragraph, announcing it', () => { + const page = mount('- []one'); + press(page.editor, 'Tab', true); + expect(page.markdown()).toBe('one'); + expect(page.host.announced).toEqual(['Text.']); + }); + + it('turns a paragraph at its start into a bulleted item, joining the list above', () => { + const page = mount('- one\n\n[]two'); + press(page.editor, 'Tab'); + expect(page.markdown()).toBe('- one\n- two'); + expect(page.host.announced).toEqual(['Bulleted list.']); + }); + + it('turns several selected paragraphs into bulleted items', () => { + const page = mount('one\n\ntwo'); + const { doc } = page.editor.state; + page.editor.view.dispatch(page.editor.state.tr.setSelection(TextSelection.create(doc, 2, doc.content.size - 2))); + press(page.editor, 'Tab'); + expect(page.markdown()).toBe('- one\n- two'); + }); + + it('types a tab character elsewhere, and Shift+Tab does nothing there', () => { + const page = mount('one[] two'); + press(page.editor, 'Tab'); + expect(page.editor.state.doc.textContent).toBe('one\t two'); + expect(press(page.editor, 'Tab', true)).toBe(true); + expect(page.editor.state.doc.textContent).toBe('one\t two'); + }); + + it('gives way in code blocks, where Tab indents the line', () => { + const page = mount('```\nco[]de\n```'); + expect(press(page.editor, 'Tab')).toBe(true); + expect(page.editor.state.doc.textContent).not.toContain('\t'); + expect(page.editor.state.doc.textContent).toMatch(/^ +code$/); + }); +}); diff --git a/app/src/editor/extensions/tabKeys.ts b/app/src/editor/extensions/tabKeys.ts new file mode 100644 index 000000000..304205544 --- /dev/null +++ b/app/src/editor/extensions/tabKeys.ts @@ -0,0 +1,43 @@ +// Tab and Shift+Tab (ARCHITECTURE.md section 22.4; owner: WP4). They stay inside the editor. A list item indents +// or outdents, a paragraph at its start becomes a bulleted list item, and elsewhere Tab types a tab character. +// Code blocks and table cells keep their own Tab keys, so these give way there. +import { Extension } from '@tiptap/core'; +import type { Editor, Extensions } from '@tiptap/core'; +import { t } from '../../strings/t'; +import { runCommand } from '../commands/command'; +import { indent, outdent, tabResult } from '../commands/tab'; +import type { EditorHost } from '../host'; + +function announceResult(host: EditorHost, editor: Editor, result: ReturnType): void { + if (result === 'bulleted') host.announce(t('editor.tab.bulleted')); + else if (result === 'numbered') host.announce(t('editor.announce.block', { name: t('editor.commands.orderedList') })); + else if (result === 'outdented' && editor.state.selection.$from.depth === 1) host.announce(t('editor.tab.paragraph')); +} + +function tab(host: EditorHost, editor: Editor, shift: boolean): boolean { + const result = tabResult(editor.state, shift); + if (result === 'tab') { + // A tab character is typing, not a command. + editor.view.dispatch(editor.state.tr.insertText('\t').scrollIntoView()); + return true; + } + const command = shift ? outdent() : indent(); + if (!command(editor.state)) return false; + runCommand(editor, command, { focus: false }); + announceResult(host, editor, result); + return true; +} + +export function tabKeysExtensions(host: EditorHost): Extensions { + return [ + Extension.create({ + name: 'opennoteTabKeys', + addKeyboardShortcuts() { + return { + Tab: () => tab(host, this.editor, false), + 'Shift-Tab': () => tab(host, this.editor, true), + }; + }, + }), + ]; +} diff --git a/app/src/editor/extensions/textColor.ts b/app/src/editor/extensions/textColor.ts new file mode 100644 index 000000000..c7299efe0 --- /dev/null +++ b/app/src/editor/extensions/textColor.ts @@ -0,0 +1,9 @@ +// The text color mark (owner: WP4). +// WP0's factory returns the schema's own definition. Its owner replaces the body, keeping the signature. +import type { Extensions } from '@tiptap/core'; +import type { EditorHost } from '../host'; +import { TextColor } from '../schema/marks'; + +export function textColorExtensions(_host: EditorHost): Extensions { + return [TextColor]; +} diff --git a/app/src/editor/extensions/textSize.ts b/app/src/editor/extensions/textSize.ts new file mode 100644 index 000000000..5a3e8a2e7 --- /dev/null +++ b/app/src/editor/extensions/textSize.ts @@ -0,0 +1,9 @@ +// The text size mark, with Larger and Smaller (owner: WP4). +// WP0's factory returns the schema's own definition. Its owner replaces the body, keeping the signature. +import type { Extensions } from '@tiptap/core'; +import type { EditorHost } from '../host'; +import { TextSize } from '../schema/marks'; + +export function textSizeExtensions(_host: EditorHost): Extensions { + return [TextSize]; +} diff --git a/app/src/editor/highlight/code.module.css b/app/src/editor/highlight/code.module.css new file mode 100644 index 000000000..85bca787d --- /dev/null +++ b/app/src/editor/highlight/code.module.css @@ -0,0 +1,90 @@ +/* + * Code blocks (ARCHITECTURE.md section 14; owner: WP6): the language button, and the colors of highlighted code. + * Each color is a code token from brand/tokens.json, 4.5:1 or more on the code background in both themes. In + * forced colors every token is CanvasText, so code reads as plain text there. + */ +@layer components { + .block { + position: relative; + } + + /* The button sits in the block's corner, out of the flow, so the block's box is the same before it mounts. */ + .language { + position: absolute; + inset-block-start: var(--space-1); + inset-inline-end: var(--space-1); + padding: 0 var(--space-2); + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: var(--color-surface-sunken); + color: var(--color-text-secondary); + font-family: var(--font-ui); + font-size: var(--type-caption-size); + line-height: var(--type-caption-line); + cursor: pointer; + user-select: none; + } + + .language:hover { + border-color: var(--color-border-subtle); + background: var(--color-surface-hover); + } + + .language:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 1px; + } + + .keyword { + color: var(--color-code-keyword); + } + + .string { + color: var(--color-code-string); + } + + .number { + color: var(--color-code-number); + } + + .comment { + color: var(--color-code-comment); + font-style: italic; + } + + .function { + color: var(--color-code-function); + } + + .type { + color: var(--color-code-type); + } + + .variable { + color: var(--color-code-variable); + } + + .punctuation { + color: var(--color-code-punctuation); + } + + .deletion { + color: var(--color-status-danger); + } + + .emphasis { + font-style: italic; + } + + .strong { + font-weight: var(--font-weight-semibold); + } + + @media (forced-colors: active) { + .language { + border-color: ButtonText; + background: ButtonFace; + color: ButtonText; + } + } +} diff --git a/app/src/editor/highlight/engine.ts b/app/src/editor/highlight/engine.ts new file mode 100644 index 000000000..cbab1f0dc --- /dev/null +++ b/app/src/editor/highlight/engine.ts @@ -0,0 +1,135 @@ +// The highlighter (ARCHITECTURE.md section 14.2; owner: WP6), the lazy chunk of highlighting. It is lowlight with +// only the grammars a page uses, and it turns lowlight's output into flat tokens in OpenNote's code colors. It +// imports nothing the page shares, so the chunk is only lowlight and highlight.js's core. Tests spy on +// `engine.highlight` to prove that no highlighting runs inside a transaction. +import type { Element, Root, RootContent } from 'hast'; +import { createLowlight } from 'lowlight'; +import { GRAMMARS } from './grammars'; + +export type TokenKind = + | 'keyword' + | 'string' + | 'number' + | 'comment' + | 'function' + | 'type' + | 'variable' + | 'punctuation' + | 'deletion' + | 'emphasis' + | 'strong'; + +/** A colored run of a code block's text, in UTF-16 offsets from the block's start. */ +export interface Token { + readonly from: number; + readonly to: number; + readonly kind: TokenKind; +} + +/** highlight.js scopes and the colors they take. `meta` takes punctuation's, as WP0's notes say. */ +const KINDS: Readonly> = { + keyword: 'keyword', + literal: 'keyword', + 'selector-tag': 'keyword', + doctag: 'keyword', + name: 'keyword', + 'template-tag': 'keyword', + string: 'string', + regexp: 'string', + char: 'string', + symbol: 'string', + link: 'string', + addition: 'string', + number: 'number', + bullet: 'number', + comment: 'comment', + quote: 'comment', + title: 'function', + section: 'function', + 'selector-id': 'function', + 'selector-class': 'function', + type: 'type', + class: 'type', + built_in: 'type', + variable: 'variable', + params: 'variable', + attr: 'variable', + attribute: 'variable', + property: 'variable', + 'template-variable': 'variable', + 'selector-attr': 'variable', + 'selector-pseudo': 'variable', + punctuation: 'punctuation', + operator: 'punctuation', + meta: 'punctuation', + tag: 'punctuation', + subst: 'punctuation', + deletion: 'deletion', + emphasis: 'emphasis', + strong: 'strong', +}; + +/** The color of one highlight.js span, from its classes such as ["hljs-title", "class_"]. */ +export function kindOf(classNames: readonly string[]): TokenKind | null { + const scope = classNames[0]?.replace(/^hljs-/, '') ?? ''; + const modifier = classNames[1] ?? ''; + if (scope === 'title' && modifier.startsWith('class')) return 'type'; + if (scope === 'variable' && modifier.startsWith('language')) return 'keyword'; + return KINDS[scope] ?? null; +} + +function flatten(tree: Root): Token[] { + const tokens: Token[] = []; + let offset = 0; + const walk = (nodes: readonly RootContent[], kind: TokenKind | null) => { + for (const node of nodes) { + if (node.type === 'text') { + const end = offset + node.value.length; + const last = tokens.at(-1); + if (kind && last && last.kind === kind && last.to === offset) tokens[tokens.length - 1] = { ...last, to: end }; + else if (kind && end > offset) tokens.push({ from: offset, to: end, kind }); + offset = end; + } else if (node.type === 'element') { + const classes = ((node as Element).properties.className ?? []) as string[]; + walk(node.children, kindOf(classes) ?? kind); + } + } + }; + walk(tree.children, null); + return tokens; +} + +const lowlight = createLowlight(); +const loads = new Map>(); + +export const engine = { + /** Colors `text` with a grammar that has loaded. */ + highlight(grammar: string, text: string): Token[] { + return flatten(lowlight.highlight(grammar, text)); + }, +}; + +export function grammarReady(grammar: string): boolean { + return lowlight.registered(grammar); +} + +/** Loads a grammar from languages.ts once. False when it can't load, and the block stays plain. */ +export function loadGrammar(grammar: string): Promise { + let load = loads.get(grammar); + if (!load) { + const loader = GRAMMARS[grammar]; + load = loader + ? loader().then( + (module) => { + lowlight.register(grammar, module.default); + // highlight.js compiles a grammar on first use. Doing it here keeps that cost out of the 20 ms guard. + lowlight.highlight(grammar, 'x'); + return true; + }, + () => false, + ) + : Promise.resolve(false); + loads.set(grammar, load); + } + return load; +} diff --git a/app/src/editor/highlight/grammars.ts b/app/src/editor/highlight/grammars.ts new file mode 100644 index 000000000..d5c8e6236 --- /dev/null +++ b/app/src/editor/highlight/grammars.ts @@ -0,0 +1,48 @@ +// Grammar loaders for the languages in languages.ts (owner: WP6). Each grammar is its own small chunk, which +// loads the first time a page shows code in that language. This module is part of the lazy highlight chunk. +import type { LanguageFn } from 'highlight.js'; + +type Loader = () => Promise<{ default: LanguageFn }>; + +export const GRAMMARS: Readonly> = { + bash: () => import('highlight.js/lib/languages/bash'), + c: () => import('highlight.js/lib/languages/c'), + cpp: () => import('highlight.js/lib/languages/cpp'), + csharp: () => import('highlight.js/lib/languages/csharp'), + css: () => import('highlight.js/lib/languages/css'), + dart: () => import('highlight.js/lib/languages/dart'), + diff: () => import('highlight.js/lib/languages/diff'), + dockerfile: () => import('highlight.js/lib/languages/dockerfile'), + elixir: () => import('highlight.js/lib/languages/elixir'), + fsharp: () => import('highlight.js/lib/languages/fsharp'), + go: () => import('highlight.js/lib/languages/go'), + graphql: () => import('highlight.js/lib/languages/graphql'), + haskell: () => import('highlight.js/lib/languages/haskell'), + xml: () => import('highlight.js/lib/languages/xml'), + ini: () => import('highlight.js/lib/languages/ini'), + java: () => import('highlight.js/lib/languages/java'), + javascript: () => import('highlight.js/lib/languages/javascript'), + json: () => import('highlight.js/lib/languages/json'), + julia: () => import('highlight.js/lib/languages/julia'), + kotlin: () => import('highlight.js/lib/languages/kotlin'), + latex: () => import('highlight.js/lib/languages/latex'), + lua: () => import('highlight.js/lib/languages/lua'), + makefile: () => import('highlight.js/lib/languages/makefile'), + markdown: () => import('highlight.js/lib/languages/markdown'), + matlab: () => import('highlight.js/lib/languages/matlab'), + objectivec: () => import('highlight.js/lib/languages/objectivec'), + perl: () => import('highlight.js/lib/languages/perl'), + php: () => import('highlight.js/lib/languages/php'), + powershell: () => import('highlight.js/lib/languages/powershell'), + python: () => import('highlight.js/lib/languages/python'), + r: () => import('highlight.js/lib/languages/r'), + ruby: () => import('highlight.js/lib/languages/ruby'), + rust: () => import('highlight.js/lib/languages/rust'), + scala: () => import('highlight.js/lib/languages/scala'), + scss: () => import('highlight.js/lib/languages/scss'), + sql: () => import('highlight.js/lib/languages/sql'), + swift: () => import('highlight.js/lib/languages/swift'), + typescript: () => import('highlight.js/lib/languages/typescript'), + vbnet: () => import('highlight.js/lib/languages/vbnet'), + yaml: () => import('highlight.js/lib/languages/yaml'), +}; diff --git a/app/src/editor/highlight/hljs.d.ts b/app/src/editor/highlight/hljs.d.ts new file mode 100644 index 000000000..0d3eaf8eb --- /dev/null +++ b/app/src/editor/highlight/hljs.d.ts @@ -0,0 +1,7 @@ +// highlight.js ships one grammar per file, typed only through its main entry (owner: WP6). +declare module 'highlight.js/lib/languages/*' { + import type { LanguageFn } from 'highlight.js'; + + const grammar: LanguageFn; + export default grammar; +} diff --git a/app/src/editor/highlight/index.test.ts b/app/src/editor/highlight/index.test.ts new file mode 100644 index 000000000..302b88006 --- /dev/null +++ b/app/src/editor/highlight/index.test.ts @@ -0,0 +1,171 @@ +// @vitest-environment jsdom +// Highlighting never runs inside a transaction (PLAN.md section 10.5). A spy on the highlighter sees no call while +// 1,000 keys are typed, and colors map through the edits without flicker. Idle time then highlights the block +// once. Slow and very long blocks stay plain, and static code is colored with the same text. +import type { Editor } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createBlockEditor } from '../extensions/kit'; +import { DEFAULT_EDITING_VIEW } from '../host'; +import type { EditorHost } from '../host'; +import { parseTextBlock } from '../markdown'; +import { renderStatic } from '../schema/dom'; +import { engine, grammarReady, kindOf, loadGrammar } from './engine'; +import { highlightPlugin, highlightStatic } from './index'; +import { findLanguage } from './languages'; +import { loadEngine, MAX_LINES, tokenClass } from './plugin'; + +// jsdom has no layout, and ProseMirror measures a Range when it scrolls the caret into view. +Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList; +Range.prototype.getBoundingClientRect ??= () => new DOMRect(); + +const host: EditorHost = { + settings: () => DEFAULT_EDITING_VIEW, + // The test adds the plugin itself, so the code block doesn't load it a second time. + flag: () => false, + announce: () => {}, + openMenu: () => Promise.resolve(null), + screenReader: () => false, + spelling: () => null, + selectBlocks: () => {}, +}; + +let idle: IdleRequestCallback[] = []; +const editors: Editor[] = []; + +beforeEach(async () => { + idle = []; + vi.stubGlobal('requestIdleCallback', (callback: IdleRequestCallback) => idle.push(callback)); + vi.stubGlobal('cancelIdleCallback', () => {}); + await loadEngine(); + await loadGrammar('javascript'); +}); + +afterEach(() => { + editors.splice(0).forEach((editor) => editor.destroy()); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +/** Runs idle callbacks until none is left. */ +function runIdle(timeRemaining = 50): number { + let runs = 0; + while (idle.length > 0 && runs < 100) { + idle.shift()!({ didTimeout: false, timeRemaining: () => timeRemaining }); + runs++; + } + return runs; +} + +function highlighted(markdown: string): Editor { + const root = document.body.appendChild(document.createElement('div')); + const editor = createBlockEditor(root, parseTextBlock(markdown), { kind: 'text', block: 'b1', host }); + editor.registerPlugin(highlightPlugin(host)); + editors.push(editor); + return editor; +} + +const colored = (editor: Editor, kind: Parameters[0]) => + [...editor.view.dom.querySelectorAll(`.${tokenClass(kind)}`)].map((span) => span.textContent); + +describe('the highlight plugin', () => { + it('colors code in idle time, with the grammar the block names', () => { + const editor = highlighted('```js\nconst a = "s"; // note\n```'); + expect(colored(editor, 'keyword')).toEqual([]); + runIdle(); + expect(colored(editor, 'keyword')).toEqual(['const']); + expect(colored(editor, 'string')).toEqual(['"s"']); + expect(colored(editor, 'comment')).toEqual(['// note']); + }); + + it('does no highlighting work in 1,000 typed keys, and keeps the colors in place meanwhile', () => { + const editor = highlighted('```js\nconst a = 1;\n```\n\nafter'); + runIdle(); + const spy = vi.spyOn(engine, 'highlight'); + for (let i = 0; i < 1000; i++) { + const end = editor.state.doc.firstChild!.nodeSize - 1; + editor.view.dispatch(editor.state.tr.insertText(i % 50 === 49 ? '\n' : 'x', end)); + expect(colored(editor, 'keyword')).toEqual(['const']); + } + expect(spy).not.toHaveBeenCalled(); + runIdle(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('re-highlights when the language changes, and clears colors for plain text', () => { + const editor = highlighted('```js\nconst a = 1;\n```'); + runIdle(); + editor.view.dispatch(editor.state.tr.setNodeAttribute(0, 'language', null)); + expect(colored(editor, 'keyword')).toEqual(['const']); + runIdle(); + expect(colored(editor, 'keyword')).toEqual([]); + }); + + it('turns a block off for the session when a highlight takes over 20 ms', () => { + const editor = highlighted('```js\nconst a = 1;\n```'); + let now = 0; + vi.spyOn(performance, 'now').mockImplementation(() => (now += 25)); + const spy = vi.spyOn(engine, 'highlight'); + runIdle(); + expect(spy).toHaveBeenCalledTimes(1); + expect(colored(editor, 'keyword')).toEqual([]); + editor.view.dispatch(editor.state.tr.insertText('x', 2)); + runIdle(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('leaves blocks over 10,000 lines plain', () => { + const lines = Array.from({ length: MAX_LINES + 1 }, (_, i) => `let v${i} = ${i};`).join('\n'); + const editor = highlighted(`\`\`\`js\n${lines}\n\`\`\``); + const spy = vi.spyOn(engine, 'highlight'); + runIdle(); + expect(spy).not.toHaveBeenCalled(); + expect(editor.view.dom.querySelector(`.${tokenClass('keyword')}`)).toBeNull(); + }); + + it('highlights a long block in slices, one chunk per idle callback when idle time is short', () => { + const lines = Array.from({ length: 3000 }, (_, i) => `let v${i} = ${i};`).join('\n'); + const editor = highlighted(`\`\`\`js\n${lines}\n\`\`\``); + const spy = vi.spyOn(engine, 'highlight'); + // Time stands still, so a busy test machine can't trip the 20 ms guard. + vi.spyOn(performance, 'now').mockReturnValue(0); + expect(runIdle(0)).toBe(6); + expect(spy).toHaveBeenCalledTimes(6); + expect(colored(editor, 'keyword')).toHaveLength(3000); + }); +}); + +describe('static highlighting', () => { + it('wraps the same text in colored spans', async () => { + const doc: PMNode = parseTextBlock('Intro\n\n```js\nlet a = 2;\n```\n\n```\nplain\n```'); + const root = document.body.appendChild(document.createElement('div')); + renderStatic(doc, root); + const done = highlightStatic(root, doc); + await vi.waitFor(() => { + runIdle(); + expect(root.querySelector(`.${tokenClass('keyword')}`)?.textContent).toBe('let'); + }); + await done; + const [js, plain] = root.querySelectorAll('pre > code'); + expect(js.textContent).toBe('let a = 2;'); + expect(plain.innerHTML).toBe('plain'); + }); +}); + +describe('the grammar map', () => { + it('maps highlight.js scopes to the eight code colors', () => { + expect(kindOf(['hljs-title', 'class_'])).toBe('type'); + expect(kindOf(['hljs-title', 'function_'])).toBe('function'); + expect(kindOf(['hljs-variable', 'language_'])).toBe('keyword'); + expect(kindOf(['hljs-meta'])).toBe('punctuation'); + expect(kindOf(['hljs-unknown'])).toBeNull(); + }); + + it('loads a grammar once, only when asked', async () => { + const python = findLanguage('py')!; + expect(python.name).toBe('Python'); + expect(grammarReady(python.grammar)).toBe(false); + await loadGrammar(python.grammar); + expect(grammarReady(python.grammar)).toBe(true); + }); +}); diff --git a/app/src/editor/highlight/index.ts b/app/src/editor/highlight/index.ts new file mode 100644 index 000000000..f0bb4476d --- /dev/null +++ b/app/src/editor/highlight/index.ts @@ -0,0 +1,3 @@ +// Code highlighting's public face (PLAN.md section 10.3; owner: WP6). Code blocks add the plugin themselves. The +// page calls highlightStatic for blocks shown before their editor mounts. The highlighter they share loads lazily. +export { highlightPlugin, highlightStatic } from './plugin'; diff --git a/app/src/editor/highlight/languages.ts b/app/src/editor/highlight/languages.ts new file mode 100644 index 000000000..3c89b380a --- /dev/null +++ b/app/src/editor/highlight/languages.ts @@ -0,0 +1,92 @@ +// The code languages OpenNote names and colors (ARCHITECTURE.md section 14.2; owner: WP6). This list is small and +// loads with the editor, for the language button and the picker. Each language's grammar loads only when a page +// shows code in it (grammars.ts). A code block keeps the info string it was written with, such as "py". + +export interface CodeLanguage { + /** The info string the picker writes. */ + readonly id: string; + /** The name people see. Language names are proper nouns, so they stay as they are in every locale. */ + readonly name: string; + /** Other info strings that mean this language, in lowercase. */ + readonly aliases: readonly string[]; + /** The highlight.js grammar that colors it. */ + readonly grammar: string; + /** The indent a new block in this language uses, until its own lines show another. */ + readonly indent: 2 | 4; +} + +const language = ( + id: string, + name: string, + aliases: readonly string[] = [], + indent: 2 | 4 = 4, + grammar = id, +): CodeLanguage => ({ id, name, aliases, grammar, indent }); + +export const CODE_LANGUAGES: readonly CodeLanguage[] = [ + language('bash', 'Bash', ['sh', 'shell', 'zsh', 'console'], 2), + language('c', 'C', ['h']), + language('cpp', 'C++', ['c++', 'cc', 'cxx', 'hpp', 'hh']), + language('csharp', 'C#', ['cs', 'c#']), + language('css', 'CSS', [], 2), + language('dart', 'Dart', [], 2), + language('diff', 'Diff', ['patch'], 2), + language('dockerfile', 'Dockerfile', ['docker'], 2), + language('elixir', 'Elixir', ['ex', 'exs'], 2), + language('fsharp', 'F#', ['fs', 'f#']), + language('go', 'Go', ['golang']), + language('graphql', 'GraphQL', ['gql'], 2), + language('haskell', 'Haskell', ['hs'], 2), + language('html', 'HTML', ['xhtml', 'svg', 'vue'], 2, 'xml'), + language('ini', 'INI', ['toml', 'cfg', 'conf'], 2), + language('java', 'Java'), + language('javascript', 'JavaScript', ['js', 'jsx', 'mjs', 'cjs'], 2), + language('json', 'JSON', ['jsonc', 'json5'], 2), + language('julia', 'Julia', ['jl']), + language('kotlin', 'Kotlin', ['kt', 'kts']), + language('latex', 'LaTeX', ['tex'], 2), + language('lua', 'Lua', [], 2), + language('makefile', 'Makefile', ['make', 'mk']), + language('markdown', 'Markdown', ['md', 'mkd'], 2), + language('matlab', 'MATLAB', ['m']), + language('objectivec', 'Objective-C', ['objc', 'obj-c', 'mm']), + language('perl', 'Perl', ['pl', 'pm']), + language('php', 'PHP'), + language('powershell', 'PowerShell', ['ps', 'ps1', 'pwsh']), + language('python', 'Python', ['py', 'py3', 'gyp']), + language('r', 'R', [], 2), + language('ruby', 'Ruby', ['rb', 'gemspec'], 2), + language('rust', 'Rust', ['rs']), + language('scala', 'Scala', ['sc'], 2), + language('scss', 'SCSS', ['sass'], 2), + language('sql', 'SQL', ['mysql', 'postgres', 'postgresql', 'sqlite']), + language('swift', 'Swift'), + language('typescript', 'TypeScript', ['ts', 'tsx', 'mts', 'cts'], 2), + language('vbnet', 'Visual Basic', ['vb', 'vba']), + language('xml', 'XML', ['xsd', 'xsl', 'plist', 'rss', 'xaml', 'csproj'], 2), + language('yaml', 'YAML', ['yml'], 2), +]; + +const byInfo = new Map(); +for (const one of CODE_LANGUAGES) for (const key of [one.id, ...one.aliases]) byInfo.set(key, one); + +/** The language an info string names, ignoring case, or null for plain text and languages OpenNote doesn't know. */ +export function findLanguage(info: string | null | undefined): CodeLanguage | null { + return info ? (byInfo.get(info.toLowerCase()) ?? null) : null; +} + +/** The name to show for an info string: the language's name, or the info string as written. Null is plain text. */ +export function languageName(info: string | null | undefined): string | null { + if (!info) return null; + return findLanguage(info)?.name ?? info; +} + +/** The languages whose name, ID, or alias contains `query`, those that start with it first. */ +export function matchLanguages(query: string): CodeLanguage[] { + const q = query.trim().toLowerCase(); + if (!q) return [...CODE_LANGUAGES]; + const keys = (one: CodeLanguage) => [one.name.toLowerCase(), one.id, ...one.aliases]; + const starts = CODE_LANGUAGES.filter((one) => keys(one).some((key) => key.startsWith(q))); + const contains = CODE_LANGUAGES.filter((one) => !starts.includes(one) && keys(one).some((key) => key.includes(q))); + return [...starts, ...contains]; +} diff --git a/app/src/editor/highlight/picker.ts b/app/src/editor/highlight/picker.ts new file mode 100644 index 000000000..f8a2a2367 --- /dev/null +++ b/app/src/editor/highlight/picker.ts @@ -0,0 +1,24 @@ +// Where the code language button finds the page's language picker (owner: WP6). editor/ can't import the page, so +// the page installs its picker here at start-up. The module holds no editor code, so start-up stays small. +import type { EditorView } from '@tiptap/pm/view'; + +export interface LanguagePickRequest { + readonly view: EditorView; + /** The position before the code block. */ + readonly pos: number; + readonly anchor: HTMLElement; + readonly language: string | null; +} + +export type LanguagePicker = (request: LanguagePickRequest) => void; + +let installed: LanguagePicker | null = null; + +/** The page installs its filterable picker here. Without one, the language button opens a plain menu. */ +export function setLanguagePicker(next: LanguagePicker | null): void { + installed = next; +} + +export function languagePicker(): LanguagePicker | null { + return installed; +} diff --git a/app/src/editor/highlight/plugin.ts b/app/src/editor/highlight/plugin.ts new file mode 100644 index 000000000..d817b7e83 --- /dev/null +++ b/app/src/editor/highlight/plugin.ts @@ -0,0 +1,306 @@ +// Code highlighting that never runs in a keystroke's task (ARCHITECTURE.md section 14.2; owner: WP6). The plugin +// is small and comes with code blocks; the highlighter (engine.ts) is the lazy chunk, loaded the first time a block +// with a language needs colors. The plugin keeps a DecorationSet. A transaction only maps the decorations through the change, so +// colors move with the text and never flicker, and marks the code blocks it touched as dirty. An idle callback +// then highlights one dirty block and swaps its decorations in a transaction that only carries META_HIGHLIGHT. +import type { Node as PMNode } from '@tiptap/pm/model'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import { AttrStep } from '@tiptap/pm/transform'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import type { EditorView } from '@tiptap/pm/view'; +import type { EditorHost } from '../host'; +import { META_HIGHLIGHT } from '../meta'; +import type { Token, TokenKind } from './engine'; +import styles from './code.module.css'; +import { findLanguage } from './languages'; + +type Engine = typeof import('./engine'); + +let loaded: Engine | null = null; +let loading: Promise | null = null; + +/** Loads the highlighter chunk once. */ +export function loadEngine(): Promise { + loading ??= import('./engine').then((module) => (loaded = module)); + return loading; +} + +/** The decoration class of a token kind. */ +export function tokenClass(kind: TokenKind): string { + return styles[kind]; +} + +/** Blocks longer than this highlight in slices of idle time. */ +export const SLICE_LINES = 2000; +/** Blocks longer than this stay plain. */ +export const MAX_LINES = 10_000; +/** A highlight slower than this turns highlighting off for the block, for the session. */ +export const SLOW_MS = 20; +const IDLE_TIMEOUT_MS = 500; +const CHUNK_LINES = 500; + +interface Deadline { + timeRemaining(): number; +} + +/** requestIdleCallback with a 500 ms timeout, or a short timer where there is none. */ +function whenIdle(callback: (deadline: Deadline) => void): () => void { + if (typeof requestIdleCallback === 'function') { + const id = requestIdleCallback(callback, { timeout: IDLE_TIMEOUT_MS }); + return () => cancelIdleCallback(id); + } + const id = setTimeout(() => { + const end = performance.now() + 10; + callback({ timeRemaining: () => Math.max(0, end - performance.now()) }); + }, 1); + return () => clearTimeout(id); +} + +/** Highlights `text` in chunks of lines, so a long block's work splits across idle callbacks. */ +function chunksOf(text: string): { from: number; text: string }[] { + const lines = text.split('\n'); + if (lines.length <= SLICE_LINES) return [{ from: 0, text }]; + const chunks: { from: number; text: string }[] = []; + let from = 0; + for (let i = 0; i < lines.length; i += CHUNK_LINES) { + const piece = lines.slice(i, i + CHUNK_LINES).join('\n'); + chunks.push({ from, text: piece }); + from += piece.length + 1; + } + return chunks; +} + +/** One timed highlight, or null when it took too long to trust. */ +function timedHighlight(engine: Engine, grammar: string, text: string): Token[] | null { + const started = performance.now(); + const tokens = engine.engine.highlight(grammar, text); + return performance.now() - started > SLOW_MS ? null : tokens; +} + +interface HighlightState { + readonly set: DecorationSet; + /** Positions before code blocks whose colors are stale, oldest first. */ + readonly dirty: readonly number[]; + /** Positions before code blocks that were too slow to highlight. */ + readonly off: readonly number[]; +} + +interface HighlightMeta { + readonly pos: number; + readonly from: number; + readonly to: number; + readonly decorations: readonly Decoration[]; + readonly done: boolean; + readonly off?: boolean; +} + +const key = new PluginKey('opennote.codeHighlight'); + +/** The code blocks in or around [from, to], as positions before them. */ +function codeBlocksIn(doc: PMNode, from: number, to: number, into: Set): void { + doc.nodesBetween(Math.max(0, from), Math.min(to, doc.content.size), (node, pos) => { + if (node.type.name === 'codeBlock') into.add(pos); + return !node.isTextblock; + }); +} + +/** The code blocks a transaction changed, in the new document. Only the changed ranges are visited. */ +function touched(tr: Transaction): Set { + const found = new Set(); + tr.steps.forEach((step, i) => { + const rest = tr.mapping.slice(i + 1); + if (step instanceof AttrStep && step.attr === 'language') { + const pos = rest.mapResult(step.pos); + if (!pos.deleted) found.add(pos.pos); + return; + } + step.getMap().forEach((_oldStart, _oldEnd, start, end) => { + codeBlocksIn(tr.doc, rest.map(start, -1), rest.map(end, 1), found); + }); + }); + return found; +} + +function mapPositions(positions: readonly number[], tr: Transaction): number[] { + const mapped: number[] = []; + for (const pos of positions) { + const result = tr.mapping.mapResult(pos, 1); + if (!result.deleted && !mapped.includes(result.pos)) mapped.push(result.pos); + } + return mapped; +} + +/** The code blocks that have a language, for the first pass. */ +function codeWithLanguage(doc: PMNode): number[] { + const found = new Set(); + codeBlocksIn(doc, 0, doc.content.size, found); + return [...found].filter((pos) => Boolean(doc.nodeAt(pos)?.attrs.language)); +} + +function apply(tr: Transaction, value: HighlightState): HighlightState { + const meta = tr.getMeta(key) as HighlightMeta | undefined; + if (!tr.docChanged && !meta) return value; + let { set, dirty, off } = value; + if (tr.docChanged) { + set = set.map(tr.mapping, tr.doc); + dirty = mapPositions(dirty, tr); + off = mapPositions(off, tr); + for (const pos of touched(tr)) if (!dirty.includes(pos)) dirty = [...dirty, pos]; + } + if (meta) { + if (meta.to > meta.from) set = set.remove(set.find(meta.from, meta.to)); + set = set.add(tr.doc, [...meta.decorations]); + if (meta.done) dirty = dirty.filter((pos) => pos !== meta.pos); + if (meta.off) off = [...off, meta.pos]; + } + return { set, dirty, off }; +} + +/** The decorations of one chunk of a block, in document positions. */ +function decorate(start: number, tokens: readonly Token[]): Decoration[] { + return tokens.map((token) => + Decoration.inline(start + token.from, start + token.to, { class: tokenClass(token.kind) }), + ); +} + +/** Works through dirty blocks in idle time, one block (or a few chunks of a long one) per callback. */ +class IdleHighlighter { + private cancel: (() => void) | null = null; + private destroyed = false; + /** A long block part way through: its node, so an edit restarts it, and the next chunk. */ + private progress: { pos: number; node: PMNode; next: number } | null = null; + + constructor(private readonly view: EditorView) { + this.schedule(); + } + + schedule(): void { + if (this.cancel || this.destroyed || !key.getState(this.view.state)?.dirty.length) return; + this.cancel = whenIdle((deadline) => { + this.cancel = null; + this.work(deadline); + }); + } + + private send(meta: HighlightMeta): void { + const tr = this.view.state.tr.setMeta(key, meta).setMeta(META_HIGHLIGHT, true).setMeta('addToHistory', false); + this.view.dispatch(tr); + } + + private clear(pos: number, node: PMNode | null, off = false): void { + const to = pos + (node?.nodeSize ?? 1); + this.send({ pos, from: pos, to, decorations: [], done: true, off }); + } + + private work(deadline: Deadline): void { + if (this.destroyed) return; + const state = key.getState(this.view.state); + const pos = state?.dirty[0]; + if (state === undefined || pos === undefined) return; + const node = this.view.state.doc.nodeAt(pos); + const language = findLanguage(node?.attrs.language as string | null); + if (!node || node.type.name !== 'codeBlock') this.send({ pos, from: pos, to: pos, decorations: [], done: true }); + else if (!language || state.off.includes(pos) || node.textContent.split('\n').length > MAX_LINES) { + this.clear(pos, node); + } else if (!loaded || !loaded.grammarReady(language.grammar)) { + void loadEngine() + .then((engine) => engine.loadGrammar(language.grammar)) + .then((ok) => { + if (!ok && !this.destroyed) this.clear(pos, this.view.state.doc.nodeAt(pos)); + this.schedule(); + }); + return; + } else this.highlightBlock(loaded, pos, node, language.grammar, deadline); + this.schedule(); + } + + private highlightBlock(engine: Engine, pos: number, node: PMNode, grammar: string, deadline: Deadline): void { + const chunks = chunksOf(node.textContent); + if (this.progress?.pos !== pos || this.progress.node !== node) this.progress = { pos, node, next: 0 }; + const progress = this.progress; + do { + const chunk = chunks[progress.next]; + const tokens = timedHighlight(engine, grammar, chunk.text); + if (!tokens) { + this.progress = null; + return this.clear(pos, node, true); + } + const start = pos + 1 + chunk.from; + const last = progress.next === chunks.length - 1; + const to = last ? pos + node.nodeSize : start + chunk.text.length; + this.send({ pos, from: progress.next === 0 ? pos : start, to, decorations: decorate(start, tokens), done: last }); + progress.next++; + } while (progress.next < chunks.length && deadline.timeRemaining() > SLOW_MS); + if (progress.next >= chunks.length) this.progress = null; + } + + destroy(): void { + this.destroyed = true; + this.cancel?.(); + } +} + +/** The editor plugin. Every transaction is O(change); all highlighting happens in idle callbacks. */ +export function highlightPlugin(_host: EditorHost): Plugin { + return new Plugin({ + key, + state: { + init: (_config, state: EditorState) => ({ + set: DecorationSet.empty, + dirty: codeWithLanguage(state.doc), + off: [], + }), + apply: (tr, value) => apply(tr, value), + }, + props: { + decorations: (state) => key.getState(state)?.set ?? DecorationSet.empty, + }, + view(view) { + const idle = new IdleHighlighter(view); + return { update: () => idle.schedule(), destroy: () => idle.destroy() }; + }, + }); +} + +/** Wraps the text of a static in token spans. The text stays the same, so nothing moves. */ +function paint(code: HTMLElement, text: string, tokens: readonly Token[]): void { + const doc = code.ownerDocument; + const parts: Node[] = []; + let at = 0; + for (const token of tokens) { + if (token.from > at) parts.push(doc.createTextNode(text.slice(at, token.from))); + const span = doc.createElement('span'); + span.className = tokenClass(token.kind); + span.textContent = text.slice(token.from, token.to); + parts.push(span); + at = token.to; + } + if (at < text.length) parts.push(doc.createTextNode(text.slice(at))); + code.replaceChildren(...parts); +} + +/** + * Colors the static code blocks of a block rendered with renderStatic, in idle time, one block per callback, so a + * block looks the same before and after its editor mounts. + */ +export async function highlightStatic(root: HTMLElement, doc: PMNode): Promise { + const blocks: PMNode[] = []; + doc.descendants((node) => { + if (node.type.name === 'codeBlock') blocks.push(node); + return !node.isTextblock; + }); + const elements = [...root.querySelectorAll('pre > code')]; + for (const [i, node] of blocks.entries()) { + const code = elements[i]; + const language = findLanguage(node.attrs.language as string | null); + const text = node.textContent; + if (!code || !language || code.textContent !== text || text.split('\n').length > SLICE_LINES) continue; + const engine = await loadEngine(); + if (!(await engine.loadGrammar(language.grammar))) continue; + await new Promise((resolve) => whenIdle(() => resolve())); + if (!code.isConnected || code.textContent !== text) continue; + const tokens = timedHighlight(engine, language.grammar, text); + if (tokens) paint(code, text, tokens); + } +} diff --git a/app/src/editor/host.ts b/app/src/editor/host.ts new file mode 100644 index 000000000..16cd9ea7f --- /dev/null +++ b/app/src/editor/host.ts @@ -0,0 +1,45 @@ +// What an editor may ask of the page around it (PLAN.md section 3.8, owned by WP0). editor/ is a library layer: it +// never imports stores, services, features, or the shell, so everything it needs from them comes through this +// interface, which the page feature implements. + +import type { FlagId } from '../app/flags'; +import type { MenuAnchor, MenuItemSpec } from '../ui'; + +/** A block's ID, as the page stores it. The same string as services/pages' BlockId. */ +export type BlockId = string; + +/** A snapshot of settings.editing, as the editor needs it. */ +export interface EditingSettingsView { + markdownShortcuts: boolean; + slashMenu: boolean; + formattingBar: 'touchAndPen' | 'always' | 'never'; + autocorrect: { enabled: boolean; entries: readonly { from: string; to: string }[] }; + spelling: { enabled: boolean; languages: readonly string[] }; +} + +/** Spelling results for the text of one textblock, from the page's cache. */ +export interface SpellingService { + /** UTF-16 ranges of the misspelled words, or null when the text hasn't been checked yet. */ + errorsFor(textblockText: string): readonly { start: number; length: number }[] | null; + requestCheck(key: string, text: string): void; +} + +export interface EditorHost { + settings(): EditingSettingsView; + flag(id: FlagId): boolean; + announce(text: string, politeness?: 'polite' | 'assertive'): void; + openMenu(options: { label: string; items: readonly MenuItemSpec[]; anchor: MenuAnchor }): Promise; + screenReader(): boolean; + spelling(): SpellingService | null; + /** Selects blocks as objects: when a selection grows past one block, on the second Ctrl+A, or on Escape. */ + selectBlocks(blocks: readonly BlockId[], reason: 'escalate' | 'selectAll' | 'escape'): void; +} + +/** The settings.editing defaults, for hosts in tests and before settings load. */ +export const DEFAULT_EDITING_VIEW: EditingSettingsView = { + markdownShortcuts: true, + slashMenu: true, + formattingBar: 'touchAndPen', + autocorrect: { enabled: true, entries: [] }, + spelling: { enabled: true, languages: [] }, +}; diff --git a/app/src/editor/ids.test.ts b/app/src/editor/ids.test.ts new file mode 100644 index 000000000..2c463bdc5 --- /dev/null +++ b/app/src/editor/ids.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { isId, newId } from './ids'; + +describe('IDs (SPEC 2.4)', () => { + it('are 26 lowercase Crockford base32 characters that start with 0 to 7', () => { + for (let i = 0; i < 50; i++) expect(isId(newId())).toBe(true); + }); + + it('sort by time, and encode the millisecond time in the first 10 characters', () => { + const zero = (bytes: Uint8Array) => bytes; + expect(newId(0, zero)).toBe(`${'0'.repeat(10)}${'0'.repeat(16)}`); + expect(newId(1, zero).slice(0, 10)).toBe('0000000001'); + expect(newId(32, zero).slice(0, 10)).toBe('0000000010'); + expect(newId(1_700_000_000_000, zero) < newId(1_700_000_000_001, zero)).toBe(true); + }); + + it('refuses the letters i, l, o, and u, and any other length', () => { + expect(isId('01m3sabc31y0rfa24eeh6j4ky4')).toBe(true); + expect(isId('01m3sabc31y0rfa24eeh6j4kyi')).toBe(false); + expect(isId('01m3sabc31y0rfa24eeh6j4k')).toBe(false); + expect(isId('81m3sabc31y0rfa24eeh6j4ky4')).toBe(false); + }); +}); diff --git a/app/src/editor/ids.ts b/app/src/editor/ids.ts new file mode 100644 index 000000000..9684ae7bf --- /dev/null +++ b/app/src/editor/ids.ts @@ -0,0 +1,31 @@ +// IDs for text elements, table rows, and table columns (SPEC 2.4): 26 lowercase Crockford base32 characters, made of +// a 48-bit time in milliseconds and 80 random bits, so that they sort by creation time. +const ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz'; +const TIME_CHARS = 10; +const RANDOM_CHARS = 16; + +type Bytes = Uint8Array; + +/** A new ID. `now` and `random` are parameters so that tests can fix them. */ +export function newId(now: number = Date.now(), random: (bytes: Bytes) => Bytes = fillRandom): string { + let time = now; + let text = ''; + for (let i = 0; i < TIME_CHARS; i++) { + text = ALPHABET[time % 32] + text; + time = Math.floor(time / 32); + } + const bytes = random(new Uint8Array(RANDOM_CHARS)); + for (const byte of bytes) text += ALPHABET[byte % 32]; + return text; +} + +function fillRandom(bytes: Bytes): Bytes { + return crypto.getRandomValues(bytes); +} + +const ID_PATTERN = /^[0-7][0-9a-hjkmnp-tv-z]{25}$/; + +/** Whether text is an ID in SPEC 2.4's text form. */ +export function isId(text: string): boolean { + return ID_PATTERN.test(text); +} diff --git a/app/src/editor/markdown/arbitrary.ts b/app/src/editor/markdown/arbitrary.ts new file mode 100644 index 000000000..453c7fa10 --- /dev/null +++ b/app/src/editor/markdown/arbitrary.ts @@ -0,0 +1,259 @@ +// fast-check generators for text block documents, for the round-trip property tests. They build canonical +// documents. No two lists of one kind sit side by side. No empty paragraph sits between blocks. No marked range +// starts or ends on whitespace. No hard break sits at the edge of a paragraph. The writer round-trips exactly those. +import * as fc from 'fast-check'; +import type { Mark, Node as PMNode } from '@tiptap/pm/model'; +import { textSchema } from '../schema/schema'; +import { CALLOUT_TYPES, FOLDS, HIGHLIGHT_COLORS, TEXT_SIZES } from '../schema/specs'; +import { isSpaceChar } from './flanking'; + +const { nodes, marks } = textSchema; + +const LETTERS = ['a', 'b', 'c', 'x', 'y', 'Z', '1', '2', '9', '\u00e9', '\u{1F600}', '\u00a0']; +const PUNCTUATION = [...'*_~=#>-+.)(!$[]{}<>|&;\\`:\'"/?%@^,']; +const FRAGMENTS = [ + '&', + '#', + '', + '', + '[!note]', + '[ ]', + '$$', + '```', + '1.', + '==', + '**', + '~~', + '#tag', + '- ', + '> ', +]; + +const char = fc.oneof( + { weight: 10, arbitrary: fc.constantFrom(...LETTERS) }, + { weight: 4, arbitrary: fc.constantFrom(' ', ' ', '\t') }, + { weight: 5, arbitrary: fc.constantFrom(...PUNCTUATION) }, + { weight: 1, arbitrary: fc.constantFrom(...FRAGMENTS) }, +); + +const words = fc.array(char, { minLength: 1, maxLength: 8 }).map((parts) => parts.join('')); + +/** Text with no whitespace at either end, which is what a mark may wrap. */ +function trimmed(text: string): string { + const chars = Array.from(text); + while (chars.length > 0 && isSpaceChar(chars[0])) chars.shift(); + while (chars.length > 0 && isSpaceChar(chars[chars.length - 1])) chars.pop(); + return chars.length > 0 ? chars.join('') : 'x'; +} + +const HREFS = [ + 'https://example.com', + 'https://example.com/a?b=1&c=2', + 'https://example.com/a b', + 'https://example.com/f(x)', + 'https://example.com/\\y', + 'https://example.com/?a=1&b=2', + 'mailto:a@example.com', + 'opennote:page/01m3sabc31y0rfa24eeh6j4ky4#01m3sabc32dwqknfawtgwtsgcj', + 'ftp://example.com/file', + 'file:///C:/notes/a.txt', + 'tel:+15551234', + 'notes.md', + 'https://example.com/caf\u00e9', +]; + +const markFactories: readonly fc.Arbitrary[] = [ + fc.constant(marks.bold.create()), + fc.constant(marks.italic.create()), + fc.constant(marks.strike.create()), + fc.constant(marks.underline.create()), + fc.constant(marks.highlight.create()), + fc.constantFrom(...HIGHLIGHT_COLORS).map((color) => marks.highlight.create({ color })), + // checks-disable-next-line brand-consistency: document content, not interface styling + fc.constantFrom('ink', 'red', '#aa0000', '#00AAff').map((color) => marks.textColor.create({ color })), + fc.constantFrom(...TEXT_SIZES).map((size) => marks.textSize.create({ size })), + fc.constant(marks.subscript.create()), + fc.constant(marks.superscript.create()), + fc.constantFrom(...HREFS).map((href) => marks.link.create({ href })), +]; + +const markSet = fc + .subarray([...markFactories.keys()], { maxLength: 3 }) + .chain((picked) => + fc + .tuple(...picked.map((i) => markFactories[i])) + .map((list) => list.reduce((set, mark) => mark.addToSet(set), [])), + ); + +type Segment = + { kind: 'text'; text: string; marks: readonly Mark[] } | { kind: 'break' } | { kind: 'node'; node: PMNode }; + +const textSegment = fc.tuple(words, markSet, fc.boolean()).map(([text, set, code]): Segment => { + // A link at the start of a paragraph with `]:` in its code reads as a reference definition. Markdown has no + // way around that, so the generator leaves `]` out of code. + if (code) return { kind: 'text', text: text.replace(/]/g, ')'), marks: marks.code.create().addToSet(set) }; + return { kind: 'text', text: set.length > 0 ? trimmed(text) : text, marks: set }; +}); + +const mathSource = fc.stringMatching(/^[a-z0-9^+=-]{1,6}$/); + +const imageSegment = fc + .tuple(fc.constantFrom('asset:01m3sabc31y0rfa24eeh6j4ky4', 'https://example.com/x.png'), words, markSet) + .map(([src, alt, set]): Segment => ({ + kind: 'node', + node: nodes.image.create({ src, alt: alt.replace(/\t/g, ' ') }, null, set), + })); + +const mathSegment = mathSource.map((source): Segment => ({ kind: 'node', node: nodes.mathInline.create({ source }) })); + +const segment = fc.oneof( + { weight: 10, arbitrary: textSegment }, + { weight: 1, arbitrary: fc.constant({ kind: 'break' }) }, + { weight: 1, arbitrary: imageSegment }, + { weight: 1, arbitrary: mathSegment }, +); + +/** Inline nodes. A break is kept only between two other pieces. */ +function inlineContent(segments: readonly Segment[], allowBreaks: boolean): PMNode[] { + const pieces = segments.filter((piece) => allowBreaks || piece.kind !== 'break'); + const built = pieces + .filter((piece, i) => piece.kind !== 'break' || (i > 0 && i < pieces.length - 1 && pieces[i - 1].kind !== 'break')) + .map((piece) => { + if (piece.kind === 'break') return nodes.hardBreak.create(); + if (piece.kind === 'node') return piece.node; + return textSchema.text(piece.text, piece.marks); + }); + while (built.at(-1)?.type.name === 'hardBreak') built.pop(); + return built.length > 0 ? built : [textSchema.text('x')]; +} + +const paragraphContent = fc.array(segment, { minLength: 1, maxLength: 6 }).map((s) => inlineContent(s, true)); +const lineContent = fc.array(segment, { minLength: 1, maxLength: 4 }).map((s) => inlineContent(s, false)); + +const isList = (node: PMNode) => node.type.name === 'bulletList' || node.type.name === 'orderedList'; + +/** Puts a paragraph between two lists of one kind, which would otherwise read back as one list. */ +function separateLists(blocks: PMNode[]): PMNode[] { + return blocks.flatMap((block, i) => { + const before = blocks[i - 1]; + const apart = before && isList(block) && before.type === block.type; + return apart ? [nodes.paragraph.create(null, textSchema.text('x')), block] : [block]; + }); +} + +const fence = fc + .array(fc.constantFrom('a', ' ', '`', '```', '~', '$$', '', ' x', '\t'), { maxLength: 4 }) + .map((l) => l.join('\n')); + +export interface Blocks { + readonly block: fc.Arbitrary; + readonly doc: fc.Arbitrary; +} + +/** Documents of any supported block, to a nesting depth of three. */ +export function documents(): Blocks { + const { block } = fc.letrec<{ block: PMNode; blocks: PMNode[] }>((tie) => ({ + blocks: fc.array(tie('block'), { minLength: 1, maxLength: 3 }).map(separateLists), + block: fc.oneof( + { depthSize: 'small', maxDepth: 3 }, + { weight: 6, arbitrary: paragraphContent.map((c) => nodes.paragraph.create(null, c)) }, + { + weight: 3, + arbitrary: fc + .tuple(fc.integer({ min: 1, max: 6 }), lineContent) + .map(([level, c]) => nodes.heading.create({ level }, c)), + }, + { + weight: 1, + arbitrary: fc + .tuple(fc.option(fc.constantFrom('js', 'c++', 'a-b.c'), { nil: null }), fence) + .map(([language, text]) => nodes.codeBlock.create({ language }, text === '' ? null : textSchema.text(text))), + }, + { weight: 1, arbitrary: fc.constant(nodes.horizontalRule.create()) }, + { + weight: 1, + arbitrary: fc + .array(mathSource, { minLength: 1, maxLength: 2 }) + .map((l) => nodes.mathBlock.create({ source: l.join('\n') })), + }, + { weight: 3, arbitrary: listOf(tie('blocks'), false) }, + { weight: 3, arbitrary: listOf(tie('blocks'), true) }, + { weight: 2, arbitrary: tie('blocks').map((b) => nodes.blockquote.create(null, b)) }, + { + weight: 2, + arbitrary: fc + .tuple( + fc.constantFrom(...CALLOUT_TYPES, 'custom'), + fc.constantFrom(...FOLDS), + lineContent, + fc.oneof(fc.constant([] as PMNode[]), tie('blocks')), + ) + .map(([type, fold, title, body]) => + nodes.callout.create({ type, fold }, [nodes.calloutTitle.create(null, title), ...body]), + ), + }, + ), + })); + return { + block, + doc: fc.array(block, { minLength: 1, maxLength: 4 }).map((b) => nodes.doc.create(null, separateLists(b))), + }; +} + +function listOf(body: fc.Arbitrary, ordered: boolean): fc.Arbitrary { + const item = fc + .tuple(fc.constantFrom(null, false, true), paragraphContent, fc.oneof(fc.constant([] as PMNode[]), body)) + .map(([checked, lead, rest]) => + nodes.listItem.create({ checked }, [nodes.paragraph.create(null, lead), ...separateLists(rest)]), + ); + return fc + .tuple(fc.array(item, { minLength: 1, maxLength: 3 }), fc.integer({ min: 0, max: 12 })) + .map(([items, start]) => + ordered ? nodes.orderedList.create({ start }, items) : nodes.bulletList.create(null, items), + ); +} + +/** Markdown-like strings, for checking that any input parses and that writing it is stable. */ +export const markdownSource: fc.Arbitrary = fc + .array( + fc.oneof( + { weight: 6, arbitrary: fc.constantFrom(...LETTERS, ' ', '\n', '\n\n', ' ') }, + { weight: 8, arbitrary: fc.constantFrom(...PUNCTUATION) }, + { + weight: 4, + arbitrary: fc.constantFrom( + ...FRAGMENTS, + '**', + '*', + '==', + '~~', + '`', + '```', + '> ', + '- ', + '1. ', + '- [ ] ', + '[x](y)', + '![a](asset:1)', + '', + '', + '', + '$x$', + '\\\n', + ' ', + ), + }, + ), + { maxLength: 40 }, + ) + .map((parts) => parts.join('')); + +/** + * How many runs a property gets: `FC_RUNS` when set (100,000 in the nightly job), 1,000 in CI as the Phase 4 plan + * asks of every pull request, and `local` on a developer's machine, where several suites share the processor. + */ +export function propertyRuns(local: number): number { + const forced = Number(process.env.FC_RUNS); + if (Number.isInteger(forced) && forced > 0) return forced; + return process.env.CI ? Math.max(1000, local) : local; +} diff --git a/app/src/editor/markdown/cache.test.ts b/app/src/editor/markdown/cache.test.ts new file mode 100644 index 000000000..6a56ad479 --- /dev/null +++ b/app/src/editor/markdown/cache.test.ts @@ -0,0 +1,108 @@ +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { Transform } from '@tiptap/pm/transform'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { textSchema } from '../schema/schema'; +import { documents, propertyRuns } from './arbitrary'; +import { createMarkdownCache, stateOf } from './cache'; +import { parseTextBlock } from './parse'; +import { serializeTextBlock, warmCache } from './serialize'; + +const RUNS = propertyRuns(300); +const TIMEOUT = 600_000; +const { doc } = documents(); + +/** One random edit, as numbers that pick where and what. Edits that don't fit the document are skipped. */ +type Edit = readonly [kind: number, at: number, size: number, text: string]; + +const edit: fc.Arbitrary = fc.tuple( + fc.nat(5), + fc.nat(10_000), + fc.nat(6), + fc.constantFrom('x', ' ', '*', '- ', '1. ', '#', '\u{1F600}', '`', '[x] ', ''), +); + +function apply(before: PMNode, [kind, at, size, text]: Edit): PMNode { + const tr = new Transform(before); + const pos = at % (before.content.size + 1); + try { + switch (kind) { + case 0: { + const $pos = before.resolve(pos); + if ($pos.parent.inlineContent && text !== '') tr.insert(pos, textSchema.text(text)); + break; + } + case 1: + tr.delete(pos, Math.min(before.content.size, pos + size)); + break; + case 2: + tr.split(pos); + break; + case 3: + tr.addMark(pos, Math.min(before.content.size, pos + size), textSchema.marks.bold.create()); + break; + case 4: + tr.join(pos); + break; + default: { + const node = before.nodeAt(pos); + if (node?.type.name === 'orderedList') tr.setNodeMarkup(pos, null, { start: size * 3 }); + if (node?.type.name === 'listItem') tr.setNodeMarkup(pos, null, { checked: size % 2 === 0 }); + } + } + } catch { + return before; + } + return tr.doc; +} + +describe('the Markdown cache', () => { + it( + 'gives the same text as writing without a cache, across random edits', + () => { + fc.assert( + fc.property(doc, fc.array(edit, { minLength: 1, maxLength: 12 }), (start, edits) => { + const cache = createMarkdownCache(); + let current = start; + expect(serializeTextBlock(current, cache)).toBe(serializeTextBlock(current)); + for (const step of edits) { + current = apply(current, step); + expect(serializeTextBlock(current, cache)).toBe(serializeTextBlock(current)); + } + }), + { numRuns: RUNS }, + ); + }, + TIMEOUT, + ); + + it('keeps the entries of nodes an edit leaves alone', () => { + const cache = createMarkdownCache(); + const before = parseTextBlock('# Title\n\none\n\n- a\n- b\n\n> quoted'); + warmCache(before, '', cache); + const heading = before.child(0); + const quote = before.child(3); + const entry = stateOf(cache).byNode.get(quote); + const tr = new Transform(before).insert(before.child(0).nodeSize + 4, textSchema.text('!')); + expect(serializeTextBlock(tr.doc, cache)).toBe('# Title\n\none!\n\n- a\n- b\n\n> quoted'); + expect(tr.doc.child(0)).toBe(heading); + expect(stateOf(cache).byNode.get(quote)).toBe(entry); + }); + + it('writes an item again when its marker width changes', () => { + const cache = createMarkdownCache(); + const items = Array.from({ length: 9 }, (_, i) => `${i + 1}. item\n\n more ${i}`).join('\n\n'); + const before = parseTextBlock(items); + expect(serializeTextBlock(before, cache)).toBe(items); + const list = before.child(0); + const end = list.nodeSize - 1; + const added = textSchema.nodes.listItem.create(null, [ + textSchema.nodes.paragraph.create(null, textSchema.text('ten')), + textSchema.nodes.paragraph.create(null, textSchema.text('wide')), + ]); + const after = new Transform(before).insert(end, added).doc; + const text = serializeTextBlock(after, cache); + expect(text).toBe(serializeTextBlock(after)); + expect(text.endsWith('10. ten\n\n wide')).toBe(true); + }); +}); diff --git a/app/src/editor/markdown/cache.ts b/app/src/editor/markdown/cache.ts new file mode 100644 index 000000000..a557aeb83 --- /dev/null +++ b/app/src/editor/markdown/cache.ts @@ -0,0 +1,44 @@ +// The per-node Markdown cache (ARCHITECTURE.md section 9.2). One cache serves one page view. ProseMirror shares +// unchanged nodes between states, so after a keystroke only the edited paragraph and its ancestors are new. A flush +// then serializes one paragraph and re-joins cached strings along one path. +import type { Node as PMNode } from '@tiptap/pm/model'; + +/** Opaque: only this folder looks inside. */ +export interface MarkdownCache { + readonly __brand: 'MarkdownCache'; +} + +/** What the serializer remembers about one node. */ +export interface CacheEntry { + /** What the text depends on from outside the node: a list item's marker width, else ''. */ + readonly ctx: string; + /** The node's Markdown as its parent joins it, with the prefixes it applies to its own children. */ + readonly text: string; + /** + * The text with this node's own prefixes taken off: `> ` for a quote or callout, the continuation indent for a + * list item. The same string as `text` for other nodes. + */ + readonly inner: string; + /** + * Where each child's text starts and ends inside `inner` (UTF-16), for re-parsing a range. A dropped empty + * paragraph has an empty span where it would be. Null when the children can't be mapped, because the writer + * joined two adjacent lists into one. + */ + readonly childStarts: Int32Array | null; + readonly childEnds: Int32Array | null; + /** A list item that holds more than one block, which makes its list loose. */ + readonly loose: boolean; +} + +export interface CacheState extends MarkdownCache { + readonly byNode: WeakMap; +} + +export function createMarkdownCache(): MarkdownCache { + const state: CacheState = { __brand: 'MarkdownCache', byNode: new WeakMap() }; + return state; +} + +export function stateOf(cache: MarkdownCache): CacheState { + return cache as CacheState; +} diff --git a/app/src/editor/markdown/commonmark.test.ts b/app/src/editor/markdown/commonmark.test.ts new file mode 100644 index 000000000..d63909ed3 --- /dev/null +++ b/app/src/editor/markdown/commonmark.test.ts @@ -0,0 +1,191 @@ +// Non-canonical input is read correctly (ARCHITECTURE.md section 9.4). Examples in the style of the CommonMark +// 0.31.2 spec, at least one for each of its sections, parse without error. Those within the dialect map to the +// expected nodes, and writing any of them is stable after the first write. +import { describe, expect, it } from 'vitest'; +import { shape } from '../shape'; +import { parseTextBlock } from './parse'; +import { serializeTextBlock } from './serialize'; + +/** A source and the document it reads as, or null when only parsing and stable writing are checked. */ +type Example = readonly [source: string, expected: string | null]; + +const p = (...inline: string[]) => `paragraph(${inline.join(', ')})`; +const doc = (...blocks: string[]) => `doc(${blocks.join(', ')})`; +const item = (...blocks: string[]) => `listItem(${blocks.join(', ')})`; +const bullets = (...items: string[]) => `bulletList(${items.join(', ')})`; +const q = (text: string) => JSON.stringify(text); + +const SECTIONS: Readonly> = { + tabs: [ + ['\tfoo\tbaz\t\tbim', doc(`codeBlock(${q('foo\tbaz\t\tbim')})`)], + [' - foo\n\n\tbar', doc(bullets(item(p(q('foo')), p(q('bar')))))], + ['>\t\tfoo', doc(`blockquote(codeBlock(${q(' foo')}))`)], + ], + 'thematic breaks': [ + ['***\n---\n___', doc('horizontalRule', 'horizontalRule', 'horizontalRule')], + ['+++', doc(p(q('+++')))], + [' - - -', doc('horizontalRule')], + ['Foo\n***\nbar', doc(p(q('Foo')), 'horizontalRule', p(q('bar')))], + ['- foo\n***\n- bar', doc(bullets(item(p(q('foo')))), 'horizontalRule', bullets(item(p(q('bar')))))], + ], + 'ATX headings': [ + ['# foo\n## foo\n###### foo', doc('heading[level=1]("foo")', 'heading[level=2]("foo")', 'heading[level=6]("foo")')], + ['####### foo', doc(p(q('####### foo')))], + ['#5 bolt\n\n#hashtag', doc(p(q('#5 bolt')), p(q('#hashtag')))], + ['\\## foo', doc(p(q('## foo')))], + ['# foo *bar* \\*baz\\*', doc(`heading[level=1](${q('foo ')}, italic("bar"), ${q(' *baz*')})`)], + ['## foo ##\n### foo \\###', doc('heading[level=2]("foo")', 'heading[level=3]("foo ###")')], + ['## ', doc('heading[level=2]()')], + ], + 'setext headings': [ + ['Foo *bar*\n=========', doc(`heading[level=1](${q('Foo ')}, italic("bar"))`)], + ['Foo\nbar\n---', doc('heading[level=2]("Foo bar")')], + [ + '---\nFoo\n---\nBar\n---\nBaz', + doc('horizontalRule', 'heading[level=2]("Foo")', 'heading[level=2]("Bar")', p(q('Baz'))), + ], + ['> foo\n-----', doc(`blockquote(${p(q('foo'))})`, 'horizontalRule')], + ], + 'indented code': [ + [' a simple\n indented code block', doc(`codeBlock(${q('a simple\n indented code block')})`)], + [' chunk1\n\n chunk2', doc(`codeBlock(${q('chunk1\n\nchunk2')})`)], + ['Foo\n bar', doc(p(q('Foo bar')))], + ], + 'fenced code': [ + ['```\n<\n >\n```', doc(`codeBlock(${q('<\n >')})`)], + ['~~~\naaa\n~~~', doc('codeBlock("aaa")')], + ['```\naaa\n~~~\n```', doc(`codeBlock(${q('aaa\n~~~')})`)], + ['```', doc('codeBlock()')], + ['```ruby\ndef foo(x)\n return 3\nend\n```', doc(`codeBlock[language=ruby](${q('def foo(x)\n return 3\nend')})`)], + ['~~~ ruby startline=3 $%@#$\ndef\n~~~', doc('codeBlock[language=ruby]("def")')], + ['``` aa ```\nfoo', doc(p('code("aa")', q(' foo')))], + ], + 'HTML blocks': [ + ['
\n*hello*\n
', doc(p(q('
'), 'hardBreak', q('*hello*'), 'hardBreak', q('
')))], + ['', doc(p(q('')))], + ['
\n
\n**Hello**,\n\n_world_.\n
\n
', null], + ['\n\nafter', null], + ], + 'link reference definitions': [ + ['[foo]: /url "title"\n\n[foo]', doc(p('link/url("foo")'))], + ['[foo]\n\n[foo]: first\n[foo]: second', doc(p('linkfirst("foo")'))], + ['[Foo]\n\n[foo]: /url', doc(p('link/url("Foo")'))], + ['[foo]: /url\n===\n[foo]', doc(p(q('=== '), 'link/url("foo")'))], + ], + 'paragraphs and blank lines': [ + ['aaa\n\nbbb', doc(p(q('aaa')), p(q('bbb')))], + [' aaa\n bbb', doc(p(q('aaa bbb')))], + ['aaa \nbbb ', doc(p(q('aaa'), 'hardBreak', q('bbb')))], + ['\n\naaa\n\n\n\nbbb\n\n', doc(p(q('aaa')), p(q('bbb')))], + ], + 'block quotes': [ + ['> # Foo\n> bar\n> baz', doc(`blockquote(heading[level=1]("Foo"), ${p(q('bar baz'))})`)], + ['> bar\nbaz\n> foo', doc(`blockquote(${p(q('bar baz foo'))})`)], + ['> foo\n\n> bar', doc(`blockquote(${p(q('foo'))})`, `blockquote(${p(q('bar'))})`)], + ['>', null], + ], + 'list items': [ + [ + '1. A paragraph\n with two lines.\n\n indented code\n\n > A block quote.', + doc( + `orderedList[start=1](${item( + p(q('A paragraph with two lines.')), + 'codeBlock("indented code")', + `blockquote(${p(q('A block quote.'))})`, + )})`, + ), + ], + ['- one\n\n two', doc(bullets(item(p(q('one')))), p(q('two')))], + ['123456789. ok', doc(`orderedList[start=123456789](${item(p(q('ok')))})`)], + ['1234567890. not ok\n\n-1. not ok', doc(p(q('1234567890. not ok')), p(q('-1. not ok')))], + [ + '- foo\n - bar\n - baz', + doc(bullets(item(p(q('foo')), bullets(item(p(q('bar')), bullets(item(p(q('baz'))))))))), + ], + ['10) foo\n - bar', doc(`orderedList[start=10](${item(p(q('foo')), bullets(item(p(q('bar')))))})`)], + ], + lists: [ + ['- foo\n- bar\n+ baz', doc(bullets(item(p(q('foo'))), item(p(q('bar')))), bullets(item(p(q('baz')))))], + [ + 'The number of windows in my house is\n14. The number of doors is 6.', + doc(p(q('The number of windows in my house is 14. The number of doors is 6.'))), + ], + ['- a\n- b\n\n- c', doc(bullets(item(p(q('a'))), item(p(q('b'))), item(p(q('c')))))], + ], + 'backslash escapes': [ + ['\\!\\"\\#\\$\\%\\&', doc(p(q('!"#$%&')))], + ['\\\\*emphasis*', doc(p(q('\\'), 'italic("emphasis")'))], + ['foo\\\nbar', doc(p(q('foo'), 'hardBreak', q('bar')))], + ['`` \\[\\` ``', doc(p(`code(${q('\\[\\`')})`))], + ], + 'entity and numeric character references': [ + ['  & © Æ', doc(p(q(String.fromCharCode(0xa0) + ' & © Æ')))], + ['# Ӓ Ϡ �', doc(p(q('# Ӓ Ϡ �')))], + ['" ആ ಫ', doc(p(q('" ആ ಫ')))], + ], + 'code spans': [ + ['`foo`', doc(p('code("foo")'))], + ['`` foo ` bar ``', doc(p(`code(${q('foo ` bar')})`))], + ['` `` `', doc(p(`code(${q('``')})`))], + ['`foo\\`bar`', doc(p(`code(${q('foo\\')})`, q('bar`')))], + ], + 'emphasis and strong emphasis': [ + ['*foo bar*', doc(p('italic("foo bar")'))], + ['a * foo bar*', doc(p(q('a * foo bar*')))], + ['foo*bar*', doc(p(q('foo'), 'italic("bar")'))], + ['_foo_bar', doc(p(q('_foo_bar')))], + ['**foo bar**', doc(p('bold("foo bar")'))], + ['__foo, __bar__, baz__', doc(p('bold("foo, bar, baz")'))], + ['*foo**bar**baz*', doc(p('italic("foo")', 'bold(italic("bar"))', 'italic("baz")'))], + ['***foo** bar*', doc(p('bold(italic("foo"))', 'italic(" bar")'))], + ], + strikethrough: [['~~Hi~~ Hello, world!', doc(p('strike("Hi")', q(' Hello, world!')))]], + links: [ + ['[link](/uri "title")', doc(p('link/uri("link")'))], + ['[link]()', doc(p('link/my uri("link")'))], + ['[link](foo(and(bar)))', doc(p('linkfoo(and(bar))("link")'))], + ['[link](#fragment)', doc(p('link#fragment("link")'))], + ['[a]()', doc(p('linkb)c("a")'))], + ['[foo][bar]\n\n[bar]: /url "title"', doc(p('link/url("foo")'))], + ['[link *foo **bar** `#`*](/uri)', null], + ], + images: [ + ['![foo](/url "title")', doc(p('image[src=/url,alt=foo]'))], + ['![foo *bar*][]\n\n[foo *bar*]: train.jpg', doc(p('image[src=train.jpg,alt=foo bar]'))], + ], + autolinks: [ + ['', doc(p('linkhttp://foo.bar.baz("http://foo.bar.baz")'))], + ['', doc(p('linkmailto:foo@bar.example.com("foo@bar.example.com")'))], + ['', doc(p(q('')))], + ], + 'raw HTML': [ + ['', doc(p(q('')))], + ['foo ', null], + ['x', null], + ], + 'hard and soft line breaks': [ + ['foo \nbar', doc(p(q('foo'), 'hardBreak', q('bar')))], + ['foo\\', doc(p(q('foo\\')))], + ['### foo\\', doc('heading[level=3]("foo\\\\")')], + ['foo\nbaz', doc(p(q('foo baz')))], + ], + 'textual content': [ + ["hello $.;'there", doc(p(q("hello $.;'there")))], + ['Foo χρῆν', doc(p(q('Foo χρῆν')))], + ], +}; + +describe('CommonMark 0.31.2 examples', () => { + for (const [section, examples] of Object.entries(SECTIONS)) { + it.each(examples.map(([source, expected]) => [JSON.stringify(source), source, expected] as const))( + `${section}: %s`, + (_label, source, expected) => { + const parsed = parseTextBlock(source); + parsed.check(); + if (expected !== null) expect(shape(parsed)).toBe(expected); + const once = serializeTextBlock(parsed); + expect(serializeTextBlock(parseTextBlock(once)), once).toBe(once); + }, + ); + } +}); diff --git a/app/src/editor/markdown/elements.test.ts b/app/src/editor/markdown/elements.test.ts new file mode 100644 index 000000000..084a16b97 --- /dev/null +++ b/app/src/editor/markdown/elements.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { applyElementData, countElements, extractElementData } from './elements'; +import { parseTextBlock } from './parse'; +import { serializeTextBlock } from './serialize'; + +const MARKDOWN = [ + '# Title', + '', + 'A paragraph.', + '', + '- first item', + '', + ' a second paragraph in the item', + '', + ' - nested item', + '', + '> quoted paragraph', + '', + '```', + 'code', + '```', + '', + '---', +].join('\n'); + +/** Eight elements: heading, paragraph, list item, its second paragraph, nested item, quote, code, and break. */ +const IDS = Array.from({ length: 8 }, (_, i) => `01m3sabc3${i}0rfa24eeh6j4ky4`); + +function counter(): () => string { + let n = 0; + return () => `01m3new${String(n++).padStart(19, '0')}`; +} + +describe('text elements', () => { + it('counts headings, paragraphs, list items, code, and breaks, but not quotes or a list item lead', () => { + expect(countElements(parseTextBlock(MARKDOWN))).toBe(IDS.length); + }); + + it('gives each element its ID, tags, style, and checked state, in document order', () => { + const doc = applyElementData(parseTextBlock(MARKDOWN), { + ids: IDS, + tags: { [IDS[1]]: ['todo', 'exam/unit-3'] }, + styles: { [IDS[0]]: 'title' }, + checked: [IDS[1]], + }); + const found: { type: string; id: string | null }[] = []; + doc.descendants((node) => { + if (node.attrs.id) found.push({ type: node.type.name, id: node.attrs.id as string | null }); + }); + expect(found.map((entry) => entry.type)).toEqual([ + 'heading', + 'paragraph', + 'listItem', + 'paragraph', + 'listItem', + 'paragraph', + 'codeBlock', + 'horizontalRule', + ]); + expect(found.map((entry) => entry.id)).toEqual(IDS); + }); + + it('round-trips the data, and never changes the Markdown', () => { + const data = { + ids: IDS, + tags: { [IDS[1]]: ['todo'], [IDS[0]]: ['a', 'b'] }, + styles: { [IDS[2]]: 'quote' }, + checked: [IDS[1], IDS[0]], + }; + const doc = applyElementData(parseTextBlock(MARKDOWN), data); + expect(serializeTextBlock(doc)).toBe(MARKDOWN); + const again = extractElementData(doc, counter()); + expect(again.data.ids).toEqual(IDS); + expect(again.data.tags).toEqual({ [IDS[0]]: ['a', 'b'], [IDS[1]]: ['todo'] }); + expect(again.data.styles).toEqual({ [IDS[2]]: 'quote' }); + expect(again.data.checked).toEqual([IDS[0], IDS[1]]); + }); + + it('adds IDs for elements that have none, and replaces a repeated ID', () => { + const doc = applyElementData(parseTextBlock('one\n\ntwo\n\nthree'), { ids: ['a', 'a'] }); + const { data } = extractElementData(doc, counter()); + expect(data.ids[0]).toBe('a'); + expect(new Set(data.ids).size).toBe(3); + expect(data.ids[1]).not.toBe('a'); + }); + + it('ignores extra IDs and keeps the remaining elements without one', () => { + const doc = applyElementData(parseTextBlock('one\n\ntwo'), { ids: ['a', 'b', 'c'], tags: { c: ['x'] } }); + const ids: (string | null)[] = []; + doc.forEach((node) => ids.push(node.attrs.id as string | null)); + expect(ids).toEqual(['a', 'b']); + expect(extractElementData(doc, counter()).data.tags).toEqual({}); + const short = applyElementData(parseTextBlock('one\n\ntwo'), { ids: ['a'] }); + expect(short.lastChild?.attrs.id).toBeNull(); + }); +}); diff --git a/app/src/editor/markdown/elements.ts b/app/src/editor/markdown/elements.ts new file mode 100644 index 000000000..7f510b59a --- /dev/null +++ b/app/src/editor/markdown/elements.ts @@ -0,0 +1,99 @@ +// Text elements (SPEC 6.6). A text block names its paragraphs, headings, list items, code blocks, math blocks, and +// thematic breaks in `data.ids`, in document order. It keeps their tags, styles, and checked to-dos beside the +// Markdown, which never carries them. A document holds them as attributes, and this module moves them between the two. +import type { Node as PMNode } from '@tiptap/pm/model'; +import { ELEMENT_TYPES } from '../schema/specs'; + +/** The `ids`, `tags`, `styles`, and `checked` fields of a text block's `data` (SPEC 6.6). */ +export interface ElementData { + ids: string[]; + tags: Record; + styles: Record; + checked: string[]; +} + +export const MAX_TAGS_PER_ELEMENT = 1000; +export const MAX_TAG_LENGTH = 200; + +export function emptyElementData(): ElementData { + return { ids: [], tags: {}, styles: {}, checked: [] }; +} + +type Update = (node: PMNode, index: number) => Record; + +interface Walk { + next: number; + readonly update: Update; +} + +function walk(node: PMNode, state: Walk, isItemLead: boolean): PMNode { + const isElement = ELEMENT_TYPES.has(node.type.name) && !isItemLead; + const attrs = isElement ? { ...node.attrs, ...state.update(node, state.next++) } : node.attrs; + if (node.isInline) return node; + const children: PMNode[] = []; + node.forEach((child, _offset, i) => children.push(walk(child, state, node.type.name === 'listItem' && i === 0))); + return node.type.create(attrs, children.length > 0 ? children : null, node.marks); +} + +/** + * The document with every element's attributes updated. The walk is in document order, so a list item comes before + * the elements inside it. A list item's first paragraph belongs to the item and is skipped. + */ +export function updateElements(doc: PMNode, update: Update): PMNode { + return walk(doc, { next: 0, update }, false); +} + +/** The number of elements in a text block. */ +export function countElements(doc: PMNode): number { + let count = 0; + updateElements(doc, () => { + count++; + return {}; + }); + return count; +} + +/** + * Gives each element the ID, tags, style, and checked state that the block's data names for it. A list with + * fewer IDs than elements leaves the last elements without one, and extra IDs are ignored (SPEC 6.6). + */ +export function applyElementData(doc: PMNode, data: Partial): PMNode { + const checked = new Set(data.checked ?? []); + return updateElements(doc, (_node, index) => { + const id = data.ids?.[index] ?? null; + return { + id, + tags: id === null ? [] : (data.tags?.[id] ?? []), + style: id === null ? null : (data.styles?.[id] ?? null), + tagChecked: id !== null && checked.has(id), + }; + }); +} + +function byKey(record: Record): Record { + return Object.fromEntries(Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); +} + +/** + * The data fields for a document, and the document with an ID on every element. An element without an ID, or + * with one that an earlier element has, gets a new one from `newId`. Maps come out with their keys in order. + */ +export function extractElementData(doc: PMNode, newId: () => string): { doc: PMNode; data: ElementData } { + const data = emptyElementData(); + const seen = new Set(); + const withIds = updateElements(doc, (node) => { + const held = node.attrs.id as string | null; + const id = held !== null && !seen.has(held) ? held : newId(); + seen.add(id); + data.ids.push(id); + const tags = (node.attrs.tags as readonly string[]).slice(0, MAX_TAGS_PER_ELEMENT); + if (tags.length > 0) data.tags[id] = tags.map((tag) => tag.slice(0, MAX_TAG_LENGTH)); + if (node.attrs.style) data.styles[id] = node.attrs.style as string; + if (node.attrs.tagChecked) data.checked.push(id); + return { id }; + }); + data.tags = byKey(data.tags); + data.styles = byKey(data.styles); + data.checked.sort(); + return { doc: withIds, data }; +} diff --git a/app/src/editor/markdown/escape.ts b/app/src/editor/markdown/escape.ts new file mode 100644 index 000000000..688a9b708 --- /dev/null +++ b/app/src/editor/markdown/escape.ts @@ -0,0 +1,123 @@ +// SPEC 7.6: escaping text exactly as the table says, and nowhere else. The functions work on code points so that a +// caller can escape one line of text that spans several marks and still get the same answer as for plain text. +import { isSpaceChar } from './flanking'; + +export interface EscapeMode { + /** True for a paragraph line: the `>`, `-`, `+`, `=`, and numbered-list rules apply at its start. */ + readonly paragraphLine: boolean; + /** True when a space first or last on the line would be dropped by CommonMark, so it is written ` `. */ + readonly spaceEdges: boolean; +} + +export const PARAGRAPH: EscapeMode = { paragraphLine: true, spaceEdges: true }; +/** Headings and callout titles: a line, but not a paragraph line. */ +export const TITLE: EscapeMode = { paragraphLine: false, spaceEdges: true }; +/** Image descriptions and other text inside brackets. */ +export const LABEL: EscapeMode = { paragraphLine: false, spaceEdges: false }; + +/** + * Places between two code points where markup is written. An index `i` means the markup sits just before code + * point `i`. A `_` or `=` beside markup is judged by what is written there, not by the text around the markup. + */ +export interface Seams { + /** Where a `==` delimiter sits. A literal `=` beside it would join it. */ + readonly highlights: ReadonlySet; + /** Where any mark starts or ends. A `_` beside it is no longer inside a word. */ + readonly marks: ReadonlySet; +} + +const NO_SEAMS: Seams = { highlights: new Set(), marks: new Set() }; + +const ALWAYS = new Set(['\\', '`', '*', '~', '$', '[', ']', '{', '<', '|']); +const LINE_START_ONLY = new Set(['>', '-', '+']); +const LETTER_OR_DIGIT = /^[\p{L}\p{N}]$/u; +const CHARACTER_REFERENCE = /^&#?[A-Za-z0-9]+;/; +const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? 9) return false; + return chars.slice(from, to).every((ch) => ch >= '0' && ch <= '9'); +} + +function escapeUnderscore({ chars, at, lineStart, seams }: Position): string { + const prev = at > lineStart ? chars[at - 1] : undefined; + const next = chars[at + 1]; + const apart = seams.marks.has(at) || seams.marks.has(at + 1); + const inWord = prev !== undefined && next !== undefined && LETTER_OR_DIGIT.test(prev) && LETTER_OR_DIGIT.test(next); + return inWord && !apart ? '_' : '\\_'; +} + +function escapeEquals({ chars, at, lineStart, opens, mode, seams }: Position): string { + const touching = (at > lineStart && chars[at - 1] === '=') || chars[at + 1] === '='; + const first = mode.paragraphLine && opens && at === lineStart; + const beside = seams.highlights.has(at) || seams.highlights.has(at + 1); + return touching || first || beside ? '\\=' : '='; +} + +function escapeSpace({ chars, at, lineStart, opens, mode }: Position): string { + const last = at + 1 >= chars.length || chars[at + 1] === '\n'; + return mode.spaceEdges && ((opens && at === lineStart) || last) ? ' ' : ' '; +} + +function escapeAt(where: Position): string { + const { chars, at, lineStart, opens, mode } = where; + const ch = chars[at]; + const first = opens && at === lineStart; + if (ALWAYS.has(ch)) return `\\${ch}`; + if (ch === '\t') return ' '; + if (ch === ' ') return escapeSpace(where); + if (ch === '_') return escapeUnderscore(where); + if (ch === '=') return escapeEquals(where); + if (ch === '&') return CHARACTER_REFERENCE.test(chars.slice(at, at + 40).join('')) ? '\\&' : '&'; + if (ch === '#') return at === lineStart || isSpaceChar(chars[at - 1]) ? '\\#' : '#'; + if (LINE_START_ONLY.has(ch)) return mode.paragraphLine && first ? `\\${ch}` : ch; + const numbered = mode.paragraphLine && where.opens && isDigits(chars, lineStart, at); + if ((ch === '.' || ch === ')') && numbered) return `\\${ch}`; + return ch; +} + +/** + * What each code point is written as. A newline ends a line, and the next line opens. `opensLine` is false when the + * first code point follows other markup on its line. + */ +export function escapeCodePoints( + chars: readonly string[], + mode: EscapeMode, + seams: Seams = NO_SEAMS, + opensLine = true, +): string[] { + const out: string[] = []; + let lineStart = 0; + let opens = opensLine; + for (let at = 0; at < chars.length; at++) { + if (at > 0 && chars[at - 1] === '\n') [lineStart, opens] = [at, true]; + out.push(chars[at] === '\n' ? '\n' : escapeAt({ chars, at, lineStart, opens, mode, seams })); + } + return out; +} + +/** + * SPEC 7.6 for paragraph text, as the shared escape fixtures pair them: a line break becomes a hard break, and + * `atLineStart` says whether the text starts a paragraph line or follows other markup on it. + */ +export function escapeParagraphText(text: string, atLineStart = true): string { + const escaped = escapeCodePoints(Array.from(cleanText(text)), PARAGRAPH, NO_SEAMS, atLineStart); + return escaped.map((written) => (written === '\n' ? '\\\n' : written)).join(''); +} + +/** Replaces `U+0000`, lone surrogates, and `U+000D`, which Markdown text never holds (SPEC 7.6 and 7.7). */ +export function cleanText(text: string): string { + return text.replace(/\r\n?/g, '\n').replace(/\0/g, '\ufffd').replace(LONE_SURROGATE, '\ufffd'); +} diff --git a/app/src/editor/markdown/fixtures.ts b/app/src/editor/markdown/fixtures.ts new file mode 100644 index 000000000..c73cf1b30 --- /dev/null +++ b/app/src/editor/markdown/fixtures.ts @@ -0,0 +1,150 @@ +// Canonical OpenNote Markdown that must survive `serialize(parse(m)) === m` (SPEC 7.8), and the escape pairs of +// SPEC Appendix B.5. The shared files in docs/format/fixtures/markdown/ will hold the same kinds of cases. + +export interface Fixture { + readonly name: string; + readonly markdown: string; +} + +const lines = (...rows: string[]) => rows.join('\n'); + +export const DOCUMENTS: readonly Fixture[] = [ + { name: 'an empty block', markdown: '' }, + { name: 'paragraphs', markdown: lines('First paragraph.', '', 'Second paragraph.') }, + { name: 'headings', markdown: lines('# One', '', '## Two', '', '###### Six', '', '#') }, + { + name: 'delimiter marks', + markdown: 'A **strong**, *emphasized*, ~~struck~~, ==highlighted==, and `code` word.', + }, + { name: 'marks inside a word', markdown: 'un**believ**able and in*cred*ible' }, + { + name: 'a delimiter that would not open becomes a tag', + markdown: lines('a(b)', '', '"quoted"x', '', '**(b)** c'), + }, + { + name: 'tags for marks without delimiters', + markdown: lines( + 'H2O and E=mc2 and under', + '', + // checks-disable-next-line brand-consistency: document content, not interface styling + 'mint and red', + '', + 'pen and big', + ), + }, + { name: 'overlapping ranges close and reopen', markdown: '**a*b***c' }, + { name: 'strong and emphasis together', markdown: '***both*** and **bold *and italic* bold**' }, + { name: 'whitespace moves outside the marks', markdown: 'one **two** three *four* ' }, + { name: 'marks around code and links', markdown: '**`code`** and [**link**](https://example.com) and [`x`](a.md)' }, + { + name: 'links and images', + markdown: lines( + '[text](https://example.com/a?b=1&c=2) and [page](opennote:page/01m3sabc31y0rfa24eeh6j4ky4#01m3sabc32dwqknfawtgwtsgcj)', + '', + '[a b]() and [f(x)]()', + '', + '![a chart](asset:01m3sabc31y0rfa24eeh6j4ky4) and ![](https://example.com/x.png)', + ), + }, + { name: 'hard breaks', markdown: lines('line one\\', 'line two\\', 'line three') }, + { + name: 'escapes', + markdown: lines( + '5 \\* 3 = 15 \\$5 \\[draft\\] \\', + '', + '\\# not a heading', + '', + '1\\. not a list and - not a bullet', + '', + 'a \\=\\= b', + '', + '\\- first and > mid and + plus', + ), + }, + { + name: 'a bullet list', + markdown: lines('- one', '- two', '- three'), + }, + { + name: 'a numbered list that starts at 3 and reaches two digits', + markdown: lines('8. eight', '9. nine', '10. ten', '11. eleven'), + }, + { name: 'task items', markdown: lines('- [ ] to do', '- [x] done', '- plain', '- [ ]') }, + { + name: 'a nested list makes its list loose', + markdown: lines('- one', '', '- two', '', ' - nested', '', ' - deeper', '', '- three'), + }, + { + name: 'an item with two blocks', + markdown: lines('1. intro', '', ' more text', '', ' ```', ' code', ' ```', '', '2. next'), + }, + { + name: 'two adjacent lists of different kinds', + markdown: lines('- bullet', '', '1. numbered', '', '- bullet again'), + }, + { name: 'a quote', markdown: lines('> quoted', '>', '> - item', '>', '> > inner') }, + { + name: 'callouts', + markdown: lines( + '> [!note]', + '', + '> [!tip] A title with **bold**', + '', + '> [!warning]- Folded', + '> Body text', + '>', + '> - item', + '', + '> [!info] A list first', + '>', + '> - item', + '', + '> [!custom]+ Open, unknown type', + ), + }, + { + name: 'code blocks', + markdown: lines( + '```js', + 'const a = 1;', + '', + 'f(a);', + '```', + '', + '```', + '```', + '', + '````', + '```', + 'inner', + '```', + '````', + ), + }, + { name: 'a thematic break', markdown: lines('above', '', '---', '', 'below') }, + { name: 'math', markdown: lines('$$', 'x^2 + y^2', '$$', '', 'inline $a_b$ math and \\$5') }, + { name: 'a unicode paragraph', markdown: 'Caf\u00e9 \u2014 \u00fcber \u{1f600} **\u{1f600}** snake_case' }, + { name: 'a line that starts with spaces', markdown: lines(' indented', '', ' ', '', 'tab here') }, +]; + +/** SPEC B.5: plain text and how a paragraph line holding it is written. */ +export const ESCAPES: readonly (readonly [text: string, written: string])[] = [ + ['5 * 3 = 15', '5 \\* 3 = 15'], + ['a == b', 'a \\=\\= b'], + ['Costs $5', 'Costs \\$5'], + ['#biology and C#', '\\#biology and C#'], + ['snake_case and _draft', 'snake_case and \\_draft'], + ['[draft]', '\\[draft\\]'], + ['AT&T and &', 'AT&T and \\&'], + ['x < y', 'x \\< y'], + ['~5 minutes', '\\~5 minutes'], + ['{note}', '\\{note}'], + ['1. Not a list', '1\\. Not a list'], + ['- not a bullet', '\\- not a bullet'], + [' starts with a space', ' starts with a space'], + ['ends with a space ', 'ends with a space '], + ['a\tb', 'a b'], + ['> not a quote', '\\> not a quote'], + ['a|b and back\\slash and `tick`', 'a\\|b and back\\\\slash and \\`tick\\`'], + ['=first and a=b', '\\=first and a=b'], +]; diff --git a/app/src/editor/markdown/flanking.ts b/app/src/editor/markdown/flanking.ts new file mode 100644 index 000000000..0a1e9189d --- /dev/null +++ b/app/src/editor/markdown/flanking.ts @@ -0,0 +1,46 @@ +// CommonMark's flanking rules, with the character classes that markdown-it uses. The writer and the reader then +// agree on which delimiters open and close. SPEC 7.7 writes an HTML tag where a delimiter would not work. +import MarkdownIt from 'markdown-it'; + +const { isWhiteSpace, isPunctChar, isMdAsciiPunct } = new MarkdownIt().utils; + +export type CharClass = 'ws' | 'punct' | 'other'; + +/** The class of one character. The start or end of a line, and a line break, count as whitespace. */ +export function classOf(ch: string | undefined): CharClass { + if (ch === undefined || ch === '\n') return 'ws'; + const code = ch.codePointAt(0) as number; + if (isWhiteSpace(code)) return 'ws'; + return isMdAsciiPunct(code) || isPunctChar(ch) ? 'punct' : 'other'; +} + +/** Whether `ch` is whitespace in the sense of SPEC 7.7's "moved outside the delimiters". */ +export function isSpaceChar(ch: string | undefined): boolean { + return classOf(ch) === 'ws'; +} + +/** The class of the first or last character of an output string. */ +export function edgeClass(text: string, side: 'first' | 'last'): CharClass { + if (text === '') return 'ws'; + const chars = Array.from(text); + return classOf(side === 'first' ? chars[0] : chars[chars.length - 1]); +} + +/** A `*` or `~~` run opens when it is left-flanking. */ +export function canOpen(prev: CharClass, next: CharClass): boolean { + return next !== 'ws' && (next !== 'punct' || prev !== 'other'); +} + +/** A `*` or `~~` run closes when it is right-flanking. */ +export function canClose(prev: CharClass, next: CharClass): boolean { + return prev !== 'ws' && (prev !== 'punct' || next !== 'other'); +} + +/** SPEC 7.3: `==` opens when the next character is not whitespace and closes when the previous one is not. */ +export function canOpenHighlight(_prev: CharClass, next: CharClass): boolean { + return next !== 'ws'; +} + +export function canCloseHighlight(prev: CharClass, _next: CharClass): boolean { + return prev !== 'ws'; +} diff --git a/app/src/editor/markdown/hostile.test.ts b/app/src/editor/markdown/hostile.test.ts new file mode 100644 index 000000000..0c47321ed --- /dev/null +++ b/app/src/editor/markdown/hostile.test.ts @@ -0,0 +1,133 @@ +// Any string gives a valid document (ARCHITECTURE.md section 9.1): deep nesting, stray delimiters, HTML, carriage +// returns, and lone surrogates included. Hand-edited reserved syntax stays editable text (section 9.5). +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { shape } from '../shape'; +import { propertyRuns } from './arbitrary'; +import { parseTextBlock } from './parse'; +import { serializeTextBlock } from './serialize'; + +const RUNS = propertyRuns(400); +const TIMEOUT = 600_000; +const LONE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? ', + '- ', + '1. ', + ' ', + ' ', + '\t', + '\n', + '\r', + '\r\n', + '\n\n', + '*', + '**', + '_', + '~~', + '==', + '`', + '```', + '$', + '$$', + '[', + ']', + '(', + ')', + '![', + '<', + '>', + '', + '', + '', + '', + '', + '', + '', + ' parts.join('')); + +/** Parsing gives a valid document, and the text it writes is clean and stable. */ +function expectSafe(source: string): void { + const parsed = parseTextBlock(source); + parsed.check(); + const once = serializeTextBlock(parsed); + expect(once).not.toMatch(/[\r\0]/); + expect(LONE.test(once)).toBe(false); + expect(serializeTextBlock(parseTextBlock(once)), JSON.stringify(source)).toBe(once); +} + +describe('hostile input', () => { + it( + 'never throws, and writing what it read is stable', + () => { + fc.assert(fc.property(hostile, expectSafe), { numRuns: RUNS }); + }, + TIMEOUT, + ); + + it('survives deep nesting and long delimiter runs', () => { + const sources = [ + '> '.repeat(500) + 'deep', + Array.from({ length: 200 }, (_, i) => `${' '.repeat(i)}- level ${i}`).join('\n'), + '['.repeat(5000) + 'x' + ']'.repeat(5000), + '*'.repeat(10_000), + '_a'.repeat(5000), + ''.repeat(2000) + 'x', + '`'.repeat(3000) + 'x', + '$'.repeat(4000), + '='.repeat(4000), + '\\'.repeat(4000), + '\r\n'.repeat(1000), + '\uD800'.repeat(1000), + ]; + for (const source of sources) expectSafe(source); + }); + + it('reads carriage returns as line ends and lone surrogates as replacement characters', () => { + expect(serializeTextBlock(parseTextBlock('one\r\ntwo\rthree'))).toBe('one two three'); + expect(serializeTextBlock(parseTextBlock('a\uD800b'))).toBe('a�b'); + }); + + it('writes no lone surrogate from math, image text, or a link', () => { + const sources = ['$$\uD800$$', '$a\uDC00$', '![\uD800](x\uDC00)', '[a](b\uD800)', '$$\n\uD800\n$$']; + // Inside a mark, the writer checks that its delimiters read back, which compares the cleaned text. + for (const source of [...sources, '*$$\uDC00$$*', '*[a](b\uD800)*', '_![a\nb](c)_']) expectSafe(source); + }); +}); + +describe('hand-edited reserved syntax', () => { + it('keeps a word-start # and a { as text, and escapes them on the next write', () => { + const parsed = parseTextBlock('#biology notes {draft} and C# code'); + expect(shape(parsed)).toBe('doc(paragraph("#biology notes {draft} and C# code"))'); + expect(serializeTextBlock(parsed)).toBe('\\#biology notes \\{draft} and C# code'); + }); + + it('keeps unknown or inert link schemes as links that are never turned into anything else', () => { + const parsed = parseTextBlock('[a](ftp://x.y/z) [b](tel:123) [c](file:///etc/hosts) [d](opennote:page/x)'); + expect(shape(parsed)).toBe( + 'doc(paragraph(linkftp://x.y/z("a"), " ", linktel:123("b"), " ", linkfile:///etc/hosts("c"), " ", ' + + 'linkopennote:page/x("d")))', + ); + expect(serializeTextBlock(parsed)).toBe( + '[a](ftp://x.y/z) [b](tel:123) [c](file:///etc/hosts) [d](opennote:page/x)', + ); + }); +}); diff --git a/app/src/editor/markdown/index.test.ts b/app/src/editor/markdown/index.test.ts new file mode 100644 index 000000000..98ae9fc6c --- /dev/null +++ b/app/src/editor/markdown/index.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { tableSchema } from '../schema/schema'; +import { + createMarkdownCache, + diffMarkdown, + parseCell, + parseInWorker, + parseTextBlock, + reparseRange, + serializeCell, + serializeTextBlock, + utf8Offset, +} from '.'; + +const cache = createMarkdownCache(); +const grin = '\u{1F600}'; +const beam = '\u{1F601}'; + +describe('the Markdown API', () => { + it('round-trips a text block', () => { + const markdown = '# Title\n\n- one\n- two\n\n> quote'; + expect(serializeTextBlock(parseTextBlock(markdown), cache)).toBe(markdown); + }); + + it('parses a cell into one table paragraph and writes it back', () => { + const cell = parseCell('a **b** `c`'); + expect(cell.type).toBe(tableSchema.nodes.paragraph); + expect(serializeCell(cell, cache)).toBe('a **b** `c`'); + expect(serializeCell(parseCell('two\nlines'), cache)).toBe('two lines'); + }); + + it('finds the one splice between two strings, never inside a surrogate pair', () => { + expect(diffMarkdown('abc', 'abc')).toBeNull(); + expect(diffMarkdown('hello world', 'hello brave world')).toEqual({ at: 6, del: '', ins: 'brave ' }); + expect(diffMarkdown(`a${grin}b`, `a${beam}b`)).toEqual({ at: 1, del: grin, ins: beam }); + }); + + it('converts UTF-16 indexes to UTF-8 offsets', () => { + const text = `aé${grin}b`; + expect(utf8Offset(text, 0)).toBe(0); + expect(utf8Offset(text, 2)).toBe(3); + expect(utf8Offset(text, 4)).toBe(7); + }); + + it('re-parses a changed range and parses through the worker API', async () => { + const doc = parseTextBlock('one\n\nthree'); + const change = reparseRange(doc, 'one\n\nthree', 'one\n\ntwo', cache); + expect(change === 'full' ? null : change.content.firstChild?.textContent).toBe('two'); + expect(serializeTextBlock(await parseInWorker('two'), cache)).toBe('two'); + }); +}); diff --git a/app/src/editor/markdown/index.ts b/app/src/editor/markdown/index.ts new file mode 100644 index 000000000..1dcbcefb8 --- /dev/null +++ b/app/src/editor/markdown/index.ts @@ -0,0 +1,52 @@ +// OpenNote Markdown in the interface (PLAN.md section 3.4). WP0 fixed this API; WP1 owns what is behind it. Other +// packages import only from here. +import type { Node as PMNode } from '@tiptap/pm/model'; +import { tableSchema, textSchema } from '../schema/schema'; +import type { MarkdownCache } from './cache'; +import { parseInlineNodes, parseTextBlock as parseBlock } from './parse'; +import { serializeCellParagraph, serializeTextBlock as serializeBlock } from './serialize'; + +export type { MarkdownCache } from './cache'; +export { createMarkdownCache } from './cache'; +export { warmCache } from './serialize'; +export type { MarkdownSplice } from './splice'; +export { diffMarkdown, utf8Offset } from './splice'; +export type { Reparse } from './reparse'; +export { reparseRange } from './reparse'; +export type { PastedPiece } from './paste'; +export { looksLikeMarkdown, parsePastedMarkdown } from './paste'; +export type { ParseRequest, ParseResponse } from './worker'; +export { MAIN_THREAD_PARSE_LIMIT, parseInWorker } from './worker'; +export { escapeParagraphText, PARAGRAPH, TITLE } from './escape'; +export { serializeInline } from './inline'; +export { createMarkdown, buildDoc } from './parse'; +export { joinAdjacentLists, joinListsDeep } from './normalize'; +export { applyElementData, countElements, emptyElementData, extractElementData } from './elements'; +export type { ElementData } from './elements'; + +/** A textSchema document for a block's Markdown. Never throws: any string gives a valid document. */ +export function parseTextBlock(markdown: string): PMNode { + return parseBlock(markdown); +} + +/** The canonical Markdown of a text block (SPEC 7.7). */ +export function serializeTextBlock(doc: PMNode, cache: MarkdownCache): string { + return serializeBlock(doc, cache); +} + +/** A table cell's Markdown as a tableSchema paragraph. Never throws. */ +export function parseCell(markdown: string): PMNode { + try { + const inline = parseInlineNodes(markdown.replace(/\r?\n/g, ' ')); + const paragraph = textSchema.nodes.paragraph.create(null, inline); + return tableSchema.nodeFromJSON(paragraph.toJSON()); + } catch { + const text = markdown.trim(); + return tableSchema.nodes.paragraph.create(null, text === '' ? [] : [tableSchema.text(text)]); + } +} + +/** A cell paragraph's Markdown: one line, escaped like paragraph text. */ +export function serializeCell(paragraph: PMNode, cache: MarkdownCache): string { + return serializeCellParagraph(paragraph, cache); +} diff --git a/app/src/editor/markdown/inline.ts b/app/src/editor/markdown/inline.ts new file mode 100644 index 000000000..bd5071725 --- /dev/null +++ b/app/src/editor/markdown/inline.ts @@ -0,0 +1,143 @@ +// The canonical Markdown of one paragraph, heading, or title (SPEC 7.3, 7.6, and 7.7). Text is escaped as one line +// of code points, so that an escape depends on the text around it and never on how marks split that text. +import { Fragment, Mark } from '@tiptap/pm/model'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { textSchema } from '../schema/schema'; +import { LABEL, cleanText, escapeCodePoints } from './escape'; +import type { EscapeMode, Seams } from './escape'; +import { collectLeaves, dropEdgeBreaks, isCode, trimMarkEdges } from './leaves'; +import type { Leaf } from './leaves'; +import { parseInlineNodes } from './parse'; +import { planRanges } from './marksPlan'; +import type { Plan, Range } from './marksPlan'; +import { + closeToken, + decideStyles, + destinationText, + formatDestination, + isPlainHighlight, + openToken, +} from './marksStyle'; +import type { Style } from './marksStyle'; + +/** A placeholder in the escape view for what is not escaped text: code spans, images, and math. */ +const ATOM = '\uE000'; + +/** SPEC 7.7: the smallest backtick run that does not appear in the text as a run of exactly that length. */ +export function codeSpan(text: string): string { + const used = new Set((text.match(/`+/g) ?? []).map((run) => run.length)); + let size = 1; + while (used.has(size)) size++; + const fence = '`'.repeat(size); + const padded = text.startsWith('`') || text.endsWith('`') || (/^ .* $/.test(text) && /[^ ]/.test(text)); + return padded ? `${fence} ${text} ${fence}` : `${fence}${text}${fence}`; +} + +/** An image description as one line, cleaned like any other text that leaves the editor (SPEC 7.7). */ +const altText = (alt: string) => cleanText(alt).replace(/\s*\n\s*/g, ' '); + +/** An image or inline math. */ +function atomSource(node: PMNode): string { + if (node.type.name === 'mathInline') return `$${cleanText(node.attrs.source as string)}$`; + const alt = escapeCodePoints(Array.from(altText(node.attrs.alt as string)), LABEL).join(''); + return `![${alt}](${formatDestination(node.attrs.src as string)})`; +} + +function viewOf(leaf: Leaf): string[] { + if (leaf.kind === 'break') return ['\n']; + if (leaf.kind === 'text' && !isCode(leaf)) return Array.from(leaf.text); + return [ATOM]; +} + +/** Where markup sits in the escape view, so that a `_` or `=` beside it is escaped. */ +function seamsOf(plan: Plan, starts: readonly number[]): Seams { + const highlights = new Set(); + const marks = new Set(); + plan.boundaries.forEach(({ closes, opens }, at) => { + if (closes.length + opens.length > 0) marks.add(starts[at]); + if ([...closes, ...opens].some((range: Range) => isPlainHighlight(range.mark))) highlights.add(starts[at]); + }); + return { highlights, marks }; +} + +/** What each leaf is written as, with its text escaped. */ +function leafOutputs(leaves: readonly Leaf[], plan: Plan, mode: EscapeMode): string[] { + const views = leaves.map(viewOf); + const starts: number[] = []; + let total = 0; + for (const view of views) { + starts.push(total); + total += view.length; + } + starts.push(total); + const escaped = escapeCodePoints(views.flat(), mode, seamsOf(plan, starts)); + return leaves.map((leaf, i) => { + if (leaf.kind === 'break') return '\\\n'; + if (leaf.kind === 'atom') return atomSource(leaf.node); + return isCode(leaf) ? codeSpan(leaf.text) : escaped.slice(starts[i], starts[i + 1]).join(''); + }); +} + +function render(plan: Plan, outputs: readonly string[], styles: ReadonlyMap): string { + let text = ''; + plan.boundaries.forEach(({ closes, opens }, at) => { + for (const range of closes) text += closeToken(range, styles.get(range) ?? 'tag'); + for (const range of opens) { + const open = openToken(range, styles.get(range) ?? 'tag'); + if (open === '[' && bangIsBare(text)) text = `${text.slice(0, -1)}\\!`; + text += open; + } + if (at < outputs.length) text += outputs[at]; + }); + return text; +} + +/** A `!` right before a link's `[` would read as an image. (SPEC 7.6 has no row for this yet.) */ +function bangIsBare(text: string): boolean { + const slashes = /\\*$/.exec(text.slice(0, -1))?.[0].length ?? 0; + return text.endsWith('!') && slashes % 2 === 0; +} + +/** Attributes as the writer writes them: their text cleaned of what Markdown never holds. */ +function writtenAttrs(attrs: Readonly>): Record { + const out = { ...attrs }; + for (const key of ['href', 'src']) if (typeof out[key] === 'string') out[key] = destinationText(out[key]); + if (typeof out.alt === 'string') out.alt = altText(out.alt); + if (typeof out.source === 'string') out.source = cleanText(out.source); + return out; +} + +/** The same marks in textSchema, which the reader builds. Editors use their own schema instance. */ +function readerMarks(marks: readonly Mark[]): readonly Mark[] { + return Mark.setFrom(marks.map((mark) => textSchema.marks[mark.type.name].create(writtenAttrs(mark.attrs)))); +} + +/** The nodes that the leaves stand for, to check that the written text reads back as the same content. */ +function expectedNodes(leaves: readonly Leaf[]): PMNode[] { + return leaves.map((leaf) => { + const marks = readerMarks(leaf.marks); + if (leaf.kind === 'break') return textSchema.nodes.hardBreak.create(null, null, marks); + if (leaf.kind === 'text') return textSchema.text(leaf.text, marks); + return textSchema.nodes[leaf.node.type.name].create(writtenAttrs(leaf.node.attrs), null, marks); + }); +} + +function readsBack(text: string, leaves: readonly Leaf[]): boolean { + return Fragment.fromArray(parseInlineNodes(text)).eq(Fragment.fromArray(expectedNodes(leaves))); +} + +/** + * The Markdown of an inline container. A heading or title passes `singleLine`, so a break becomes a space. + * Delimiters are checked against the reader, because CommonMark's matching rules (the rule of 3, and runs that + * can both open and close) can pair delimiters that each pass the flanking test. If the text would read back + * differently, every range is written with tags instead, which always reads back exactly. + */ +export function serializeInline(container: PMNode, mode: EscapeMode, singleLine = false): string { + const leaves = trimMarkEdges(dropEdgeBreaks(collectLeaves(container, singleLine))); + const plan = planRanges(leaves); + const outputs = leafOutputs(leaves, plan, mode); + const styles = decideStyles(plan, outputs); + const text = render(plan, outputs, styles); + if (![...styles.values()].includes('delim') || readsBack(text, leaves)) return text; + return render(plan, outputs, new Map()); +} diff --git a/app/src/editor/markdown/leaves.ts b/app/src/editor/markdown/leaves.ts new file mode 100644 index 000000000..53d626006 --- /dev/null +++ b/app/src/editor/markdown/leaves.ts @@ -0,0 +1,118 @@ +// The inline content of one paragraph, heading, or title as a flat list of leaves, ready to be written (SPEC 7.7). +// A leaf is a run of text, a hard break, or an atom such as an image. Marks over whitespace at either edge of their +// range are moved off it here, so that a delimiter never has whitespace just inside it. +import type { Mark, Node as PMNode } from '@tiptap/pm/model'; +import { MARK_ORDER } from '../schema/specs'; +import type { MarkName } from '../schema/specs'; +import { cleanText } from './escape'; +import { isSpaceChar } from './flanking'; + +export type Leaf = + | { readonly kind: 'text'; readonly text: string; readonly marks: readonly Mark[] } + | { readonly kind: 'break'; readonly marks: readonly Mark[] } + | { readonly kind: 'atom'; readonly node: PMNode; readonly marks: readonly Mark[] }; + +export function isCode(leaf: Leaf): boolean { + return leaf.kind === 'text' && leaf.marks.some((mark) => mark.type.name === 'code'); +} + +/** The marks a leaf is wrapped in, outermost first. Code is written inside its leaf, so it is left out. */ +export function wrapping(leaf: Leaf | undefined): Mark[] { + if (!leaf) return []; + return leaf.marks + .filter((mark) => mark.type.name !== 'code') + .sort((a, b) => MARK_ORDER.indexOf(a.type.name as MarkName) - MARK_ORDER.indexOf(b.type.name as MarkName)); +} + +function textLeaves(text: string, marks: readonly Mark[], singleLine: boolean): Leaf[] { + return cleanText(text) + .split('\n') + .flatMap((part, i): Leaf[] => { + const run: Leaf[] = part === '' ? [] : [{ kind: 'text', text: part, marks }]; + if (i === 0) return run; + return [singleLine ? { kind: 'text', text: ' ', marks } : { kind: 'break', marks }, ...run]; + }); +} + +/** The leaves of an inline container. In a heading or a title a line break becomes a space. */ +export function collectLeaves(container: PMNode, singleLine: boolean): Leaf[] { + const leaves: Leaf[] = []; + container.forEach((child) => { + if (child.isText) leaves.push(...textLeaves(child.text as string, child.marks, singleLine)); + else if (child.type.name === 'hardBreak') leaves.push(...textLeaves('\n', child.marks, singleLine)); + else leaves.push({ kind: 'atom', node: child, marks: child.marks }); + }); + return mergeLeaves(leaves); +} + +/** A hard break at the start or end of a paragraph has nothing to break, so Markdown cannot hold it. */ +export function dropEdgeBreaks(leaves: Leaf[]): Leaf[] { + let from = 0; + let to = leaves.length; + while (from < to && leaves[from].kind === 'break') from++; + while (to > from && leaves[to - 1].kind === 'break') to--; + return leaves.slice(from, to); +} + +export function mergeLeaves(leaves: readonly Leaf[]): Leaf[] { + const out: Leaf[] = []; + for (const leaf of leaves) { + const last = out.at(-1); + if (last?.kind === 'text' && leaf.kind === 'text' && sameMarks(last.marks, leaf.marks)) { + out[out.length - 1] = { ...last, text: last.text + leaf.text }; + } else out.push(leaf); + } + return out; +} + +function sameMarks(a: readonly Mark[], b: readonly Mark[]): boolean { + return a.length === b.length && a.every((mark, i) => mark.eq(b[i])); +} + +function has(leaf: Leaf | undefined, mark: Mark): boolean { + return leaf !== undefined && leaf.marks.some((other) => other.eq(mark)); +} + +function without(leaf: Leaf, mark: Mark): readonly Mark[] { + return leaf.marks.filter((other) => !other.eq(mark)); +} + +/** Splits the whitespace at one edge of a leaf off, giving it the marks minus `mark`. Null if there is none. */ +function peel(leaf: Leaf, mark: Mark, side: 'start' | 'end'): Leaf[] | null { + if (leaf.kind === 'atom') return null; + if (leaf.kind === 'break') return [{ kind: 'break', marks: without(leaf, mark) }]; + const chars = Array.from(leaf.text); + let count = 0; + while (count < chars.length && isSpaceChar(chars[side === 'start' ? count : chars.length - 1 - count])) count++; + if (count === 0) return null; + const edge = side === 'start' ? chars.slice(0, count) : chars.slice(chars.length - count); + const rest = side === 'start' ? chars.slice(count) : chars.slice(0, chars.length - count); + const spaces: Leaf = { kind: 'text', text: edge.join(''), marks: without(leaf, mark) }; + if (rest.length === 0) return [spaces]; + const core: Leaf = { kind: 'text', text: rest.join(''), marks: leaf.marks }; + return side === 'start' ? [spaces, core] : [core, spaces]; +} + +function trimOnce(leaves: readonly Leaf[]): Leaf[] | null { + for (let i = 0; i < leaves.length; i++) { + if (isCode(leaves[i])) continue; + for (const mark of wrapping(leaves[i])) { + const sides = [ + ['start', !has(leaves[i - 1], mark)], + ['end', !has(leaves[i + 1], mark)], + ] as const; + for (const [side, edge] of sides) { + const peeled = edge ? peel(leaves[i], mark, side) : null; + if (peeled) return [...leaves.slice(0, i), ...peeled, ...leaves.slice(i + 1)]; + } + } + } + return null; +} + +/** SPEC 7.7: whitespace at either edge of a marked range is moved outside the delimiters. */ +export function trimMarkEdges(leaves: readonly Leaf[]): Leaf[] { + let current = leaves; + for (let next = trimOnce(current); next; next = trimOnce(current)) current = next; + return mergeLeaves(current); +} diff --git a/app/src/editor/markdown/markdown.test.ts b/app/src/editor/markdown/markdown.test.ts new file mode 100644 index 000000000..aace03fe6 --- /dev/null +++ b/app/src/editor/markdown/markdown.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { DOCUMENTS, ESCAPES } from './fixtures'; +import { escapeParagraphText } from './escape'; +import { parseTextBlock } from './parse'; +import { serializeTextBlock } from './serialize'; + +describe('canonical fixtures', () => { + it.each(DOCUMENTS.map((fixture) => [fixture.name, fixture.markdown] as const))('%s', (_name, markdown) => { + expect(serializeTextBlock(parseTextBlock(markdown))).toBe(markdown); + }); +}); + +describe('escaping', () => { + it.each(ESCAPES)('writes %j as %j', (text, written) => { + expect(escapeParagraphText(text)).toBe(written); + }); +}); diff --git a/app/src/editor/markdown/marksPlan.ts b/app/src/editor/markdown/marksPlan.ts new file mode 100644 index 000000000..d8d29aecd --- /dev/null +++ b/app/src/editor/markdown/marksPlan.ts @@ -0,0 +1,45 @@ +// Where each mark range opens and closes (SPEC 7.7). Marks nest in a fixed order, outermost first. Where two ranges +// overlap, the mark that comes later in the order is closed and reopened. A mark stays open across leaves as long +// as every mark outside it does too. +import type { Mark } from '@tiptap/pm/model'; +import { wrapping } from './leaves'; +import type { Leaf } from './leaves'; + +export interface Range { + readonly mark: Mark; + /** The boundary before the first leaf of the range. Boundary `i` lies just before leaf `i`. */ + readonly open: number; + close: number; +} + +export interface Boundary { + /** Ranges that end here, innermost first. */ + closes: Range[]; + /** Ranges that start here, outermost first. */ + opens: Range[]; +} + +export interface Plan { + readonly ranges: Range[]; + readonly boundaries: Boundary[]; +} + +export function planRanges(leaves: readonly Leaf[]): Plan { + const boundaries: Boundary[] = Array.from({ length: leaves.length + 1 }, () => ({ closes: [], opens: [] })); + const ranges: Range[] = []; + let stack: Range[] = []; + for (let at = 0; at <= leaves.length; at++) { + const target = wrapping(leaves[at]); + let keep = 0; + while (keep < stack.length && keep < target.length && stack[keep].mark.eq(target[keep])) keep++; + for (const range of stack.slice(keep).reverse()) { + range.close = at; + boundaries[at].closes.push(range); + } + const opened = target.slice(keep).map((mark): Range => ({ mark, open: at, close: -1 })); + boundaries[at].opens.push(...opened); + ranges.push(...opened); + stack = [...stack.slice(0, keep), ...opened]; + } + return { ranges, boundaries }; +} diff --git a/app/src/editor/markdown/marksStyle.ts b/app/src/editor/markdown/marksStyle.ts new file mode 100644 index 000000000..bcfc980ae --- /dev/null +++ b/app/src/editor/markdown/marksStyle.ts @@ -0,0 +1,150 @@ +// How each mark range is written: with delimiters (`**`, `*`, `~~`, `==`) or with the HTML tags of SPEC 7.4. +// SPEC 7.7: where a delimiter would not open or close under CommonMark's rules for that position, the writer uses +// the matching tag for that range. +// +// Every character that starts or ends a delimiter, a tag, or a link is punctuation. The class of a neighbor +// therefore does not depend on how the neighbor is written. The one exception is a run of `*` from strong and +// emphasis, which reads as a single run. Decisions start with every delimiter and fall back to tags until +// nothing changes. +import type { Mark } from '@tiptap/pm/model'; +import { cleanText } from './escape'; +import { canClose, canCloseHighlight, canOpen, canOpenHighlight, edgeClass } from './flanking'; +import type { CharClass } from './flanking'; +import type { Plan, Range } from './marksPlan'; + +export type Style = 'delim' | 'tag'; + +const DELIMITERS: Readonly> = { bold: '**', italic: '*', strike: '~~' }; +const TAGS: Readonly> = { + bold: 'strong', + italic: 'em', + strike: 'del', + underline: 'u', + highlight: 'mark', + subscript: 'sub', + superscript: 'sup', +}; + +/** Honey, the default highlight color, is the only one with a delimiter form. */ +export function isPlainHighlight(mark: Mark): boolean { + return mark.type.name === 'highlight' && !mark.attrs.color; +} + +/** Whether a mark has a delimiter form at all. */ +export function hasDelimiter(mark: Mark): boolean { + return mark.type.name in DELIMITERS || isPlainHighlight(mark); +} + +function isStar(mark: Mark): boolean { + return mark.type.name === 'bold' || mark.type.name === 'italic'; +} + +function delimiterOf(mark: Mark): string { + return isPlainHighlight(mark) ? '==' : DELIMITERS[mark.type.name]; +} + +/** An attribute value as the tag may hold it: the schema checks it on the way in, and this keeps any other value + * from closing the quote or the tag. */ +function attrValue(value: unknown): string { + return String(value).replace(/[^#A-Za-z0-9-]/g, ''); +} + +function openTag(mark: Mark): string { + const name = mark.type.name; + if (name === 'highlight' && mark.attrs.color) return ``; + if (name === 'textColor') return ``; + if (name === 'textSize') return ``; + return `<${TAGS[name]}>`; +} + +function closeTag(mark: Mark): string { + const name = mark.type.name; + return name === 'textColor' || name === 'textSize' ? '' : ``; +} + +const ENTITY_LIKE = /&(?=#?[A-Za-z0-9]+;)/g; + +/** A destination as the reader gets it back. Line ends are percent-encoded, and other control text is replaced. */ +export function destinationText(href: string): string { + return cleanText(href.replace(/\r/g, '%0D')).replace(/\n/g, '%0A'); +} + +/** SPEC 7.5: bare when it holds no spaces, parentheses, angle brackets, backslashes, or control characters. */ +export function formatDestination(href: string): string { + const text = destinationText(href); + const bare = text !== '' && !/[\s()<>\\\p{Cc}]/u.test(text); + const body = (bare ? text : text.replace(/[<>\\]/g, '\\$&')).replace(ENTITY_LIKE, '\\&'); + return bare ? body : `<${body}>`; +} + +export function openToken(range: Range, style: Style): string { + const mark = range.mark; + if (mark.type.name === 'link') return '['; + return style === 'delim' ? delimiterOf(mark) : openTag(mark); +} + +export function closeToken(range: Range, style: Style): string { + const mark = range.mark; + if (mark.type.name === 'link') return `](${formatDestination(mark.attrs.href as string)})`; + return style === 'delim' ? delimiterOf(mark) : closeTag(mark); +} + +type Styles = ReadonlyMap; + +function isStarDelimiter(range: Range, styles: Styles): boolean { + return isStar(range.mark) && styles.get(range) === 'delim'; +} + +/** The class of the character next to a token. Another token beside it is punctuation. */ +function neighbor(seq: readonly Range[], at: number, step: -1 | 1, styles: Styles, fallback: CharClass): CharClass { + const joins = isStarDelimiter(seq[at], styles); + for (let i = at + step; i >= 0 && i < seq.length; i += step) { + if (!(joins && isStarDelimiter(seq[i], styles))) return 'punct'; + } + return fallback; +} + +interface Sides { + readonly prev: CharClass; + readonly next: CharClass; +} + +function sidesAt(plan: Plan, outputs: readonly string[], range: Range, styles: Styles, opening: boolean): Sides { + const at = opening ? range.open : range.close; + const { closes, opens } = plan.boundaries[at]; + const seq = [...closes, ...opens]; + const index = opening ? closes.length + opens.indexOf(range) : closes.indexOf(range); + const before = at > 0 ? edgeClass(outputs[at - 1], 'last') : 'ws'; + const after = at < outputs.length ? edgeClass(outputs[at], 'first') : 'ws'; + return { + prev: neighbor(seq, index, -1, styles, before), + next: neighbor(seq, index, 1, styles, after), + }; +} + +function delimiterWorks(plan: Plan, outputs: readonly string[], range: Range, styles: Styles): boolean { + const open = sidesAt(plan, outputs, range, styles, true); + const close = sidesAt(plan, outputs, range, styles, false); + if (isPlainHighlight(range.mark)) { + return canOpenHighlight(open.prev, open.next) && canCloseHighlight(close.prev, close.next); + } + // A `*` that opens right where another `*` closes would join its run, so it is written as a tag. + const joinsRun = isStar(range.mark) && plan.boundaries[range.open].closes.some((r) => isStarDelimiter(r, styles)); + return !joinsRun && canOpen(open.prev, open.next) && canClose(close.prev, close.next); +} + +/** Chooses delimiters or tags for every range. `outputs` are the written forms of the leaves. */ +export function decideStyles(plan: Plan, outputs: readonly string[]): Map { + const styles = new Map(); + for (const range of plan.ranges) styles.set(range, hasDelimiter(range.mark) ? 'delim' : 'tag'); + for (let changed = true; changed;) { + changed = false; + for (const range of plan.ranges) { + if (styles.get(range) === 'delim' && !delimiterWorks(plan, outputs, range, styles)) { + styles.set(range, 'tag'); + changed = true; + } + } + } + return styles; +} diff --git a/app/src/editor/markdown/neutral.ts b/app/src/editor/markdown/neutral.ts new file mode 100644 index 000000000..959da4bfe --- /dev/null +++ b/app/src/editor/markdown/neutral.ts @@ -0,0 +1,202 @@ +// The neutral document tree of docs/format/fixtures/markdown/documents/README.md, to and from text block documents. +// The shared fixtures pair canonical Markdown with this tree, so every implementation can check its parser and +// serializer against the same cases (SPEC 7.8). +import type { Mark, Node as PMNode, Schema } from '@tiptap/pm/model'; +import { textSchema } from '../schema/schema'; +import { MARK_ORDER } from '../schema/specs'; +import type { MarkName } from '../schema/specs'; + +export type NeutralMark = + | 'strong' + | 'emphasis' + | 'strike' + | 'underline' + | 'highlight' + | 'sub' + | 'sup' + | 'code' + | { link: string } + | { highlight: string } + | { color: string } + | { size: string }; + +export type NeutralInline = + { text: string; marks: NeutralMark[] } | { hardBreak: true } | { image: string; alt: string } | { math: string }; + +export interface NeutralItem { + task: 'open' | 'done' | null; + blocks: NeutralBlock[]; +} + +export type NeutralBlock = + | { type: 'paragraph'; content: NeutralInline[] } + | { type: 'heading'; level: number; content: NeutralInline[] } + | { type: 'list'; ordered: boolean; start?: number; items: NeutralItem[] } + | { type: 'quote'; blocks: NeutralBlock[] } + | { + type: 'callout'; + callout: string; + fold: 'folded' | 'open' | null; + title: NeutralInline[]; + blocks: NeutralBlock[]; + } + | { type: 'code'; language: string; text: string } + | { type: 'math'; source: string } + | { type: 'break' }; + +const SIMPLE: Readonly>> = { + bold: 'strong', + italic: 'emphasis', + strike: 'strike', + underline: 'underline', + subscript: 'sub', + superscript: 'sup', + code: 'code', +}; + +function markOf(mark: Mark): NeutralMark { + const name = mark.type.name as MarkName; + if (name === 'link') return { link: mark.attrs.href as string }; + if (name === 'highlight') return mark.attrs.color ? { highlight: mark.attrs.color as string } : 'highlight'; + if (name === 'textColor') return { color: mark.attrs.color as string }; + if (name === 'textSize') return { size: mark.attrs.size as string }; + return SIMPLE[name] as NeutralMark; +} + +const rank = (mark: Mark) => MARK_ORDER.indexOf(mark.type.name as MarkName); + +function inlinesOf(node: PMNode): NeutralInline[] { + const out: NeutralInline[] = []; + node.forEach((child) => { + if (child.isText) { + const marks = [...child.marks].sort((a, b) => rank(a) - rank(b)).map(markOf); + out.push({ text: child.text as string, marks }); + } else if (child.type.name === 'hardBreak') out.push({ hardBreak: true }); + else if (child.type.name === 'image') + out.push({ image: child.attrs.src as string, alt: child.attrs.alt as string }); + else if (child.type.name === 'mathInline') out.push({ math: child.attrs.source as string }); + }); + return out; +} + +function blocksOf(node: PMNode, from = 0): NeutralBlock[] { + const out: NeutralBlock[] = []; + node.forEach((child, _offset, index) => { + if (index >= from) out.push(blockOf(child)); + }); + return out; +} + +const FOLD_NAMES: Readonly> = { '': null, '-': 'folded', '+': 'open' }; + +function blockOf(node: PMNode): NeutralBlock { + switch (node.type.name) { + case 'heading': + return { type: 'heading', level: node.attrs.level as number, content: inlinesOf(node) }; + case 'bulletList': + case 'orderedList': { + const ordered = node.type.name === 'orderedList'; + const items: NeutralItem[] = []; + node.forEach((item) => { + const checked = item.attrs.checked as boolean | null; + items.push({ task: checked === null ? null : checked ? 'done' : 'open', blocks: blocksOf(item) }); + }); + return ordered + ? { type: 'list', ordered, start: node.attrs.start as number, items } + : { type: 'list', ordered, items }; + } + case 'blockquote': + return { type: 'quote', blocks: blocksOf(node) }; + case 'callout': + return { + type: 'callout', + callout: node.attrs.type as string, + fold: FOLD_NAMES[node.attrs.fold as string] ?? null, + title: node.firstChild ? inlinesOf(node.firstChild) : [], + blocks: blocksOf(node, 1), + }; + case 'codeBlock': + return { type: 'code', language: (node.attrs.language as string | null) ?? '', text: node.textContent }; + case 'mathBlock': + return { type: 'math', source: node.attrs.source as string }; + case 'horizontalRule': + return { type: 'break' }; + default: + return { type: 'paragraph', content: inlinesOf(node) }; + } +} + +/** The neutral tree of a text block document. */ +export function toNeutral(doc: PMNode): NeutralBlock[] { + return blocksOf(doc); +} + +function markFrom(schema: Schema, mark: NeutralMark): Mark { + const { marks } = schema; + if (typeof mark === 'string') { + const name = (Object.keys(SIMPLE) as MarkName[]).find((key) => SIMPLE[key] === mark); + return name ? marks[name].create() : marks.highlight.create(); + } + if ('link' in mark) return marks.link.create({ href: mark.link }); + if ('highlight' in mark) return marks.highlight.create({ color: mark.highlight }); + if ('color' in mark) return marks.textColor.create({ color: mark.color }); + return marks.textSize.create({ size: mark.size }); +} + +function inlinesFrom(schema: Schema, inlines: readonly NeutralInline[]): PMNode[] { + return inlines.flatMap((inline): PMNode[] => { + if ('hardBreak' in inline) return [schema.nodes.hardBreak.create()]; + if ('image' in inline) return [schema.nodes.image.create({ src: inline.image, alt: inline.alt })]; + if ('math' in inline) return [schema.nodes.mathInline.create({ source: inline.math })]; + if (inline.text === '') return []; + return [ + schema.text( + inline.text, + inline.marks.map((mark) => markFrom(schema, mark)), + ), + ]; + }); +} + +const FOLDS_BY_NAME: Readonly> = { folded: '-', open: '+' }; + +function blockFrom(schema: Schema, block: NeutralBlock): PMNode { + const { nodes } = schema; + const blocks = (list: readonly NeutralBlock[]) => list.map((child) => blockFrom(schema, child)); + switch (block.type) { + case 'paragraph': + return nodes.paragraph.create(null, inlinesFrom(schema, block.content)); + case 'heading': + return nodes.heading.create({ level: block.level }, inlinesFrom(schema, block.content)); + case 'list': { + const items = block.items.map((item) => + nodes.listItem.create( + { checked: item.task === null ? null : item.task === 'done' }, + item.blocks.length > 0 ? blocks(item.blocks) : [nodes.paragraph.create()], + ), + ); + return block.ordered + ? nodes.orderedList.create({ start: block.start ?? 1 }, items) + : nodes.bulletList.create(null, items); + } + case 'quote': + return nodes.blockquote.create(null, blocks(block.blocks)); + case 'callout': { + const title = nodes.calloutTitle.create(null, inlinesFrom(schema, block.title)); + const fold = block.fold ? FOLDS_BY_NAME[block.fold] : ''; + return nodes.callout.create({ type: block.callout, fold }, [title, ...blocks(block.blocks)]); + } + case 'code': + return nodes.codeBlock.create({ language: block.language || null }, block.text ? schema.text(block.text) : null); + case 'math': + return nodes.mathBlock.create({ source: block.source }); + case 'break': + return nodes.horizontalRule.create(); + } +} + +/** A text block document from a neutral tree. */ +export function fromNeutral(blocks: readonly NeutralBlock[], schema: Schema = textSchema): PMNode { + const children = blocks.map((block) => blockFrom(schema, block)); + return schema.nodes.doc.create(null, children.length > 0 ? children : [schema.nodes.paragraph.create()]); +} diff --git a/app/src/editor/markdown/normalize.ts b/app/src/editor/markdown/normalize.ts new file mode 100644 index 000000000..8bb20c0b0 --- /dev/null +++ b/app/src/editor/markdown/normalize.ts @@ -0,0 +1,32 @@ +// Changes that keep a document inside what OpenNote Markdown can write. Canonical Markdown cannot hold two lists +// of one kind side by side, because CommonMark reads them as one list (Phase 4 design, 8.1). The editor joins them +// after every change, and the writer joins them again, so an edit that puts two lists together never changes text. +import { Fragment } from '@tiptap/pm/model'; +import type { Node as PMNode } from '@tiptap/pm/model'; + +function childrenOf(node: PMNode): PMNode[] { + const out: PMNode[] = []; + node.forEach((child) => out.push(child)); + return out; +} + +const isList = (node: PMNode) => node.type.name === 'bulletList' || node.type.name === 'orderedList'; + +/** Joins lists of one kind that sit side by side in a list of sibling blocks. The first list keeps its start number. */ +export function joinAdjacentLists(nodes: readonly PMNode[]): PMNode[] { + const out: PMNode[] = []; + for (const node of nodes) { + const last = out.at(-1); + if (last && isList(node) && last.type === node.type) { + out[out.length - 1] = last.type.create(last.attrs, Fragment.from([...childrenOf(last), ...childrenOf(node)])); + } else out.push(node); + } + return out; +} + +/** The document with every pair of adjacent lists of one kind joined, at every depth. */ +export function joinListsDeep(node: PMNode): PMNode { + if (node.isLeaf) return node; + const children = joinAdjacentLists(childrenOf(node).map(joinListsDeep)); + return node.type.create(node.attrs, Fragment.from(children), node.marks); +} diff --git a/app/src/editor/markdown/parse.test.ts b/app/src/editor/markdown/parse.test.ts new file mode 100644 index 000000000..3920361ce --- /dev/null +++ b/app/src/editor/markdown/parse.test.ts @@ -0,0 +1,165 @@ +import type { Node as PMNode } from '@tiptap/pm/model'; +import { describe, expect, it } from 'vitest'; +import { joinListsDeep } from './normalize'; +import { parseTextBlock } from './parse'; +import { serializeTextBlock } from './serialize'; +import { textSchema } from '../schema/schema'; +import { shape } from '../shape'; + +const read = (markdown: string) => shape(parseTextBlock(markdown)); + +describe('parsing OpenNote Markdown blocks', () => { + it('reads task items, checked or not, in any list', () => { + expect(read('- [ ] open\n- [x] done\n- [X] shout\n- plain')).toBe( + 'doc(bulletList(listItem[checked=false](paragraph("open")), listItem[checked=true](paragraph("done")), ' + + 'listItem[checked=true](paragraph("shout")), listItem(paragraph("plain"))))', + ); + }); + + it('keeps an escaped task marker as text', () => { + expect(read('- \\[ \\] not a task')).toBe('doc(bulletList(listItem(paragraph("[ ] not a task"))))'); + }); + + it('reads nested lists, numbered starts, and mixed kinds', () => { + expect(read('3. a\n - b\n - c\n4. d')).toBe( + 'doc(orderedList[start=3](listItem(paragraph("a"), bulletList(listItem(paragraph("b")), ' + + 'listItem(paragraph("c")))), listItem(paragraph("d"))))', + ); + }); + + it('reads callouts with a fold and a title, and keeps unknown types', () => { + expect(read('> [!warning]- Careful now\n>\n> Body')).toBe( + 'doc(callout[type=warning,fold=-](calloutTitle("Careful now"), paragraph("Body")))', + ); + expect(read('> [!Odd]+ Open')).toBe('doc(callout[type=odd,fold=+](calloutTitle("Open")))'); + expect(read('> \\[!note] plain quote')).toBe('doc(blockquote(paragraph("[!note] plain quote")))'); + }); + + it('reads a callout body that follows the title line directly', () => { + expect(read('> [!tip] Title\n> body line')).toBe( + 'doc(callout[type=tip](calloutTitle("Title"), paragraph("body line")))', + ); + }); +}); + +describe('parsing marks, atoms, and other syntax', () => { + it('reads highlights in the default color and in the named ones', () => { + expect(read('==honey== and mint and plain')).toBe( + 'doc(paragraph(highlight("honey"), " and ", highlightmint("mint"), " and ", highlight("plain")))', + ); + expect(read('a == b')).toBe('doc(paragraph("a == b"))'); + }); + + it('opens == only before text and closes it only after text, inside words too (SPEC 7.3)', () => { + expect(read('in==side==word and x==.==y')).toBe( + 'doc(paragraph("in", highlight("side"), "word and x", highlight("."), "y"))', + ); + expect(read('==open ==')).toBe('doc(paragraph("==open =="))'); + expect(read('== closed==')).toBe('doc(paragraph("== closed=="))'); + }); + + it('reads $ as math only when it opens and closes like a delimiter', () => { + expect(read('$x$ and $ y$ and $z $ and $5 and $6')).toBe( + 'doc(paragraph(mathInline[source=x], " and $ y$ and $z $ and $5 and $6"))', + ); + }); + + it('reads the allowed HTML tags as marks, and any other HTML as text', () => { + // checks-disable-next-line brand-consistency: document content, not interface styling + expect(read('under big red')).toBe( + 'doc(paragraph(underline("under"), " ", textSizelarge("big"), " ", textColor#aa0000("red")))', + ); + expect(read('bold and open')).toBe('doc(paragraph("bold and open"))'); + expect(read('a b c')).toBe('doc(paragraph("a ", subscript("b c")))'); + expect(read('
block
')).toBe('doc(paragraph("
block
"))'); + }); + + it('turns other CommonMark constructs into their canonical equivalents', () => { + expect(read('Title\n=====')).toBe('doc(heading[level=1]("Title"))'); + expect(read(' indented code')).toBe('doc(codeBlock("indented code"))'); + expect(read('* star\n* bullets')).toBe( + 'doc(bulletList(listItem(paragraph("star")), listItem(paragraph("bullets"))))', + ); + expect(read('__strong__ and _em_')).toBe('doc(paragraph(bold("strong"), " and ", italic("em")))'); + expect(read('[ref] and \n\n[ref]: https://example.com/ref')).toBe( + 'doc(paragraph(linkhttps://example.com/ref("ref"), " and ", linkhttps://example.com("https://example.com")))', + ); + expect(read('wrapped\nlines')).toBe('doc(paragraph("wrapped lines"))'); + }); + + it('keeps links with unknown schemes and drops script links', () => { + expect(read('[a](ftp://x.y/z) [b](javascript:alert(1)) [c]()')).toBe( + 'doc(paragraph(linkftp://x.y/z("a"), " [b](javascript:alert(1)) [c]()"))', + ); + }); + + it('drops script links hidden by the characters a browser removes from a URL', () => { + // A browser drops tabs and line ends anywhere in a URL, and control characters at its start. + const links: string[] = []; + for (const source of [ + '[a](java script:alert(1))', + '[b](<\x01javascript:alert(1)>)', + '[c]()', + ]) { + parseTextBlock(source).descendants((node) => { + node.marks.forEach((mark) => mark.type.name === 'link' && links.push(String(mark.attrs.href))); + }); + } + expect(links).toEqual([]); + }); + + it('reads math atoms and keeps their source', () => { + expect(read('$$\nx^2\n$$\n\nsolve $a_b$ and cost \\$5')).toBe( + 'doc(mathBlock[source=x^2], paragraph("solve ", mathInline[source=a_b], " and cost $5"))', + ); + }); + + it('reads images with any source, and code fences with a language', () => { + expect(read('![chart](asset:01m3sabc) ![](https://example.com/a.png)')).toBe( + 'doc(paragraph(image[src=asset:01m3sabc,alt=chart], " ", image[src=https://example.com/a.png]))', + ); + expect(read('```js title="x"\nlet a;\n```')).toBe('doc(codeBlock[language=js]("let a;"))'); + }); + + it('gives every string a valid document', () => { + for (const source of ['', '\n\n', '>', '-', '- >', '> [!note', '```', '$$', '[', '<', '\\', '1.']) { + parseTextBlock(source).check(); + } + }); +}); + +describe('writing lists', () => { + const item = (text: string) => + textSchema.nodes.listItem.create(null, textSchema.nodes.paragraph.create(null, textSchema.text(text))); + const bullets = (...texts: string[]) => textSchema.nodes.bulletList.create(null, texts.map(item)); + const numbers = (...texts: string[]) => textSchema.nodes.orderedList.create({ start: 1 }, texts.map(item)); + const doc = (...blocks: PMNode[]) => textSchema.nodes.doc.create(null, blocks); + + it('joins two adjacent lists of one kind, because Markdown reads them as one', () => { + const adjacent = doc(bullets('a'), bullets('b')); + expect(serializeTextBlock(adjacent)).toBe('- a\n- b'); + expect(parseTextBlock('- a\n\n- b').childCount).toBe(1); + expect(joinListsDeep(adjacent).childCount).toBe(1); + }); + + it('keeps two adjacent lists of different kinds apart', () => { + const mixed = doc(bullets('a'), numbers('b'), bullets('c')); + const markdown = serializeTextBlock(mixed); + expect(markdown).toBe('- a\n\n1. b\n\n- c'); + expect(parseTextBlock(markdown).eq(mixed)).toBe(true); + }); + + it('joins lists that become adjacent inside an item', () => { + const nested = doc( + textSchema.nodes.bulletList.create(null, [ + textSchema.nodes.listItem.create(null, [ + textSchema.nodes.paragraph.create(null, textSchema.text('a')), + bullets('b'), + bullets('c'), + ]), + ]), + ); + expect(joinListsDeep(nested).firstChild?.firstChild?.childCount).toBe(2); + expect(serializeTextBlock(nested)).toBe('- a\n\n - b\n - c'); + }); +}); diff --git a/app/src/editor/markdown/parse.ts b/app/src/editor/markdown/parse.ts new file mode 100644 index 000000000..96be1d1f5 --- /dev/null +++ b/app/src/editor/markdown/parse.ts @@ -0,0 +1,232 @@ +// OpenNote Markdown to a ProseMirror document (SPEC 7; Phase 4 design, 9.1). markdown-it reads the whole CommonMark +// syntax, because text can come from hand edits and imports. Each construct maps to its canonical equivalent. +// Setext headings become headings, indented code becomes fenced code, reference links become links, and other HTML +// becomes literal text. The parser never throws. +import MarkdownIt from 'markdown-it'; +import type { MarkdownIt as Md, Token } from 'markdown-it'; +import { Fragment } from '@tiptap/pm/model'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { textSchema } from '../schema/schema'; +import { CALLOUT_TYPE_PATTERN, LANGUAGE_PATTERN, isBlockedHref } from '../schema/specs'; +import { inlineNodes } from './parseInline'; +import { highlightPlugin, mathPlugin } from './rules'; + +const { nodes } = textSchema; +const DATA_IMAGE = /^data:image\/(?:gif|png|jpeg|webp);/i; + +/** One markdown-it instance: CommonMark, strikethrough, highlights, and math. The paste parser also turns tables on. */ +export function createMarkdown(tables = false): Md { + const md = new MarkdownIt('commonmark', { html: true, linkify: false, typographer: false }); + md.enable('strikethrough'); + if (tables) md.enable('table'); + md.use(highlightPlugin).use(mathPlugin); + // Links are kept as written: no percent-encoding, no punycode. The writer and the reader then agree. + md.normalizeLink = (url) => url; + md.normalizeLinkText = (text) => text; + // SPEC 7.5 keeps links with any scheme, `file:` included, except the few the schema blocks. Data images stay. + md.validateLink = (url) => !isBlockedHref(url) || DATA_IMAGE.test(url); + return md; +} + +const dialect = createMarkdown(); + +interface Cursor { + readonly tokens: readonly Token[]; + readonly md: Md; + i: number; +} + +function make(type: string, attrs: Record | null, content: readonly PMNode[]): PMNode { + return ( + nodes[type].createAndFill(attrs, Fragment.from(content as PMNode[])) ?? + nodes[type].create(attrs, Fragment.from(content as PMNode[])) + ); +} + +function paragraph(inline: Token | undefined): PMNode { + return make('paragraph', null, inline ? inlineNodes(inline.children ?? []) : []); +} + +function literalLines(text: string): PMNode[] { + const lines = text.replace(/\n+$/, '').split('\n'); + const content = lines.flatMap((line, i) => { + const parts: PMNode[] = i > 0 ? [nodes.hardBreak.create()] : []; + return line === '' ? parts : [...parts, textSchema.text(line)]; + }); + return [make('paragraph', null, content)]; +} + +function code(text: string, language: string | null): PMNode[] { + const source = text.replace(/\n$/, ''); + return [make('codeBlock', { language }, source === '' ? [] : [textSchema.text(source)])]; +} + +function fenceLanguage(info: string): string | null { + const word = info.trim().split(/\s+/)[0] ?? ''; + return LANGUAGE_PATTERN.test(word) ? word : null; +} + +const TASK_MARKER = /^\[([ xX])\](?=[ \t]|$)/; + +/** A task item starts with `[ ]` or `[x]`. The marker is cut from the first text, and the state is returned. */ +function takeTaskMarker(c: Cursor): boolean | null { + const open = c.tokens[c.i]; + const inline = c.tokens[c.i + 1]; + if (open?.type !== 'paragraph_open' || inline?.type !== 'inline') return null; + const match = TASK_MARKER.exec(inline.content); + const first = inline.children?.[0]; + if (!match || first?.type !== 'text' || !first.content.startsWith(match[0])) return null; + first.content = first.content.slice(match[0].length).replace(/^[ \t]/, ''); + if (first.content === '') inline.children?.shift(); + return match[1] !== ' '; +} + +function readList(c: Cursor, open: Token): PMNode[] { + const ordered = open.type === 'ordered_list_open'; + c.i++; + const items: PMNode[] = []; + while (c.tokens[c.i]?.type === 'list_item_open') { + c.i++; + const checked = takeTaskMarker(c); + items.push(make('listItem', { checked }, readBlocks(c))); + } + if (c.tokens[c.i]?.nesting === -1) c.i++; + const start = Number(open.attrGet('start') ?? 1); + return [make(ordered ? 'orderedList' : 'bulletList', ordered ? { start } : null, items)]; +} + +const CALLOUT_LINE = /^\[!([A-Za-z][A-Za-z0-9_-]{0,31})\]([+-])?(?:[ \t]+(.*))?$/; + +interface CalloutHead { + readonly type: string; + readonly fold: string; + readonly title: string; + readonly rest: string; +} + +/** A block quote whose first paragraph starts with `[!type]` is a callout. Reads the first paragraph if so. */ +function takeCalloutHead(c: Cursor): CalloutHead | null { + const open = c.tokens[c.i]; + const inline = c.tokens[c.i + 1]; + if (open?.type !== 'paragraph_open' || inline?.type !== 'inline') return null; + const [first, ...more] = inline.content.split('\n'); + const match = CALLOUT_LINE.exec(first); + if (!match || !CALLOUT_TYPE_PATTERN.test(match[1])) return null; + c.i += 3; + const title = (match[3] ?? '').replace(/[ \t]+$/, ''); + return { type: match[1].toLowerCase(), fold: match[2] ?? '', title, rest: more.join('\n') }; +} + +function inlineOf(c: Cursor, text: string): PMNode[] { + return inlineNodes(c.md.parseInline(text, {})[0]?.children ?? []); +} + +function readQuote(c: Cursor): PMNode[] { + c.i++; + const head = takeCalloutHead(c); + const body = readBlocks(c); + if (!head) return [make('blockquote', null, body)]; + const title = make('calloutTitle', null, inlineOf(c, head.title)); + const first = head.rest === '' ? [] : [make('paragraph', null, inlineOf(c, head.rest))]; + return [make('callout', { type: head.type, fold: head.fold }, [title, ...first, ...body])]; +} + +/** + * A table inside a list or quote, which only the paste parser reads: each row becomes a paragraph with its cells + * apart. A text block holds no tables (SPEC 6.3), and a table at the top level becomes a table block instead. + */ +function readTableRows(c: Cursor): PMNode[] { + const rows: PMNode[] = []; + let cells: PMNode[][] = []; + for (c.i++; c.i < c.tokens.length && c.tokens[c.i].type !== 'table_close'; c.i++) { + const token = c.tokens[c.i]; + if (token.type === 'tr_open') cells = []; + else if (token.type === 'inline') cells.push(inlineNodes(token.children ?? [])); + else if (token.type === 'tr_close') { + const content = cells.flatMap((cell, i) => (i === 0 ? cell : [textSchema.text(' | '), ...cell])); + rows.push(make('paragraph', null, content)); + } + } + c.i++; + return rows; +} + +function readBlock(c: Cursor): PMNode[] { + const token = c.tokens[c.i]; + switch (token.type) { + case 'table_open': + return readTableRows(c); + case 'paragraph_open': + c.i += 3; + return [paragraph(c.tokens[c.i - 2])]; + case 'heading_open': + c.i += 3; + return [make('heading', { level: Number(token.tag.slice(1)) }, inlineNodes(c.tokens[c.i - 2].children ?? []))]; + case 'bullet_list_open': + case 'ordered_list_open': + return readList(c, token); + case 'blockquote_open': + return readQuote(c); + case 'fence': + c.i++; + return code(token.content, fenceLanguage(token.info)); + case 'code_block': + c.i++; + return code(token.content, null); + case 'hr': + c.i++; + return [nodes.horizontalRule.create()]; + case 'math_block': + c.i++; + return [nodes.mathBlock.create({ source: token.content })]; + case 'html_block': + c.i++; + return literalLines(token.content); + default: + c.i++; + return token.nesting === 1 ? readBlocks(c) : []; + } +} + +/** Reads blocks up to the close token of the container the cursor is in, and steps past that token. */ +function readBlocks(c: Cursor): PMNode[] { + const out: PMNode[] = []; + while (c.i < c.tokens.length) { + if (c.tokens[c.i].nesting === -1) { + c.i++; + break; + } + out.push(...readBlock(c)); + } + return out; +} + +/** A document from block tokens. The caller says which markdown-it instance made them. */ +export function buildDoc(tokens: readonly Token[], md: Md): PMNode { + const blocks = readBlocks({ tokens, md, i: 0 }); + return nodes.doc.createAndFill(null, Fragment.from(blocks)) ?? (nodes.doc.createAndFill() as PMNode); +} + +/** The blocks of a piece of Markdown, with no empty paragraph added when there are none. Throws on parser errors. */ +export function parseBlocks(markdown: string): PMNode[] { + return readBlocks({ tokens: dialect.parse(markdown, {}), md: dialect, i: 0 }); +} + +/** The inline nodes of a line of Markdown, for checking what the writer produced. */ +export function parseInlineNodes(text: string): PMNode[] { + return inlineNodes(dialect.parseInline(text, {})[0]?.children ?? []); +} + +/** The text block document for a block's `markdown` string. Any string gives a valid document. */ +export function parseTextBlock(markdown: string): PMNode { + try { + return buildDoc(dialect.parse(markdown, {}), dialect); + } catch { + const lines = markdown + .split(/\n{2,}/) + .map((chunk) => + make('paragraph', null, chunk.trim() === '' ? [] : [textSchema.text(chunk.trim().replace(/\n/g, ' '))]), + ); + return make('doc', null, lines); + } +} diff --git a/app/src/editor/markdown/parseInline.ts b/app/src/editor/markdown/parseInline.ts new file mode 100644 index 000000000..6416d3c37 --- /dev/null +++ b/app/src/editor/markdown/parseInline.ts @@ -0,0 +1,173 @@ +// Inline tokens to ProseMirror nodes: text with marks, hard breaks, images, and math. The allowed HTML tags of SPEC 7.4 +// become marks when they nest properly within one paragraph, heading, or title. Any other HTML is literal text. +import type { Token } from 'markdown-it'; +import type { Mark, Node as PMNode } from '@tiptap/pm/model'; +import { textSchema } from '../schema/schema'; +import { HIGHLIGHT_COLORS, TEXT_COLOR_PATTERN, TEXT_SIZES, isBlockedHref } from '../schema/specs'; + +const { marks, nodes } = textSchema; + +interface OpenTag { + readonly name: string; + readonly mark: Mark; +} + +interface Live { + readonly role: 'open' | 'close'; + readonly mark?: Mark; + readonly id: number; +} + +const SIMPLE_TAGS: Readonly> = { + '': ['u', 'underline'], + '': ['sub', 'subscript'], + '': ['sup', 'superscript'], + '': ['em', 'italic'], + '': ['strong', 'bold'], + '': ['del', 'strike'], + '': ['mark', 'highlight'], +}; +const MARK_COLOR = /^$/; +const SPAN_COLOR = /^$/; +const SPAN_SIZE = /^$/; +const CLOSING = /^<\/(u|sub|sup|em|strong|del|mark|span)>$/; + +function openTag(html: string): OpenTag | null { + const simple = SIMPLE_TAGS[html]; + if (simple) return { name: simple[0], mark: marks[simple[1]].create() }; + const color = MARK_COLOR.exec(html)?.[1]; + if (color && (HIGHLIGHT_COLORS as readonly string[]).includes(color)) { + return { name: 'mark', mark: marks.highlight.create({ color }) }; + } + const text = SPAN_COLOR.exec(html)?.[1]; + if (text !== undefined && TEXT_COLOR_PATTERN.test(text)) { + return { name: 'span', mark: marks.textColor.create({ color: text }) }; + } + const size = SPAN_SIZE.exec(html)?.[1]; + if (size && (TEXT_SIZES as readonly string[]).includes(size)) { + return { name: 'span', mark: marks.textSize.create({ size }) }; + } + return null; +} + +/** Pairs the allowed tags. A tag that does not pair stays out of the result, so it becomes literal text. */ +function pairTags(tokens: readonly Token[]): Map { + const live = new Map(); + const stack: { index: number; tag: OpenTag }[] = []; + tokens.forEach((token, index) => { + if (token.type !== 'html_inline') return; + const tag = openTag(token.content); + if (tag) { + stack.push({ index, tag }); + return; + } + const name = CLOSING.exec(token.content)?.[1]; + if (name === undefined || stack.at(-1)?.tag.name !== name) return; + const open = stack.pop() as { index: number; tag: OpenTag }; + live.set(open.index, { role: 'open', mark: open.tag.mark, id: open.index }); + live.set(index, { role: 'close', id: open.index }); + }); + return live; +} + +/** The plain text of inline tokens, for an image's description. */ +export function plainText(tokens: readonly Token[]): string { + return tokens + .map((token) => { + if (token.type === 'text' || token.type === 'code_inline') return token.content; + if (token.type === 'softbreak' || token.type === 'hardbreak') return ' '; + if (token.type === 'image') return plainText(token.children ?? []); + return ''; + }) + .join(''); +} + +const MARK_OPEN: Readonly> = { + strong_open: 'strong', + em_open: 'em', + s_open: 's', + mark_open: 'mark', + link_open: 'a', +}; + +/** Builds inline nodes from tokens, keeping a stack of open marks. */ +class InlineBuilder { + readonly out: PMNode[] = []; + private readonly open: { key: string; mark: Mark | null }[] = []; + + private active(): readonly Mark[] { + return this.open.reduce((set, { mark }) => (mark ? mark.addToSet(set) : set), []); + } + + push(key: string, mark: Mark | null): void { + this.open.push({ key, mark }); + } + + close(key: string): void { + const at = this.open.map((entry) => entry.key).lastIndexOf(key); + if (at >= 0) this.open.splice(at, 1); + } + + text(content: string, extra?: Mark): void { + if (content === '') return; + const set = extra ? extra.addToSet(this.active()) : this.active(); + this.out.push(textSchema.text(content, set)); + } + + node(type: string, attrs: Record | null): void { + this.out.push(nodes[type].create(attrs, null, this.active())); + } +} + +function linkMark(token: Token): Mark | null { + const href = String(token.attrGet('href') ?? ''); + return href === '' || isBlockedHref(href) ? null : marks.link.create({ href }); +} + +function markFor(token: Token): Mark | null { + switch (token.type) { + case 'strong_open': + return marks.bold.create(); + case 'em_open': + return marks.italic.create(); + case 's_open': + return marks.strike.create(); + case 'mark_open': + return marks.highlight.create(); + default: + return linkMark(token); + } +} + +function addToken(builder: InlineBuilder, token: Token, index: number, live: Map): void { + const tag = token.type === 'html_inline' ? live.get(index) : undefined; + if (MARK_OPEN[token.type]) return builder.push(MARK_OPEN[token.type], markFor(token)); + if (token.type.endsWith('_close') && token.type !== 'html_inline') { + return builder.close(token.type === 'link_close' ? 'a' : token.tag); + } + switch (token.type) { + case 'text': + return builder.text(token.content); + case 'code_inline': + return builder.text(token.content, marks.code.create()); + case 'softbreak': + return builder.text(' '); + case 'hardbreak': + return builder.node('hardBreak', null); + case 'image': + return builder.node('image', { src: String(token.attrGet('src') ?? ''), alt: plainText(token.children ?? []) }); + case 'math_inline': + return builder.node('mathInline', { source: token.content }); + case 'html_inline': + if (!tag) return builder.text(token.content.replace(/\n/g, ' ')); + return tag.role === 'open' ? builder.push(`html${tag.id}`, tag.mark ?? null) : builder.close(`html${tag.id}`); + } +} + +/** The inline nodes of an `inline` token's children. */ +export function inlineNodes(tokens: readonly Token[]): PMNode[] { + const live = pairTags(tokens); + const builder = new InlineBuilder(); + tokens.forEach((token, index) => addToken(builder, token, index, live)); + return builder.out; +} diff --git a/app/src/editor/markdown/paste.test.ts b/app/src/editor/markdown/paste.test.ts new file mode 100644 index 000000000..435173354 --- /dev/null +++ b/app/src/editor/markdown/paste.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { createMarkdownCache, parseCell, serializeCell } from '.'; +import { shape } from '../shape'; +import { looksLikeMarkdown, parsePastedMarkdown } from './paste'; +import type { PastedPiece } from './paste'; + +function ids(): () => string { + let next = 0; + return () => `id${++next}`; +} + +const tableOf = (piece: PastedPiece | undefined) => (piece?.kind === 'table' ? piece.data : null); +const docOf = (piece: PastedPiece | undefined) => (piece?.kind === 'text' ? shape(piece.doc) : null); + +describe('whether pasted text looks like Markdown', () => { + it.each([ + '# Heading', + 'intro\n- item', + '1. first', + '> quoted', + '```\ncode\n```', + 'see [the docs](https://example.com)', + 'a **strong** word', + '| a | b |\n| --- | :-: |\n| 1 | 2 |', + 'a | b\n---|---', + '| one |\n|---|', + ])('%j does', (text) => { + expect(looksLikeMarkdown(text)).toBe(true); + }); + + it.each([ + 'Just a sentence.', + 'https://example.com/a_b', + 'two * stars * apart', + 'a | b without a delimiter row', + 'heading\n---', + 'price: 5 - 3 = 2', + ])('%j does not', (text) => { + expect(looksLikeMarkdown(text)).toBe(false); + }); +}); + +describe('pasted Markdown', () => { + it('splits text and tables into pieces, in order', () => { + const pieces = parsePastedMarkdown('# Plan\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nAfter **all**', ids()); + expect(pieces.map((piece) => piece.kind)).toEqual(['text', 'table', 'text']); + expect(docOf(pieces[0])).toBe('doc(heading[level=1]("Plan"))'); + expect(docOf(pieces[2])).toBe('doc(paragraph("After ", bold("all")))'); + }); + + it('makes table data with a header, fresh IDs, and canonical cell Markdown', () => { + const data = tableOf(parsePastedMarkdown('| Name | *Note* |\n|---|---|\n| - x | a \\| b |\n| only |', ids())[0]); + expect(data?.header).toBe(true); + expect(data?.columns.map((column) => column.id)).toEqual(['id1', 'id2']); + expect(data?.rows.map((row) => row.id)).toEqual(['id3', 'id4', 'id5']); + const cells = data?.rows.map((row) => data.columns.map((column) => row.cells[column.id].markdown)); + expect(cells).toEqual([ + ['Name', '*Note*'], + ['\\- x', 'a \\| b'], + ['only', ''], + ]); + const cache = createMarkdownCache(); + for (const markdown of cells?.flat() ?? []) expect(serializeCell(parseCell(markdown), cache)).toBe(markdown); + }); + + it('keeps a table inside a list as one paragraph for each row', () => { + const [piece] = parsePastedMarkdown('- item\n\n | a | b |\n |---|---|\n | 1 | **2** |', ids()); + expect(docOf(piece)).toBe( + 'doc(bulletList(listItem(paragraph("item"), paragraph("a | b"), paragraph("1 | ", bold("2")))))', + ); + }); + + it('gives valid documents for any pasted text, and nothing for blank text', () => { + expect(parsePastedMarkdown(' \n\n', ids())).toEqual([]); + for (const text of ['|', '|---|', '| a |\n|---|\n|', '> | a |\n> |---|', '\r\n# a\r\n']) { + for (const piece of parsePastedMarkdown(text, ids())) if (piece.kind === 'text') piece.doc.check(); + } + }); +}); diff --git a/app/src/editor/markdown/paste.ts b/app/src/editor/markdown/paste.ts new file mode 100644 index 000000000..d6dc2eb07 --- /dev/null +++ b/app/src/editor/markdown/paste.ts @@ -0,0 +1,96 @@ +// Plain text and Markdown paste (Phase 4 design, 15.6). Text that looks like Markdown is parsed with GFM tables on, +// and a table becomes a table block. Other text becomes one paragraph for each line. +import type { Token } from 'markdown-it'; +import { Fragment } from '@tiptap/pm/model'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { buildDoc, createMarkdown } from './parse'; +import { inlineNodes } from './parseInline'; +import { serializeCellParagraph } from './serialize'; +import { textSchema } from '../schema/schema'; +import { newId as makeId } from '../ids'; +import { DEFAULT_COLUMN_WIDTH } from '../schema/specs'; +import type { TableData } from '../schema/specs'; + +/** A run of blocks for the text editor, or a table that becomes its own block. */ +export type PastedPiece = { kind: 'text'; doc: PMNode } | { kind: 'table'; data: TableData }; + +const { nodes } = textSchema; +const pasteMarkdown = createMarkdown(true); + +const MARKER_LINE = /^ {0,3}(?:#{1,6}(?:\s|$)|[-*+]\s|\d{1,9}[.)]\s|>|```|~~~)/m; +const LINK = /\[[^\]\n]+\]\([^)\s]+\)/; +const STRONG_PAIR = /\*\*[^*\n]+\*\*/; +const ONLY_URL = /^\s*(?:https?|ftp):\/\/\S+\s*$/i; +/** A GFM table's head row, then its delimiter row, such as `| --- | :-: |`. */ +const TABLE_HEAD = /^[^\n]*\|[^\n]*\n {0,3}\|?(?: *:?-+:? *\|)+(?: *:?-+:? *)?$/m; + +/** + * Text looks like Markdown when at least one line starts with a heading, list, quote, or fence marker, or the text + * has a Markdown link, a pair of `**`, or a table's head. A single line that holds only a web address is not. + */ +export function looksLikeMarkdown(text: string): boolean { + if (ONLY_URL.test(text)) return false; + return MARKER_LINE.test(text) || LINK.test(text) || STRONG_PAIR.test(text) || TABLE_HEAD.test(text); +} + +/** A cell's canonical Markdown, as `serializeCell` writes it, so a table block's first edit changes nothing. */ +function cellMarkdownFrom(inline: Token | undefined): string { + return serializeCellParagraph(nodes.paragraph.create(null, inlineNodes(inline?.children ?? []))); +} + +/** Table data from the tokens between `table_open` and `table_close`. */ +function tableFromTokens(tokens: readonly Token[], newId: () => string): TableData { + const rows: Record[] = []; + let header = false; + let current: string[] = []; + tokens.forEach((token, i) => { + if (token.type === 'thead_open') header = true; + if (token.type === 'tr_open') current = []; + if (token.type === 'th_open' || token.type === 'td_open') current.push(cellMarkdownFrom(tokens[i + 1])); + if (token.type === 'tr_close') rows.push(Object.fromEntries(current.map((markdown, at) => [String(at), markdown]))); + }); + const count = Math.max(1, ...rows.map((row) => Object.keys(row).length)); + const columns = Array.from({ length: count }, () => ({ id: newId(), width: DEFAULT_COLUMN_WIDTH })); + return { + header, + columns, + rows: rows.map((row) => ({ + id: newId(), + cells: Object.fromEntries(columns.map((column, at) => [column.id, { markdown: row[String(at)] ?? '' }])), + })), + }; +} + +function textPiece(tokens: readonly Token[]): PastedPiece[] { + return tokens.length === 0 ? [] : [{ kind: 'text', doc: buildDoc(tokens, pasteMarkdown) }]; +} + +/** Pasted Markdown as text pieces, with each GFM table as a table piece. */ +export function parsePastedMarkdown(text: string, newId: () => string = makeId): PastedPiece[] { + const tokens = pasteMarkdown.parse(text, {}); + const pieces: PastedPiece[] = []; + let start = 0; + for (let i = 0; i < tokens.length; i++) { + if (tokens[i].type !== 'table_open' || tokens[i].level !== 0) continue; + let end = i; + while (end < tokens.length && tokens[end].type !== 'table_close') end++; + pieces.push(...textPiece(tokens.slice(start, i))); + pieces.push({ kind: 'table', data: tableFromTokens(tokens.slice(i, end + 1), newId) }); + start = end + 1; + i = end; + } + return [...pieces, ...textPiece(tokens.slice(start))]; +} + +/** Plain text that is not Markdown: each non-empty line is a paragraph. */ +export function plainTextPieces(text: string): PastedPiece[] { + const lines = text + .replace(/\r\n?/g, '\n') + .split('\n') + .filter((line) => line.trim() !== ''); + if (lines.length === 0) return []; + const paragraphs: PMNode[] = lines.map((line) => + nodes.paragraph.create(null, textSchema.text(line.replace(/\0/g, '\ufffd').trim().replace(/\t/g, ' '))), + ); + return [{ kind: 'text', doc: nodes.doc.create(null, Fragment.from(paragraphs)) }]; +} diff --git a/app/src/editor/markdown/reparse.test.ts b/app/src/editor/markdown/reparse.test.ts new file mode 100644 index 000000000..75df9e455 --- /dev/null +++ b/app/src/editor/markdown/reparse.test.ts @@ -0,0 +1,140 @@ +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { getSchema } from '@tiptap/core'; +import { Fragment, Slice } from '@tiptap/pm/model'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { textExtensions } from '../schema/schema'; +import { documents, propertyRuns } from './arbitrary'; +import { createMarkdownCache } from './cache'; +import { parseTextBlock } from './parse'; +import { reparseRange } from './reparse'; +import type { Reparse } from './reparse'; +import { serializeTextBlock, warmCache } from './serialize'; + +const RUNS = propertyRuns(300); +const TIMEOUT = 600_000; +const { doc } = documents(); + +function applied(before: PMNode, change: Exclude): PMNode { + return before.replace(change.from, change.to, new Slice(change.content, 0, 0)); +} + +/** The re-parse of `before` into `after`, both canonical, from a warm cache. */ +function reparse(before: string, after: string, start = parseTextBlock(before)) { + const cache = createMarkdownCache(); + warmCache(start, before, cache); + return { start, change: reparseRange(start, before, after, cache) }; +} + +function expectRange(before: string, after: string): Exclude { + for (const text of [before, after]) expect(serializeTextBlock(parseTextBlock(text)), 'canonical').toBe(text); + const { start, change } = reparse(before, after); + expect(change).not.toBe('full'); + const range = change as Exclude; + expect(applied(start, range).toJSON()).toEqual(parseTextBlock(after).toJSON()); + return range; +} + +const pieces = fc.constantFrom('x', ' ', '\n', '\n\n', '- ', '1. ', '> ', '# ', '`', '*', '[ ] ', '$$', '===', 'é'); +const insertion = fc.array(pieces, { maxLength: 4 }).map((parts) => parts.join('')); + +describe('re-parsing the changed range', () => { + it( + 'gives the same document as a full parse, for random documents and random splices', + () => { + let ranges = 0; + let runs = 0; + fc.assert( + fc.property(doc, fc.nat(), fc.nat(8), insertion, (d, at, size, ins) => { + const before = serializeTextBlock(d); + const from = at % (before.length + 1); + const after = serializeTextBlock(parseTextBlock(before.slice(0, from) + ins + before.slice(from + size))); + const { start, change } = reparse(before, after); + runs++; + if (change === 'full') return; + ranges++; + expect(applied(start, change).toJSON()).toEqual(parseTextBlock(after).toJSON()); + }), + { numRuns: RUNS }, + ); + expect(ranges / runs).toBeGreaterThan(0.3); + }, + TIMEOUT, + ); + + it( + 'never asks for a full parse when one paragraph gains a word', + () => { + fc.assert( + fc.property(doc, fc.nat(), (d, pick) => { + const before = serializeTextBlock(d); + const paragraphs: number[] = []; + d.descendants((node, pos) => { + if (node.type.name === 'paragraph' && node.content.size > 0) paragraphs.push(pos + 1); + }); + fc.pre(paragraphs.length > 0); + const at = paragraphs[pick % paragraphs.length]; + const after = serializeTextBlock( + d.replace(at, at, new Slice(Fragment.from(d.type.schema.text('word ')), 0, 0)), + ); + fc.pre(after !== before); + expectRange(before, after); + }), + { numRuns: RUNS }, + ); + }, + TIMEOUT, + ); + + it('replaces one paragraph of a long outline, and nothing else', () => { + const items = Array.from({ length: 200 }, (_, i) => `- point ${i}\n\n - detail ${i}\n - more ${i}`).join('\n\n'); + const before = `# Notes\n\n${items}`; + const after = before.replace('detail 120', 'detail one hundred twenty'); + const range = expectRange(before, after); + expect(range.content.childCount).toBe(1); + expect(range.content.firstChild?.textContent).toBe('detail one hundred twenty'); + }); + + it('reaches into callouts, quotes, and task items', () => { + const body = Array.from({ length: 50 }, (_, i) => `> Line ${i} of the callout.`).join('\n>\n'); + const callout = `> [!tip]- Title\n${body}`; + expect(expectRange(callout, callout.replace('Line 30', 'Line thirty')).content.childCount).toBe(1); + const quote = '> one\n>\n> > two\n> >\n> > three'; + expect(expectRange(quote, quote.replace('three', 'four')).content.firstChild?.textContent).toBe('four'); + const tasks = '- [ ] buy milk\n- [x] call home\n- [ ] write'; + expect(expectRange(tasks, tasks.replace('call home', 'call work')).content.firstChild?.textContent).toBe( + 'call work', + ); + }); + + it('handles changes to the containers themselves', () => { + expectRange('- [ ] one\n- [ ] two', '- [ ] one\n- [x] two'); + expectRange('> [!note] Old\n> body', '> [!tip] New\n> body'); + expectRange('> [!note] Head\n> body', '> [!note] Head\n>\n> - body'); + expectRange('1. a\n2. b\n3. c', '1. a\n2. b\n3. c\n4. d'); + expectRange('- a\n- b', '- a\n\n more\n\n- b'); + expectRange('para\n\n- a\n- b', 'para\n\n- a\n\n---\n\n- b'); + expectRange('one\n\ntwo\n\nthree', 'one\n\nthree'); + }); + + it('asks for a full parse when the known Markdown is not the document', () => { + expect(reparse('_a_', 'b', parseTextBlock('_a_')).change).toBe('full'); + expect(reparse('a', 'b', parseTextBlock('c')).change).toBe('full'); + }); + + it('returns an empty change when nothing changed', () => { + expect(reparse('same', 'same').change).toEqual({ from: 0, to: 0, content: expect.anything() }); + }); + + it("works on an editor's own schema, and gives content in it", () => { + const schema = getSchema(textExtensions); + const before = '- one\n- two\n\nend'; + const start = schema.nodeFromJSON(parseTextBlock(before).toJSON()); + const after = '- one\n- three\n\nend'; + const { change } = reparse(before, after, start); + expect(change).not.toBe('full'); + const range = change as Exclude; + expect(range.content.firstChild?.type.schema).toBe(schema); + expect(applied(start, range).toJSON()).toEqual(parseTextBlock(after).toJSON()); + }); +}); diff --git a/app/src/editor/markdown/reparse.ts b/app/src/editor/markdown/reparse.ts new file mode 100644 index 000000000..43f8043f6 --- /dev/null +++ b/app/src/editor/markdown/reparse.ts @@ -0,0 +1,202 @@ +// Re-parsing only the range of a block that changed (ARCHITECTURE.md section 9.3). Undo, redo, and frames from other +// windows deliver a block's new Markdown. Parsing a long block again on the main thread can miss the feedback budget. +// So this finds the splice and walks down through the containers that hold all of it, using each cache entry's child +// spans. It parses only the children the splice touches, widened by one sibling on each side. +// +// The new document is written again and compared with the new Markdown. Every canonical string round-trips (section +// 9.4), so equal text proves the result equals a full parse. When a level can't prove it, the level above tries. +// When no level can, the caller parses the whole block. +import { Fragment, Slice } from '@tiptap/pm/model'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { textSchema } from '../schema/schema'; +import { stateOf } from './cache'; +import type { CacheEntry, CacheState, MarkdownCache } from './cache'; +import { parseBlocks } from './parse'; +import { entryOf, markerOf, serializeTextBlock } from './serialize'; +import { diffMarkdown } from './splice'; +import type { MarkdownSplice } from './splice'; + +/** + * Replace the document's content between `from` and `to` with `content`, in the document's own schema, or parse + * the whole block again. `from` equals `to` and `content` is empty when nothing changed. + */ +export type Reparse = { from: number; to: number; content: Fragment } | 'full'; + +type Kind = 'blocks' | 'callout' | 'list' | 'item'; + +const KINDS: Readonly> = { + blockquote: 'blocks', + callout: 'callout', + bulletList: 'list', + orderedList: 'list', + listItem: 'item', +}; + +/** One container on the way down, with its text before and after the change, without its own prefixes. */ +interface Level { + readonly node: PMNode; + readonly entry: CacheEntry; + /** Where the node's content starts in the document. */ + readonly pos: number; + readonly kind: Kind; + readonly after: string; + readonly splice: MarkdownSplice; +} + +/** A quote's lines without their `> `, or null when a line has none. */ +function unquote(text: string): string | null { + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i] === '>') lines[i] = ''; + else if (lines[i].startsWith('> ')) lines[i] = lines[i].slice(2); + else return null; + } + return lines.join('\n'); +} + +/** Lines after the first without their indent, or null when a line that holds text is indented less. */ +function unindent(text: string, width: number): string | null { + const pad = ' '.repeat(width); + const lines = text.split('\n'); + for (let i = 1; i < lines.length; i++) { + if (lines[i] === '') continue; + if (!lines[i].startsWith(pad)) return null; + lines[i] = lines[i].slice(width); + } + return lines.join('\n'); +} + +/** An item's text from its line in the list, without the marker. The marker must not have changed. */ +function itemText(line: string, marker: string): string | null { + if (line.startsWith(marker)) return unindent(line.slice(marker.length), marker.length); + const bare = marker.trimEnd(); + if (line !== bare && !line.startsWith(`${bare}\n`)) return null; + return unindent(line.slice(bare.length), marker.length); +} + +function offsetOf(node: PMNode, index: number): number { + let offset = 0; + for (let i = 0; i < index; i++) offset += node.child(i).nodeSize; + return offset; +} + +const firstChild = (kind: Kind) => (kind === 'callout' ? 1 : 0); + +/** The one child whose text holds the whole splice, or -1. */ +function holder({ entry, kind, splice }: Level): number { + const { childStarts: starts, childEnds: ends } = entry; + if (!starts || !ends) return -1; + const end = splice.at + splice.del.length; + for (let i = firstChild(kind); i < starts.length; i++) { + if (starts[i] <= splice.at && end <= ends[i] && starts[i] < ends[i]) return i; + } + return -1; +} + +/** The level for child `index` of `level`, when it is a container whose prefixes the new text still has. */ +function descend(level: Level, index: number, store: CacheState): Level | null { + const child = level.node.child(index); + const kind = KINDS[child.type.name] as Kind | undefined; + const starts = level.entry.childStarts; + const ends = level.entry.childEnds; + if (!kind || !starts || !ends) return null; + const marker = level.kind === 'list' ? markerOf(level.node, index) : ''; + const entry = store.byNode.get(child); + if (!entry || entry.ctx !== (marker === '' ? '' : String(marker.length))) return null; + const delta = level.splice.ins.length - level.splice.del.length; + const region = level.after.slice(starts[index], ends[index] + delta); + let after: string | null = region; + if (kind === 'blocks' || kind === 'callout') after = unquote(region); + else if (kind === 'item') after = itemText(region, marker); + const splice = after === null ? null : diffMarkdown(entry.inner, after); + if (after === null || !splice) return null; + const pos = level.pos + offsetOf(level.node, index) + 1; + return { node: child, entry, pos, kind, after, splice }; +} + +function childrenOf(node: PMNode): PMNode[] { + const out: PMNode[] = []; + node.forEach((child) => out.push(child)); + return out; +} + +/** The new children for a range of a level, parsed from its new text, or null when they don't fit the level. */ +function parseRange(level: Level, text: string): PMNode[] | null { + const blocks = parseBlocks(text); + if (level.kind !== 'list') return blocks; + const [list] = blocks; + return blocks.length === 1 && list.type.name === level.node.type.name ? childrenOf(list) : null; +} + +/** The replacement at one level, proved by writing the result, or null. */ +function tryLevel(doc: PMNode, level: Level, next: string, store: CacheState): Reparse | null { + const { node, entry, kind, splice } = level; + const starts = entry.childStarts; + const ends = entry.childEnds; + const count = node.childCount; + const first = firstChild(kind); + if (!starts || !ends || starts.length !== count || count <= first) return null; + const end = splice.at + splice.del.length; + // A change to a callout's head or an item's task box changes the container itself. + if (splice.at < starts[first]) return null; + let a = first; + while (a < count - 1 && ends[a] < splice.at) a++; + let b = count - 1; + while (b > a && starts[b] > end) b--; + a = Math.max(first, a - 1); + b = Math.min(count - 1, b + 1); + if (splice.at < starts[a] || end > ends[b]) return null; + const delta = splice.ins.length - splice.del.length; + let nodes = parseRange(level, level.after.slice(starts[a], ends[b] + delta)); + if (!nodes) return null; + const schema = doc.type.schema; + if (schema !== textSchema) nodes = nodes.map((child) => schema.nodeFromJSON(child.toJSON())); + // Unchanged children at either end keep their identity, and with it their cache entries. + let lo = a; + let hi = b + 1; + let s = 0; + let e = nodes.length; + while (lo < hi && s < e && node.child(lo).eq(nodes[s])) [lo, s] = [lo + 1, s + 1]; + while (hi > lo && e > s && node.child(hi - 1).eq(nodes[e - 1])) [hi, e] = [hi - 1, e - 1]; + const from = level.pos + offsetOf(node, lo); + const to = from + offsetOf(node, hi) - offsetOf(node, lo); + const content = Fragment.fromArray(nodes.slice(s, e)); + try { + const result = doc.replace(from, to, new Slice(content, 0, 0)); + return serializeTextBlock(result, store) === next ? { from, to, content } : null; + } catch { + return null; + } +} + +function rangeFor(doc: PMNode, docMarkdown: string, next: string, store: CacheState): Reparse { + const entry = entryOf(doc, '', store); + if (entry.text !== docMarkdown) return 'full'; + const splice = diffMarkdown(docMarkdown, next); + if (!splice) return { from: 0, to: 0, content: Fragment.empty }; + const levels: Level[] = [{ node: doc, entry, pos: 0, kind: 'blocks', after: next, splice }]; + for (;;) { + const level = levels[levels.length - 1]; + const index = holder(level); + const inner = index < 0 ? null : descend(level, index, store); + if (!inner) break; + levels.push(inner); + } + for (let i = levels.length - 1; i >= 0; i--) { + const result = tryLevel(doc, levels[i], next, store); + if (result) return result; + } + return 'full'; +} + +/** + * How to turn `doc`, whose Markdown is `docMarkdown`, into the document for `next`. Never throws: anything it can't + * prove asks for a full parse. + */ +export function reparseRange(doc: PMNode, docMarkdown: string, next: string, cache: MarkdownCache): Reparse { + try { + return rangeFor(doc, docMarkdown, next, stateOf(cache)); + } catch { + return 'full'; + } +} diff --git a/app/src/editor/markdown/roundtrip.test.ts b/app/src/editor/markdown/roundtrip.test.ts new file mode 100644 index 000000000..0224ca2ef --- /dev/null +++ b/app/src/editor/markdown/roundtrip.test.ts @@ -0,0 +1,71 @@ +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { documents, markdownSource, propertyRuns } from './arbitrary'; +import { parseTextBlock } from './parse'; +import { serializeTextBlock } from './serialize'; + +// 400 runs here, 1,000 in CI, and FC_RUNS=100000 for the nightly depth of the Phase 4 design. +const RUNS = propertyRuns(400); +const TIMEOUT = 600_000; +const { doc, block } = documents(); + +describe('round trips (SPEC 7.8)', () => { + it( + 'parse(serialize(d)) equals d for every canonical document', + () => { + fc.assert( + fc.property(doc, (d) => { + const markdown = serializeTextBlock(d); + const back = parseTextBlock(markdown); + expect(back.toJSON(), markdown).toEqual(d.toJSON()); + }), + { numRuns: RUNS }, + ); + }, + TIMEOUT, + ); + + it( + 'serialize(parse(m)) equals m for every canonical m', + () => { + fc.assert( + fc.property(doc, (d) => { + const markdown = serializeTextBlock(d); + expect(serializeTextBlock(parseTextBlock(markdown))).toBe(markdown); + }), + { numRuns: RUNS }, + ); + }, + TIMEOUT, + ); + + it('keeps a single block alone in a document', () => { + fc.assert( + fc.property(block, (b) => { + const markdown = serializeTextBlock(b.type.schema.nodes.doc.create(null, b)); + expect(parseTextBlock(markdown).childCount).toBeGreaterThan(0); + }), + { numRuns: 100 }, + ); + }); +}); + +describe('any input', () => { + it( + 'parses to a valid document, and writing it is stable', + () => { + fc.assert( + fc.property(markdownSource, (source) => { + const parsed = parseTextBlock(source); + parsed.check(); + const once = serializeTextBlock(parsed); + expect(serializeTextBlock(parseTextBlock(once)), `${JSON.stringify(source)} -> ${JSON.stringify(once)}`).toBe( + once, + ); + }), + { numRuns: RUNS }, + ); + }, + TIMEOUT, + ); +}); diff --git a/app/src/editor/markdown/rules.ts b/app/src/editor/markdown/rules.ts new file mode 100644 index 000000000..0c20262a4 --- /dev/null +++ b/app/src/editor/markdown/rules.ts @@ -0,0 +1,118 @@ +// The markdown-it rules that the OpenNote dialect adds to CommonMark and strikethrough (SPEC 7.3): highlights with +// `==`, inline math with `$...$`, and display math between `$$` lines. Callouts, task items, and the allowed HTML +// tags are read from the token stream in parse.ts and inline.ts instead, because they need the raw source. +import MarkdownIt from 'markdown-it'; +import type { Delimiter, MarkdownIt as Md, StateBlock, StateInline } from 'markdown-it'; + +const { isWhiteSpace } = new MarkdownIt().utils; + +const EQUALS = 0x3d; +const DOLLAR = 0x24; + +function isSpaceAt(src: string, at: number): boolean { + return at < 0 || at >= src.length || isWhiteSpace(src.charCodeAt(at)); +} + +/** A run of `==`. It opens when the next character is not whitespace and closes when the previous one is not. */ +function highlightTokenize(state: StateInline, silent: boolean): boolean { + const { src, pos: start } = state; + if (silent || src.charCodeAt(start) !== EQUALS) return false; + let end = start; + while (end < state.posMax && src.charCodeAt(end) === EQUALS) end++; + if (end - start < 2) return false; + const open = !isSpaceAt(src, end); + const close = !isSpaceAt(src, start - 1); + let length = end - start; + if (length % 2) { + state.push('text', '', 0).content = '='; + length--; + } + for (let i = 0; i < length; i += 2) { + state.push('text', '', 0).content = '=='; + const delimiter: Delimiter = { marker: EQUALS, length: 0, token: state.tokens.length - 1, end: -1, open, close }; + state.delimiters.push(delimiter); + } + state.pos = end; + return true; +} + +function highlightPair(state: StateInline, delimiters: Delimiter[]): void { + const lone: number[] = []; + for (const start of delimiters) { + if (start.marker !== EQUALS || start.end === -1) continue; + const end = delimiters[start.end]; + Object.assign(state.tokens[start.token], { type: 'mark_open', tag: 'mark', nesting: 1, markup: '==', content: '' }); + Object.assign(state.tokens[end.token], { type: 'mark_close', tag: 'mark', nesting: -1, markup: '==', content: '' }); + const before = state.tokens[end.token - 1]; + if (before.type === 'text' && before.content === '=') lone.push(end.token - 1); + } + // An odd run is split as `=` then `==`. Move the lone `=` after the closers that follow it. + for (let i = lone.pop(); i !== undefined; i = lone.pop()) { + let j = i + 1; + while (j < state.tokens.length && state.tokens[j].type === 'mark_close') j++; + j--; + if (i !== j) [state.tokens[i], state.tokens[j]] = [state.tokens[j], state.tokens[i]]; + } +} + +function highlightPostProcess(state: StateInline): void { + highlightPair(state, state.delimiters); + for (const meta of state.tokens_meta) { + if (meta?.delimiters) highlightPair(state, meta.delimiters); + } +} + +/** `$math$`: the opening `$` is followed by a non-space and the closing `$` is preceded by one. */ +function mathInline(state: StateInline, silent: boolean): boolean { + const { src, pos: start, posMax } = state; + if (src.charCodeAt(start) !== DOLLAR || start + 1 >= posMax) return false; + if (isSpaceAt(src, start + 1) || src.charCodeAt(start + 1) === DOLLAR) return false; + let at = start + 1; + while (at < posMax && src.charCodeAt(at) !== DOLLAR) { + if (src[at] === '\n') return false; + at += src[at] === '\\' ? 2 : 1; + } + if (at >= posMax || isSpaceAt(src, at - 1)) return false; + if (!silent) { + const token = state.push('math_inline', '', 0); + token.content = src.slice(start + 1, at); + token.markup = '$'; + } + state.pos = at + 1; + return true; +} + +function lineText(state: StateBlock, line: number): string { + return state.src.slice(state.bMarks[line] + state.tShift[line], state.eMarks[line]).trim(); +} + +/** `$$` on its own line, the source, then `$$` on its own line. Like a code fence, it may run to the end. */ +function mathBlock(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean { + if (state.sCount[startLine] - state.blkIndent >= 4 || lineText(state, startLine) !== '$$') return false; + if (silent) return true; + let next = startLine + 1; + let closed = false; + for (; next < endLine; next++) { + const text = lineText(state, next); + if (text !== '' && state.sCount[next] < state.blkIndent) break; + if (text === '$$' && state.sCount[next] - state.blkIndent < 4) { + closed = true; + break; + } + } + const token = state.push('math_block', 'div', 0); + token.content = state.getLines(startLine + 1, next, state.blkIndent, false); + state.line = next + (closed ? 1 : 0); + token.map = [startLine, state.line]; + return true; +} + +export function highlightPlugin(md: Md): void { + md.inline.ruler.before('emphasis', 'highlight', highlightTokenize); + md.inline.ruler2.before('emphasis', 'highlight', highlightPostProcess); +} + +export function mathPlugin(md: Md): void { + md.inline.ruler.before('emphasis', 'math_inline', mathInline); + md.block.ruler.before('fence', 'math_block', mathBlock, { alt: ['paragraph', 'reference', 'blockquote', 'list'] }); +} diff --git a/app/src/editor/markdown/serialize.ts b/app/src/editor/markdown/serialize.ts new file mode 100644 index 000000000..95e16b446 --- /dev/null +++ b/app/src/editor/markdown/serialize.ts @@ -0,0 +1,308 @@ +// A ProseMirror text block document to canonical OpenNote Markdown (SPEC 7.2 and 7.7). The same document always gives +// the same string, so saving, reloading, and undo give identical text. With a cache, every node the writer visits +// keeps its text (ARCHITECTURE.md section 9.2), so writing a document after a small change only writes what changed. +// Node types are compared by name, so documents from any schema instance work: editors build their own. +import type { Node as PMNode } from '@tiptap/pm/model'; +import { CALLOUT_TYPE_PATTERN, FOLDS, LANGUAGE_PATTERN } from '../schema/specs'; +import { stateOf } from './cache'; +import type { CacheEntry, CacheState, MarkdownCache } from './cache'; +import { PARAGRAPH, TITLE, cleanText } from './escape'; +import { serializeInline } from './inline'; +import { joinAdjacentLists } from './normalize'; + +type Store = CacheState | null; + +const NO_SPANS = new Int32Array(0); + +/** Markdown cannot hold an empty paragraph between blocks, so one is dropped. */ +const isEmptyParagraph = (node: PMNode) => node.type.name === 'paragraph' && node.content.size === 0; +const isList = (node: PMNode) => node.type.name === 'bulletList' || node.type.name === 'orderedList'; + +function leaf(text: string): CacheEntry { + return { ctx: '', text, inner: text, childStarts: NO_SPANS, childEnds: NO_SPANS, loose: false }; +} + +function fenceText(node: PMNode): string { + const source = cleanText(node.textContent); + const language = node.attrs.language as string | null; + const runs = source.split('\n').map((line) => /^ {0,3}(`+)/.exec(line)?.[1].length ?? 0); + const fence = '`'.repeat(Math.max(3, ...runs.map((run) => run + 1))); + const info = language && LANGUAGE_PATTERN.test(language) ? language : ''; + return source === '' ? `${fence}${info}\n${fence}` : `${fence}${info}\n${source}\n${fence}`; +} + +/** `> ` before every line, and `>` alone on a blank one. */ +export function quoted(text: string): string { + return text + .split('\n') + .map((line) => (line === '' ? '>' : `> ${line}`)) + .join('\n'); +} + +/** Every line after the first that holds text, indented by `width` spaces. */ +export function indented(text: string, width: number): string { + return text.replace(/\n(?=[^\n])/g, `\n${' '.repeat(width)}`); +} + +function taskPrefix(checked: boolean | null): string { + return checked === null ? '' : checked ? '[x] ' : '[ ] '; +} + +/** The marker of item `index` in a list: `- `, or `10. ` for the tenth item of a numbered list starting at 1. */ +export function markerOf(list: PMNode, index: number): string { + if (list.type.name !== 'orderedList') return '- '; + return `${Math.max(0, Math.floor(Number(list.attrs.start) || 0)) + index}. `; +} + +interface Joined { + readonly text: string; + readonly starts: Int32Array | null; + readonly ends: Int32Array | null; +} + +function hasAdjacentLists(node: PMNode, from: number): boolean { + for (let i = from + 1; i < node.childCount; i++) { + const a = node.child(i - 1); + if (isList(a) && a.type === node.child(i).type) return true; + } + return false; +} + +/** + * The blocks of `node` from child `from` on, joined by one blank line. Spans are relative to the joined text, one + * for each child from `from` on. + */ +function joinBlocks(node: PMNode, from: number, store: Store): Joined { + if (hasAdjacentLists(node, from)) { + const children: PMNode[] = []; + for (let i = from; i < node.childCount; i++) children.push(node.child(i)); + const parts = joinAdjacentLists(children) + .filter((child) => !isEmptyParagraph(child)) + .map((child) => entryOf(child, '', store).text); + return { text: parts.join('\n\n'), starts: null, ends: null }; + } + const count = node.childCount - from; + const starts = new Int32Array(count); + const ends = new Int32Array(count); + const parts: string[] = []; + const waiting: number[] = []; + let at = 0; + for (let i = 0; i < count; i++) { + const child = node.child(from + i); + if (isEmptyParagraph(child)) { + waiting.push(i); + continue; + } + if (parts.length > 0) at += 2; + for (const dropped of waiting) starts[dropped] = ends[dropped] = at; + waiting.length = 0; + const text = entryOf(child, '', store).text; + starts[i] = at; + at += text.length; + ends[i] = at; + parts.push(text); + } + for (const dropped of waiting) starts[dropped] = ends[dropped] = at; + return { text: parts.join('\n\n'), starts, ends }; +} + +function shifted(spans: Int32Array | null, by: number, limit: number): Int32Array | null { + return spans && spans.map((at) => Math.min(limit, at + by)); +} + +function withHead(head: Int32Array | null, rest: Int32Array | null, first: number): Int32Array | null { + if (!head || !rest) return null; + const out = new Int32Array(rest.length + 1); + out[0] = first; + out.set(rest, 1); + return out; +} + +function blocksEntry(node: PMNode, store: Store): CacheEntry { + const joined = joinBlocks(node, 0, store); + return { + ctx: '', + text: joined.text, + inner: joined.text, + childStarts: joined.starts, + childEnds: joined.ends, + loose: false, + }; +} + +function quoteEntry(node: PMNode, store: Store): CacheEntry { + const joined = joinBlocks(node, 0, store); + return { + ctx: '', + text: quoted(joined.text), + inner: joined.text, + childStarts: joined.starts, + childEnds: joined.ends, + loose: false, + }; +} + +/** Whether the first block a container writes from child `from` on is a paragraph. */ +function writesParagraphFirst(node: PMNode, from: number): boolean { + for (let i = from; i < node.childCount; i++) { + const child = node.child(i); + if (!isEmptyParagraph(child)) return child.type.name === 'paragraph'; + } + return false; +} + +/** + * A callout: its head line, then its blocks. A paragraph first in the body follows the head line directly, as the + * shared fixtures write it. Its lines can't start another block, because SPEC 7.6 escapes every such start. Any other + * block follows a blank line, since some, like a thematic break, would change the head line. + */ +function calloutEntry(node: PMNode, store: Store): CacheEntry { + const title = node.firstChild; + const type = CALLOUT_TYPE_PATTERN.test(node.attrs.type as string) ? (node.attrs.type as string) : 'note'; + const fold = (FOLDS as readonly string[]).includes(node.attrs.fold as string) ? (node.attrs.fold as string) : ''; + const line = title ? entryOf(title, '', store).text : ''; + const head = `[!${type}]${fold}${line === '' ? '' : ` ${line}`}`; + const body = joinBlocks(node, 1, store); + const gap = writesParagraphFirst(node, 1) ? '\n' : '\n\n'; + const inner = body.text === '' ? head : `${head}${gap}${body.text}`; + const offset = head.length + gap.length; + return { + ctx: '', + text: quoted(inner), + inner, + childStarts: withHead(NO_SPANS, shifted(body.starts, offset, inner.length), head.length - line.length), + childEnds: withHead(NO_SPANS, shifted(body.ends, offset, inner.length), head.length), + loose: false, + }; +} + +/** + * A list item: its blocks with every later line indented to the marker's width (`ctx`). The list writes the marker + * on the first line. An item with no text can't be followed by a paragraph, so its blocks start on the next line. A + * task item still has its `[ ]` line, which a block on the next line would join, so a blank line separates them. + */ +function itemEntry(item: PMNode, ctx: string, store: Store): CacheEntry { + const first = item.firstChild; + const lead = first ? entryOf(first, '', store).text : ''; + const rest = item.childCount > 1 ? joinBlocks(item, 1, store) : { text: '', starts: NO_SPANS, ends: NO_SPANS }; + const hasOthers = rest.text !== ''; + const checked = item.attrs.checked as boolean | null; + const bare = lead === '' && checked === null && hasOthers; + let inner: string; + let leadAt: number; + let restAt: number; + if (bare) { + inner = `\n${rest.text}`; + leadAt = 0; + restAt = 1; + } else { + const body = hasOthers ? `${lead}\n\n${rest.text}` : lead; + const prefix = taskPrefix(checked); + const head = lead === '' || lead.charCodeAt(0) === 10 ? prefix.trimEnd() : prefix; + inner = head + body; + leadAt = head.length; + restAt = head.length + lead.length + 2; + } + return { + ctx, + text: indented(inner, Number(ctx)), + inner, + childStarts: withHead(NO_SPANS, shifted(rest.starts, restAt, inner.length), leadAt), + childEnds: withHead(NO_SPANS, shifted(rest.ends, restAt, inner.length), leadAt + lead.length), + loose: hasOthers, + }; +} + +/** The marker joined to an item's text. An item whose first line is empty writes the marker without its space. */ +export function itemLine(marker: string, text: string): string { + return text === '' || text.charCodeAt(0) === 10 ? marker.trimEnd() + text : marker + text; +} + +function listEntry(list: PMNode, store: Store): CacheEntry { + const count = list.childCount; + const lines: string[] = []; + let loose = false; + for (let i = 0; i < count; i++) { + const marker = markerOf(list, i); + const item = entryOf(list.child(i), String(marker.length), store); + loose ||= item.loose; + lines.push(itemLine(marker, item.text)); + } + const gap = loose ? 2 : 1; + const starts = new Int32Array(count); + const ends = new Int32Array(count); + let at = 0; + lines.forEach((line, i) => { + starts[i] = at; + at += line.length; + ends[i] = at; + at += gap; + }); + const text = lines.join(loose ? '\n\n' : '\n'); + return { ctx: '', text, inner: text, childStarts: starts, childEnds: ends, loose: false }; +} + +function build(node: PMNode, ctx: string, store: Store): CacheEntry { + switch (node.type.name) { + case 'doc': + return blocksEntry(node, store); + case 'paragraph': + return leaf(serializeInline(node, PARAGRAPH)); + case 'heading': { + const level = Math.min(6, Math.max(1, Number(node.attrs.level) || 1)); + const text = serializeInline(node, TITLE, true); + return leaf(text === '' ? '#'.repeat(level) : `${'#'.repeat(level)} ${text}`); + } + case 'calloutTitle': + return leaf(serializeInline(node, TITLE, true)); + case 'bulletList': + case 'orderedList': + return listEntry(node, store); + case 'listItem': + return itemEntry(node, ctx, store); + case 'blockquote': + return quoteEntry(node, store); + case 'callout': + return calloutEntry(node, store); + case 'codeBlock': + return leaf(fenceText(node)); + case 'mathBlock': + return leaf(`$$\n${cleanText(node.attrs.source as string)}\n$$`); + case 'horizontalRule': + return leaf('---'); + default: + return leaf(''); + } +} + +/** A node's entry: from the cache when it holds one made in the same context, else written now. */ +export function entryOf(node: PMNode, ctx: string, store: Store): CacheEntry { + const hit = store?.byNode.get(node); + if (hit && hit.ctx === ctx) return hit; + const made = build(node, ctx, store); + store?.byNode.set(node, made); + return made; +} + +/** The canonical `markdown` string of a text block (SPEC 7.7): no blank line at the start, no newline at the end. */ +export function serializeTextBlock(doc: PMNode, cache: MarkdownCache | null = null): string { + return entryOf(doc, '', cache && stateOf(cache)).text; +} + +/** A table cell's paragraph as one line, escaped like paragraph text. */ +export function serializeCellParagraph(paragraph: PMNode, cache: MarkdownCache | null = null): string { + const store = cache && stateOf(cache); + const hit = store?.byNode.get(paragraph); + if (hit && hit.ctx === 'cell') return hit.text; + const made: CacheEntry = { ...leaf(serializeInline(paragraph, PARAGRAPH, true)), ctx: 'cell' }; + store?.byNode.set(paragraph, made); + return made.text; +} + +/** + * After a mount, in idle time: writes the document once so that every node has its entry. Nothing is sent: a block + * whose stored Markdown isn't canonical stays as it is until it is next edited (ARCHITECTURE.md section 9.5). + */ +export function warmCache(doc: PMNode, _markdown: string, cache: MarkdownCache): void { + serializeTextBlock(doc, cache); +} diff --git a/app/src/editor/markdown/shared.test.ts b/app/src/editor/markdown/shared.test.ts new file mode 100644 index 000000000..3e03170af --- /dev/null +++ b/app/src/editor/markdown/shared.test.ts @@ -0,0 +1,79 @@ +// The shared Markdown fixtures of docs/format/fixtures/markdown/ (SPEC 7.8). The Rust core and the Python reader +// check the same files, so the interface agrees with them byte for byte. +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { getSchema } from '@tiptap/core'; +import { textExtensions } from '../schema/schema'; +import { createMarkdownCache } from './cache'; +import { escapeParagraphText } from './escape'; +import { fromNeutral, toNeutral } from './neutral'; +import type { NeutralBlock } from './neutral'; +import { parseTextBlock } from './parse'; +import { serializeTextBlock } from './serialize'; + +const FIXTURES = fileURLToPath(new URL('../../../../docs/format/fixtures/markdown/', import.meta.url)); + +interface DocumentCase { + readonly name: string; + readonly markdown: string; + readonly document: NeutralBlock[]; + readonly alsoWrittenFrom?: NeutralBlock[][]; +} + +interface EscapeCase { + readonly text: string; + readonly atLineStart: boolean; + readonly escaped: string; +} + +function cases(...path: string[]): T[] { + return (JSON.parse(readFileSync(join(FIXTURES, ...path), 'utf8')) as { cases: T[] }).cases; +} + +const documents = cases('documents', 'cases.json'); +const escapes = cases('escape', 'cases.json'); + +describe('the shared document fixtures', () => { + it.each(documents.map((c) => [c.name, c] as const))('%s', (_name, c) => { + expect(toNeutral(parseTextBlock(c.markdown)), 'parse').toEqual(c.document); + expect(serializeTextBlock(fromNeutral(c.document)), 'serialize').toBe(c.markdown); + for (const other of c.alsoWrittenFrom ?? []) { + expect(serializeTextBlock(fromNeutral(other)), 'also written from').toBe(c.markdown); + } + }); + + it("gives the same text from an editor's own schema, with a cache", () => { + const schema = getSchema(textExtensions); + const cache = createMarkdownCache(); + for (const c of documents) expect(serializeTextBlock(fromNeutral(c.document, schema), cache)).toBe(c.markdown); + }); + + it('covers every block, inline, and fold the neutral tree has', () => { + const seen = new Set(); + const walk = (value: unknown): void => { + if (Array.isArray(value)) value.forEach(walk); + else if (value && typeof value === 'object') { + const record = value as Record; + if (typeof record.type === 'string') seen.add(record.type); + if (typeof record.fold === 'string') seen.add(`fold:${record.fold}`); + for (const key of ['hardBreak', 'image', 'math', 'link']) if (key in record) seen.add(key); + Object.values(record).forEach(walk); + } + }; + walk(documents.map((c) => c.document)); + const blocks = ['paragraph', 'heading', 'list', 'quote', 'callout', 'code', 'math', 'break']; + const inlines = ['hardBreak', 'image', 'math', 'link', 'fold:folded', 'fold:open']; + expect([...blocks, ...inlines].filter((kind) => !seen.has(kind))).toEqual([]); + }); +}); + +describe('the shared escape fixtures', () => { + it.each(escapes.map((c) => [JSON.stringify(c.text), c.atLineStart, c] as const))( + '%s, at a line start: %s', + (_text, _start, c) => { + expect(escapeParagraphText(c.text, c.atLineStart)).toBe(c.escaped); + }, + ); +}); diff --git a/app/src/editor/markdown/splice.test.ts b/app/src/editor/markdown/splice.test.ts new file mode 100644 index 000000000..2bc73df34 --- /dev/null +++ b/app/src/editor/markdown/splice.test.ts @@ -0,0 +1,56 @@ +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { propertyRuns } from './arbitrary'; +import { diffMarkdown, utf8Offset } from './splice'; + +const RUNS = propertyRuns(1000); +const encoder = new TextEncoder(); +const isLow = (code: number) => code >= 0xdc00 && code <= 0xdfff; +const isHigh = (code: number) => code >= 0xd800 && code <= 0xdbff; + +/** Strings with astral characters, accents, and the odd lone surrogate. */ +const text = fc + .array(fc.constantFrom('a', ' ', '\n', 'é', '中', '\u{1F600}', '\u{1F601}', '\u{10FFFF}', '\uD800', '\uDC00'), { + maxLength: 30, + }) + .map((parts) => parts.join('')); + +describe('UTF-8 offsets', () => { + it('equal the length TextEncoder gives for the text before the index', () => { + fc.assert( + fc.property(text, fc.nat(), (s, pick) => { + const at = pick % (s.length + 1); + fc.pre(at === 0 || at === s.length || !(isHigh(s.charCodeAt(at - 1)) && isLow(s.charCodeAt(at)))); + expect(utf8Offset(s, at)).toBe(encoder.encode(s.slice(0, at)).length); + }), + { numRuns: RUNS }, + ); + }); + + it('clamp indexes outside the text', () => { + expect(utf8Offset('éa', -1)).toBe(0); + expect(utf8Offset('éa', 99)).toBe(3); + }); +}); + +describe('the splice between two strings', () => { + it('turns the first into the second, and never splits a surrogate pair', () => { + fc.assert( + fc.property(text, text, (before, after) => { + const splice = diffMarkdown(before, after); + if (before === after) { + expect(splice).toBeNull(); + return; + } + expect(splice).not.toBeNull(); + const { at, del, ins } = splice as NonNullable; + expect(before.slice(0, at) + ins + before.slice(at + del.length)).toBe(after); + expect(before.slice(at, at + del.length)).toBe(del); + const splits = (s: string, i: number) => + i > 0 && i < s.length && isHigh(s.charCodeAt(i - 1)) && isLow(s.charCodeAt(i)); + expect(splits(before, at) || splits(before, at + del.length)).toBe(false); + }), + { numRuns: RUNS }, + ); + }); +}); diff --git a/app/src/editor/markdown/splice.ts b/app/src/editor/markdown/splice.ts new file mode 100644 index 000000000..1b4bd4d26 --- /dev/null +++ b/app/src/editor/markdown/splice.ts @@ -0,0 +1,53 @@ +// The one splice between two Markdown strings, and UTF-8 offsets for the core (ARCHITECTURE.md section 10.2; +// owner after WP0: WP1). The core's `spliceText` takes its offset in UTF-8 bytes. + +/** `at` is an index in UTF-16 units, never inside a surrogate pair. */ +export interface MarkdownSplice { + at: number; + del: string; + ins: string; +} + +const isHigh = (code: number) => code >= 0xd800 && code <= 0xdbff; +const isLow = (code: number) => code >= 0xdc00 && code <= 0xdfff; + +/** The shortest single splice that turns `before` into `after`, or null when they are equal. */ +export function diffMarkdown(before: string, after: string): MarkdownSplice | null { + if (before === after) return null; + const limit = Math.min(before.length, after.length); + let start = 0; + while (start < limit && before.charCodeAt(start) === after.charCodeAt(start)) start++; + if (start > 0 && isHigh(before.charCodeAt(start - 1))) start--; + let end = 0; + while ( + end < limit - start && + before.charCodeAt(before.length - 1 - end) === after.charCodeAt(after.length - 1 - end) + ) { + end++; + } + if (end > 0 && isLow(before.charCodeAt(before.length - end))) end--; + return { + at: start, + del: before.slice(start, before.length - end), + ins: after.slice(start, after.length - end), + }; +} + +/** + * The UTF-8 byte offset of an index in UTF-16 units in `text`, counted without encoding. A lone surrogate counts 3 + * bytes, as the U+FFFD that `TextEncoder` writes for it. + */ +export function utf8Offset(text: string, utf16Index: number): number { + const end = Math.min(Math.max(0, utf16Index), text.length); + let bytes = 0; + for (let i = 0; i < end; i++) { + const code = text.charCodeAt(i); + if (code < 0x80) bytes += 1; + else if (code < 0x800) bytes += 2; + else if (isHigh(code) && i + 1 < end && isLow(text.charCodeAt(i + 1))) { + bytes += 4; + i++; + } else bytes += 3; + } + return bytes; +} diff --git a/app/src/editor/markdown/worker.test.ts b/app/src/editor/markdown/worker.test.ts new file mode 100644 index 000000000..1cd672ef8 --- /dev/null +++ b/app/src/editor/markdown/worker.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { parseTextBlock } from './parse'; +import { parseInWorker, resetParseWorker } from './worker'; +import type { ParseRequest, ParseResponse } from './worker'; +import { respond } from './workerMain'; + +/** A stand-in for a module worker that answers with the real worker's handler, a task later. */ +class FakeWorker { + static made: FakeWorker[] = []; + static answer: (request: ParseRequest) => ParseResponse = respond; + onmessage: ((event: { data: ParseResponse }) => void) | null = null; + onerror: (() => void) | null = null; + onmessageerror: (() => void) | null = null; + readonly sent: ParseRequest[] = []; + terminated = false; + + constructor( + readonly url: URL, + readonly options: WorkerOptions, + ) { + FakeWorker.made.push(this); + } + + postMessage(request: ParseRequest): void { + this.sent.push(request); + setTimeout(() => this.onmessage?.({ data: FakeWorker.answer(request) }), 0); + } + + terminate(): void { + this.terminated = true; + } +} + +afterEach(() => { + resetParseWorker(); + vi.unstubAllGlobals(); + FakeWorker.made = []; + FakeWorker.answer = respond; +}); + +describe('parsing in the worker', () => { + it('parses on the main thread where there are no workers', async () => { + const doc = await parseInWorker('# Title\n\nbody'); + expect(doc.toJSON()).toEqual(parseTextBlock('# Title\n\nbody').toJSON()); + }); + + it('starts one module worker and gives back the document it sends', async () => { + vi.stubGlobal('Worker', FakeWorker); + const [a, b] = await Promise.all([parseInWorker('- one\n- two'), parseInWorker('> quote')]); + expect(a.toJSON()).toEqual(parseTextBlock('- one\n- two').toJSON()); + expect(b.toJSON()).toEqual(parseTextBlock('> quote').toJSON()); + expect(FakeWorker.made).toHaveLength(1); + expect(FakeWorker.made[0].options.type).toBe('module'); + expect(FakeWorker.made[0].sent.map((request) => request.markdown)).toEqual(['- one\n- two', '> quote']); + }); + + it('parses here when the worker could not', async () => { + vi.stubGlobal('Worker', FakeWorker); + FakeWorker.answer = ({ id }) => ({ id, doc: null }); + expect((await parseInWorker('*text*')).toJSON()).toEqual(parseTextBlock('*text*').toJSON()); + }); + + it('finishes what the worker owed when it fails, and stays on the main thread after', async () => { + vi.stubGlobal('Worker', FakeWorker); + FakeWorker.answer = () => ({ id: -1, doc: null }); + const owed = parseInWorker('owed'); + FakeWorker.made[0].onerror?.(); + expect((await owed).textContent).toBe('owed'); + expect(FakeWorker.made[0].terminated).toBe(true); + expect((await parseInWorker('later')).textContent).toBe('later'); + expect(FakeWorker.made).toHaveLength(1); + }); + + it('rejects with the reason when its signal aborts', async () => { + vi.stubGlobal('Worker', FakeWorker); + const controller = new AbortController(); + const parsing = parseInWorker('slow', controller.signal); + controller.abort(new Error('stale')); + await expect(parsing).rejects.toThrow('stale'); + const aborted = AbortSignal.abort(new Error('already')); + await expect(parseInWorker('x', aborted)).rejects.toThrow('already'); + }); + + it('answers every request with JSON, or null when parsing throws', () => { + expect(respond({ id: 7, markdown: 'a' })).toEqual({ id: 7, doc: parseTextBlock('a').toJSON() }); + const bad = { + id: 8, + get markdown(): string { + throw new Error('no'); + }, + }; + expect(respond(bad)).toEqual({ id: 8, doc: null }); + }); +}); diff --git a/app/src/editor/markdown/worker.ts b/app/src/editor/markdown/worker.ts new file mode 100644 index 000000000..5f99dd6e3 --- /dev/null +++ b/app/src/editor/markdown/worker.ts @@ -0,0 +1,91 @@ +// Parsing large blocks off the main thread (ARCHITECTURE.md section 9.3). The page creates one module worker on first +// need, and it parses blocks into document JSON. Where workers aren't available, as in unit tests, or the worker +// fails to start, the same API parses on the main thread instead. +import type { Node as PMNode } from '@tiptap/pm/model'; +import { textSchema } from '../schema/schema'; +import { parseTextBlock } from './parse'; + +export interface ParseRequest { + id: number; + markdown: string; +} +/** `doc` is the textSchema document as JSON, or null when the worker could not parse it. */ +export interface ParseResponse { + id: number; + doc: unknown; +} + +/** Blocks up to this many UTF-16 units parse on the main thread within the feedback budget; longer ones here. */ +export const MAIN_THREAD_PARSE_LIMIT = 64 * 1024; + +interface Pending { + readonly markdown: string; + resolve(doc: PMNode): void; +} + +const pending = new Map(); +let nextId = 1; +/** Undefined until the first request; null when there is no worker to use. */ +let worker: Worker | null | undefined; + +function settle(id: number, json: unknown): void { + const request = pending.get(id); + if (!request) return; + pending.delete(id); + try { + request.resolve(json === null ? parseTextBlock(request.markdown) : textSchema.nodeFromJSON(json)); + } catch { + request.resolve(parseTextBlock(request.markdown)); + } +} + +/** The worker failed: what it still owed is parsed here, and later requests stay on the main thread. */ +function fail(): void { + worker?.terminate(); + worker = null; + for (const id of [...pending.keys()]) settle(id, null); +} + +function start(): Worker | null { + if (worker !== undefined) return worker; + if (typeof Worker === 'undefined') return (worker = null); + try { + const made = new Worker(new URL('./workerMain.ts', import.meta.url), { type: 'module', name: 'markdown' }); + made.onmessage = (event: MessageEvent) => settle(event.data.id, event.data.doc); + made.onerror = fail; + made.onmessageerror = fail; + worker = made; + } catch { + worker = null; + } + return worker; +} + +/** A text block's document, parsed in the worker. Rejects with the signal's reason when it aborts first. */ +export function parseInWorker(markdown: string, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(signal.reason); + const target = start(); + if (!target) return Promise.resolve(parseTextBlock(markdown)); + const id = nextId++; + return new Promise((resolve, reject) => { + const abort = () => { + pending.delete(id); + reject(signal?.reason); + }; + signal?.addEventListener('abort', abort, { once: true }); + const done = (doc: PMNode) => { + signal?.removeEventListener('abort', abort); + resolve(doc); + }; + pending.set(id, { markdown, resolve: done }); + const request: ParseRequest = { id, markdown }; + target.postMessage(request); + }); +} + +/** For tests: forgets the worker, so the next request starts a new one. */ +export function resetParseWorker(): void { + worker?.terminate(); + worker = undefined; + pending.clear(); +} diff --git a/app/src/editor/markdown/workerMain.ts b/app/src/editor/markdown/workerMain.ts new file mode 100644 index 000000000..41b1a0ddc --- /dev/null +++ b/app/src/editor/markdown/workerMain.ts @@ -0,0 +1,22 @@ +// The Markdown worker's entry (worker.ts creates it). It answers each request with the document as JSON, or null when +// parsing failed, and the page then parses that block itself. +import { parseTextBlock } from './parse'; +import type { ParseRequest, ParseResponse } from './worker'; + +export function respond(request: ParseRequest): ParseResponse { + try { + return { id: request.id, doc: parseTextBlock(request.markdown).toJSON() }; + } catch { + return { id: request.id, doc: null }; + } +} + +interface WorkerScope { + onmessage: ((event: MessageEvent) => void) | null; + postMessage(message: ParseResponse): void; +} + +if ('WorkerGlobalScope' in globalThis) { + const scope = globalThis as unknown as WorkerScope; + scope.onmessage = (event) => scope.postMessage(respond(event.data)); +} diff --git a/app/src/editor/meta.ts b/app/src/editor/meta.ts new file mode 100644 index 000000000..1dd7bea49 --- /dev/null +++ b/app/src/editor/meta.ts @@ -0,0 +1,22 @@ +// Transaction meta keys that every Phase 4 package agrees on (PLAN.md section 3.6, owned by WP0). The text sync +// reads them to decide what a transaction is: typing, a command, an automatic change, or a change from the core. + +/** A change that came from the core (undo, redo, or another window). The sync never sends it back. */ +export const META_REMOTE = 'opennote.remote'; +/** A command's transaction. The sync flushes pending typing first and sends it as its own step. */ +export const META_COMMAND = 'opennote.command'; +/** An automatic change, such as AutoCorrect or an input rule, with `{ from, to, text }`: its own undo step. */ +export const META_AUTO_CHANGE = 'opennote.autoChange'; +/** A decoration-only transaction, such as spelling or code highlighting. The sync ignores it. */ +export const META_HIGHLIGHT = 'opennote.highlight'; + +/** + * The value of META_AUTO_CHANGE: the range of the typed text that the change replaced, and the text it put there + * (empty when it only removed a marker). The typed character is in the document before this transaction, from a + * transaction of its own, so the document before the change is the text as typed. + */ +export interface AutoChangeMeta { + from: number; + to: number; + text: string; +} diff --git a/app/src/editor/schema/constants.ts b/app/src/editor/schema/constants.ts new file mode 100644 index 000000000..3c03c3c70 --- /dev/null +++ b/app/src/editor/schema/constants.ts @@ -0,0 +1,113 @@ +// Plain data that the editor schema, the Markdown layer, and the paste pipeline share (SPEC 6.6, 7.2 to 7.5). + +/** SPEC 7.2: the callout types. A reader keeps an unknown type and shows it as "note". */ +export const CALLOUT_TYPES = [ + 'note', + 'tip', + 'important', + 'warning', + 'caution', + 'info', + 'question', + 'success', + 'danger', + 'example', + 'quote', +] as const; + +/** SPEC 7.4: the highlighter colors besides Honey, which is the default and has no name. */ +export const HIGHLIGHT_COLORS = ['mint', 'rose', 'apricot', 'lilac'] as const; +export const TEXT_SIZES = ['small', 'large', 'xlarge'] as const; +/** SPEC 7.2: a callout is folded with "-", open with "+", or neither. */ +export const FOLDS = ['', '-', '+'] as const; + +/** SPEC 7.2: the info string of a code fence. */ +export const LANGUAGE_PATTERN = /^[A-Za-z0-9_+#.-]{1,32}$/; +/** SPEC 7.4: a text color is a pen name or `#rrggbb`. */ +export const TEXT_COLOR_PATTERN = /^(#[0-9A-Fa-f]{6}|[a-z][a-z0-9-]{0,31})$/; +/** A callout type as typed after `[!`. */ +export const CALLOUT_TYPE_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/; + +/** SPEC 7.7: the marks, outermost first. */ +export const MARK_ORDER = [ + 'link', + 'bold', + 'italic', + 'strike', + 'underline', + 'highlight', + 'textColor', + 'textSize', + 'subscript', + 'superscript', + 'code', +] as const; +export type MarkName = (typeof MARK_ORDER)[number]; + +/** SPEC 6.6: the nodes that count as text elements. A list item's first paragraph belongs to the item. */ +export const ELEMENT_TYPES: ReadonlySet = new Set([ + 'paragraph', + 'heading', + 'listItem', + 'codeBlock', + 'mathBlock', + 'horizontalRule', +]); + +export type LinkKind = 'web' | 'mail' | 'opennote' | 'asset' | 'inert'; + +const SCHEME = /^([A-Za-z][A-Za-z0-9+.-]*):/; +const BLOCKED_SCHEMES = new Set(['javascript', 'vbscript', 'data']); + +/** Whether a character code is a C0 control or a space, which a browser trims from both ends of a URL. */ +const trimmed = (code: number) => code <= 0x20; + +/** A destination as a browser reads it (URL standard): controls and spaces at either end and every tab and line + * end are removed first. */ +function asBrowserReads(href: string): string { + let start = 0; + let end = href.length; + while (start < end && trimmed(href.charCodeAt(start))) start++; + while (end > start && trimmed(href.charCodeAt(end - 1))) end--; + return href.slice(start, end).replace(/[\t\n\r]/g, ''); +} + +/** Whether a destination still holds a control character once a browser has read it. */ +function hasControl(href: string): boolean { + const read = asBrowserReads(href); + for (let i = 0; i < read.length; i++) { + const code = read.charCodeAt(i); + if (code < 0x20 || code === 0x7f) return true; + } + return false; +} + +/** The scheme of a link destination in lower case, or null for a relative one. */ +export function schemeOf(href: string): string | null { + const match = SCHEME.exec(asBrowserReads(href)); + return match ? match[1].toLowerCase() : null; +} + +/** SPEC 7.5: how the interface treats a destination. "inert" links are kept and never opened. */ +export function linkKind(href: string): LinkKind { + switch (schemeOf(href)) { + case 'http': + case 'https': + return 'web'; + case 'mailto': + return 'mail'; + case 'opennote': + return 'opennote'; + case 'asset': + return 'asset'; + default: + return 'inert'; + } +} + +/** Destinations that never become links, even inert ones (Phase 4 design, 15.9), and any with a control character + * a browser would keep. */ +export function isBlockedHref(href: string): boolean { + const scheme = schemeOf(href); + return (scheme !== null && BLOCKED_SCHEMES.has(scheme)) || hasControl(href); +} diff --git a/app/src/editor/schema/dom.test.ts b/app/src/editor/schema/dom.test.ts new file mode 100644 index 000000000..7f4c0e89f --- /dev/null +++ b/app/src/editor/schema/dom.test.ts @@ -0,0 +1,35 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { parseTextBlock } from '../markdown'; +import { renderStatic, renderStaticSliced } from './dom'; +import { markSpecs, nodeSpecs, tableNodeSpecs } from './specs'; + +describe('static DOM', () => { + it('renders a document through the schema, never as HTML text', () => { + const into = document.createElement('div'); + renderStatic(parseTextBlock('# Hi\n\nSome *this* and '), into); + expect(into.querySelector('h1')?.textContent).toBe('Hi'); + expect(into.querySelector('script')).toBeNull(); + expect(into.querySelector('em')?.textContent).toBe('this'); + }); + + it('renders in slices and stops when aborted', async () => { + const items = ['one', 'two', 'three'].map((text) => ({ + doc: parseTextBlock(text), + into: document.createElement('div'), + })); + await renderStaticSliced(items, 0, new AbortController().signal); + expect(items.map(({ into }) => into.textContent)).toEqual(['one', 'two', 'three']); + const stop = new AbortController(); + stop.abort(); + await expect(renderStaticSliced(items, 0, stop.signal)).rejects.toBeDefined(); + }); +}); + +describe('specs', () => { + it('lists every node and mark of both schemas', () => { + expect(Object.keys(nodeSpecs)).toContain('callout'); + expect(Object.keys(tableNodeSpecs)).toEqual(expect.arrayContaining(['table', 'tableCell', 'paragraph'])); + expect(Object.keys(markSpecs)[0]).toBe('link'); + }); +}); diff --git a/app/src/editor/schema/dom.ts b/app/src/editor/schema/dom.ts new file mode 100644 index 000000000..9ff9c1fa9 --- /dev/null +++ b/app/src/editor/schema/dom.ts @@ -0,0 +1,37 @@ +// Static DOM from a document (PLAN.md section 3.3; ARCHITECTURE.md section 7.1). Text renders through the schema's +// DOMSerializer, never through innerHTML, so note content can't become script. Long pages render in slices, so +// no task runs past its budget and the viewport's blocks come first. +import { DOMSerializer } from '@tiptap/pm/model'; +import type { Node as PMNode } from '@tiptap/pm/model'; + +/** Replaces the children of `into` with the document's static DOM. */ +export function renderStatic(doc: PMNode, into: HTMLElement): void { + const serializer = DOMSerializer.fromSchema(doc.type.schema); + const fragment = serializer.serializeFragment(doc.content, { document: into.ownerDocument }); + into.replaceChildren(fragment); +} + +function nextTask(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** + * Renders each item in order, yielding to the browser whenever a slice has run for `budgetMs`. Rejects with the + * signal's reason when it aborts; items rendered before then stay. + */ +export async function renderStaticSliced( + items: readonly { doc: PMNode; into: HTMLElement }[], + budgetMs: number, + signal: AbortSignal, +): Promise { + let sliceStart = performance.now(); + for (const { doc, into } of items) { + signal.throwIfAborted(); + if (performance.now() - sliceStart >= budgetMs) { + await nextTask(); + signal.throwIfAborted(); + sliceStart = performance.now(); + } + renderStatic(doc, into); + } +} diff --git a/app/src/editor/schema/marks.ts b/app/src/editor/schema/marks.ts new file mode 100644 index 000000000..7c45edb63 --- /dev/null +++ b/app/src/editor/schema/marks.ts @@ -0,0 +1,158 @@ +// The marks of a text block, in SPEC 7.7's nesting order (Phase 4 design, 8.1). A mark's rank in the schema is its +// position in that order, which the priorities below fix. Parse rules read tags and data attributes only, so a +// pasted `style` never reaches the page; the paste normalizers turn styles into these tags first. +import { Mark } from '@tiptap/core'; +import type { TagParseRule } from '@tiptap/pm/model'; +import { HIGHLIGHT_COLORS, TEXT_COLOR_PATTERN, TEXT_SIZES, isBlockedHref, schemeOf } from './constants'; + +/** Mark priorities: higher comes first in the schema, so it is outermost in the canonical order. */ +const RANK = { + link: 1100, + bold: 1090, + italic: 1080, + strike: 1070, + underline: 1060, + highlight: 1050, + textColor: 1040, + textSize: 1030, + subscript: 1020, + superscript: 1010, + code: 1000, +} as const; + +function isOneOf(list: readonly T[], value: string | null): value is T { + return value !== null && (list as readonly string[]).includes(value); +} + +/** `` is how Google Docs wraps a whole paste, not bold text. */ +function notNormalWeight(element: HTMLElement): false | null { + return /^(normal|400)$/.test(element.style.fontWeight) ? false : null; +} + +export const Link = Mark.create({ + name: 'link', + priority: RANK.link, + inclusive: false, + addAttributes: () => ({ + href: { default: '', rendered: false, parseHTML: (element: HTMLElement) => element.getAttribute('href') ?? '' }, + }), + parseHTML: () => [ + { + tag: 'a[href]', + getAttrs: (element: HTMLElement) => { + const href = element.getAttribute('href') ?? ''; + return href !== '' && schemeOf(href) !== null && !isBlockedHref(href) ? null : false; + }, + } satisfies TagParseRule, + ], + renderHTML: ({ mark }) => ['a', { href: mark.attrs.href as string }, 0], +}); + +export const Bold = Mark.create({ + name: 'bold', + priority: RANK.bold, + parseHTML: () => [{ tag: 'strong' }, { tag: 'b', getAttrs: notNormalWeight }], + renderHTML: () => ['strong', 0], +}); + +export const Italic = Mark.create({ + name: 'italic', + priority: RANK.italic, + parseHTML: () => [{ tag: 'em' }, { tag: 'i' }], + renderHTML: () => ['em', 0], +}); + +export const Strike = Mark.create({ + name: 'strike', + priority: RANK.strike, + parseHTML: () => [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }], + renderHTML: () => ['del', 0], +}); + +export const Underline = Mark.create({ + name: 'underline', + priority: RANK.underline, + parseHTML: () => [{ tag: 'u' }], + renderHTML: () => ['u', 0], +}); + +/** Honey is the default color and has no name, so `color` is null for it. */ +export const Highlight = Mark.create({ + name: 'highlight', + priority: RANK.highlight, + addAttributes: () => ({ + color: { + default: null, + rendered: false, + parseHTML: (element: HTMLElement) => { + const color = element.getAttribute('data-color'); + return isOneOf(HIGHLIGHT_COLORS, color) ? color : null; + }, + }, + }), + parseHTML: () => [{ tag: 'mark' }], + renderHTML: ({ mark }) => { + const color = mark.attrs.color as string | null; + return ['mark', color ? { 'data-color': color } : {}, 0]; + }, +}); + +function textColorOf(element: HTMLElement): string | null { + const color = element.getAttribute('data-color') ?? ''; + return TEXT_COLOR_PATTERN.test(color) ? color : null; +} + +function textSizeOf(element: HTMLElement): string | null { + const size = element.getAttribute('data-size'); + return isOneOf(TEXT_SIZES, size) ? size : null; +} + +export const TextColor = Mark.create({ + name: 'textColor', + priority: RANK.textColor, + addAttributes: () => ({ color: { default: '', rendered: false, parseHTML: textColorOf } }), + parseHTML: () => [ + { + tag: 'span[data-color]', + getAttrs: (element: HTMLElement) => (textColorOf(element) === null ? false : null), + } satisfies TagParseRule, + ], + renderHTML: ({ mark }) => ['span', { 'data-color': mark.attrs.color as string }, 0], +}); + +export const TextSize = Mark.create({ + name: 'textSize', + priority: RANK.textSize, + addAttributes: () => ({ size: { default: 'large', rendered: false, parseHTML: textSizeOf } }), + parseHTML: () => [ + { + tag: 'span[data-size]', + getAttrs: (element: HTMLElement) => (textSizeOf(element) === null ? false : null), + } satisfies TagParseRule, + ], + renderHTML: ({ mark }) => ['span', { 'data-size': mark.attrs.size as string }, 0], +}); + +export const Subscript = Mark.create({ + name: 'subscript', + priority: RANK.subscript, + excludes: 'subscript superscript', + parseHTML: () => [{ tag: 'sub' }], + renderHTML: () => ['sub', 0], +}); + +export const Superscript = Mark.create({ + name: 'superscript', + priority: RANK.superscript, + excludes: 'subscript superscript', + parseHTML: () => [{ tag: 'sup' }], + renderHTML: () => ['sup', 0], +}); + +/** Other marks can wrap code, so this mark excludes only itself. */ +export const Code = Mark.create({ + name: 'code', + priority: RANK.code, + parseHTML: () => [{ tag: 'code' }], + renderHTML: () => ['code', 0], +}); diff --git a/app/src/editor/schema/nodes.ts b/app/src/editor/schema/nodes.ts new file mode 100644 index 000000000..050993e71 --- /dev/null +++ b/app/src/editor/schema/nodes.ts @@ -0,0 +1,233 @@ +// The nodes of a text block (Phase 4 design, 8.1). Every node here can be written in OpenNote Markdown 1, so every +// canonical string round-trips. Parse rules are the paste allowlist: only what a rule reads survives a paste. Every +// attribute has its own parseHTML: without one, Tiptap reads the pasted element's attribute of the same name and lets +// it override what the rule set. +import { Node } from '@tiptap/core'; +import type { TagParseRule } from '@tiptap/pm/model'; +import { CALLOUT_TYPE_PATTERN, FOLDS, LANGUAGE_PATTERN } from './constants'; + +const META_OFF = { rendered: false, parseHTML: () => null }; + +/** Element metadata (SPEC 6.6). It lives in the block's `ids`, `tags`, `styles`, and `checked`, not in the Markdown. */ +function elementAttributes() { + const noTags: readonly string[] = Object.freeze([]); + return { + id: { default: null, ...META_OFF }, + tags: { default: noTags, ...META_OFF }, + style: { default: null, ...META_OFF }, + tagChecked: { default: false, ...META_OFF }, + }; +} + +/** A heading's tag, from a level clamped to 1 to 6 so no value can name another element. */ +export function headingTag(level: unknown): string { + return `h${Math.min(6, Math.max(1, Math.trunc(Number(level)) || 1))}`; +} + +export const Doc = Node.create({ name: 'doc', topNode: true, content: 'block+' }); +export const TextNode = Node.create({ name: 'text', group: 'inline' }); + +export const Paragraph = Node.create({ + name: 'paragraph', + priority: 1000, + group: 'block', + content: 'inline*', + addAttributes: elementAttributes, + parseHTML: () => [{ tag: 'p' }], + renderHTML: () => ['p', 0], +}); + +export const Heading = Node.create({ + name: 'heading', + group: 'block', + content: 'inline*', + defining: true, + // The level comes from the tag the rule matched, never from a `level` attribute on the pasted element. + addAttributes: () => ({ level: { default: 1, rendered: false, parseHTML: () => null }, ...elementAttributes() }), + parseHTML: () => [1, 2, 3, 4, 5, 6].map((level): TagParseRule => ({ tag: `h${level}`, attrs: { level } })), + renderHTML: ({ node }) => [headingTag(node.attrs.level), 0], +}); + +export const HardBreak = Node.create({ + name: 'hardBreak', + group: 'inline', + inline: true, + selectable: false, + parseHTML: () => [{ tag: 'br' }], + renderHTML: () => ['br'], + renderText: () => '\n', +}); + +export const HorizontalRule = Node.create({ + name: 'horizontalRule', + group: 'block', + addAttributes: elementAttributes, + parseHTML: () => [{ tag: 'hr' }], + renderHTML: () => ['hr'], +}); + +export const Blockquote = Node.create({ + name: 'blockquote', + group: 'block', + content: 'block+', + defining: true, + parseHTML: () => [{ tag: 'blockquote' }], + renderHTML: () => ['blockquote', 0], +}); + +function taskState(element: HTMLElement): boolean | null { + const flag = element.getAttribute('data-checked'); + if (flag !== null) return flag === 'true'; + const box = element.firstElementChild; + if (box instanceof HTMLInputElement && box.type === 'checkbox') return box.checked; + return null; +} + +export const ListItem = Node.create({ + name: 'listItem', + content: 'paragraph block*', + defining: true, + addAttributes: () => ({ + checked: { + default: null, + rendered: false, + parseHTML: (element: HTMLElement) => taskState(element), + }, + ...elementAttributes(), + }), + parseHTML: () => [{ tag: 'li' }], + renderHTML: ({ node }) => { + const checked = node.attrs.checked as boolean | null; + return ['li', checked === null ? {} : { 'data-checked': String(checked) }, 0]; + }, +}); + +export const BulletList = Node.create({ + name: 'bulletList', + group: 'block', + content: 'listItem+', + parseHTML: () => [{ tag: 'ul' }], + renderHTML: () => ['ul', 0], +}); + +function listStart(element: HTMLElement): number { + const start = Number.parseInt(element.getAttribute('start') ?? '1', 10); + return Number.isInteger(start) && start >= 0 && start <= 999_999_999 ? start : 1; +} + +export const OrderedList = Node.create({ + name: 'orderedList', + group: 'block', + content: 'listItem+', + addAttributes: () => ({ + start: { default: 1, rendered: false, parseHTML: listStart }, + }), + parseHTML: () => [{ tag: 'ol' }], + renderHTML: ({ node }) => { + const start = node.attrs.start as number; + return ['ol', start === 1 ? {} : { start }, 0]; + }, +}); + +export const CalloutTitle = Node.create({ + name: 'calloutTitle', + content: 'inline*', + parseHTML: () => [{ tag: 'div[data-callout-title]' }], + renderHTML: () => ['div', { 'data-callout-title': '' }, 0], +}); + +function calloutType(element: HTMLElement): string { + const type = element.getAttribute('data-callout') ?? ''; + return CALLOUT_TYPE_PATTERN.test(type) ? type.toLowerCase() : 'note'; +} + +function calloutFold(element: HTMLElement): string { + const fold = element.getAttribute('data-fold') ?? ''; + return (FOLDS as readonly string[]).includes(fold) ? fold : ''; +} + +/** A block quote whose first line is `[!type]` (SPEC 7.2). Its first child is the title line. */ +export const Callout = Node.create({ + name: 'callout', + group: 'block', + content: 'calloutTitle block*', + defining: true, + addAttributes: () => ({ + type: { default: 'note', rendered: false, parseHTML: calloutType }, + fold: { default: '', rendered: false, parseHTML: calloutFold }, + }), + parseHTML: () => [{ tag: 'div[data-callout]' }], + renderHTML: ({ node }) => { + const { type, fold } = node.attrs as { type: string; fold: string }; + return ['div', { 'data-callout': type, ...(fold ? { 'data-fold': fold } : {}), role: 'note' }, 0]; + }, +}); + +/** The language of a pasted `
`: a `language-x` class on it or on its ``, or `data-language`. */
+function codeLanguage(element: HTMLElement): string | null {
+  const holder = element.querySelector('code') ?? element;
+  const named = /(?:^|\s)(?:language|lang)-([^\s]+)/.exec(`${element.className} ${holder.className}`)?.[1];
+  const language = named ?? element.getAttribute('data-language') ?? '';
+  return LANGUAGE_PATTERN.test(language) ? language : null;
+}
+
+export const CodeBlock = Node.create({
+  name: 'codeBlock',
+  group: 'block',
+  content: 'text*',
+  marks: '',
+  code: true,
+  defining: true,
+  addAttributes: () => ({
+    language: { default: null, rendered: false, parseHTML: codeLanguage },
+    ...elementAttributes(),
+  }),
+  parseHTML: () => [{ tag: 'pre', preserveWhitespace: 'full' }],
+  renderHTML: ({ node }) => {
+    const language = node.attrs.language as string | null;
+    return ['pre', ['code', language ? { class: `language-${language}` } : {}, 0]];
+  },
+});
+
+/** Display math. Phase 10 renders it; until then the source is kept as written. */
+export const MathBlock = Node.create({
+  name: 'mathBlock',
+  group: 'block',
+  atom: true,
+  addAttributes: () => ({
+    source: { default: '', rendered: false, parseHTML: (el: HTMLElement) => el.getAttribute('data-math-block') ?? '' },
+    ...elementAttributes(),
+  }),
+  parseHTML: () => [{ tag: 'div[data-math-block]' }],
+  renderHTML: ({ node }) => ['div', { 'data-math-block': node.attrs.source as string }],
+});
+
+export const MathInline = Node.create({
+  name: 'mathInline',
+  group: 'inline',
+  inline: true,
+  atom: true,
+  addAttributes: () => ({
+    source: { default: '', rendered: false, parseHTML: (el: HTMLElement) => el.getAttribute('data-math') ?? '' },
+  }),
+  parseHTML: () => [{ tag: 'span[data-math]' }],
+  renderHTML: ({ node }) => ['span', { 'data-math': node.attrs.source as string }],
+});
+
+/** An inline image. Sources other than `asset:` are kept and shown as a chip, never loaded (SPEC 7.3). */
+export const ImageNode = Node.create({
+  name: 'image',
+  group: 'inline',
+  inline: true,
+  atom: true,
+  draggable: true,
+  addAttributes: () => ({
+    src: { default: '', rendered: false, parseHTML: (el: HTMLElement) => el.getAttribute('src') ?? '' },
+    alt: { default: '', rendered: false, parseHTML: (el: HTMLElement) => el.getAttribute('alt') ?? '' },
+  }),
+  parseHTML: () => [{ tag: 'img[src]' }],
+  renderHTML: ({ node }) => {
+    const { src, alt } = node.attrs as { src: string; alt: string };
+    return ['img', { src, alt }];
+  },
+});
diff --git a/app/src/editor/schema/schema.test.ts b/app/src/editor/schema/schema.test.ts
new file mode 100644
index 000000000..cc748b421
--- /dev/null
+++ b/app/src/editor/schema/schema.test.ts
@@ -0,0 +1,89 @@
+import { getAttributesFromExtensions, resolveExtensions } from '@tiptap/core';
+import { describe, expect, it } from 'vitest';
+import { serializeTextBlock } from '../markdown/serialize';
+import { MARK_ORDER, linkKind, isBlockedHref } from './specs';
+import { tableExtensions, textExtensions, textSchema } from './schema';
+
+describe('attribute parse rules', () => {
+  // Without its own parseHTML, Tiptap reads an attribute from the pasted element's attribute of the same name and
+  // lets it override what the parse rule set, so a pasted `

` chose the heading's tag name. + it.each([ + ['text', textExtensions], + ['table', tableExtensions], + ])('reads every %s attribute through its own parseHTML', (_name, extensions) => { + const unparsed = getAttributesFromExtensions(resolveExtensions(extensions)) + .filter(({ attribute }) => typeof attribute.parseHTML !== 'function') + .map(({ type, name }) => `${type}.${name}`); + expect(unparsed).toEqual([]); + }); + + it('writes a heading tag from a clamped level', () => { + const heading = textSchema.nodes.heading; + const tagOf = (level: unknown) => (heading.spec.toDOM?.(heading.create({ level })) as readonly unknown[])[0]; + expect(tagOf('ttp://www.w3.org/1999/xhtml script')).toBe('h1'); + expect(tagOf(9)).toBe('h6'); + }); + + it('never writes a color or size that breaks out of its Markdown tag', () => { + const { marks } = textSchema; + const doc = textSchema.nodes.doc.create(null, [ + textSchema.nodes.paragraph.create(null, [ + textSchema.text('a', [marks.textColor.create({ color: '">' })]), + textSchema.text('b', [marks.textSize.create({ size: '">' })]), + textSchema.text('c', [marks.highlight.create({ color: '">' })]), + ]), + ]); + expect(serializeTextBlock(doc)).not.toMatch(/"> { + it('has the marks in SPEC 7.7 nesting order', () => { + expect(Object.keys(textSchema.marks)).toEqual([...MARK_ORDER]); + }); + + it('starts a block with a paragraph', () => { + expect(textSchema.nodes.doc.createAndFill()?.firstChild?.type.name).toBe('paragraph'); + }); + + it('keeps a list item as a paragraph followed by blocks', () => { + const item = textSchema.nodes.listItem; + expect(item.createAndFill(null, textSchema.nodes.horizontalRule.create())?.firstChild?.type.name).toBe('paragraph'); + }); + + it('gives a callout a title before its body', () => { + const callout = textSchema.nodes.callout.createAndFill({ type: 'tip' }); + expect(callout?.firstChild?.type.name).toBe('calloutTitle'); + }); + + it('never lets subscript and superscript share a range', () => { + const sub = textSchema.marks.subscript.create(); + const sup = textSchema.marks.superscript.create(); + expect(sup.addToSet([sub]).map((mark) => mark.type.name)).toEqual(['superscript']); + }); +}); + +describe('link destinations', () => { + it('sorts schemes into live and inert kinds', () => { + expect(linkKind('https://example.com')).toBe('web'); + expect(linkKind('mailto:a@example.com')).toBe('mail'); + expect(linkKind('opennote:page/01m3sabc')).toBe('opennote'); + expect(linkKind('asset:01m3sabc')).toBe('asset'); + expect(linkKind('ftp://example.com')).toBe('inert'); + expect(linkKind('notes.md')).toBe('inert'); + }); + + it('blocks script-like schemes', () => { + expect(isBlockedHref(' JavaScript:alert(1)')).toBe(true); + expect(isBlockedHref('data:text/html,x')).toBe(true); + expect(isBlockedHref('https://example.com')).toBe(false); + }); + + it('reads the scheme as a browser does, after it drops tabs, line ends, and leading controls', () => { + expect(isBlockedHref('java\tscript:alert(1)')).toBe(true); + expect(isBlockedHref('java\r\nscript:alert(1)')).toBe(true); + expect(isBlockedHref('\x01\x1f javascript:alert(1)')).toBe(true); + expect(isBlockedHref('https://example.com/a\x00b')).toBe(true); + expect(linkKind('\thttps://example.com')).toBe('web'); + }); +}); diff --git a/app/src/editor/schema/schema.ts b/app/src/editor/schema/schema.ts new file mode 100644 index 000000000..3e827f361 --- /dev/null +++ b/app/src/editor/schema/schema.ts @@ -0,0 +1,121 @@ +// The text and table schemas, built once from the Tiptap extensions (PLAN.md section 3.3, owned by WP0). The +// editor kit uses the same extension list, so a document parsed here is a document an editor can mount. +import { getSchema, Node } from '@tiptap/core'; +import type { Extensions } from '@tiptap/core'; +import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'; +import type { Schema } from '@tiptap/pm/model'; +import type { MarkName } from './constants'; +import * as marks from './marks'; +import * as nodes from './nodes'; + +export type TextNodeName = + | 'doc' + | 'paragraph' + | 'heading' + | 'bulletList' + | 'orderedList' + | 'listItem' + | 'blockquote' + | 'callout' + | 'calloutTitle' + | 'codeBlock' + | 'horizontalRule' + | 'mathBlock' + | 'text' + | 'hardBreak' + | 'image' + | 'mathInline'; +export type TableNodeName = + | 'doc' + | 'table' + | 'tableRow' + | 'tableHeader' + | 'tableCell' + | 'paragraph' + | 'text' + | 'hardBreak' + | 'image' + | 'mathInline'; + +/** SPEC 7.7's marks, in nesting order, outermost first. */ +export const markExtensions: Extensions = [ + marks.Link, + marks.Bold, + marks.Italic, + marks.Strike, + marks.Underline, + marks.Highlight, + marks.TextColor, + marks.TextSize, + marks.Subscript, + marks.Superscript, + marks.Code, +]; + +/** Nodes first, with the paragraph ahead of the other blocks so it is the default block. */ +export const textExtensions: Extensions = [ + nodes.Doc, + nodes.TextNode, + nodes.Paragraph, + nodes.Heading, + nodes.HardBreak, + nodes.HorizontalRule, + nodes.Blockquote, + nodes.ListItem, + nodes.BulletList, + nodes.OrderedList, + nodes.CalloutTitle, + nodes.Callout, + nodes.CodeBlock, + nodes.MathBlock, + nodes.MathInline, + nodes.ImageNode, + ...markExtensions, +]; + +/** A table editor's document is one table. */ +export const TableDoc = Node.create({ name: 'doc', topNode: true, content: 'table' }); +/** A pasted cell's span as a whole number from 1 to 1000, so no other value reaches the table's layout. */ +function spanOf(name: string) { + return (element: HTMLElement): number => { + const span = Number(element.getAttribute(name) ?? '1'); + return Number.isInteger(span) && span >= 1 && span <= 1000 ? span : 1; + }; +} + +/** Tiptap's cells read their spans without a parse rule of their own, which lets any value through. */ +const SPANS = { + colspan: { default: 1, parseHTML: spanOf('colspan') }, + rowspan: { default: 1, parseHTML: spanOf('rowspan') }, +}; + +/** A cell holds exactly one paragraph, because a table cell is one line of Markdown (SPEC 6.3). */ +export const OneParagraphCell = TableCell.extend({ + content: 'paragraph', + addAttributes() { + return { ...this.parent?.(), ...SPANS }; + }, +}); +export const OneParagraphHeader = TableHeader.extend({ + content: 'paragraph', + addAttributes() { + return { ...this.parent?.(), ...SPANS }; + }, +}); + +export const tableExtensions: Extensions = [ + TableDoc, + Table.configure({ resizable: false }), + TableRow, + OneParagraphHeader, + OneParagraphCell, + nodes.Paragraph, + nodes.TextNode, + nodes.HardBreak, + nodes.MathInline, + nodes.ImageNode, + ...markExtensions, +]; + +export const textSchema = getSchema(textExtensions) as Schema; +export const tableSchema = getSchema(tableExtensions) as Schema; diff --git a/app/src/editor/schema/specs.ts b/app/src/editor/schema/specs.ts new file mode 100644 index 000000000..61abaa131 --- /dev/null +++ b/app/src/editor/schema/specs.ts @@ -0,0 +1,39 @@ +// The schema as data (PLAN.md section 3.3, owned by WP0). It holds the shared constants, a spec for each node +// and mark of both schemas, and the data a table block stores. The specs come from the Tiptap extensions in +// nodes.ts and marks.ts, so the static schema and the editor kit can't drift apart. +import type { MarkSpec, NodeSpec, Schema } from '@tiptap/pm/model'; +import type { MarkName } from './constants'; +import { HIGHLIGHT_COLORS, MARK_ORDER, TEXT_SIZES } from './constants'; +import { tableSchema, textSchema } from './schema'; +import type { TableNodeName, TextNodeName } from './schema'; + +export * from './constants'; +export type { TableNodeName, TextNodeName } from './schema'; + +/** A highlight's color. Null in the attribute means Honey, the default. */ +export type HighlightColor = (typeof HIGHLIGHT_COLORS)[number]; +export type TextSizeName = (typeof TEXT_SIZES)[number]; + +/** A table block's data (SPEC 6.3). */ +export interface TableData { + header: boolean; + columns: { id: string; width: number }[]; + rows: { id: string; cells: Record }[]; +} + +/** A new table column's width, in page units (SPEC 2.6). */ +export const DEFAULT_COLUMN_WIDTH = 160; + +function nodeSpecsOf(schema: Schema): Readonly> { + const specs = {} as Record; + schema.spec.nodes.forEach((name, spec) => { + specs[name as N] = spec; + }); + return Object.freeze(specs); +} + +export const nodeSpecs: Readonly> = nodeSpecsOf(textSchema); +export const tableNodeSpecs: Readonly> = nodeSpecsOf(tableSchema); +export const markSpecs: Readonly> = Object.freeze( + Object.fromEntries(MARK_ORDER.map((name) => [name, textSchema.marks[name].spec])) as Record, +); diff --git a/app/src/editor/shape.ts b/app/src/editor/shape.ts new file mode 100644 index 000000000..1106b5874 --- /dev/null +++ b/app/src/editor/shape.ts @@ -0,0 +1,23 @@ +// A compact outline of a document, for tests: nodes by name with their attributes, text in quotes, and marks as +// wrappers. `paragraph("a", bold("b"))` is a paragraph with a plain word and a bold one. +import type { Node as PMNode } from '@tiptap/pm/model'; + +const SHOWN = ['level', 'checked', 'start', 'type', 'fold', 'language', 'src', 'alt', 'source']; + +function markName(mark: PMNode['marks'][number]): string { + const detail = mark.attrs.color ?? mark.attrs.href ?? mark.attrs.size ?? ''; + return `${mark.type.name}${String(detail)}`; +} + +export function shape(node: PMNode): string { + if (node.isText) { + return node.marks.map(markName).reduceRight((inner, name) => `${name}(${inner})`, JSON.stringify(node.text)); + } + const parts: string[] = []; + node.forEach((child) => parts.push(shape(child))); + const attrs = SHOWN.filter( + (key) => node.attrs[key] !== undefined && node.attrs[key] !== null && node.attrs[key] !== '', + ).map((key) => `${key}=${String(node.attrs[key])}`); + const label = node.type.name + (attrs.length > 0 ? `[${attrs.join(',')}]` : ''); + return parts.length > 0 || !node.isLeaf ? `${label}(${parts.join(', ')})` : label; +} diff --git a/app/src/editor/static.ts b/app/src/editor/static.ts new file mode 100644 index 000000000..399067f16 --- /dev/null +++ b/app/src/editor/static.ts @@ -0,0 +1,3 @@ +// Static DOM for text that has no editor yet (ARCHITECTURE.md section 7.1): the page renders every block this way +// at open, and the editor pool mounts editors in place of it. +export { renderStatic, renderStaticSliced } from './schema/dom'; diff --git a/app/src/editor/table/mapping.test.ts b/app/src/editor/table/mapping.test.ts new file mode 100644 index 000000000..a2976193d --- /dev/null +++ b/app/src/editor/table/mapping.test.ts @@ -0,0 +1,218 @@ +// The table mapping (PLAN.md section 10.5): data and documents round trip, the ID normalizer makes any data safe, +// and after random table commands every row still has the first row's columns, in order. +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { applyTableOp } from '../commands/tables'; +import type { TableOp } from '../commands/tables'; +import { createMarkdownCache } from '../markdown'; +import type { TableData } from '../schema/specs'; +import { + MAX_COLUMN_WIDTH, + MIN_COLUMN_WIDTH, + docToTableData, + headerNames, + newTableData, + normalizeTableData, + sameTableData, + tableDataToDoc, + tableOf, +} from './mapping'; + +const cache = createMarkdownCache(); + +const WORDS = ['alpha', 'Beta', 'x', '42', 'Stage', 'Where it happens', '**bold**', '*it*', '`code`', '']; +const word = fc.constantFrom(...WORDS); +const id = fc.stringMatching(/^[0-9a-z]{6}$/); + +/** Normal table data with canonical cell Markdown, so it round trips exactly. */ +const tableData: fc.Arbitrary = fc + .record({ + header: fc.boolean(), + columns: fc.uniqueArray(fc.record({ id, width: fc.integer({ min: MIN_COLUMN_WIDTH, max: MAX_COLUMN_WIDTH }) }), { + minLength: 1, + maxLength: 6, + selector: (column) => column.id, + }), + rowIds: fc.uniqueArray( + id.map((text) => `r${text}`), + { minLength: 1, maxLength: 8 }, + ), + words: fc.array(word, { minLength: 48, maxLength: 48 }), + }) + .map(({ header, columns, rowIds, words }) => ({ + header, + columns, + rows: rowIds.map((rowId, r) => ({ + id: rowId, + cells: Object.fromEntries(columns.map((column, c) => [column.id, { markdown: words[(r * 6 + c) % 48] }])), + })), + })); + +const op: fc.Arbitrary = fc.oneof( + fc.constantFrom( + { op: 'rowAbove' }, + { op: 'rowBelow' }, + { op: 'columnLeft' }, + { op: 'columnRight' }, + { op: 'deleteRow' }, + { op: 'deleteColumn' }, + { op: 'headerRow' }, + { op: 'moveRowUp' }, + { op: 'moveRowDown' }, + ), + fc.integer({ min: 0, max: 3000 }).map((width): TableOp => ({ op: 'columnWidth', width })), +); + +const ids = (data: TableData) => [...data.columns.map((c) => c.id), ...data.rows.map((r) => r.id)]; + +describe('the table mapping', () => { + it('round trips data through the document', () => { + fc.assert( + fc.property(tableData, (data) => { + const back = docToTableData(tableDataToDoc(data), data, cache); + expect(back).toEqual(data); + }), + ); + }); + + it('builds one row per data row, each with a cell per column, and a header row only when asked', () => { + fc.assert( + fc.property(tableData, (data) => { + const table = tableOf(tableDataToDoc(data))!; + expect(table.childCount).toBe(data.rows.length); + table.forEach((row, _offset, r) => { + expect(row.childCount).toBe(data.columns.length); + row.forEach((cell, _at, c) => { + expect(cell.type.name).toBe(data.header && r === 0 ? 'tableHeader' : 'tableCell'); + expect(cell.attrs.colwidth).toEqual([data.columns[c].width]); + }); + }); + }), + ); + }); + + it('settles any cell text after one trip', () => { + fc.assert( + fc.property(tableData, fc.array(fc.string(), { minLength: 1, maxLength: 6 }), (data, texts) => { + const messy = { + ...data, + rows: data.rows.map((row) => ({ + ...row, + cells: Object.fromEntries( + data.columns.map((column, c) => [column.id, { markdown: texts[c % texts.length] }]), + ), + })), + }; + const once = docToTableData(tableDataToDoc(messy), messy, cache); + expect(docToTableData(tableDataToDoc(once), once, cache)).toEqual(once); + }), + ); + }); + + it('gives new rows and columns in the document fresh IDs and keeps the rest by position', () => { + const data = newTableData(2, 2, true); + const { nodes } = tableDataToDoc(data).type.schema; + const doc = tableDataToDoc(data); + const table = doc.firstChild!; + const extraCell = () => nodes.tableCell.create(null, nodes.paragraph.create()); + const wider = table.content.replaceChild(1, table.child(1).copy(table.child(1).content.addToEnd(extraCell()))); + const grown = doc.copy( + doc.content.replaceChild(0, table.copy(wider.addToEnd(nodes.tableRow.create(null, [extraCell()])))), + ); + const next = docToTableData(grown, data, cache); + expect(next.columns.slice(0, 2)).toEqual(data.columns); + expect(next.rows.slice(0, 2).map((row) => row.id)).toEqual(data.rows.map((row) => row.id)); + expect(next.columns).toHaveLength(3); + expect(next.rows).toHaveLength(3); + expect(new Set(ids(next)).size).toBe(ids(next).length); + expect(next.rows[2].cells[next.columns[1].id]).toEqual({ markdown: '' }); + }); + + it('reads a header row from the first row’s cells', () => { + const data = { ...newTableData(2, 2, true) }; + data.rows[0].cells[data.columns[0].id].markdown = 'Stage'; + data.rows[0].cells[data.columns[1].id].markdown = 'Where it happens'; + expect(headerNames(tableDataToDoc(data))).toEqual(['Stage', 'Where it happens']); + expect(headerNames(tableDataToDoc({ ...data, header: false }))).toEqual([]); + }); +}); + +describe('the ID normalizer', () => { + it('makes anything into a table with unique IDs and a cell for every column', () => { + fc.assert( + fc.property(fc.anything(), (input) => { + const data = normalizeTableData(input); + expect(data.columns.length).toBeGreaterThan(0); + expect(data.rows.length).toBeGreaterThan(0); + expect(new Set(ids(data)).size).toBe(ids(data).length); + for (const row of data.rows) + expect(Object.keys(row.cells).sort()).toEqual(data.columns.map((c) => c.id).sort()); + }), + ); + }); + + it('leaves normal data equal', () => { + fc.assert( + fc.property(tableData, (data) => { + expect(normalizeTableData(data)).toEqual(data); + }), + ); + }); + + it('replaces duplicate and missing IDs, drops unknown cells, and clamps widths', () => { + const data = normalizeTableData({ + header: 'yes', + columns: [{ id: 'a', width: 1 }, { id: 'a', width: 99999 }, { width: 100 }], + rows: [{ id: 'a', cells: { a: { markdown: 'x' }, z: { markdown: 'lost' } } }, { id: 'r' }, { id: 'r' }], + }); + expect(data.header).toBe(false); + expect(data.columns[0]).toEqual({ id: 'a', width: MIN_COLUMN_WIDTH }); + expect(data.columns[1].width).toBe(MAX_COLUMN_WIDTH); + expect(new Set(ids(data)).size).toBe(6); + expect(data.rows[0].cells.a).toEqual({ markdown: 'x' }); + expect(Object.values(data.rows[0].cells).some((cell) => cell.markdown === 'lost')).toBe(false); + }); +}); + +describe('the table commands on data', () => { + it('keep every row on the first row’s columns, in order, after any sequence', () => { + fc.assert( + fc.property(tableData, fc.array(fc.tuple(op, fc.nat(10), fc.nat(10)), { maxLength: 25 }), (start, steps) => { + let data = start; + for (const [step, row, column] of steps) { + const change = applyTableOp(data, { row, column }, step); + if (!change) continue; + data = change.data; + if (change.caret) { + expect(change.caret.row).toBeLessThan(data.rows.length); + expect(change.caret.column).toBeLessThan(data.columns.length); + } + } + const order = data.columns.map((column) => column.id); + // Object keys keep insertion order except integer-like ones, which real IDs never are, so compare as sets. + for (const row of data.rows) expect(Object.keys(row.cells).sort()).toEqual([...order].sort()); + expect(new Set(ids(data)).size).toBe(ids(data).length); + const table = tableOf(tableDataToDoc(data))!; + table.forEach((row) => expect(row.childCount).toBe(order.length)); + expect(sameTableData(docToTableData(tableDataToDoc(data), data, cache), data)).toBe(true); + }), + ); + }); + + it('keep a moved row’s ID and the header row on top', () => { + const data = newTableData(2, 4, true); + const moved = applyTableOp(data, { row: 2, column: 0 }, { op: 'moveRowUp' })!; + expect(moved.data.rows.map((row) => row.id)).toEqual( + [data.rows[0], data.rows[2], data.rows[1], data.rows[3]].map((row) => row.id), + ); + expect(applyTableOp(data, { row: 1, column: 0 }, { op: 'moveRowUp' })).toBeNull(); + expect(applyTableOp(data, { row: 0, column: 0 }, { op: 'moveRowDown' })).toBeNull(); + expect(applyTableOp(data, { row: 0, column: 1 }, { op: 'rowAbove' })!.caret).toEqual({ row: 1, column: 1 }); + }); + + it('never delete the last row or column', () => { + const one = newTableData(1, 1, false); + expect(applyTableOp(one, { row: 0, column: 0 }, { op: 'deleteRow' })).toBeNull(); + expect(applyTableOp(one, { row: 0, column: 0 }, { op: 'deleteColumn' })).toBeNull(); + }); +}); diff --git a/app/src/editor/table/mapping.ts b/app/src/editor/table/mapping.ts new file mode 100644 index 000000000..825150ed0 --- /dev/null +++ b/app/src/editor/table/mapping.ts @@ -0,0 +1,187 @@ +// Between a table block's data and its editor document (ARCHITECTURE.md section 13; PLAN.md section 10.3). The +// data is SPEC 6.3's: columns with IDs and widths, and rows with IDs whose cells are keyed by column ID. The +// document is one table whose first row holds header cells when the table has a header row. IDs aren't in the +// document: they follow positions, and the table commands change the data first, so a moved row keeps its ID. +import type { Node as PMNode, Schema } from '@tiptap/pm/model'; +import { newId } from '../ids'; +import { parseCell, serializeCell } from '../markdown'; +import type { MarkdownCache } from '../markdown'; +import { tableSchema } from '../schema/schema'; +import { DEFAULT_COLUMN_WIDTH } from '../schema/specs'; +import type { TableData } from '../schema/specs'; + +/** The narrowest and widest a column can be, in page units. */ +export const MIN_COLUMN_WIDTH = 40; +export const MAX_COLUMN_WIDTH = 2000; + +export type TableColumn = TableData['columns'][number]; +export type TableRow = TableData['rows'][number]; + +export function clampColumnWidth(width: number): number { + if (!Number.isFinite(width)) return DEFAULT_COLUMN_WIDTH; + return Math.round(Math.min(MAX_COLUMN_WIDTH, Math.max(MIN_COLUMN_WIDTH, width))); +} + +function emptyCells(columns: readonly TableColumn[]): TableRow['cells'] { + return Object.fromEntries(columns.map((column) => [column.id, { markdown: '' }])); +} + +export function newColumn(width: number = DEFAULT_COLUMN_WIDTH): TableColumn { + return { id: newId(), width: clampColumnWidth(width) }; +} + +export function newRow(columns: readonly TableColumn[]): TableRow { + return { id: newId(), cells: emptyCells(columns) }; +} + +/** A table with fresh IDs and empty cells. */ +export function newTableData(columns: number, rows: number, header: boolean): TableData { + const made = Array.from({ length: Math.max(1, columns) }, () => newColumn()); + return { header, columns: made, rows: Array.from({ length: Math.max(1, rows) }, () => newRow(made)) }; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** A unique ID: the given one when it is a new, non-empty string, else a fresh one. */ +function uniqueId(id: unknown, seen: Set): string { + const next = typeof id === 'string' && id !== '' && !seen.has(id) ? id : newId(); + seen.add(next); + return next; +} + +/** + * The ID normalizer: block data as the core holds it, made safe to show. IDs are unique and present, widths are + * clamped, every row has a cell for every column and none for unknown columns, and a table has at least one row + * and one column. Data that is already normal comes back equal. + */ +export function normalizeTableData(data: unknown): TableData { + const source = isRecord(data) ? data : {}; + const seen = new Set(); + const columns = (Array.isArray(source.columns) ? source.columns : []).map((column): TableColumn => { + const fields = isRecord(column) ? column : {}; + const width = typeof fields.width === 'number' ? fields.width : DEFAULT_COLUMN_WIDTH; + return { id: uniqueId(fields.id, seen), width: clampColumnWidth(width) }; + }); + if (columns.length === 0) columns.push(newColumn()); + const rows = (Array.isArray(source.rows) ? source.rows : []).map((row): TableRow => { + const fields = isRecord(row) ? row : {}; + const cells = isRecord(fields.cells) ? fields.cells : {}; + const id = uniqueId(fields.id, seen); + return { + id, + cells: Object.fromEntries( + columns.map((column) => { + const cell = cells[column.id]; + const markdown = isRecord(cell) && typeof cell.markdown === 'string' ? cell.markdown : ''; + return [column.id, { markdown }]; + }), + ), + }; + }); + if (rows.length === 0) rows.push(newRow(columns)); + return { header: source.header === true, columns, rows }; +} + +/** Whether two tables hold the same data. Cell keys may come in any order. */ +export function sameTableData(a: TableData, b: TableData): boolean { + if (a === b) return true; + if (a.header !== b.header || a.columns.length !== b.columns.length || a.rows.length !== b.rows.length) return false; + const columnsSame = a.columns.every( + (column, i) => column.id === b.columns[i].id && column.width === b.columns[i].width, + ); + return ( + columnsSame && + a.rows.every( + (row, i) => + row.id === b.rows[i].id && + a.columns.every((column) => row.cells[column.id]?.markdown === b.rows[i].cells[column.id]?.markdown), + ) + ); +} + +/** The table's document in `schema`'s types: the table schema's by default, or an editor's own schema. */ +export function tableDataToDoc(data: TableData, schema: Schema = tableSchema): PMNode { + const { nodes } = schema; + const sameSchema = schema === tableSchema; + const paragraph = (markdown: string) => { + const parsed = parseCell(markdown); + return sameSchema ? parsed : schema.nodeFromJSON(parsed.toJSON()); + }; + const rows = data.rows.map((row, r) => { + const type = data.header && r === 0 ? nodes.tableHeader : nodes.tableCell; + const cells = data.columns.map((column) => + type.create({ colwidth: [column.width] }, paragraph(row.cells[column.id]?.markdown ?? '')), + ); + return nodes.tableRow.create(null, cells); + }); + return nodes.doc.create(null, nodes.table.create(null, rows)); +} + +/** Each cell paragraph's Markdown, kept while ProseMirror keeps the node, so a flush serializes only edited cells. */ +const serialized = new WeakMap(); + +function cellMarkdown(paragraph: PMNode | null, cache: MarkdownCache): string { + if (!paragraph || paragraph.content.size === 0) return ''; + let markdown = serialized.get(paragraph); + if (markdown === undefined) { + markdown = serializeCell(paragraph, cache); + serialized.set(paragraph, markdown); + } + return markdown; +} + +/** The table node of a table document, or the node itself when it is a table. */ +export function tableOf(doc: PMNode): PMNode | null { + if (doc.type.name === 'table') return doc; + const first = doc.firstChild; + return first && first.type.name === 'table' ? first : null; +} + +/** + * The data a table document shows. Rows and columns take `previous`'s IDs by position, and new ones get fresh + * IDs. Widths come from the first row's cells. A row shorter than the widest row reads as empty cells. + */ +export function docToTableData(doc: PMNode, previous: TableData, cache: MarkdownCache): TableData { + const table = tableOf(doc); + if (!table || table.childCount === 0) return normalizeTableData(previous); + let width = 0; + table.forEach((row) => (width = Math.max(width, row.childCount))); + const firstRow = table.child(0); + const seen = new Set(); + const columns = Array.from({ length: Math.max(1, width) }, (_, c): TableColumn => { + const before = previous.columns[c]; + const cell = c < firstRow.childCount ? firstRow.child(c) : null; + const colwidth = cell?.attrs.colwidth as unknown; + const fromDoc = Array.isArray(colwidth) && typeof colwidth[0] === 'number' ? colwidth[0] : null; + return { + id: uniqueId(before?.id, seen), + width: clampColumnWidth(fromDoc ?? before?.width ?? DEFAULT_COLUMN_WIDTH), + }; + }); + let header = firstRow.childCount > 0; + firstRow.forEach((cell) => (header &&= cell.type.name === 'tableHeader')); + const rows: TableRow[] = []; + table.forEach((row, _offset, r) => { + const cells: TableRow['cells'] = {}; + columns.forEach((column, c) => { + const cell = c < row.childCount ? row.child(c) : null; + cells[column.id] = { markdown: cellMarkdown(cell?.firstChild ?? null, cache) }; + }); + rows.push({ id: uniqueId(previous.rows[r]?.id, seen), cells }); + }); + return { header, columns, rows }; +} + +/** The header row's cell text, for the table's accessible name. Empty when there is no header row. */ +export function headerNames(doc: PMNode, limit = 4): string[] { + const table = tableOf(doc); + const first = table?.firstChild; + if (!first || first.firstChild?.type.name !== 'tableHeader') return []; + const names: string[] = []; + first.forEach((cell) => { + const text = cell.textContent.trim(); + if (text && names.length < limit) names.push(text); + }); + return names; +} diff --git a/app/src/editor/table/tableKit.ts b/app/src/editor/table/tableKit.ts new file mode 100644 index 000000000..16395df8d --- /dev/null +++ b/app/src/editor/table/tableKit.ts @@ -0,0 +1,167 @@ +// Table editing in a table block's editor (ARCHITECTURE.md sections 13 and 22.4; owner: WP6). Tab and Shift+Tab +// move between cells, and Tab in the last cell adds a row. Enter and Shift+Enter break the line inside the cell. +// A normalizing plugin keeps the grid whole. Decorations give the table its name and the header cells their scope. +// The table nodes come from editor/schema, so this file adds behavior only. +import { Extension } from '@tiptap/core'; +import type { Extensions } from '@tiptap/core'; +import type { Node as PMNode, NodeType } from '@tiptap/pm/model'; +import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import { goToNextCell } from '@tiptap/pm/tables'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import { t } from '../../strings/t'; +import { cellAt, cellTextPos } from '../commands/tables'; +import type { EditorHost } from '../host'; +import { META_REMOTE } from '../meta'; +import { headerNames, tableOf } from './mapping'; + +/** The table's accessible name: "Table: Stage, Where it happens", or "Table" without a header row. */ +export function tableName(doc: PMNode): string { + const names = headerNames(doc); + return names.length > 0 ? t('tables.name', { columns: names.join(', ') }) : t('tables.nameUnnamed'); +} + +/** Whether the table is a grid: every row as wide as the widest, no merged cells, header cells only on top. */ +function isWhole(table: PMNode): boolean { + let width = -1; + let whole = true; + table.forEach((row, _offset, r) => { + if (!whole) return; + if (width === -1) width = row.childCount; + if (row.childCount !== width) whole = false; + const firstType = row.firstChild?.type.name; + row.forEach((cell) => { + if ((cell.attrs.colspan as number) !== 1 || (cell.attrs.rowspan as number) !== 1) whole = false; + if (r > 0 && cell.type.name === 'tableHeader') whole = false; + if (r === 0 && cell.type.name !== firstType) whole = false; + }); + }); + return whole && width > 0; +} + +/** The same table as a grid: merged cells split, short rows padded, header cells only in a whole first row. */ +export function wholeTable(table: PMNode): PMNode { + const { nodes } = table.type.schema; + const grid: PMNode[][] = []; + table.forEach((row) => { + const cells: PMNode[] = []; + row.forEach((cell) => { + const span = Math.max(1, (cell.attrs.colspan as number) || 1); + const widths = (cell.attrs.colwidth as number[] | null) ?? []; + cells.push(cell.type.create({ colwidth: widths[0] ? [widths[0]] : null }, cell.content)); + for (let i = 1; i < span; i++) { + cells.push(cell.type.create({ colwidth: widths[i] ? [widths[i]] : null }, nodes.paragraph.create())); + } + }); + grid.push(cells); + }); + const width = Math.max(1, ...grid.map((cells) => cells.length)); + const header = grid[0]?.length > 0 && grid[0][0].type === nodes.tableHeader; + const widths = Array.from({ length: width }, (_, c) => grid.find((cells) => cells[c])?.[c].attrs.colwidth ?? null); + const rows = grid.map((cells, r) => { + const type: NodeType = header && r === 0 ? nodes.tableHeader : nodes.tableCell; + const filled = Array.from({ length: width }, (_, c) => { + const content = cells[c]?.content.size ? cells[c].content : nodes.paragraph.create(); + return type.create({ colwidth: widths[c] }, content); + }); + return nodes.tableRow.create(null, filled); + }); + return table.type.create( + table.attrs, + rows.length > 0 ? rows : [nodes.tableRow.create(null, [nodes.tableCell.create(null, nodes.paragraph.create())])], + ); +} + +const normalizeKey = new PluginKey('opennote.tableNormalize'); + +/** Keeps the table a grid after any change, such as a paste of cells, in the same undo step. */ +function normalizePlugin(): Plugin { + return new Plugin({ + key: normalizeKey, + appendTransaction(transactions: readonly Transaction[], _old: EditorState, state: EditorState) { + if (!transactions.some((tr) => tr.docChanged)) return null; + const table = tableOf(state.doc); + if (!table || isWhole(table)) return null; + const tr = state.tr.replaceWith(0, state.doc.content.size, wholeTable(table)); + if (transactions.some((one) => one.getMeta(META_REMOTE))) tr.setMeta(META_REMOTE, true); + return tr; + }, + }); +} + +/** The table's name on the table, and scope="col" on header cells, as attributes the static view also gets. */ +function decorationsFor(doc: PMNode): DecorationSet { + const table = tableOf(doc); + if (!table) return DecorationSet.empty; + const decorations = [Decoration.node(0, table.nodeSize, { 'aria-label': tableName(doc) })]; + const first = table.firstChild; + if (first?.firstChild?.type.name === 'tableHeader') { + let pos = 2; + first.forEach((cell) => { + decorations.push(Decoration.node(pos, pos + cell.nodeSize, { scope: 'col' })); + pos += cell.nodeSize; + }); + } + return DecorationSet.create(doc, decorations); +} + +const a11yKey = new PluginKey('opennote.tableA11y'); + +function a11yPlugin(): Plugin { + return new Plugin({ + key: a11yKey, + state: { + init: (_config, state) => decorationsFor(state.doc), + // The header row is a few cells, so a change re-reads it; selection moves keep the set. + apply: (tr, set, _old, state) => (tr.docChanged ? decorationsFor(state.doc) : set), + }, + props: { decorations: (state) => a11yKey.getState(state) }, + }); +} + +function insertBreak(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + const hardBreak = state.schema.nodes.hardBreak; + if (!hardBreak || !cellAt(state)) return false; + dispatch?.(state.tr.replaceSelectionWith(hardBreak.create()).scrollIntoView()); + return true; +} + +/** Tab in the last cell adds a row below and moves into its first cell. */ +function nextCellOrRow(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + if (goToNextCell(1)(state, dispatch)) return true; + const at = cellAt(state); + const table = tableOf(state.doc); + if (!at || !table || at.row !== table.childCount - 1) return false; + const last = table.child(table.childCount - 1); + const { nodes } = state.schema; + const cells: PMNode[] = []; + last.forEach((cell) => + cells.push(nodes.tableCell.create({ colwidth: cell.attrs.colwidth as unknown }, nodes.paragraph.create())), + ); + const tr = state.tr.insert(1 + table.content.size, nodes.tableRow.create(null, cells)); + const pos = cellTextPos(tr.doc, { row: at.row + 1, column: 0 }); + if (pos !== null) tr.setSelection(TextSelection.create(tr.doc, pos)); + dispatch?.(tr.scrollIntoView()); + return true; +} + +const TableKeys = Extension.create({ + name: 'opennoteTableKeys', + // Ahead of the table extension's own Tab handling and of every keymap shared with text editors. + priority: 1000, + addKeyboardShortcuts() { + const run = (command: (state: EditorState, dispatch?: (tr: Transaction) => void) => boolean) => () => + command(this.editor.state, (tr) => this.editor.view.dispatch(tr)); + return { + Tab: run(nextCellOrRow), + 'Shift-Tab': run(goToNextCell(-1)), + Enter: run(insertBreak), + 'Shift-Enter': run(insertBreak), + }; + }, + addProseMirrorPlugins: () => [normalizePlugin(), a11yPlugin()], +}); + +export function tableKitExtensions(_host: EditorHost): Extensions { + return [TableKeys]; +} diff --git a/app/src/features/page/EmptyPageArt.tsx b/app/src/features/page/EmptyPageArt.tsx new file mode 100644 index 000000000..cd704132a --- /dev/null +++ b/app/src/features/page/EmptyPageArt.tsx @@ -0,0 +1,30 @@ +// The empty page's illustration (BRAND.md section 8): a window at dusk with a crescent moon and stars, a candle +// and a potted plant on the sill, and a vine along the frame. Hand-drawn strokes in three token colors, with plenty +// of empty paper. It is decorative, so screen readers skip it; the text beside it says what to do. +import styles from './PageView.module.css'; + +export function EmptyPageArt() { + return ( + + ); +} diff --git a/app/src/features/page/PageBody.tsx b/app/src/features/page/PageBody.tsx new file mode 100644 index 000000000..c7c1304df --- /dev/null +++ b/app/src/features/page/PageBody.tsx @@ -0,0 +1,71 @@ +// The page's blocks (owner after WP0: WP3), in the page chunk that loads after start-up: the editor, Markdown, and +// sync code stay out of the start-up bundle. They mount imperatively, so React never re-renders on a keystroke. + +import { useEffect, useRef, useState } from 'react'; +import { useFlag } from '../../app/flags'; +import { getSizeClass } from '../../state/layout'; +import { useStore } from '../../state/store'; +import { t } from '../../strings/t'; +import { mountPage } from './mount'; +import styles from './PageView.module.css'; +import { pagesClient, pageView } from './runtime'; +import type { MountedPage } from './mount'; +import paneStyles from './readingOrder/pane.module.css'; +import { ReadingOrderPane } from './readingOrder/ReadingOrderPane'; +import { returnBand } from './title/TitleSlot'; +import { readingOrderOpen } from './viewport/shown'; + +const LAYERS = { viewport: '', world: '', underlay: '' }; + +export interface PageBodyProps { + pageId: string; + /** The tree's name for the page, shown when the page itself has no title yet. */ + title: string; + changed: string | null; + /** The title band the page view shows while the page loads; the world adopts it. */ + band: HTMLElement | null; +} + +export default function PageBody({ pageId, title, changed, band }: PageBodyProps) { + const host = useRef(null); + const [failed, setFailed] = useState(false); + const [mounted, setMounted] = useState(null); + const paneWanted = useStore(readingOrderOpen, (open) => open); + const paneAllowed = useFlag('page.readingOrder'); + const paneOpen = paneWanted && paneAllowed; + useEffect(() => { + let cancelled = false; + let destroy: (() => Promise) | null = null; + pagesClient() + .open(pageId, { viewport: null }) + .then((page) => { + if (cancelled || !host.current) return void page.close(); + const mounted = mountPage(host.current, page, { + classNames: LAYERS, + title: { text: page.initial.title || title, changed, band }, + compact: getSizeClass() === 'compact', + savedView: pageView(page.id)?.view ?? null, + }); + destroy = mounted.destroy; + setMounted(mounted); + }) + .catch(() => !cancelled && setFailed(true)); + return () => { + cancelled = true; + // The band goes back beside the page at once, before the page's slower teardown. + if (band) returnBand(band); + void destroy?.(); + }; + // The title and the changed line are read once, when the page opens. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pageId]); + return ( + <> + {failed &&

{t('page.openFailed')}

} +
+
+ {mounted && paneOpen && readingOrderOpen.set(false)} />} +
+ + ); +} diff --git a/app/src/features/page/PageView.module.css b/app/src/features/page/PageView.module.css index 0c386690e..e3912b9c8 100644 --- a/app/src/features/page/PageView.module.css +++ b/app/src/features/page/PageView.module.css @@ -1,4 +1,5 @@ @layer components { + /* Warm paper on the oat desk, lit softly from above (BRAND.md sections 1 and 7). */ .page { inline-size: min(100% - 2 * var(--space-8), var(--size-reading-measure)); margin: var(--space-8) auto; @@ -8,16 +9,94 @@ box-shadow: var(--elevation-2); } - .page h1 { + .title { margin: 0; + font-family: var(--font-reading); font-size: var(--type-title1-size); line-height: var(--type-title1-line); } - .page p { + /* A short clay stroke under the title, like the logo's pen tip. */ + .title::after { + content: ''; + display: block; + inline-size: var(--space-10); + block-size: 2px; + margin-block-start: var(--space-2); + border-radius: 1px; + background: var(--color-accent-clay); + } + + .changed { + margin: var(--space-2) 0 var(--space-6); + color: var(--color-text-muted); + font-size: var(--type-caption-size); + line-height: var(--type-caption-line); + } + + .note { margin: var(--space-4) 0 0; color: var(--color-text-secondary); font-size: var(--type-note-size); line-height: var(--type-note-line); } + + .empty { + display: grid; + justify-items: center; + padding-block: var(--space-8); + text-align: center; + } + + .art { + inline-size: min(12rem, 60%); + block-size: auto; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + } + + .artFrame { + stroke: var(--color-border-control); + } + + .artSky { + stroke: var(--color-status-warning); + } + + .artCandle { + stroke: var(--color-accent-clay); + } + + .artPlant { + stroke: var(--color-accent-primary); + } + + /* With the editor on, the page fills the region and its viewport scrolls (ARCHITECTURE.md section 5.1). */ + .editing { + display: flex; + flex-direction: column; + block-size: 100%; + } + + .header { + padding: var(--space-6) var(--space-8) var(--space-2); + } + + .body { + position: relative; + flex: 1; + min-block-size: 0; + } + + @media (forced-colors: active) { + .title::after { + background: CanvasText; + } + + .art g { + stroke: CanvasText; + } + } } diff --git a/app/src/features/page/PageView.test.tsx b/app/src/features/page/PageView.test.tsx index e3f085bc7..05bd6ca4a 100644 --- a/app/src/features/page/PageView.test.tsx +++ b/app/src/features/page/PageView.test.tsx @@ -1,10 +1,12 @@ -// The Phase 2 page placeholder in the browser: a heading for the open page, and a prompt when none is open. +// The page view in the browser: a heading for the open page and its text box, and a prompt when none is open. import { screen, within } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import type { MemoryPageService } from '../../services/pages/memory'; import { expectFocus, pressChord, renderApp } from '../../test'; -describe('the page placeholder', () => { +describe('the page view', () => { it('says when no page is open', async () => { await renderApp(); expect(await screen.findByRole('heading', { level: 1, name: 'No page open' })).toBeTruthy(); @@ -24,6 +26,24 @@ describe('the page placeholder', () => { const heading = await screen.findByRole('heading', { level: 1, name: 'Mitosis' }); await expectFocus(heading); expect(screen.getByText(/^Changed Sep 23, 20\d\d$/)).toBeTruthy(); - expect(screen.getByText(/Writing and drawing on pages arrive in a later version/)).toBeTruthy(); + expect(await screen.findByRole('textbox', { name: 'Page text' }, { timeout: 10_000 })).toBeTruthy(); + }); + + it('sends what is typed into an empty page to the page service', async () => { + const { platform } = await renderApp(); + const notebooks = await screen.findByRole('tree', { name: 'Notebooks' }); + await expect.poll(() => within(notebooks).queryByRole('treeitem', { name: 'Lectures' })).toBeTruthy(); + within(notebooks).getByRole('treeitem', { name: 'Lectures' }).focus(); + await pressChord('Space'); + const pages = await screen.findByRole('tree', { name: 'Pages' }); + (await within(pages).findByRole('treeitem', { name: 'Mitosis' })).focus(); + await pressChord('Enter'); + const box = await screen.findByRole('textbox', { name: 'Page text' }, { timeout: 10_000 }); + box.focus(); + await userEvent.keyboard('Cells divide'); + const sent = () => (platform.pages as MemoryPageService).sent('p-mitosis').flatMap((batch) => batch.edits); + const held = () => (platform.pages as MemoryPageService).held('p-mitosis')?.blocks[0]?.data.markdown; + await expect.poll(held).toBe('Cells divide'); + expect(sent()[0]).toMatchObject({ edit: 'insertBlock', block: { type: 'text' } }); }); }); diff --git a/app/src/features/page/PageView.tsx b/app/src/features/page/PageView.tsx index 39eb7a652..748f77158 100644 --- a/app/src/features/page/PageView.tsx +++ b/app/src/features/page/PageView.tsx @@ -1,8 +1,10 @@ -// The Phase 2 page placeholder (ARCHITECTURE.md section 13): the open page's title as a heading that Enter in the -// tree moves focus to, when it was last changed, and a line that says writing and drawing come later. With no -// page open, a heading, and a prompt to choose one. Page content arrives in Phase 4. +// The page view (ARCHITECTURE.md section 5; owner after WP0: WP3). The open page's title is a heading that Enter +// in the tree moves focus to. With page.editor on, the page's blocks follow it in the viewport, and typing goes +// to the core through the sync queue; with it off, the Phase 2 placeholder stays. With no page open, a quiet +// empty state says how to start. -import { useEffect, useRef, useState } from 'react'; +import { lazy, Suspense, useEffect, useRef, useState } from 'react'; +import { useFlag } from '../../app/flags'; import { useLocation } from '../../app/location'; import { useNotes } from '../../services/notes'; import type { NodeId, NodeSummary } from '../../services/notes'; @@ -11,9 +13,14 @@ import { formatDate } from '../../strings/format'; import { t } from '../../strings/t'; import { ProgressBar, useDelayedFlag } from '../../ui'; import { titleOf, useTreeNode } from '../tree'; +import { EmptyPageArt } from './EmptyPageArt'; +import { TitleSlot, useTitleBand } from './title/TitleSlot'; import styles from './PageView.module.css'; import { usePageZoom } from './zoom'; +/** The editor, Markdown, and sync code load in the page chunk, after start-up. */ +const PageBody = lazy(() => import('./PageBody')); + /** The page from the tree store, else from the service, which happens for a page the tree hasn't listed yet. */ function usePage(pageId: NodeId | null): { page: NodeSummary | null; loading: boolean } { const notes = useNotes(); @@ -36,25 +43,46 @@ export function PageView() { const { page, loading } = usePage(location.view === 'workspace' ? location.pageId : null); const heading = useRef(null); const showProgress = useDelayedFlag(loading); - const zoom = usePageZoom(page?.id ?? null); - useEffect(() => registerRegionMain('page', () => heading.current), []); + const editing = useFlag('page.editor'); + const shown = editing && page !== null; + const zoom = usePageZoom(shown ? null : (page?.id ?? null)); + const title = page ? titleOf(page) : t('tree.page.noneTitle'); + const changed = page ? t('tree.page.changed', { date: formatDate(page.modified) }) : null; + // With the editor on, the heading is the title band's. It is one element that moves into each page's world. + const band = useTitleBand(); + useEffect( + () => registerRegionMain('page', () => heading.current ?? (shown ? band.querySelector('h1') : null)), + [band, shown], + ); return (
{showProgress && } -

- {page ? titleOf(page) : t('tree.page.noneTitle')} -

- {page ? ( + {shown ? ( <> -

{t('tree.page.changed', { date: formatDate(page.modified) })}

-

{t('tree.page.empty')}

+ + + + ) : ( - !loading &&

{t('tree.page.none')}

+ <> +

+ {title} +

+ {changed &&

{changed}

} + + )} + {page && !editing &&

{t('tree.page.empty')}

} + {!page && !loading && ( +
+ +

{t('tree.page.none')}

+
)}
); diff --git a/app/src/features/page/blocks/blockLayer.ts b/app/src/features/page/blocks/blockLayer.ts new file mode 100644 index 000000000..0af452ce9 --- /dev/null +++ b/app/src/features/page/blocks/blockLayer.ts @@ -0,0 +1,211 @@ +// The block layer (ARCHITECTURE.md sections 5.1, 7.1, and 20.1; owner after WP0: WP3). It creates, orders, and +// removes block wrappers itself, so a keystroke never re-renders React and React never moves a focused wrapper. +// - DOM order is reading order; floating blocks sit at their frames, and z-index carries the drawing order. +// - The viewport's blocks render before the first paint, and the rest in idle slices of at most 8 ms. +// - A new order moves the fewest wrappers, and never the one that holds focus or the editor in use. +import type { BlockId, BlockJson, PageJson } from '../../../services/pages/types'; +import type { PagePool } from '../pool/pool'; +import { byOrder, readingOrder } from '../readingOrder/order'; +import { nameBlocks } from '../readingOrder/names'; +import { pinnedReorder } from '../readingOrder/pinned'; +import { rendererFor } from './renderers'; +import { isFloating, isLazy } from './textBlock'; +import type { LazyBlockView } from './textBlock'; +import type { BlockLayer, BlockRenderContext, BlockView } from './types'; + +/** Idle rendering yields after this long. */ +const SLICE_MS = 8; +/** Blocks this far outside the viewport (page units) render with it. */ +const NEAR_UNITS = 200; + +interface Entry { + view: BlockView; + block: BlockJson; +} + +/** The page view's block layer: the contract, plus what the page's own parts read. */ +export interface PageBlockLayer extends BlockLayer { + /** The blocks in reading order. */ + blocks(): readonly BlockJson[]; + block(id: BlockId): BlockJson | null; + /** view.readingOrder, which comes first in the reading order. */ + setPreferredOrder(order: readonly BlockId[]): void; + /** After blocks are added, removed, changed, or reordered. */ + onChange(listener: () => void): () => void; + /** Rendered blocks' heights in page units, to remember on this device. */ + heights(): Record; + destroy(): void; +} + +type IdleWindow = Window & { requestIdleCallback?: (callback: () => void) => number }; + +class Layer implements PageBlockLayer { + private readonly entries = new Map(); + private readonly listeners = new Set<() => void>(); + private preferred: readonly BlockId[] = []; + private order: BlockId[] = []; + private idle = 0; + private destroyed = false; + + constructor( + private readonly container: HTMLElement, + private readonly ctx: BlockRenderContext, + ) {} + + apply(page: PageJson): void { + [...this.entries.keys()].forEach((id) => this.drop(id)); + this.preferred = page.view.readingOrder ?? []; + for (const block of page.blocks) { + this.entries.set(block.id, { view: rendererFor(block.type, this.ctx).create(block, this.ctx), block }); + } + this.order = readingOrder(page.blocks, this.preferred); + this.container.append(...this.order.map((id) => this.entries.get(id)!.view.element)); + this.stack(); + this.renderNear(); + this.scheduleRest(); + this.changed(); + } + + upsert(block: BlockJson): void { + const entry = this.entries.get(block.id); + if (entry && entry.block.type === block.type) { + entry.block = block; + entry.view.update(block); + } else { + if (entry) this.drop(block.id); + const view = rendererFor(block.type, this.ctx).create(block, this.ctx); + this.entries.set(block.id, { view, block }); + if (isLazy(view)) view.render(); + } + this.refresh(); + } + + remove(id: BlockId): void { + if (!this.entries.has(id)) return; + this.drop(id); + this.refresh(); + } + + setOrder(order: readonly BlockId[]): void { + const elements = order.flatMap((id) => this.entries.get(id)?.view.element ?? []); + pinnedReorder(this.container, elements, this.pinned()); + this.order = order.filter((id) => this.entries.has(id)); + } + + view(id: BlockId): BlockView | null { + return this.entries.get(id)?.view ?? null; + } + + insertionPoint(): { before?: BlockId; after?: BlockId } { + const byKey = [...this.entries.values()].map(({ block }) => block).sort(byOrder); + const ink = byKey.find((block) => block.type === 'ink'); + if (ink) return { before: ink.id }; + const last = byKey.filter(isFloating).at(-1); + return last ? { after: last.id } : {}; + } + + blocks(): readonly BlockJson[] { + return this.order.flatMap((id) => this.entries.get(id)?.block ?? []); + } + + block(id: BlockId): BlockJson | null { + return this.entries.get(id)?.block ?? null; + } + + setPreferredOrder(order: readonly BlockId[]): void { + this.preferred = order; + this.refresh(); + } + + onChange(listener: () => void): () => void { + this.listeners.add(listener); + return () => void this.listeners.delete(listener); + } + + heights(): Record { + const out: Record = {}; + for (const [id, { view }] of this.entries) { + if (isLazy(view) && view.rendered) out[id] = Math.round(view.element.offsetHeight); + } + return out; + } + + destroy(): void { + this.destroyed = true; + [...this.entries.keys()].forEach((id) => this.drop(id)); + this.listeners.clear(); + } + + private drop(id: BlockId): void { + this.entries.get(id)?.view.destroy(); + this.entries.delete(id); + } + + /** The wrapper that holds focus, or the active editor's: a reorder never moves it. */ + private pinned(): Element | null { + const focused = this.container.ownerDocument.activeElement; + for (const { view } of this.entries.values()) if (view.element.contains(focused)) return view.element; + const active = (this.ctx.pool as PagePool).active?.(); + return (active && this.entries.get(active.block)?.view.element) || null; + } + + private refresh(): void { + const blocks = [...this.entries.values()].map(({ block }) => block); + this.setOrder(readingOrder(blocks, this.preferred)); + this.stack(); + this.changed(); + } + + /** Floating blocks draw in order key order, above flowing ones. */ + private stack(): void { + const floating = [...this.entries.values()].filter(({ block }) => isFloating(block)); + floating.sort((a, b) => byOrder(a.block, b.block)); + floating.forEach(({ view }, rank) => (view.element.style.zIndex = String(rank + 1))); + for (const { block, view } of this.entries.values()) if (!isFloating(block)) view.element.style.zIndex = ''; + nameBlocks(this.blocks(), (id) => this.view(id)); + } + + private unrendered(): LazyBlockView[] { + return this.order.flatMap((id) => { + const view = this.entries.get(id)?.view; + return view && isLazy(view) && !view.rendered ? [view] : []; + }); + } + + /** Renders the blocks that meet the viewport; all of them when it has no size yet. */ + private renderNear(): void { + const camera = this.ctx.viewport.camera(); + const waiting = this.unrendered(); + if (camera.viewport.h <= 0) return waiting.forEach((view) => view.render()); + const top = camera.scrollY / camera.zoom - NEAR_UNITS; + const bottom = (camera.scrollY + camera.viewport.h) / camera.zoom + NEAR_UNITS; + for (const view of waiting) { + const rect = view.measure(); + if (rect.y < bottom && rect.y + rect.h > top) view.render(); + } + } + + private scheduleRest(): void { + if (this.idle || this.destroyed) return; + const view = this.container.ownerDocument.defaultView as IdleWindow | null; + if (!view) return; + const run = () => { + this.idle = 0; + if (this.destroyed) return; + const started = performance.now(); + for (const lazy of this.unrendered()) { + if (performance.now() - started >= SLICE_MS) return this.scheduleRest(); + lazy.render(); + } + }; + this.idle = view.requestIdleCallback ? view.requestIdleCallback(run) : view.setTimeout(run, 0); + } + + private changed(): void { + this.listeners.forEach((listener) => listener()); + } +} + +export function createBlockLayer(container: HTMLElement, ctx: BlockRenderContext): PageBlockLayer { + return new Layer(container, ctx); +} diff --git a/app/src/features/page/blocks/blocks.module.css b/app/src/features/page/blocks/blocks.module.css new file mode 100644 index 000000000..7402d0383 --- /dev/null +++ b/app/src/features/page/blocks/blocks.module.css @@ -0,0 +1,307 @@ +@layer components { + .block { + position: relative; + margin-block-end: var(--space-4); + border-radius: var(--radius-sm); + } + + .block:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + } + + .floating { + position: absolute; + margin: 0; + } + + .text { + color: var(--color-text-primary); + font-family: var(--font-reading); + font-size: var(--type-note-size); + line-height: var(--type-note-line); + } + + .text :global(.ProseMirror) { + min-block-size: 1lh; + outline: none; + white-space: pre-wrap; + overflow-wrap: break-word; + caret-color: var(--color-accent-clay); + } + + /* + * ADR 0005's first rule, applied per textblock (ARCHITECTURE.md section 5.7): a key press repaints only the + * block it changes, so a 20-page note paints like a short one. List markers, checkboxes, and fold buttons are + * siblings of the paragraph, outside its containment. The clip margin keeps the caret and italic overhang + * visible at a block's edges. + */ + .text :global(.ProseMirror) :is(p, h1, h2, h3, h4, h5, h6, [data-callout-title]) { + contain: paint; + overflow-clip-margin: 4px; + } + + /* An empty text box invites writing, in muted text that is never part of the note. */ + .text :global(.ProseMirror) > p:only-child:has(> br:only-child)::before { + content: var(--placeholder); + float: inline-start; + block-size: 0; + color: var(--color-text-muted); + pointer-events: none; + } + + .text :global(.ProseMirror) ::selection { + background: var(--color-selection-highlight); + } + + /* The first block of text keeps this with and without an editor: an editor's widgets, such as a heading's fold + button, don't count as the first child. The selectors are the cheap form of `:nth-child(1 of ...)`: with + that one, every widget the editor redrew restyled all of a long note's blocks, 3.5 ms a key. */ + .text :global(.ProseMirror) > :first-child, + .text :global(.ProseMirror) > :global(.ProseMirror-widget):first-child + * { + margin-block-start: 0; + } + + .text :global(.ProseMirror) h1, + .text :global(.ProseMirror) h2, + .text :global(.ProseMirror) h3 { + color: var(--color-text-primary); + font-family: var(--font-reading); + line-height: 1.25; + } + + .text :global(.ProseMirror) h2 { + padding-block-end: var(--space-1); + border-block-end: 1px solid var(--color-border-subtle); + } + + .text :global(.ProseMirror) a { + color: var(--color-accent-primary); + } + + .text :global(.ProseMirror) blockquote { + margin-inline: 0; + padding-inline-start: var(--space-4); + border-inline-start: 3px solid var(--color-accent-clay); + color: var(--color-text-secondary); + } + + .text :global(.ProseMirror) mark { + padding-inline: 0.1em; + border-radius: 2px; + background: var(--color-selection-highlight); + color: inherit; + } + + /* The other highlighters are the pens' colors, washed out like a highlighter on paper (BRAND.md section 4). */ + .text :global(.ProseMirror) mark[data-color='mint'] { + background: color-mix(in srgb, var(--ink-fern) 22%, transparent); + } + + .text :global(.ProseMirror) mark[data-color='rose'] { + background: color-mix(in srgb, var(--ink-brick) 20%, transparent); + } + + .text :global(.ProseMirror) mark[data-color='apricot'] { + background: color-mix(in srgb, var(--ink-amber) 24%, transparent); + } + + .text :global(.ProseMirror) mark[data-color='lilac'] { + background: color-mix(in srgb, var(--ink-plum) 20%, transparent); + } + + .text :global(.ProseMirror) span[data-color='ink'] { + color: var(--ink-ink); + } + + .text :global(.ProseMirror) span[data-color='indigo'] { + color: var(--ink-indigo); + } + + .text :global(.ProseMirror) span[data-color='brick'] { + color: var(--ink-brick); + } + + .text :global(.ProseMirror) span[data-color='fern'] { + color: var(--ink-fern); + } + + .text :global(.ProseMirror) span[data-color='plum'] { + color: var(--ink-plum); + } + + .text :global(.ProseMirror) span[data-color='amber'] { + color: var(--ink-amber); + } + + .text :global(.ProseMirror) span[data-color='walnut'] { + color: var(--ink-walnut); + } + + .text :global(.ProseMirror) span[data-size='small'] { + font-size: 0.85em; + } + + .text :global(.ProseMirror) span[data-size='large'] { + font-size: 1.25em; + } + + .text :global(.ProseMirror) span[data-size='xlarge'] { + font-size: 1.5em; + } + + .text :global(.ProseMirror) :is(ul, ol) { + margin-block: var(--space-2); + padding-inline-start: 1.5em; + } + + .text :global(.ProseMirror) li > p { + margin-block: var(--space-1); + } + + .text :global(.ProseMirror) li::marker { + color: var(--color-accent-clay); + } + + .text :global(.ProseMirror) li[data-checked]::marker { + content: '\2610\00a0\00a0'; + color: var(--color-text-secondary); + } + + .text :global(.ProseMirror) li[data-checked='true']::marker { + content: '\2611\00a0\00a0'; + color: var(--color-accent-primary); + } + + .text :global(.ProseMirror) li[data-checked='true'] > p { + color: var(--color-text-secondary); + } + + /* + * Callouts: a soft card tinted by its type, with the type's color on its edge and beside its title. The title + * names the type in words, so color is never the only signal. + */ + .text :global(.ProseMirror) [data-callout] { + --callout: var(--color-border-control); + margin-block: var(--space-4); + padding: var(--space-3) var(--space-4); + border-inline-start: 3px solid var(--callout); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--callout) 8%, var(--color-surface-page)); + } + + .text :global(.ProseMirror) [data-callout] > p { + margin-block: var(--space-1) 0; + } + + .text :global(.ProseMirror) [data-callout-title] { + display: flex; + align-items: baseline; + gap: var(--space-2); + font-weight: var(--font-weight-semibold); + } + + .text :global(.ProseMirror) [data-callout-title]::before { + content: ''; + flex: none; + inline-size: 0.55em; + block-size: 0.55em; + border-radius: 50%; + background: var(--callout); + } + + .text :global(.ProseMirror) :is([data-callout='note'], [data-callout='info']) { + --callout: var(--ink-indigo); + } + + .text :global(.ProseMirror) :is([data-callout='tip'], [data-callout='success']) { + --callout: var(--color-accent-primary); + } + + .text :global(.ProseMirror) :is([data-callout='important'], [data-callout='example']) { + --callout: var(--ink-amber); + } + + .text :global(.ProseMirror) [data-callout='question'] { + --callout: var(--ink-plum); + } + + .text :global(.ProseMirror) :is([data-callout='warning'], [data-callout='caution']) { + --callout: var(--color-accent-clay); + } + + .text :global(.ProseMirror) [data-callout='danger'] { + --callout: var(--color-status-danger); + } + + .text :global(.ProseMirror) [data-callout='quote'] { + --callout: var(--ink-walnut); + } + + @media (forced-colors: active) { + .text :global(.ProseMirror) [data-callout] { + border-inline-start-color: CanvasText; + background: Canvas; + } + + .text :global(.ProseMirror) [data-callout-title]::before { + background: CanvasText; + } + } + + .text :global(.ProseMirror) pre, + .text :global(.ProseMirror) code { + font-family: var(--font-mono); + background: var(--color-surface-sunken); + border-radius: var(--radius-sm); + } + + .text :global(.ProseMirror) pre { + padding: var(--space-3) var(--space-4); + overflow-x: auto; + } + + .text :global(.ProseMirror) hr { + border: 0; + border-block-start: 1px solid var(--color-border-subtle); + } + + /* + * Math in static text. An editor's math view shows the source in a code element; static text has only the + * empty element with the source in an attribute, so math vanished until an editor mounted. Generated content + * shows the same source, in the same style, and screen readers read it. + */ + .text :global(.ProseMirror) :is([data-math-block], [data-math]):empty::before { + font-family: var(--font-mono); + background: var(--color-surface-sunken); + border-radius: var(--radius-sm); + } + + .text :global(.ProseMirror) [data-math-block]:empty { + display: block; + margin-block: var(--space-3); + color: var(--color-text-secondary); + white-space: pre-wrap; + } + + .text :global(.ProseMirror) [data-math-block]:empty::before { + content: '$$\A' attr(data-math-block) '\A$$'; + } + + .text :global(.ProseMirror) [data-math]:empty { + color: var(--color-text-secondary); + } + + .text :global(.ProseMirror) [data-math]:empty::before { + content: '$' attr(data-math) '$'; + } + + .unknown { + padding: var(--space-3) var(--space-4); + border: 1px dashed var(--color-border-control); + border-radius: var(--radius-md); + color: var(--color-text-secondary); + background: var(--color-surface-sunken); + font-size: var(--type-note-size); + } +} diff --git a/app/src/features/page/blocks/frameContext.ts b/app/src/features/page/blocks/frameContext.ts new file mode 100644 index 000000000..01c3ebda5 --- /dev/null +++ b/app/src/features/page/blocks/frameContext.ts @@ -0,0 +1,55 @@ +// How undo, redo, and other windows' frames reach the page view (ARCHITECTURE.md section 10.5; owner WP3): text +// goes to a block's editor when it has one, and every changed block goes through the block layer, which renders +// static text again for blocks without an editor. +import { Fragment } from '@tiptap/pm/model'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { META_REMOTE } from '../../../editor/meta'; +import type { AppliedFrame } from '../../../services/pages/types'; +import type { EditorPool } from '../pool/pool'; +import { blockLaidOut } from '../seams/geometry'; +import { acceptRemoteText } from '../sync'; +import type { FrameContext } from '../sync'; +import { syncOf } from './textBlock'; +import type { BlockLayer } from './types'; + +export interface FrameHooks { + /** Undo or another window changed the title, tags, or view. */ + setPageFields(fields: NonNullable): void; + /** Undo or redo restored an object selection. */ + selectObjects(blocks: readonly string[]): void; +} + +export function frameContext(pool: EditorPool, layer: () => BlockLayer, hooks: FrameHooks): FrameContext { + return { + textState(block) { + const editor = pool.editor(block); + const sync = editor && syncOf(editor); + return editor && sync ? { doc: editor.state.doc, markdown: sync.lastSent() } : null; + }, + replaceText(block, change, markdown) { + const editor = pool.editor(block); + if (!editor || change === 'full') return; + const { tr } = editor.state; + if ('full' in change) { + const doc: PMNode = editor.schema.nodeFromJSON(change.full.toJSON()); + tr.replaceWith(0, tr.doc.content.size, doc.content); + } else { + tr.replaceWith(change.from, change.to, Fragment.fromJSON(editor.schema, change.content.toJSON())); + } + editor.view.dispatch(tr.setMeta(META_REMOTE, true)); + const sync = syncOf(editor); + if (sync) acceptRemoteText(sync, markdown); + blockLaidOut(block); + }, + upsertBlock: (block) => layer().upsert(block), + removeBlock: (block) => layer().remove(block), + setPageFields: (fields) => hooks.setPageFields(fields), + restoreSelection(selection) { + if (selection.kind === 'objects') return hooks.selectObjects(selection.blocks); + if (selection.kind !== 'text') return; + pool.mount(selection.block, { kind: 'selection', anchor: selection.anchor, head: selection.head }, 'target'); + pool.editor(selection.block)?.commands.focus(); + }, + focusedBlock: () => pool.active()?.block ?? null, + }; +} diff --git a/app/src/features/page/blocks/imageBlock.ts b/app/src/features/page/blocks/imageBlock.ts new file mode 100644 index 000000000..8adcb83f2 --- /dev/null +++ b/app/src/features/page/blocks/imageBlock.ts @@ -0,0 +1,337 @@ +// The image block (Phase 4 ARCHITECTURE.md sections 12.3 to 12.5, owner: WP5). The image sits in a clipping frame +// at the size the asset table gives, so layout never shifts and the page never decodes an image to learn its size. +// The crop is plain layout in percent, so every WebView and print show the same part. A selected image shows its +// handles, a small toolbar, and a "No alt text" badge when it has neither a description nor the decorative mark. +import type { AssetJson, BlockJson, Frame } from '../../../services/pages/types'; +import { t } from '../../../strings/t'; +import { assetTable } from '../images/assets'; +import type { AssetTable } from '../images/assets'; +import { arrowStep, trackDrag } from '../images/drag'; +import type { Crop, Handle, Rect } from '../images/geometry'; +import { cropOf, FREEFORM_MAX_WIDTH, HANDLES, heightFor, initialSize, resizeRect } from '../images/geometry'; +import { shownImageIds } from '../images/shown'; +import styles from '../images/images.module.css'; +import { pageSelection, selectOnPage } from '../seams/selectionStore'; +import blockStyles from './blocks.module.css'; +import type { BlockRenderContext, BlockRendererDef, BlockView } from './types'; + +/** What the image commands reach through a shown image. */ +export interface ImageHandle { + readonly id: string; + readonly ctx: BlockRenderContext; + readonly element: HTMLElement; + block(): BlockJson; + asset(): AssetJson | null; + /** The frame as shown, in page units. */ + rect(): Rect; + floating(): boolean; + /** The image element and its frame, for crop mode. */ + parts(): { frame: HTMLElement; picture: HTMLImageElement | null }; + setCropping(on: boolean): void; +} + +const shownImages = new Map(); + +/** The shown image with this block ID. */ +export function imageHandle(id: string): ImageHandle | null { + return shownImages.get(id) ?? null; +} + +/** The one selected image, if exactly one image is selected. */ +export function selectedImage(): ImageHandle | null { + const { blocks, strokes } = pageSelection.get(); + return blocks.length === 1 && strokes.length === 0 ? imageHandle(blocks[0]) : null; +} + +const assetOf = (block: BlockJson) => (typeof block.data.asset === 'string' ? block.data.asset : null); +const altOf = (block: BlockJson) => (typeof block.data.alt === 'string' ? block.data.alt : ''); +const decorativeOf = (block: BlockJson) => block.data.decorative === true; + +/** The accessible name: the description, "Decorative image", or "Image, no description". */ +export function imageLabel(block: BlockJson): string { + if (decorativeOf(block)) return t('images.decorativeLabel'); + const alt = altOf(block).trim(); + return alt ? t('images.label', { alt }) : t('images.unnamed'); +} + +const isFloating = (frame: Frame | undefined) => frame?.x !== undefined && frame?.y !== undefined; +const px = (value: number) => `${Math.round(value * 100) / 100}px`; + +/** The shown size: the frame's, or the asset's at the screen's density for an image that has none yet. */ +function shownSize(block: BlockJson, asset: AssetJson | null, crop: Crop | null): { w: number; h: number } { + const pixels = asset?.width && asset?.height ? { width: asset.width, height: asset.height } : null; + const fallback = pixels + ? initialSize( + { width: pixels.width * (crop?.w ?? 1), height: pixels.height * (crop?.h ?? 1) }, + { dpr: window.devicePixelRatio || 1, maxWidth: FREEFORM_MAX_WIDTH }, + ) + : { w: 240, h: 180 }; + const w = block.frame?.w ?? fallback.w; + const h = block.frame?.h ?? (pixels ? heightFor(w, pixels, crop) : (w * fallback.h) / fallback.w); + return { w, h }; +} + +function layoutPicture(picture: HTMLImageElement, crop: Crop | null): void { + const whole = crop ?? { x: 0, y: 0, w: 1, h: 1 }; + picture.style.inlineSize = `${100 / whole.w}%`; + picture.style.blockSize = `${100 / whole.h}%`; + picture.style.left = `${(-100 * whole.x) / whole.w}%`; + picture.style.top = `${(-100 * whole.y) / whole.h}%`; +} + +class ImageView implements BlockView, ImageHandle { + readonly element = document.createElement('div'); + readonly editRoot = null; + readonly id: string; + private readonly frameElement: HTMLElement; + private readonly assets: AssetTable; + private picture: HTMLImageElement | null = null; + private placeholder: HTMLElement | null = null; + private failed = false; + private cropping = false; + private chrome: HTMLElement[] = []; + /** Counts refreshes, so a toolbar that loads late never joins a newer one. */ + private chromeGeneration = 0; + private readonly stops: (() => void)[] = []; + + constructor( + private current: BlockJson, + readonly ctx: BlockRenderContext, + ) { + this.id = current.id; + this.assets = assetTable(ctx.page); + const element = this.element; + element.className = `${blockStyles.block} ${styles.image}`; + element.setAttribute('role', 'group'); + element.tabIndex = 0; + element.dataset.blockId = current.id; + element.dataset.scope = 'pageObject'; + element.dataset.appMenu = 'page.image'; + this.frameElement = element.appendChild(document.createElement('div')); + this.frameElement.className = styles.frame; + element.addEventListener('pointerdown', (event) => this.onPointerDown(event)); + element.addEventListener('dblclick', () => { + if (this.editable()) void import('../images/cropMode').then(({ startCrop }) => startCrop(this)); + }); + this.stops.push( + this.assets.onChange((id) => { + if (id !== assetOf(this.current)) return; + this.failed = false; + this.render(); + }), + pageSelection.subscribe(() => this.refreshChrome()), + ); + shownImages.set(this.id, this); + shownImageIds.add(this.id); + this.render(); + } + + block(): BlockJson { + return this.current; + } + + asset(): AssetJson | null { + const id = assetOf(this.current); + return id ? this.assets.get(id) : null; + } + + floating(): boolean { + return isFloating(this.current.frame); + } + + parts() { + return { frame: this.frameElement, picture: this.picture }; + } + + setCropping(on: boolean): void { + this.cropping = on; + this.element.classList.toggle(styles.cropping, on); + this.refreshChrome(); + } + + update(next: BlockJson): void { + if (next.data.asset !== this.current.data.asset) this.failed = false; + this.current = next; + this.render(); + } + + measure() { + const shown = this.rect(); + return { x: shown.x, y: shown.y, w: this.element.offsetWidth || shown.w, h: this.element.offsetHeight || shown.h }; + } + + rect(): Rect { + const block = this.current; + const size = shownSize(block, this.asset(), cropOf(block.data)); + return { + x: block.frame?.x ?? this.element.offsetLeft, + y: block.frame?.y ?? this.element.offsetTop, + w: this.frameElement.offsetWidth || size.w, + h: this.frameElement.offsetHeight || size.h, + }; + } + + destroy(): void { + this.stops.forEach((stop) => stop()); + if (shownImages.get(this.id) === this) { + shownImages.delete(this.id); + shownImageIds.delete(this.id); + } + this.element.remove(); + } + + private editable(): boolean { + const { lock } = this.current; + return !this.ctx.reading && !this.ctx.page.readOnly && lock !== 'all' && lock !== 'position'; + } + + private render(): void { + const block = this.current; + const id = assetOf(block); + const asset = this.asset(); + const crop = cropOf(block.data); + const floating = this.floating(); + this.element.classList.toggle(blockStyles.floating, floating); + this.element.style.left = floating ? px(block.frame!.x!) : ''; + this.element.style.top = floating ? px(block.frame!.y!) : ''; + this.showSize(shownSize(block, asset, crop), floating); + if (asset && id && !this.failed) this.showPicture(asset, id); + else this.showPlaceholder(); + if (this.picture) layoutPicture(this.picture, crop); + this.element.setAttribute('aria-label', imageLabel(block)); + this.element.dataset.lock = block.lock ?? ''; + this.refreshChrome(); + } + + /** A flowing image keeps its ratio when the column is narrower than its width. */ + private showSize(size: { w: number; h: number }, floating: boolean): void { + const style = this.frameElement.style; + style.inlineSize = px(size.w); + style.blockSize = floating ? px(size.h) : ''; + style.aspectRatio = floating ? '' : `${size.w} / ${size.h}`; + } + + private showPlaceholder(): void { + this.picture?.remove(); + this.picture = null; + if (!this.placeholder) { + this.placeholder = this.frameElement.appendChild(document.createElement('div')); + this.placeholder.className = styles.placeholder; + } + const alt = altOf(this.current).trim(); + this.placeholder.textContent = alt ? `${alt} (${t('images.missing')})` : t('images.missing'); + } + + private showPicture(asset: AssetJson, id: string): void { + this.placeholder?.remove(); + this.placeholder = null; + const src = this.ctx.page.assetUrl(id); + if (!this.picture) { + const picture = this.frameElement.appendChild(document.createElement('img')); + picture.className = styles.picture; + picture.decoding = 'async'; + picture.loading = 'lazy'; + picture.draggable = false; + picture.addEventListener('error', () => { + this.failed = true; + this.showPlaceholder(); + }); + this.picture = picture; + } + if (this.picture.getAttribute('src') !== src) this.picture.src = src; + if (asset.width) this.picture.width = asset.width; + if (asset.height) this.picture.height = asset.height; + this.picture.alt = decorativeOf(this.current) ? '' : altOf(this.current); + } + + private onPointerDown(event: PointerEvent): void { + if (this.cropping || event.button !== 0) return; + const { blocks } = pageSelection.get(); + const extend = event.shiftKey || event.ctrlKey; + if (!blocks.includes(this.id) || extend) { + selectOnPage({ blocks: extend ? [...new Set([...blocks, this.id])] : [this.id], strokes: [] }); + } + this.element.focus({ preventScroll: true }); + } + + private sendFrame(next: Rect): void { + const block = this.current; + const rotate = block.frame?.rotate ? { rotate: block.frame.rotate } : {}; + const frame: Frame = this.floating() ? { x: next.x, y: next.y, w: next.w, h: next.h, ...rotate } : { w: next.w }; + this.current = { ...block, frame }; + this.render(); + void this.ctx.sync + .send({ edits: [{ edit: 'moveBlock', block: this.id, frame }], coalesce: { kind: 'resize', target: this.id } }) + .catch(() => undefined); + } + + private startResize(event: PointerEvent, handle: Handle): void { + const start = this.rect(); + const floating = this.floating(); + let next = start; + trackDrag( + event, + this.ctx.viewport.camera().zoom || 1, + (dx, dy, moveEvent) => { + next = resizeRect(start, handle, dx, dy, moveEvent.shiftKey); + this.showSize(next, floating); + if (!floating) return; + this.element.style.left = px(next.x); + this.element.style.top = px(next.y); + }, + () => next !== start && this.sendFrame(next), + ); + } + + private resizeByKey(event: KeyboardEvent, handle: Handle): void { + const step = arrowStep(event, event.shiftKey ? 1 : 10); + if (step) this.sendFrame(resizeRect(this.rect(), handle, step[0], step[1])); + } + + private refreshChrome(): void { + this.chrome.forEach((part) => part.remove()); + this.chrome = []; + const generation = ++this.chromeGeneration; + const selected = selectedImage()?.id === this.id; + this.element.classList.toggle(styles.selected, selected); + if (!selected || this.cropping) return; + if (!altOf(this.current).trim() && !decorativeOf(this.current)) { + const badge = this.frameElement.appendChild(document.createElement('span')); + badge.className = styles.badge; + badge.textContent = t('images.noAltBadge'); + badge.setAttribute('aria-hidden', 'true'); + this.chrome.push(badge); + } + if (!this.editable()) return; + this.addHandles(); + void import('../images/toolbar').then(({ imageToolbar }) => { + if (generation !== this.chromeGeneration || !this.element.isConnected) return; + const bar = imageToolbar(this); + this.element.append(bar); + this.chrome.push(bar); + }); + } + + /** A flowing image has one corner handle and stores only its width. */ + private addHandles(): void { + const handles = this.floating() ? HANDLES : (['se'] as const); + for (const handle of handles) { + const button = this.element.appendChild(document.createElement('button')); + button.type = 'button'; + button.className = `${styles.handle} ${styles[handle]}`; + button.tabIndex = -1; + button.setAttribute('aria-label', t('images.resizeHandle', { edge: t(`images.edges.${handle}`) })); + button.addEventListener('pointerdown', (event) => this.startResize(event, handle)); + button.addEventListener('keydown', (event) => this.resizeByKey(event, handle)); + this.chrome.push(button); + } + } +} + +export const imageBlockRenderer: BlockRendererDef = { + id: 'image', + types: ['image'], + priority: 0, + flag: 'page.images', + create: (block, ctx) => new ImageView(block, ctx), +}; diff --git a/app/src/features/page/blocks/inkSlot.ts b/app/src/features/page/blocks/inkSlot.ts new file mode 100644 index 000000000..0b79e03b3 --- /dev/null +++ b/app/src/features/page/blocks/inkSlot.ts @@ -0,0 +1,31 @@ +// The slot an ink block holds until Phase 5's renderer draws it (ARCHITECTURE.md section 25.3; owner WP3): an empty +// box at the block's frame, so handwriting keeps its place in the order, the layout, and the z-order. It has no +// content to read, so assistive technology skips it. +import styles from './blocks.module.css'; +import { placeBlock } from './textBlock'; +import type { BlockRendererDef } from './types'; + +export const inkSlotRenderer: BlockRendererDef = { + id: 'inkSlot', + types: ['ink'], + priority: -1, + create(block) { + const element = document.createElement('div'); + element.className = styles.block; + element.dataset.blockId = block.id; + element.dataset.ink = ''; + element.setAttribute('aria-hidden', 'true'); + const size = (next: typeof block) => { + placeBlock(element, next); + element.style.blockSize = next.frame?.h !== undefined ? `${next.frame.h}px` : ''; + }; + size(block); + return { + element, + editRoot: null, + update: size, + measure: () => ({ x: element.offsetLeft, y: element.offsetTop, w: element.offsetWidth, h: element.offsetHeight }), + destroy: () => element.remove(), + }; + }, +}; diff --git a/app/src/features/page/blocks/renderers.ts b/app/src/features/page/blocks/renderers.ts new file mode 100644 index 000000000..099cdbca2 --- /dev/null +++ b/app/src/features/page/blocks/renderers.ts @@ -0,0 +1,17 @@ +// Which renderer draws a block (owner WP3): the highest-priority registered or built-in renderer for its type whose +// flag is on, and the placeholder for types nothing renders. Phase 5 registers its ink renderer above the slot. +import { blockRenderers } from '../registries'; +import { inkSlotRenderer } from './inkSlot'; +import { textBlockRenderer } from './textBlock'; +import type { BlockRenderContext, BlockRendererDef } from './types'; +import { unknownBlockRenderer } from './unknownBlock'; + +const BUILT_IN: readonly BlockRendererDef[] = [textBlockRenderer, inkSlotRenderer]; + +export function rendererFor(type: string, ctx: BlockRenderContext): BlockRendererDef { + const candidates = [...blockRenderers.list(), ...BUILT_IN].filter( + (def) => def.types.includes(type) && (!def.flag || ctx.host.flag(def.flag)), + ); + candidates.sort((a, b) => b.priority - a.priority); + return candidates[0] ?? unknownBlockRenderer; +} diff --git a/app/src/features/page/blocks/tableBlock.test.ts b/app/src/features/page/blocks/tableBlock.test.ts new file mode 100644 index 000000000..e061dd473 --- /dev/null +++ b/app/src/features/page/blocks/tableBlock.test.ts @@ -0,0 +1,217 @@ +// @vitest-environment jsdom +// The table block on the page (PLAN.md section 10.5). It shows a named at once. +// Cell typing reaches the core as a patch of the rows, and Tab and Enter behave as in a table. +// Every table command keeps each row's cells in the first row's column order. New rows and columns get fresh IDs, +// and a moved row keeps its own. +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Editor } from '@tiptap/core'; +import { CellSelection } from '@tiptap/pm/tables'; +import { executeCommand } from '../../../commands/registry'; +import type { TableData } from '../../../editor/schema/specs'; +import { newTableData } from '../../../editor/table/mapping'; +import type { BlockJson, Edit } from '../../../services/pages/types'; +import type { PageCommandId } from '../keys'; +import { textPageFixture } from '../test/fixtures'; +import { cleanupPages, renderPage } from '../test/harness'; +import type { PageHarness } from '../test/harness'; +import { currentTable } from '../tables/current'; +import { whenViewReady } from '../tables/lazyView'; +import '../register'; + +afterEach(cleanupPages); + +// jsdom has no layout, and ProseMirror measures a Range when it scrolls the caret into view. +Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList; +Range.prototype.getBoundingClientRect ??= () => new DOMRect(); + +const TABLE = '01k6f0000000000000000t0002'; + +function tableData(): TableData { + const data = newTableData(3, 3, true); + const words = [ + ['Stage', 'Where it happens', 'Notes'], + ['One', 'Here', 'a'], + ['Two', 'There', 'b'], + ]; + data.rows.forEach((row, r) => + data.columns.forEach((column, c) => (row.cells[column.id] = { markdown: words[r][c] })), + ); + return data; +} + +async function tablePage(data: TableData = tableData()): Promise<{ page: PageHarness; editor: Editor }> { + const fixture = textPageFixture('Before the table'); + const at = fixture.page.blocks[0].created; + const block: BlockJson = { + id: TABLE, + type: 'table', + order: 'a1', + created: at, + modified: at, + data: data as unknown as Record, + }; + fixture.page.blocks.push(block); + const page = await renderPage({ fixture, flags: { 'page.tables': true } }); + await whenViewReady(page.wrapper(TABLE)); + const editor = page.mounted.pool.mount(TABLE, { kind: 'start' }, 'target'); + if (!editor) throw new Error('The table has no editor.'); + editor.commands.focus('start'); + return { page, editor }; +} + +/** A key press through the editor's key handlers, as a real keydown reaches them. */ +function press(editor: Editor, key: string, shiftKey = false): void { + const event = new KeyboardEvent('keydown', { key, shiftKey, bubbles: true, cancelable: true }); + editor.view.someProp('handleKeyDown', (handle) => handle(editor.view, event)); +} + +/** The table data the core holds: the fixture's, with every patch sent since applied. */ +function held(page: PageHarness, initial: TableData): TableData { + let data: TableData = structuredClone(initial); + for (const edit of page.sent().flatMap((batch) => batch.edits) as Edit[]) { + if (edit.edit === 'patchBlock' && edit.block === TABLE) data = { ...data, ...(edit.data as Partial) }; + } + return data; +} + +function expectWhole(data: TableData): void { + const ids = data.columns.map((column) => column.id); + for (const row of data.rows) expect(Object.keys(row.cells)).toEqual(ids); + expect(new Set(ids).size).toBe(ids.length); + expect(new Set(data.rows.map((row) => row.id)).size).toBe(data.rows.length); +} + +describe('the table block', () => { + it('shows a real table named by its header cells', async () => { + const { page } = await tablePage(); + const wrapper = page.wrapper(TABLE); + expect(wrapper.getAttribute('aria-label')).toBe('Table: Stage, Where it happens, Notes'); + const headers = [...wrapper.querySelectorAll('th')]; + expect(headers.map((th) => th.textContent)).toEqual(['Stage', 'Where it happens', 'Notes']); + expect(headers.every((th) => th.getAttribute('scope') === 'col')).toBe(true); + }); + + it('sends cell typing as a patch of the rows, coalesced as typing', async () => { + const data = tableData(); + const { page } = await tablePage(data); + await page.type(TABLE, '!'); + const last = page.sent().at(-1)!; + // Typing groups by cell: the target is the table, the row, and the column. + const cell: unknown = expect.stringMatching(new RegExp(`^${TABLE}/[^/]+/[^/]+$`)); + expect(last.coalesce).toEqual({ kind: 'typing', target: cell }); + expect(last.edits[0]).toMatchObject({ edit: 'patchBlock', block: TABLE }); + const after = held(page, data); + const lastRow = after.rows.at(-1)!; + expect(lastRow.cells[after.columns[2].id].markdown).toBe('b!'); + expect(lastRow.id).toBe(data.rows[2].id); + }); + + it('moves between cells with Tab and adds a row from the last cell', async () => { + const data = tableData(); + const { page, editor } = await tablePage(data); + press(editor, 'Tab'); + expect(editor.state.selection.$head.parent.textContent).toBe('Where it happens'); + press(editor, 'Tab', true); + expect(editor.state.selection.$head.parent.textContent).toBe('Stage'); + page.mounted.pool.mount(TABLE, { kind: 'end' }, 'target'); + press(editor, 'Tab'); + expect(editor.state.doc.firstChild!.childCount).toBe(4); + await page.mounted.sync.flushAll('timer'); + const after = held(page, data); + expect(after.rows).toHaveLength(4); + expectWhole(after); + }); + + it('breaks the line inside a cell on Enter and Shift+Enter', async () => { + const { editor } = await tablePage(); + press(editor, 'Enter'); + press(editor, 'Enter', true); + const table = editor.state.doc.firstChild!; + expect(table.childCount).toBe(3); + const cell = table.firstChild!.firstChild!; + expect(cell.childCount).toBe(1); + const breaks = cell.firstChild!.content.content.filter((node) => node.type.name === 'hardBreak'); + expect(breaks).toHaveLength(2); + }); +}); + +describe('table commands', () => { + const run = async (id: PageCommandId) => expect(await executeCommand(id), `${id} ran`).toBe(true); + + it('adds rows and columns with fresh IDs, in the first row column order', async () => { + const data = tableData(); + const { page } = await tablePage(data); + for (const id of ['table.rowBelow', 'table.rowAbove', 'table.columnRight', 'table.columnLeft'] as const) { + await run(id); + } + const after = held(page, data); + expect(after.rows).toHaveLength(5); + expect(after.columns).toHaveLength(5); + expectWhole(after); + const old = new Set([...data.rows.map((row) => row.id), ...data.columns.map((column) => column.id)]); + const fresh = [...after.rows.map((row) => row.id), ...after.columns.map((column) => column.id)].filter( + (id) => !old.has(id), + ); + expect(fresh).toHaveLength(4); + }); + + it('moves a row and keeps its ID', async () => { + const data = tableData(); + const { page } = await tablePage(data); + page.mounted.pool.mount(TABLE, { kind: 'end' }, 'target'); + await run('table.moveRowUp'); + const after = held(page, data); + expect(after.rows.map((row) => row.id)).toEqual([data.rows[0].id, data.rows[2].id, data.rows[1].id]); + }); + + it('deletes a row and a column, and turns the header row off', async () => { + const data = tableData(); + const { page } = await tablePage(data); + page.mounted.pool.mount(TABLE, { kind: 'end' }, 'target'); + await run('table.deleteRow'); + await run('table.deleteColumn'); + await run('table.headerRow'); + const after = held(page, data); + expect(after.rows).toHaveLength(2); + expect(after.columns.map((column) => column.id)).toEqual([data.columns[0].id, data.columns[1].id]); + expect(after.header).toBe(false); + expectWhole(after); + expect(page.wrapper(TABLE).querySelector('th')).toBeNull(); + }); + + it('sets a column width through the table handle', async () => { + const data = tableData(); + const { page } = await tablePage(data); + expect(await currentTable.get()!.run({ op: 'columnWidth', width: 240 })).toBe(true); + expect(held(page, data).columns[0].width).toBe(240); + }); + + it('opens the column width field and selects the table', async () => { + const { editor } = await tablePage(); + await run('table.columnWidth'); + await vi.waitFor(() => expect(document.querySelector('input')).not.toBeNull()); + await run('table.select'); + expect(editor.state.selection).toBeInstanceOf(CellSelection); + }); + + it('deletes the table as one block', async () => { + const { page } = await tablePage(); + await run('table.deleteTable'); + expect(page.sent().at(-1)!.edits).toEqual([{ edit: 'deleteBlocks', blocks: [TABLE] }]); + expect(page.mounted.layer.view(TABLE)).toBeNull(); + }); + + it('inserts a 3 by 3 table with a header row after the text', async () => { + const fixture = textPageFixture('First\n\nSecond'); + const text = fixture.page.blocks[0].id; + const page = await renderPage({ fixture, flags: { 'page.tables': true } }); + page.mounted.pool.mount(text, { kind: 'start' }, 'target'); + await run('insert.table'); + const edits = page.sent().at(-1)!.edits; + expect(edits.map((edit) => edit.edit)).toEqual(['setText', 'insertBlock', 'insertBlock']); + const inserted = edits[1] as Extract; + const data = inserted.block.data as unknown as TableData; + expect([data.columns.length, data.rows.length, data.header]).toEqual([3, 3, true]); + expect(page.markdown(text)).toBe('First'); + }); +}); diff --git a/app/src/features/page/blocks/tableBlock.ts b/app/src/features/page/blocks/tableBlock.ts new file mode 100644 index 000000000..b982bb52d --- /dev/null +++ b/app/src/features/page/blocks/tableBlock.ts @@ -0,0 +1,382 @@ +// The table block (ARCHITECTURE.md section 13; owner: WP6). It shows a real
at once, then edits it in a +// table editor through the pool. Cell typing syncs through attachTableSync as a patch of the rows, flushed like +// text. A command that changes the table's shape changes its data first. It then sends its own patch and replaces +// the editor's document, so IDs follow rows and columns. Arrow keys leave a flowing table at its edges. +import type { Editor } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; +import { CellSelection } from '@tiptap/pm/tables'; +import { createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import type { Root } from 'react-dom/client'; +import { commandContext } from '../../../commands/registry'; +import { menuItemsFor } from '../../../commands/menus'; +import { applyTableOp, cellAt, cellTextPos } from '../../../editor/commands/tables'; +import type { CellAt, TableChange, TableOp } from '../../../editor/commands/tables'; +import { createBlockEditor } from '../../../editor/extensions/kit'; +import { META_REMOTE } from '../../../editor/meta'; +import { renderStatic } from '../../../editor/schema/dom'; +import type { TableData } from '../../../editor/schema/specs'; +import { docToTableData, normalizeTableData, sameTableData, tableDataToDoc } from '../../../editor/table/mapping'; +import { tableName } from '../../../editor/table/tableKit'; +import type { BlockJson } from '../../../services/pages/types'; +import { t } from '../../../strings/t'; +import type { MessageKey } from '../../../strings/t'; +import { openMenu } from '../../../ui'; +import { shownLayer } from '../mount'; +import type { StaticPool } from '../pool/pool'; +import { blockLaidOut } from '../seams/geometry'; +import { acceptRemoteText, attachTableSync } from '../sync'; +import type { TableSyncHandle } from '../sync'; +import { currentTable } from '../tables/current'; +import type { TableHandle } from '../tables/current'; +import type { InnerView } from '../tables/lazyView'; +import { attachColumnHandles } from '../tables/columnResize'; +import '../tables/commands'; +import { TableToolbar } from '../tables/TableToolbar'; +import tableStyles from '../tables/tables.module.css'; +import styles from './blocks.module.css'; +import { placeBlock } from './textBlock'; +import type { BlockRenderContext } from './types'; + +const ANNOUNCE: Partial> = { + rowAbove: 'tables.announce.rowAdded', + rowBelow: 'tables.announce.rowAdded', + columnLeft: 'tables.announce.columnAdded', + columnRight: 'tables.announce.columnAdded', + deleteRow: 'tables.announce.rowDeleted', + deleteColumn: 'tables.announce.columnDeleted', +}; + +/** What a screen reader hears after a table command. */ +function announcement(op: TableOp, change: TableChange): string { + const key = ANNOUNCE[op.op]; + if (key) return t(key); + if (op.op === 'headerRow') return t(change.data.header ? 'tables.announce.headerOn' : 'tables.announce.headerOff'); + if (op.op === 'columnWidth') return t('tables.announce.width', { width: op.width }); + return t('tables.announce.rowMoved', { position: (change.caret?.row ?? 0) + 1 }); +} + +const isFloating = (block: BlockJson) => block.frame?.x !== undefined && block.frame?.y !== undefined; + +/** The flowing block before or after this one, by the page's order on screen. */ +function flowingNeighbor(element: HTMLElement, direction: -1 | 1): HTMLElement | null { + const step = (at: Element) => (direction < 0 ? at.previousElementSibling : at.nextElementSibling); + for (let at = step(element); at; at = step(at)) { + if (at instanceof HTMLElement && at.dataset.blockId && !at.classList.contains(styles.floating)) return at; + } + return null; +} + +const DIRECTIONS: Record = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }; + +/** The direction an arrow key leaves the table in, or 0 when the caret isn't at that edge. */ +function leavingDirection(view: EditorView, event: KeyboardEvent): -1 | 0 | 1 { + const direction = DIRECTIONS[event.key] ?? 0; + if (!direction || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return 0; + const at = cellAt(view.state); + const table = view.state.doc.firstChild; + if (!at || !table || !view.state.selection.empty) return 0; + const lastRow = table.childCount - 1; + const vertical = event.key === 'ArrowUp' || event.key === 'ArrowDown'; + const atEdge = vertical + ? at.row === (direction < 0 ? 0 : lastRow) + : direction < 0 + ? at.row === 0 && at.column === 0 + : at.row === lastRow && at.column === table.child(lastRow).childCount - 1; + const side = vertical ? (direction < 0 ? 'up' : 'down') : direction < 0 ? 'left' : 'right'; + return atEdge && view.endOfTextblock(side) ? direction : 0; +} + +/** The editors of mounted table blocks, so a test or the palette can find a table's handle. */ +const handles = new WeakMap(); + +export function tableHandleOf(editor: Editor): TableHandle | null { + return handles.get(editor) ?? null; +} + +class TableView implements InnerView { + readonly editRoot: HTMLElement; + readonly handle: TableHandle; + private readonly scroller: HTMLElement; + private readonly toolbarHost: HTMLElement; + private readonly pool: Partial & BlockRenderContext['pool']; + private data: TableData; + private editor: Editor | null = null; + private sync: TableSyncHandle | null = null; + private toolbar: Root | null = null; + private widthOpen = false; + private stopHandles = () => {}; + private stopActive = () => {}; + + constructor( + readonly element: HTMLElement, + private current: BlockJson, + private readonly ctx: BlockRenderContext, + ) { + this.data = normalizeTableData(current.data); + this.pool = ctx.pool as Partial & BlockRenderContext['pool']; + element.classList.add(styles.block, tableStyles.wrapper); + placeBlock(element, current); + this.toolbarHost = element.appendChild(document.createElement('div')); + this.scroller = element.appendChild(document.createElement('div')); + this.scroller.className = tableStyles.scroller; + this.editRoot = this.scroller.appendChild(document.createElement('div')); + this.editRoot.className = `${styles.text} ${tableStyles.table}`; + this.editRoot.dataset.scope = 'editor editor.table'; + this.handle = { + block: current.id, + can: (op) => applyTableOp(this.data, this.caretCell(), op) !== null, + header: () => this.data.header, + run: (op) => this.run(op), + deleteTable: () => this.deleteTable(), + selectAll: () => this.selectAll(), + openColumnWidth: () => this.openColumnWidth(), + }; + // Static first: the same DOM the editor will show, so nothing moves when it mounts. + this.showStatic(tableDataToDoc(this.data)); + this.mountEditor(); + } + + private name(doc: PMNode): void { + this.element.setAttribute('aria-label', tableName(doc)); + } + + private showStatic(doc: PMNode): void { + renderStatic(doc, this.editRoot); + this.editRoot.querySelectorAll('th').forEach((th) => th.setAttribute('scope', 'col')); + this.editRoot.querySelector('table')?.setAttribute('aria-label', tableName(doc)); + this.name(doc); + } + + private caretCell(): CellAt { + return (this.editor && cellAt(this.editor.state)) || { row: 0, column: 0 }; + } + + /** The data with the editor's latest typing. */ + private latest(): TableData { + if (this.editor) this.data = docToTableData(this.editor.state.doc, this.data, this.ctx.cache); + return this.data; + } + + /** Shows `next` in the editor without sending it, with the caret in `caret`. */ + private show(next: TableData, caret: CellAt | null): void { + this.data = next; + const editor = this.editor; + if (!editor) return this.showStatic(tableDataToDoc(next)); + const { state } = editor; + const doc = tableDataToDoc(next, editor.schema); + const tr = state.tr.replaceWith(0, state.doc.content.size, doc.content).setMeta(META_REMOTE, true); + const pos = caret ? cellTextPos(tr.doc, caret) : null; + if (pos !== null) tr.setSelection(TextSelection.create(tr.doc, pos)); + editor.view.dispatch(tr); + this.name(editor.state.doc); + blockLaidOut(this.current.id); + this.renderToolbar(); + } + + private send(next: TableData, coalesce?: 'resize'): Promise { + const { header, columns, rows } = next; + const block = this.current.id; + return this.ctx.sync.send({ + edits: [{ edit: 'patchBlock', block, data: { header, columns, rows } }], + ...(coalesce ? { coalesce: { kind: coalesce, target: block } } : {}), + }); + } + + private async run(op: TableOp): Promise { + if (this.sync) await this.sync.flush('command'); + const change = applyTableOp(this.latest(), this.caretCell(), op); + if (!change) return false; + this.show(change.data, change.caret); + this.editor?.commands.focus(); + this.ctx.host.announce(announcement(op, change)); + await this.send(change.data); + return true; + } + + private async deleteTable(): Promise { + if (this.sync) await this.sync.flush('command'); + const id = this.current.id; + const before = flowingNeighbor(this.element, -1) ?? flowingNeighbor(this.element, 1); + await this.ctx.sync.send({ edits: [{ edit: 'deleteBlocks', blocks: [id] }] }); + this.ctx.host.announce(t('tables.announce.tableDeleted')); + shownLayer.get()?.remove(id); + const next = before?.dataset.blockId; + if (next && this.ctx.pool.mount(next, { kind: 'end' }, 'focus')) this.ctx.pool.editor(next)?.commands.focus('end'); + else before?.focus(); + } + + private selectAll(): void { + const editor = this.editor; + if (!editor) return; + const { doc } = editor.state; + const lastRow = doc.firstChild!.lastChild!; + // From the first cell's start to the last cell's start. + const head = doc.content.size - 2 - lastRow.lastChild!.nodeSize; + editor.view.dispatch(editor.state.tr.setSelection(CellSelection.create(doc, 2, head))); + editor.commands.focus(); + this.ctx.host.announce(t('tables.announce.selected')); + } + + private openColumnWidth(): void { + this.widthOpen = true; + currentTable.set(this.handle); + this.setCurrent(true); + } + + private closeColumnWidth(width: number | null): void { + this.widthOpen = false; + this.renderToolbar(); + this.editor?.commands.focus(); + if (width !== null) void this.run({ op: 'columnWidth', width }); + } + + private renderToolbar(): void { + if (currentTable.get() !== this.handle && !this.widthOpen) return; + this.toolbar ??= createRoot(this.toolbarHost); + const caret = this.caretCell(); + const columns = this.data.columns; + this.toolbar.render( + createElement(TableToolbar, { + can: this.handle.can, + header: this.data.header, + run: (op: TableOp) => void this.run(op), + more: (anchor: HTMLElement) => void this.openMore(anchor), + width: columns[Math.min(caret.column, columns.length - 1)]?.width ?? 0, + widthOpen: this.widthOpen, + openWidth: this.handle.openColumnWidth, + closeWidth: (width: number | null) => this.closeColumnWidth(width), + }), + ); + } + + private async openMore(anchor: HTMLElement): Promise { + const ids = ['table.moveRowUp', 'table.moveRowDown', 'table.select', 'table.deleteTable']; + const items = menuItemsFor('page.table', commandContext('menu')).filter((item) => ids.includes(item.id)); + const returnFocus = this.editor?.view.dom ?? null; + await openMenu({ label: t('tables.toolbar.more'), items, anchor, returnFocus }); + } + + private setCurrent(on: boolean): void { + if (on) currentTable.set(this.handle); + else if (currentTable.get() === this.handle) currentTable.set(null); + this.element.toggleAttribute('data-current', on); + if (on) this.renderToolbar(); + } + + private mountEditor(): void { + if (this.editor || this.ctx.page.readOnly) return; + const block = this.current.id; + const editor = createBlockEditor(this.editRoot, tableDataToDoc(this.data), { + kind: 'table', + block, + host: this.ctx.host, + }); + this.editor = editor; + editor.view.dom.setAttribute('data-app-menu', 'table'); + editor.view.dom.setAttribute('aria-label', t('tables.block')); + editor.registerPlugin(this.edgeKeys()); + handles.set(editor, this.handle); + this.pool.adopt?.(block, editor); + this.sync = attachTableSync(editor, block, this.ctx.sync, (doc) => { + this.data = docToTableData(doc, this.data, this.ctx.cache); + return this.data; + }); + this.stopActive = this.ctx.pool.onActiveChange((active) => { + // Focus visiting the palette or a menu keeps this the current table. + if (active !== null) this.setCurrent(active === block); + }); + editor.on('focus', () => this.setCurrent(true)); + editor.on('selectionUpdate', () => this.renderToolbar()); + editor.on('transaction', ({ transaction }) => { + if (transaction.docChanged) this.afterTyping(); + }); + this.placeHandles(); + } + + /** Keeps the data, the name, and the column edges in step with the editor's document. */ + private afterTyping(): void { + if (!this.editor) return; + const before = this.data.columns; + const data = docToTableData(this.editor.state.doc, this.data, this.ctx.cache); + this.data = data; + this.name(this.editor.state.doc); + const moved = + before.length !== data.columns.length || before.some((column, i) => column.width !== data.columns[i].width); + if (moved) this.placeHandles(); + } + + /** Arrow keys at a flowing table's edges move to the flowing block beside it. */ + private edgeKeys(): Plugin { + return new Plugin({ + key: new PluginKey('opennote.tableEdges'), + props: { + handleKeyDown: (view: EditorView, event: KeyboardEvent) => { + if (isFloating(this.current)) return false; + const direction = leavingDirection(view, event); + const neighbor = direction ? flowingNeighbor(this.element, direction) : null; + const id = neighbor?.dataset.blockId; + if (!neighbor || !id) return false; + const target = direction < 0 ? { kind: 'end' as const } : { kind: 'start' as const }; + const next = this.ctx.pool.mount(id, target, 'focus'); + if (next) next.commands.focus(target.kind); + else neighbor.focus(); + return true; + }, + }, + }); + } + + private placeHandles(): void { + this.stopHandles(); + const resize = (column: number, width: number) => this.resize(column, width); + this.stopHandles = attachColumnHandles(this.scroller, this.data.columns, this.ctx.viewport, resize); + } + + /** The end of a column edge drag: one change, coalesced with other resizes of the table. */ + private resize(column: number, width: number): void { + const caret = this.editor ? this.caretCell() : null; + const change = applyTableOp(this.latest(), { row: 0, column }, { op: 'columnWidth', width }); + if (!change) return this.placeHandles(); + this.show(change.data, caret); + this.ctx.host.announce(t('tables.announce.width', { width: change.data.columns[column].width })); + void this.send(change.data, 'resize'); + } + + update(next: BlockJson): void { + this.current = next; + placeBlock(this.element, next); + const incoming = normalizeTableData(next.data); + if (sameTableData(incoming, this.latest())) return; + const caret = this.editor ? this.caretCell() : null; + const rows = incoming.rows.length - 1; + const columns = incoming.columns.length - 1; + this.show(incoming, caret && { row: Math.min(caret.row, rows), column: Math.min(caret.column, columns) }); + // The core now holds these rows, so typing back to the old ones is still sent. + if (this.sync) acceptRemoteText(this.sync, JSON.stringify({ rows: this.data.rows })); + } + + measure() { + const { element } = this; + return { x: element.offsetLeft, y: element.offsetTop, w: element.offsetWidth, h: element.offsetHeight }; + } + + destroy(): void { + this.stopHandles(); + this.stopActive(); + this.setCurrent(false); + this.sync?.detach(); + if (this.pool.release) this.pool.release(this.current.id); + else this.editor?.destroy(); + const root = this.toolbar; + this.toolbar = null; + // React warns when a root unmounts during another root's render, so it waits a task. + if (root) setTimeout(() => root.unmount(), 0); + } +} + +export function createInner(wrapper: HTMLElement, block: BlockJson, ctx: BlockRenderContext): InnerView { + return new TableView(wrapper, block, ctx); +} diff --git a/app/src/features/page/blocks/textBlock.ts b/app/src/features/page/blocks/textBlock.ts new file mode 100644 index 000000000..0372a28c4 --- /dev/null +++ b/app/src/features/page/blocks/textBlock.ts @@ -0,0 +1,247 @@ +// The text block renderer (ARCHITECTURE.md section 7; owner after WP0: WP3). A text block opens as static DOM from +// its parsed Markdown, inside the element its editor will edit. The pool mounts the editor in place on pointer +// down, focus, a target, or in idle time, and demotes it back to static DOM when it is far away and idle. The +// editing root keeps its role, name, and focus through both, so assistive technology sees one node throughout. +import type { Editor } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { createBlockEditor } from '../../../editor/extensions/kit'; +import { highlightStatic } from '../../../editor/highlight'; +import { parseTextBlock } from '../../../editor/markdown'; +import { renderStatic } from '../../../editor/schema/dom'; +import type { BlockId, BlockJson } from '../../../services/pages/types'; +import { t } from '../../../strings/t'; +import type { PagePool } from '../pool/pool'; +import { pageView } from '../runtime'; +import { blockLaidOut } from '../seams/geometry'; +import { attachTextSync } from '../sync'; +import type { PendingInsert, TextSyncHandle } from '../sync'; +import layoutStyles from '../layout/layout.module.css'; +import styles from './blocks.module.css'; +import type { BlockRenderContext, BlockRendererDef, BlockView } from './types'; + +const drafts = new WeakMap(); +const syncs = new WeakMap(); +const ephemeral = new WeakSet(); + +/** Fired on a draft's wrapper when it loses focus empty and untouched; the page removes it unsaved. */ +export const DISCARD_DRAFT = 'opennote-discard-draft'; + +/** A text box that isn't in page.json yet. Its first change inserts it. */ +export function markDraft(block: BlockJson, insert: PendingInsert): BlockJson { + drafts.set(block, insert); + return block; +} + +/** A draft that disappears if it loses focus while still empty: a caret for a new text box. */ +export function markEphemeral(block: BlockJson): BlockJson { + ephemeral.add(block); + return block; +} + +/** The sync of a mounted text block's editor. */ +export function syncOf(editor: Editor): TextSyncHandle | null { + return syncs.get(editor) ?? null; +} + +/** A view the block layer renders in two steps: a sized wrapper first, its content when it is near the viewport. */ +export interface LazyBlockView extends BlockView { + readonly rendered: boolean; + render(): void; +} + +export function isLazy(view: BlockView): view is LazyBlockView { + return typeof (view as Partial).render === 'function'; +} + +const markdownOf = (block: BlockJson) => (typeof block.data.markdown === 'string' ? block.data.markdown : ''); + +/** Whether a block sits at its own frame rather than in the flow. */ +export function isFloating(block: BlockJson): boolean { + return block.frame?.x !== undefined && block.frame?.y !== undefined; +} + +/** Floating blocks sit at their frame; flowing ones stack in the column. */ +export function placeBlock(element: HTMLElement, block: BlockJson): void { + const frame = block.frame; + const floating = isFloating(block); + element.classList.toggle(styles.floating, floating); + element.style.left = floating ? `${frame!.x}px` : ''; + element.style.top = floating ? `${frame!.y}px` : ''; + element.style.inlineSize = frame?.w !== undefined ? `${frame.w}px` : ''; + // A floating text box without a width is as wide as its content (ARCHITECTURE.md section 6.3). + const text = floating && block.type === 'text'; + element.classList.toggle(layoutStyles.autoWidth, text && frame?.w === undefined); + element.classList.toggle(layoutStyles.setWidth, text && frame?.w !== undefined); +} + +/** The height an unrendered block holds: the one remembered on this device, or a guess from its Markdown. */ +function heldHeight(page: BlockRenderContext['page'], block: BlockId, markdown: string): number { + const remembered = pageView(page.id)?.heights?.[block]; + if (remembered) return remembered; + const lines = markdown.split('\n').reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / 72)), 0); + return lines * 24; +} + +function attributesOf(element: HTMLElement): [string, string][] { + return [...element.attributes].map((attribute) => [attribute.name, attribute.value]); +} + +function restoreAttributes(element: HTMLElement, saved: readonly [string, string][]): void { + for (const name of element.getAttributeNames()) element.removeAttribute(name); + for (const [name, value] of saved) element.setAttribute(name, value); +} + +function editingRoot(block: BlockJson): HTMLElement { + const root = document.createElement('div'); + root.className = 'ProseMirror'; + root.setAttribute('role', 'textbox'); + root.setAttribute('aria-multiline', 'true'); + root.setAttribute('aria-label', t('page.block.textEditor')); + root.setAttribute('spellcheck', 'false'); + root.setAttribute('autocapitalize', 'sentences'); + root.tabIndex = 0; + root.dataset.scope = 'editor'; + root.dataset.block = block.id; + return root; +} + +class TextBlockView implements LazyBlockView { + readonly element: HTMLElement; + readonly editRoot: HTMLElement; + rendered = false; + private markdown: string; + private doc: PMNode | null = null; + private editor: Editor | null = null; + private sync: TextSyncHandle | null = null; + private draft: PendingInsert | null; + private readonly ephemeral: boolean; + private touched = false; + private readonly saved: [string, string][]; + private readonly unregister: () => void; + + constructor( + private block: BlockJson, + private readonly ctx: BlockRenderContext, + ) { + this.markdown = markdownOf(block); + this.draft = drafts.get(block) ?? null; + this.ephemeral = ephemeral.has(block); + this.element = document.createElement('div'); + this.element.className = styles.block; + this.element.setAttribute('role', 'group'); + this.element.setAttribute('aria-label', t('page.block.text')); + this.element.tabIndex = -1; + this.element.dataset.blockId = block.id; + const box = document.createElement('div'); + box.className = styles.text; + box.style.setProperty('--placeholder', JSON.stringify(t('page.block.placeholder'))); + this.editRoot = editingRoot(block); + this.editRoot.style.minBlockSize = `${heldHeight(ctx.page, block.id, this.markdown)}px`; + this.saved = attributesOf(this.editRoot); + box.append(this.editRoot); + this.element.append(box); + placeBlock(this.element, block); + this.editRoot.addEventListener('focus', this.onFocus); + this.unregister = (ctx.pool as PagePool).register(block.id, { + root: this.editRoot, + mount: () => this.mount(), + unmount: () => this.unmount(), + dirty: () => (this.sync?.dirty ?? false) || this.draft !== null, + }); + // A new text box is ready for typing at once, with its placeholder showing. + if (this.draft) ctx.pool.mount(block.id, null, 'target'); + } + + render(): void { + if (this.rendered || this.editor) return; + this.rendered = true; + this.doc ??= parseTextBlock(this.markdown); + this.drawStatic(this.doc); + this.editRoot.style.minBlockSize = ''; + blockLaidOut(this.block.id); + } + + /** Draws the static text. Its code is colored in idle time, so it looks the same before an editor mounts. */ + private drawStatic(doc: PMNode): void { + renderStatic(doc, this.editRoot); + if (this.ctx.host.flag('page.codeHighlight')) void highlightStatic(this.editRoot, doc).catch(() => undefined); + } + + update(next: BlockJson): void { + const markdown = markdownOf(next); + this.block = next; + placeBlock(this.element, next); + if (this.editor || markdown === this.markdown) return; + this.markdown = markdown; + this.doc = null; + if (!this.rendered) return; + this.rendered = false; + this.render(); + } + + measure() { + const { element } = this; + return { x: element.offsetLeft, y: element.offsetTop, w: element.offsetWidth, h: element.offsetHeight }; + } + + destroy(): void { + this.editRoot.removeEventListener('focus', this.onFocus); + this.unregister(); + this.element.remove(); + } + + private mount(): Editor { + this.render(); + const doc = this.doc ?? parseTextBlock(this.markdown); + const editor = createBlockEditor(this.editRoot, doc, { kind: 'text', block: this.block.id, host: this.ctx.host }); + const sync = attachTextSync(editor, this.block.id, this.ctx.sync, this.ctx.cache, this.markdown, this.draft); + syncs.set(editor, sync); + editor.on('update', this.onUpdate); + editor.on('blur', this.onBlur); + this.editor = editor; + this.sync = sync; + return editor; + } + + private unmount(): void { + const { editor, sync } = this; + if (!editor || !sync) return; + this.doc = editor.state.doc; + this.markdown = sync.lastSent(); + this.draft = null; + sync.detach(); + editor.off('update', this.onUpdate); + editor.off('blur', this.onBlur); + editor.destroy(); + this.editor = null; + this.sync = null; + const name = this.editRoot.getAttribute('aria-label'); + restoreAttributes(this.editRoot, this.saved); + if (name) this.editRoot.setAttribute('aria-label', name); + this.editRoot.style.minBlockSize = ''; + this.drawStatic(this.doc); + } + + private readonly onUpdate = () => { + this.touched = true; + blockLaidOut(this.block.id); + }; + + private readonly onBlur = () => { + if (!this.ephemeral || this.touched || (this.editor?.state.doc.textContent ?? '') !== '') return; + const discard = new CustomEvent(DISCARD_DRAFT, { bubbles: true, detail: this.block.id }); + queueMicrotask(() => this.element.dispatchEvent(discard)); + }; + + /** Tab, a screen reader, or a script focused the static root: mount with the caret where it last was. */ + private readonly onFocus = () => { + if (!this.editor) this.ctx.pool.mount(this.block.id, { kind: 'remembered' }, 'focus'); + }; +} + +export const textBlockRenderer: BlockRendererDef = { + id: 'text', + types: ['text'], + priority: 0, + create: (block, ctx) => new TextBlockView(block, ctx), +}; diff --git a/app/src/features/page/blocks/types.ts b/app/src/features/page/blocks/types.ts new file mode 100644 index 000000000..6e1e3ccda --- /dev/null +++ b/app/src/features/page/blocks/types.ts @@ -0,0 +1,51 @@ +// What a block renderer gets and gives back (PLAN.md section 3.7, owned by WP0). Phase 5 registers its ink +// renderer with the same types. +import type { FlagId } from '../../../app/flags'; +import type { MarkdownCache } from '../../../editor/markdown'; +import type { EditorHost } from '../../../editor/host'; +import type { BlockId, BlockJson, OpenPage, PageJson, PageRect } from '../../../services/pages/types'; +import type { EditorPool } from '../pool/pool'; +import type { SyncQueue } from '../sync'; +import type { PageViewportApi } from '../viewport/viewport'; + +export interface BlockRenderContext { + readonly page: OpenPage; + readonly host: EditorHost; + readonly viewport: PageViewportApi; + readonly pool: EditorPool; + readonly sync: SyncQueue; + readonly cache: MarkdownCache; + /** The compact Reading view. */ + readonly reading: boolean; +} + +export interface BlockView { + /** The wrapper: role="group", tabindex="-1" (images: "0"). */ + readonly element: HTMLElement; + /** The focusable editing root, if any. */ + readonly editRoot: HTMLElement | null; + update(block: BlockJson): void; + /** Page units, from the last layout. */ + measure(): PageRect; + destroy(): void; +} + +export interface BlockRendererDef { + id: string; + types: readonly string[]; + priority: number; + flag?: FlagId; + create(block: BlockJson, ctx: BlockRenderContext): BlockView; +} + +export interface BlockLayer { + /** First render, viewport first. */ + apply(page: PageJson): void; + upsert(block: BlockJson): void; + remove(id: BlockId): void; + /** A pinned reorder: never moves the focused wrapper. */ + setOrder(order: readonly BlockId[]): void; + view(id: BlockId): BlockView | null; + /** Before the ink layer (ARCHITECTURE.md section 6.5). */ + insertionPoint(kind: 'floating'): { before?: BlockId; after?: BlockId }; +} diff --git a/app/src/features/page/blocks/unknownBlock.ts b/app/src/features/page/blocks/unknownBlock.ts new file mode 100644 index 000000000..2444e52ea --- /dev/null +++ b/app/src/features/page/blocks/unknownBlock.ts @@ -0,0 +1,44 @@ +// Blocks no renderer handles yet (owner after WP0: WP3): a placeholder that names the block and shows its fallback +// text, so nothing on the page is lost or hidden. Images and tables use it until WP5 and WP6 register theirs. +import type { BlockJson } from '../../../services/pages/types'; +import { t } from '../../../strings/t'; +import styles from './blocks.module.css'; +import { placeBlock } from './textBlock'; +import type { BlockRendererDef } from './types'; + +function label(block: BlockJson): string { + const alt = typeof block.data.alt === 'string' ? block.data.alt : ''; + if (block.type === 'image') return alt ? t('page.block.image', { alt }) : t('page.block.imageUnnamed'); + if (block.type === 'table') return t('page.block.table'); + return block.fallback?.markdown || t('page.block.unknown', { type: block.type }); +} + +export const unknownBlockRenderer: BlockRendererDef = { + id: 'unknown', + types: [], + priority: -1, + create(block) { + const element = document.createElement('div'); + element.className = `${styles.block} ${styles.unknown}`; + element.setAttribute('role', 'group'); + element.tabIndex = -1; + element.dataset.blockId = block.id; + const text = () => { + element.textContent = label(block); + element.setAttribute('aria-label', label(block)); + }; + text(); + placeBlock(element, block); + return { + element, + editRoot: null, + update(next) { + block = next; + text(); + placeBlock(element, next); + }, + measure: () => ({ x: element.offsetLeft, y: element.offsetTop, w: element.offsetWidth, h: element.offsetHeight }), + destroy: () => element.remove(), + }; + }, +}; diff --git a/app/src/features/page/chrome/chrome.module.css b/app/src/features/page/chrome/chrome.module.css new file mode 100644 index 000000000..a778dc7e0 --- /dev/null +++ b/app/src/features/page/chrome/chrome.module.css @@ -0,0 +1,117 @@ +@layer components { + /* The chrome layer (ARCHITECTURE.md section 11.2): screen space above the world, pointer events on handles only. */ + .chrome { + position: absolute; + inset: 0; + overflow: hidden; + z-index: var(--layer-page-chrome); + pointer-events: none; + contain: strict; + } + + .chrome > * { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + } + + .outline { + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-sm); + } + + .selected { + border: 2px solid var(--color-accent-primary); + } + + /* A grip bar along the top of a text box, like OneNote's container header. The hit area is the whole box; the + mark is a short bar at its bottom. */ + .grip, + .width { + pointer-events: auto; + touch-action: none; + } + + .grip { + cursor: move; + } + + .grip::after { + content: ''; + position: absolute; + inset-inline: 0; + inset-block-end: 2px; + block-size: 6px; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + background: var(--color-border-control); + } + + .grip:hover::after { + background: var(--color-accent-primary); + } + + .width { + cursor: ew-resize; + } + + .width::after { + content: ''; + position: absolute; + inset-block: 25%; + inset-inline-start: calc(50% - 2px); + inline-size: 4px; + border-radius: var(--radius-pill); + background: var(--color-accent-primary); + } + + .lock::after { + content: ''; + position: absolute; + inset-block-start: -10px; + inset-inline-end: -10px; + inline-size: 12px; + block-size: 12px; + border: 2px solid var(--color-text-secondary); + border-radius: 3px; + background: var(--color-surface-raised); + } + + .marquee { + border: 1px dashed var(--color-accent-primary); + background: color-mix(in srgb, var(--color-accent-primary-subtle) 50%, transparent); + } + + .dropLine { + block-size: 2px; + background: var(--color-accent-primary); + } + + .badge { + inset-block-start: var(--space-2); + inset-inline: auto var(--space-4); + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-pill); + color: var(--color-text-on-accent); + background: var(--color-accent-primary); + font-size: var(--type-caption-size); + } + + /* While a block moves, it gets a layer of its own (ADR 0005 rule 3). */ + :global([data-dragging]) { + will-change: transform; + } + + @media (forced-colors: active) { + .selected, + .marquee { + border-color: Highlight; + } + + .grip::after, + .width::after, + .dropLine, + .badge { + background: Highlight; + } + } +} diff --git a/app/src/features/page/chrome/chrome.ts b/app/src/features/page/chrome/chrome.ts new file mode 100644 index 000000000..98145023e --- /dev/null +++ b/app/src/features/page/chrome/chrome.ts @@ -0,0 +1,201 @@ +// The chrome layer (ARCHITECTURE.md section 11.2; owner WP3): grips, handles, outlines, the marquee, the drop +// indicator, and the count badge, drawn in screen space above the world. It is a sibling of the viewport, so it +// never scales or clips with the page. It reads block rectangles once per animation frame, and only while +// something is selected, hovered, edited, or moving. Pointer events reach only the handles. +import type { BlockId, BlockJson } from '../../../services/pages/types'; +import { getDensity } from '../../../state/layout'; +import { t } from '../../../strings/t'; +import type { PageBlockLayer } from '../blocks/blockLayer'; +import { isFloating } from '../blocks/textBlock'; +import type { PagePool } from '../pool/pool'; +import { pageSelection } from '../seams/selectionStore'; +import type { PageViewport } from '../viewport/viewport'; +import styles from './chrome.module.css'; + +export interface ChromeParts { + readonly container: HTMLElement; + readonly viewport: PageViewport; + readonly layer: PageBlockLayer; + readonly pool: PagePool; +} + +export interface Chrome { + readonly element: HTMLElement; + /** A client rectangle to draw as the marquee, or null. */ + setMarquee(rect: DOMRectReadOnly | null): void; + /** A client y to draw the flow's insertion line at, or null. */ + setDropLine(y: number | null): void; + /** Redraw in the next frame. */ + update(): void; + destroy(): void; +} + +/** Handles' hit areas at mouse and touch density (BRAND.md section 6). */ +const MOUSE_HIT = 32; +const TOUCH_HIT = 44; + +function div(parent: HTMLElement, className: string): HTMLElement { + const element = parent.ownerDocument.createElement('div'); + element.className = className; + parent.append(element); + return element; +} + +function name(block: BlockJson): string { + const markdown = typeof block.data.markdown === 'string' ? block.data.markdown : ''; + return ( + markdown + .split('\n')[0]! + .replace(/[#>*_`[\]-]/g, '') + .trim() + .slice(0, 40) || t('page.block.text') + ); +} + +class ChromeLayer implements Chrome { + readonly element: HTMLElement; + private readonly marquee: HTMLElement; + private readonly dropLine: HTMLElement; + private readonly badge: HTMLElement; + private readonly marks = new Map(); + private hovered: BlockId | null = null; + private frame = 0; + private readonly stops: (() => void)[] = []; + + constructor(private readonly parts: ChromeParts) { + this.element = div(parts.container, styles.chrome); + this.element.setAttribute('aria-hidden', 'true'); + this.marquee = div(this.element, styles.marquee); + this.dropLine = div(this.element, styles.dropLine); + this.badge = div(this.element, styles.badge); + this.marquee.hidden = this.dropLine.hidden = this.badge.hidden = true; + const world = parts.viewport.world; + world.addEventListener('pointerover', this.onOver); + world.addEventListener('pointerleave', this.onLeave); + this.stops.push( + () => world.removeEventListener('pointerover', this.onOver), + () => world.removeEventListener('pointerleave', this.onLeave), + pageSelection.subscribe(() => this.update()), + parts.pool.onActiveChange(() => this.update()), + parts.viewport.onCamera(() => this.update()), + parts.layer.onChange(() => this.update()), + ); + } + + setMarquee(rect: DOMRectReadOnly | null): void { + this.marquee.hidden = rect === null; + if (!rect) return; + const origin = this.element.getBoundingClientRect(); + this.place(this.marquee, new DOMRect(rect.x - origin.left, rect.y - origin.top, rect.width, rect.height)); + } + + setDropLine(y: number | null): void { + this.dropLine.hidden = y === null; + if (y === null) return; + const flow = this.parts.layer.blocks().find((block) => !isFloating(block)); + const box = flow ? this.parts.layer.view(flow.id)?.element.getBoundingClientRect() : null; + const origin = this.element.getBoundingClientRect(); + this.dropLine.style.transform = `translate(${(box?.left ?? origin.left) - origin.left}px, ${y - origin.top}px)`; + this.dropLine.style.inlineSize = `${box?.width ?? 200}px`; + } + + update(): void { + this.frame ||= requestAnimationFrame(() => { + this.frame = 0; + this.draw(); + }); + } + + destroy(): void { + cancelAnimationFrame(this.frame); + this.stops.forEach((stop) => stop()); + this.element.remove(); + } + + /** The blocks with chrome: the selection, the hovered block, and the block being edited. */ + private shown(): { block: BlockJson; selected: boolean; editing: boolean }[] { + const selected = new Set(pageSelection.get().blocks); + const editing = this.parts.pool.active()?.block ?? null; + const ids = new Set([...selected, ...(this.hovered ? [this.hovered] : []), ...(editing ? [editing] : [])]); + return [...ids].flatMap((id) => { + const block = this.parts.layer.block(id); + return block ? [{ block, selected: selected.has(id), editing: id === editing }] : []; + }); + } + + private draw(): void { + this.element.dataset.density = getDensity(); + const used = new Set(); + const origin = this.element.getBoundingClientRect(); + for (const { block, selected, editing } of this.shown()) { + const element = this.parts.layer.view(block.id)?.element; + if (!element) continue; + const rect = element.getBoundingClientRect(); + const box = new DOMRect(rect.left - origin.left, rect.top - origin.top, rect.width, rect.height); + this.mark(used, `outline:${block.id}`, styles.outline!, box).classList.toggle(styles.selected!, selected); + if (block.type !== 'text') continue; + if (block.lock) { + this.mark(used, `lock:${block.id}`, styles.lock!, box); + continue; + } + this.handle(used, block, 'grip', box); + if ((selected || editing) && isFloating(block)) this.handle(used, block, 'width', box); + } + for (const [key, element] of this.marks) { + if (used.has(key)) continue; + element.remove(); + this.marks.delete(key); + } + const count = pageSelection.get().blocks.length; + this.badge.hidden = count < 2; + this.badge.textContent = t('page.selection.badge', { count }); + } + + /** A grip bar along the top of a text box, or a width handle on its right edge, with a full-size hit area. */ + private handle(used: Set, block: BlockJson, kind: 'grip' | 'width', box: DOMRect): void { + const hit = getDensity() === 'touch' ? TOUCH_HIT : MOUSE_HIT; + const area = + kind === 'grip' + ? new DOMRect(box.x, box.y - hit, Math.max(box.width, hit), hit) + : new DOMRect(box.right - hit / 2, box.y, hit, Math.max(box.height, hit)); + const element = this.mark(used, `${kind}:${block.id}`, kind === 'grip' ? styles.grip! : styles.width!, area); + element.dataset.handle = kind; + element.dataset.block = block.id; + element.title = t(kind === 'grip' ? 'page.object.grip' : 'page.object.widthHandle', { name: name(block) }); + } + + private mark(used: Set, key: string, className: string, box: DOMRect): HTMLElement { + used.add(key); + let element = this.marks.get(key); + if (!element) { + element = div(this.element, className); + this.marks.set(key, element); + } + this.place(element, box); + return element; + } + + private place(element: HTMLElement, box: DOMRectReadOnly): void { + element.style.transform = `translate(${box.x}px, ${box.y}px)`; + element.style.inlineSize = `${box.width}px`; + element.style.blockSize = `${box.height}px`; + } + + private readonly onOver = (event: PointerEvent) => { + if (event.pointerType === 'touch') return; + const target = event.target instanceof Element ? event.target : null; + const hovered = target?.closest('[data-block-id]')?.dataset.blockId ?? null; + if (hovered === this.hovered) return; + this.hovered = hovered; + this.update(); + }; + + private readonly onLeave = () => { + this.hovered = null; + this.update(); + }; +} + +export function createChrome(parts: ChromeParts): Chrome { + return new ChromeLayer(parts); +} diff --git a/app/src/features/page/chrome/menu.ts b/app/src/features/page/chrome/menu.ts new file mode 100644 index 000000000..2549b2459 --- /dev/null +++ b/app/src/features/page/chrome/menu.ts @@ -0,0 +1,40 @@ +// The object menu (ARCHITECTURE.md sections 11.3, 11.6, and 11.7; owner WP3): right-click, a long press, the +// barrel button, Shift+F10, or the Menu key on a selected block. Every drag on the page has a menu or field +// alternative here, and z-order items at an end say why they are off ("Already in front"). +import type { MessageKey } from '../../../strings/t'; +import { t } from '../../../strings/t'; +import { openMenu } from '../../../ui'; +import type { MenuAnchor, MenuItemSpec } from '../../../ui'; +import type { ObjectCommand, Objects } from '../objects/objects'; + +const ITEMS: readonly { id: ObjectCommand; label: MessageKey; off?: MessageKey; separatorBefore?: boolean }[] = [ + { id: 'edit', label: 'page.object.edit' }, + { id: 'bringToFront', label: 'page.object.bringToFront', off: 'page.object.alreadyFront', separatorBefore: true }, + { id: 'bringForward', label: 'page.object.bringForward', off: 'page.object.alreadyFront' }, + { id: 'sendBackward', label: 'page.object.sendBackward', off: 'page.object.alreadyBack' }, + { id: 'sendToBack', label: 'page.object.sendToBack', off: 'page.object.alreadyBack' }, + { id: 'float', label: 'page.object.float', separatorBefore: true }, + { id: 'putInFlow', label: 'page.object.putInFlow' }, + { id: 'sizeAndPosition', label: 'page.object.sizeAndPosition' }, + { id: 'lockPosition', label: 'page.object.lockPosition', separatorBefore: true }, + { id: 'lock', label: 'page.object.lock' }, + { id: 'unlock', label: 'page.object.unlock' }, + { id: 'delete', label: 'page.object.delete', separatorBefore: true }, +]; + +/** The menu's items for the current selection; items that don't apply are left out or say why they are off. */ +export function objectMenuItems(objects: Objects): MenuItemSpec[] { + return ITEMS.flatMap(({ id, label, off, separatorBefore }) => { + const enabled = objects.enabled(id); + if (!enabled && !off) return []; + const item: MenuItemSpec = { id, label: t(enabled || !off ? label : off), disabled: !enabled }; + if (separatorBefore) item.separatorBefore = true; + if (id === 'delete') item.danger = true; + return [item]; + }); +} + +export async function openObjectMenu(objects: Objects, anchor: MenuAnchor): Promise { + const chosen = await openMenu({ label: t('page.object.menuLabel'), items: objectMenuItems(objects), anchor }); + if (chosen) objects.command(chosen as ObjectCommand); +} diff --git a/app/src/features/page/chrome/sizePosition.module.css b/app/src/features/page/chrome/sizePosition.module.css new file mode 100644 index 000000000..9b400d865 --- /dev/null +++ b/app/src/features/page/chrome/sizePosition.module.css @@ -0,0 +1,46 @@ +@layer components { + /* "Size and position": a small raised card beside the block, above the chrome layer. */ + .popover { + position: absolute; + z-index: var(--layer-dropdown); + display: grid; + grid-template-columns: repeat(2, minmax(6rem, 1fr)); + gap: var(--space-2) var(--space-3); + padding: var(--space-3); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + color: var(--color-text-primary); + background: var(--color-surface-raised); + box-shadow: var(--elevation-2); + font-size: var(--type-caption-size); + } + + .field { + display: grid; + gap: var(--space-1); + } + + .field input { + inline-size: 100%; + min-block-size: 32px; + padding-inline: var(--space-2); + border: 1px solid var(--color-border-control); + border-radius: var(--radius-sm); + color: inherit; + background: var(--color-surface-page); + } + + .popover button { + grid-column: 1 / -1; + min-block-size: 32px; + border: none; + border-radius: var(--radius-sm); + color: var(--color-text-on-accent); + background: var(--color-accent-primary); + } + + .popover :focus-visible { + outline: var(--size-focus-ring-width) solid var(--color-focus-ring); + outline-offset: 1px; + } +} diff --git a/app/src/features/page/chrome/sizePosition.ts b/app/src/features/page/chrome/sizePosition.ts new file mode 100644 index 000000000..2c34e338d --- /dev/null +++ b/app/src/features/page/chrome/sizePosition.ts @@ -0,0 +1,107 @@ +// "Size and position" (ARCHITECTURE.md section 11.3; owner WP3): exact X, Y, width, and height for one block, the +// single-pointer and keyboard alternative to every move and resize drag. Text boxes have no height field, since +// they are as tall as their content, and a flowing block has no X or Y. +import type { BlockId, Frame } from '../../../services/pages/types'; +import { t } from '../../../strings/t'; +import type { MessageKey } from '../../../strings/t'; +import type { PageBlockLayer } from '../blocks/blockLayer'; +import { isFloating } from '../blocks/textBlock'; +import { frameValue } from '../objects/arrange'; +import { MIN_TEXT_WIDTH } from '../objects/objects'; +import type { Objects } from '../objects/objects'; +import styles from './sizePosition.module.css'; + +type Field = 'x' | 'y' | 'w' | 'h'; + +const LABELS: Record = { + x: 'page.object.x', + y: 'page.object.y', + w: 'page.object.width', + h: 'page.object.height', +}; + +let open: (() => void) | null = null; + +function field(form: HTMLFormElement, name: Field, value: number): HTMLInputElement { + const label = form.ownerDocument.createElement('label'); + label.className = styles.field; + label.textContent = t(LABELS[name]); + const input = form.ownerDocument.createElement('input'); + input.type = 'number'; + input.name = name; + input.step = '1'; + input.min = '0'; + input.value = String(Math.round(value * 100) / 100); + label.append(input); + form.append(label); + return input; +} + +/** Opens the popover beside a block. Apply sends one moveBlock; Escape or a click outside closes it. */ +export function openSizeAndPosition( + container: HTMLElement, + objects: Objects, + layer: PageBlockLayer, + id: BlockId, +): void { + open?.(); + const block = layer.block(id); + const view = layer.view(id); + if (!block || !view) return; + const rect = view.measure(); + const doc = container.ownerDocument; + const form = doc.createElement('form'); + form.className = styles.popover; + form.setAttribute('role', 'dialog'); + form.setAttribute('aria-label', t('page.object.sizeAndPosition')); + const floating = isFloating(block); + const inputs: Partial> = {}; + if (floating) { + inputs.x = field(form, 'x', block.frame?.x ?? rect.x); + inputs.y = field(form, 'y', block.frame?.y ?? rect.y); + } + inputs.w = field(form, 'w', block.frame?.w ?? rect.w); + if (block.type !== 'text') inputs.h = field(form, 'h', block.frame?.h ?? rect.h); + const apply = doc.createElement('button'); + apply.type = 'submit'; + apply.textContent = t('page.object.apply'); + form.append(apply); + const box = view.element.getBoundingClientRect(); + const origin = container.getBoundingClientRect(); + form.style.insetInlineStart = `${Math.max(8, box.left - origin.left)}px`; + form.style.insetBlockStart = `${Math.max(8, box.bottom - origin.top + 8)}px`; + container.append(form); + + const close = () => { + open = null; + form.remove(); + doc.removeEventListener('pointerdown', onOutside, true); + view.element.focus({ preventScroll: true }); + }; + const onOutside = (event: Event) => { + if (!(event.target instanceof Node) || !form.contains(event.target)) close(); + }; + form.addEventListener('submit', (event) => { + event.preventDefault(); + const value = (name: Field) => { + const raw = inputs[name]?.valueAsNumber; + return raw === undefined || Number.isNaN(raw) ? undefined : raw; + }; + const frame: Frame = { ...block.frame }; + if (value('x') !== undefined) frame.x = frameValue(value('x')!); + if (value('y') !== undefined) frame.y = frameValue(value('y')!); + if (value('w') !== undefined) frame.w = frameValue(value('w')!, block.type === 'text' ? MIN_TEXT_WIDTH : 16); + if (value('h') !== undefined) frame.h = frameValue(value('h')!, 16); + if (!objects.locked(block)) objects.commitFrames(new Map([[id, frame]])); + close(); + }); + form.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + close(); + }); + doc.addEventListener('pointerdown', onOutside, true); + open = close; + (inputs.x ?? inputs.w)?.focus(); +} diff --git a/app/src/features/page/code/LanguagePicker.test.tsx b/app/src/features/page/code/LanguagePicker.test.tsx new file mode 100644 index 000000000..d41ccc4c1 --- /dev/null +++ b/app/src/features/page/code/LanguagePicker.test.tsx @@ -0,0 +1,88 @@ +// The code language picker in a real browser (PLAN.md section 10.5). It has no axe violations in either theme. +// Typing filters the list, and arrow keys move the highlight. Enter or a click chooses, and Escape closes. +import { fireEvent, screen } from '@testing-library/react'; +import { useRef } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import { installLayerEscape } from '../../../state/layers'; +import { expectNoAxeViolations, renderUi } from '../../../test'; +import { LanguagePicker } from './LanguagePicker'; + +function Harness({ + current, + onPick, + onClose, +}: { + current: string | null; + onPick(id: string | null): void; + onClose(): void; +}) { + const anchor = useRef(null); + return ( +
+ + +
+ ); +} + +const filter = () => screen.getByRole('combobox', { name: 'Filter languages' }); +const highlighted = () => document.getElementById(filter().getAttribute('aria-activedescendant') ?? ''); + +describe('the language picker', () => { + for (const theme of ['light', 'dark'] as const) { + it(`has no axe violations in the ${theme} theme`, async () => { + renderUi(, { theme }); + await vi.waitFor(() => expect(document.activeElement).toBe(filter())); + await expectNoAxeViolations(document.body); + }); + } + + it('starts on the block’s language, written as an alias or not', async () => { + renderUi(); + await vi.waitFor(() => expect(highlighted()?.textContent).toBe('Python')); + expect(screen.getByRole('listbox', { name: 'Code language' })).toBeTruthy(); + }); + + it('filters by name or alias, moves with arrows, and chooses with Enter', async () => { + const onPick = vi.fn(); + renderUi(); + await vi.waitFor(() => expect(document.activeElement).toBe(filter())); + fireEvent.change(filter(), { target: { value: 'ts' } }); + expect(highlighted()?.textContent).toBe('TypeScript'); + fireEvent.keyDown(filter(), { key: 'ArrowDown' }); + expect(highlighted()?.textContent).not.toBe('TypeScript'); + fireEvent.keyDown(filter(), { key: 'Home' }); + fireEvent.keyDown(filter(), { key: 'Enter' }); + expect(onPick).toHaveBeenCalledWith('typescript'); + }); + + it('chooses plain text, and says when nothing matches', async () => { + const onPick = vi.fn(); + renderUi(); + fireEvent.change(filter(), { target: { value: 'zzz' } }); + expect(screen.getByRole('status').textContent).toBe('No language matches “zzz”.'); + fireEvent.change(filter(), { target: { value: '' } }); + fireEvent.change(filter(), { target: { value: 'plain' } }); + fireEvent.click(screen.getByRole('option', { name: 'Plain text' })); + expect(onPick).toHaveBeenCalledWith(null); + }); + + it('closes on Escape without a choice', async () => { + const onPick = vi.fn(); + const onClose = vi.fn(); + // The shell listens for Escape on the window and closes the top layer. + const stop = installLayerEscape(); + try { + renderUi(); + await vi.waitFor(() => expect(document.activeElement).toBe(filter())); + await userEvent.keyboard('{Escape}'); + expect(onClose).toHaveBeenCalled(); + expect(onPick).not.toHaveBeenCalled(); + } finally { + stop(); + } + }); +}); diff --git a/app/src/features/page/code/LanguagePicker.tsx b/app/src/features/page/code/LanguagePicker.tsx new file mode 100644 index 000000000..e5cf9b56e --- /dev/null +++ b/app/src/features/page/code/LanguagePicker.tsx @@ -0,0 +1,115 @@ +// The code language picker (ARCHITECTURE.md section 14.2; owner: WP6): a filterable listbox in a popover at the +// language button. Focus stays in the filter, which points at the highlighted language with +// aria-activedescendant. Enter or a click chooses it, and Escape closes the picker without a change. +import { CheckIcon } from '@phosphor-icons/react/dist/csr/Check'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import type { KeyboardEvent, RefObject } from 'react'; +import { findLanguage, matchLanguages } from '../../../editor/highlight/languages'; +import { t } from '../../../strings/t'; +import { Popover } from '../../../ui'; +import styles from './code.module.css'; + +export interface LanguagePickerProps { + anchor: RefObject; + /** The block's language now, or null for plain text. */ + current: string | null; + onPick(language: string | null): void; + onClose(): void; +} + +interface Choice { + id: string | null; + name: string; +} + +function useChoices(query: string): Choice[] { + return useMemo(() => { + const plain = t('code.language.plain'); + const languages = matchLanguages(query).map((one) => ({ id: one.id, name: one.name })); + const showPlain = !query.trim() || plain.toLowerCase().includes(query.trim().toLowerCase()); + return showPlain ? [{ id: null, name: plain }, ...languages] : languages; + }, [query]); +} + +export function LanguagePicker({ anchor, current, onPick, onClose }: LanguagePickerProps) { + const [query, setQuery] = useState(''); + const choices = useChoices(query); + // A block written as ```py is Python here. + const currentId = current === null ? null : (findLanguage(current)?.id ?? current); + const startAt = Math.max( + 0, + choices.findIndex((choice) => choice.id === currentId), + ); + const [active, setActive] = useState(startAt); + const index = choices.length === 0 ? -1 : Math.min(active, choices.length - 1); + const listId = useId(); + const optionId = (i: number) => `${listId}-${i}`; + const list = useRef(null); + const filter = useRef(null); + // The picker opens on a press or a command, so its filter takes focus, as a menu would. + useEffect(() => { + filter.current?.focus(); + list.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }); + }, []); + const moveTo = (next: number) => { + setActive(next); + list.current?.querySelector(`[id="${optionId(next)}"]`)?.scrollIntoView({ block: 'nearest' }); + }; + const onKeyDown = (event: KeyboardEvent) => { + const last = choices.length - 1; + const moves: Record = { ArrowDown: index + 1, ArrowUp: index - 1, Home: 0, End: last }; + if (event.key in moves && last >= 0) { + event.preventDefault(); + moveTo(Math.min(last, Math.max(0, moves[event.key]))); + } else if (event.key === 'Enter') { + event.preventDefault(); + if (index >= 0) onPick(choices[index].id); + } + }; + return ( + +
+ 0} + aria-controls={listId} + aria-autocomplete="list" + aria-activedescendant={index >= 0 ? optionId(index) : undefined} + autoComplete="off" + spellCheck={false} + value={query} + onChange={(event) => { + setQuery(event.target.value); + setActive(0); + }} + onKeyDown={onKeyDown} + /> +
+ {choices.map((choice, i) => ( +
i !== index && setActive(i)} + onMouseDown={(event) => event.preventDefault()} + onClick={() => onPick(choice.id)} + > + {choice.name} + {choice.id === currentId ?
+ ))} +
+ {choices.length === 0 ? ( +

+ {t('code.language.none', { query: query.trim() })} +

+ ) : null} +
+
+ ); +} diff --git a/app/src/features/page/code/code.module.css b/app/src/features/page/code/code.module.css new file mode 100644 index 000000000..9b29f4da4 --- /dev/null +++ b/app/src/features/page/code/code.module.css @@ -0,0 +1,69 @@ +/* + * The code language picker (owner: WP6): a filter above a short list, in a popover at the language button. + */ +@layer components { + .picker { + display: grid; + gap: var(--space-2); + inline-size: 16rem; + } + + .filter { + inline-size: 100%; + min-block-size: var(--size-row-pointer); + padding-inline: var(--space-3); + border: 1px solid var(--color-border-control); + border-radius: var(--radius-md); + background: var(--color-surface-page); + color: var(--color-text-primary); + font: inherit; + } + + :global(:root[data-density='touch']) .filter { + min-block-size: var(--size-target-touch); + } + + .filter:focus-visible { + outline: var(--size-focus-ring-width) solid var(--color-focus-ring); + outline-offset: var(--size-focus-ring-offset); + } + + .list { + display: grid; + max-block-size: 18rem; + overflow-y: auto; + overscroll-behavior: contain; + } + + .option { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + min-block-size: var(--size-row-pointer); + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-md); + color: var(--color-text-primary); + cursor: pointer; + } + + :global(:root[data-density='touch']) .option { + min-block-size: var(--size-row-touch); + } + + /* The highlighted option. Forced colors keep the outline, which marks it without color. */ + .option[aria-selected='true'] { + outline: var(--size-focus-ring-width) solid var(--color-focus-ring); + outline-offset: calc(-1 * var(--size-focus-ring-width)); + background: var(--color-surface-selected); + } + + .check { + color: var(--color-text-secondary); + } + + .none { + margin: 0; + padding: var(--space-1) var(--space-3); + color: var(--color-text-secondary); + } +} diff --git a/app/src/features/page/code/commands.test.ts b/app/src/features/page/code/commands.test.ts new file mode 100644 index 000000000..ba576566f --- /dev/null +++ b/app/src/features/page/code/commands.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +// The code commands on a page (PLAN.md section 10.5): Set code language and Leave code block, through +// expectCommand, as the palette and the keyboard run them. +import { afterEach, describe, expect, it } from 'vitest'; +import { executeCommand } from '../../../commands/registry'; +import { applyLanguage } from '../../../editor/extensions/codeBlock'; +import { setLanguagePicker } from '../../../editor/highlight/picker'; +import { expectCommand } from '../test/harness'; +import '../register'; +import './register'; + +// jsdom has no layout, and ProseMirror measures a Range when it scrolls the caret into view. +Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList; +Range.prototype.getBoundingClientRect ??= () => new DOMRect(); + +/** A picker that chooses `language` at once, as a person would in the listbox. */ +function choose(language: string | null): void { + setLanguagePicker(({ view, pos }) => applyLanguage(view, pos, language, { announce: () => {} })); +} + +afterEach(() => setLanguagePicker(null)); + +describe('code commands', () => { + it('sets the code language', async () => { + choose('python'); + await expectCommand('code.setLanguage', '```\nprint(1)[]\n```', '```python\nprint(1)\n```'); + }); + + it('sets plain text', async () => { + choose(null); + await expectCommand('code.setLanguage', '```js\nlet a[]\n```', '```\nlet a\n```'); + }); + + it('leaves the code block for a new paragraph, which Markdown keeps once it has text', async () => { + await expectCommand('code.exit', '```js\nlet []a\n```\n\nafter', '```js\nlet a\n```\n\nafter'); + }); + + it('does not run outside code', async () => { + expect(await executeCommand('code.exit')).toBe(false); + expect(await executeCommand('code.setLanguage')).toBe(false); + }); +}); diff --git a/app/src/features/page/code/commands.ts b/app/src/features/page/code/commands.ts new file mode 100644 index 000000000..6e5734650 --- /dev/null +++ b/app/src/features/page/code/commands.ts @@ -0,0 +1,27 @@ +// The code commands' work (owner: WP6), loaded on first use so start-up stays small. +import type { Editor } from '@tiptap/core'; +import { codeBlockAt, leaveCode } from '../../../editor/commands/code'; +import { languagePicker } from '../../../editor/highlight/picker'; +import { announce } from '../../../ui'; +import { openLanguagePicker } from './picker'; + +/** Opens the language picker at the language button of the code block with the caret. */ +export function openCodeLanguage(editor: Editor): boolean { + const at = codeBlockAt(editor.state); + if (!at) return false; + const block = editor.view.nodeDOM(at.pos); + const anchor = + (block instanceof HTMLElement && block.querySelector('[data-code-language]')) || + (block instanceof HTMLElement ? block : editor.view.dom); + const language = (at.node.attrs.language as string | null) ?? null; + const request = { view: editor.view, pos: at.pos, anchor, language }; + const picker = languagePicker(); + if (picker) picker(request); + else openLanguagePicker(request, { announce }); + return true; +} + +/** Leaves the code block with the caret for a new paragraph below it. */ +export function leaveCodeBlock(editor: Editor): boolean { + return leaveCode(editor.state, editor.view.dispatch); +} diff --git a/app/src/features/page/code/picker.tsx b/app/src/features/page/code/picker.tsx new file mode 100644 index 000000000..0f7895289 --- /dev/null +++ b/app/src/features/page/code/picker.tsx @@ -0,0 +1,41 @@ +// Opens the code language picker for a code block (owner: WP6). The code block's language button and the +// "Set code language" command both land here. It loads with the picker, on first use. +import { createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import type { EditorHost } from '../../../editor/host'; +import { applyLanguage } from '../../../editor/extensions/codeBlock'; +import type { LanguagePickRequest } from '../../../editor/highlight/picker'; +import { LanguagePicker } from './LanguagePicker'; + +let closeOpen: (() => void) | null = null; + +export function openLanguagePicker(request: LanguagePickRequest, host: Pick): void { + closeOpen?.(); + const { view, pos, anchor, language } = request; + const container = document.body.appendChild(document.createElement('div')); + const root = createRoot(container); + let open = true; + const close = () => { + if (!open) return; + open = false; + closeOpen = null; + // The popover gives focus back to its control first; the caret's editor takes it after. + setTimeout(() => { + root.unmount(); + container.remove(); + if (!view.isDestroyed) view.focus(); + }, 0); + }; + closeOpen = close; + root.render( + createElement(LanguagePicker, { + anchor: { current: anchor }, + current: language, + onClose: close, + onPick(chosen: string | null) { + close(); + if (!view.isDestroyed) applyLanguage(view, pos, chosen, host); + }, + }), + ); +} diff --git a/app/src/features/page/code/register.ts b/app/src/features/page/code/register.ts new file mode 100644 index 000000000..2f391eb6d --- /dev/null +++ b/app/src/features/page/code/register.ts @@ -0,0 +1,49 @@ +// The code commands (ARCHITECTURE.md section 14; owner: WP6): Set code language and Leave code block. They apply +// only with the caret in code, so they register from this chunk in idle time after start-up. Ctrl+Enter also works +// before then, because code blocks handle it themselves. +import type { Editor } from '@tiptap/core'; +import { commands } from '../../../registries'; +import { pageCommandDef } from '../keys'; +import { shownPool } from '../pool/shown'; +import { lateRefines } from '../tables/refines'; +import { leaveCodeBlock, openCodeLanguage } from './commands'; + +/** The active editor when its selection is inside one code block. */ +function codeEditor(): Editor | null { + const editor = shownPool.get()?.active()?.editor ?? null; + const selection = editor?.state.selection; + if (!selection || selection.$from.parent.type.name !== 'codeBlock') return null; + return selection.$from.sameParent(selection.$to) ? editor : null; +} + +commands.register( + lateRefines( + pageCommandDef({ + id: 'code.setLanguage', + title: 'code.commands.setLanguage', + keywords: 'code.commands.keywords', + category: 'format', + when: () => codeEditor() !== null, + run() { + const editor = codeEditor(); + if (editor) openCodeLanguage(editor); + }, + }), + ), +); + +commands.register( + lateRefines( + pageCommandDef({ + id: 'code.exit', + title: 'code.commands.exit', + keywords: 'code.commands.keywords', + category: 'editing', + when: () => codeEditor() !== null, + run() { + const editor = codeEditor(); + if (editor) leaveCodeBlock(editor); + }, + }), + ), +); diff --git a/app/src/features/page/editorHost.ts b/app/src/features/page/editorHost.ts new file mode 100644 index 000000000..7ec527afa --- /dev/null +++ b/app/src/features/page/editorHost.ts @@ -0,0 +1,33 @@ +// The page's EditorHost (PLAN.md section 3.8; owner after WP0: WP3): how an editor reads settings and flags, +// announces, opens menus, and selects blocks, without importing stores itself. +import { isEnabled } from '../../app/flags'; +import type { EditorHost, EditingSettingsView } from '../../editor/host'; +import { getSettings } from '../../state/settings'; +import { osStore } from '../../state/os'; +import { announce, openMenu } from '../../ui'; +import { selectOnPage } from './seams/selectionStore'; +import { pageSpelling } from './spelling/current'; + +function editingView(): EditingSettingsView { + const editing = getSettings().editing; + return { + markdownShortcuts: editing.markdownShortcuts, + slashMenu: editing.slashMenu, + formattingBar: editing.formattingBar, + autocorrect: { enabled: editing.autocorrect.enabled, entries: editing.autocorrect.entries }, + spelling: { enabled: editing.spelling.enabled, languages: editing.spelling.languages }, + }; +} + +export function createEditorHost(overrides: Partial = {}): EditorHost { + return { + settings: editingView, + flag: isEnabled, + announce, + openMenu: ({ label, items, anchor }) => openMenu({ label, items, anchor }), + screenReader: () => osStore.get().screenReader, + spelling: pageSpelling, + selectBlocks: (blocks) => selectOnPage({ blocks, strokes: [] }, { announce: true }), + ...overrides, + }; +} diff --git a/app/src/features/page/flags.ts b/app/src/features/page/flags.ts new file mode 100644 index 000000000..9111820cc --- /dev/null +++ b/app/src/features/page/flags.ts @@ -0,0 +1,37 @@ +// Phase 4's flags (ARCHITECTURE.md section 2.2; PLAN.md section 3.1, owned by WP0). Each unfinished feature stays +// behind its flag, hidden rather than disabled, until it meets the definition of done. app/flags.ts joins these to +// Phase 2's list, so they load at start-up, before the page's chunk. +import type { FlagDef, FlagId } from '../../app/flags'; + +export type PageFlagId = Extract; + +const ISSUES = 'https://github.com/XrxcGH/OpenNote/issues?q=label%3Aflag%3A'; +/** On in development and nightly builds, off in Beta and Stable until the feature is done. */ +const building = { dev: true, nightly: true, beta: false, stable: false }; +const off = { dev: false, nightly: false, beta: false, stable: false }; + +const flag = (id: PageFlagId, description: string, enabled: FlagDef['enabled']): FlagDef => ({ + id, + description, + issue: `${ISSUES}${encodeURIComponent(id)}`, + enabled, +}); + +export const PAGE_FLAGS: readonly FlagDef[] = [ + flag('page.editor', 'Typed notes: the page view, text boxes, formatting, and undo.', building), + flag('page.images', 'Images, crop, and alt text.', building), + flag('page.tables', 'Tables.', building), + flag('page.codeHighlight', 'Highlighting in code blocks.', building), + flag('page.slashMenu', 'The slash menu and Turn into.', building), + flag('page.styles', 'Editable text styles.', building), + flag('page.outline', 'Outline moves and folding.', building), + flag('page.typingHelpers', 'AutoCorrect, date and time helpers, and the date line on new pages.', building), + flag('page.pasteExtras', 'Source links, PDF line joining, and saving web images.', building), + flag('page.formattingBar', 'The formatting bar after touch and pen selections.', building), + flag('page.readingOrder', 'The Reading order pane.', building), + flag('page.history', 'Compare versions, restoring parts, named versions, and deleting history.', building), + flag('page.imageRenditions', 'Display-size image renditions, if spike S3 needs them.', off), + flag('page.heicImport', 'Converting HEIC and TIFF images at import.', off), + flag('editor.spelling', 'Spell check and its settings.', building), + flag('editor.readAloud', 'Read aloud.', building), +]; diff --git a/app/src/features/page/formattingBar/a11y.test.tsx b/app/src/features/page/formattingBar/a11y.test.tsx new file mode 100644 index 000000000..e59460fab --- /dev/null +++ b/app/src/features/page/formattingBar/a11y.test.tsx @@ -0,0 +1,96 @@ +// WP4's interface in a real browser, checked with axe in both themes. It covers the formatting bar, the link +// popover, the slash menu, the fold button, the text styles editor, and the settings parts. +import { screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../registrations/editor'; +import { mountEditor, typeInto } from '../../../editor/commands/testing'; +import type { TestEditor } from '../../../editor/commands/testing'; +import { expectNoAxeViolations, renderUi } from '../../../test'; +import EditingAutoCorrect from '../settings/EditingAutoCorrect'; +import EditingGeneral from '../settings/EditingGeneral'; +import { StyleEditor } from '../styles/TextStylesDialog'; +import { openLinkPopover } from '../linkPopover/LinkPopover'; +import { closeFormattingBar, showFormattingBar } from './bar'; + +const THEMES = ['light', 'dark'] as const; +// A custom color a person might pick, which shows the contrast warning. +// checks-disable-next-line brand-consistency: a person's own color, to show the warning +const HARD_TO_READ = '#f2d40c'; +let mounted: TestEditor | null = null; + +afterEach(() => { + closeFormattingBar(); + mounted?.destroy(); + mounted = null; + document.querySelectorAll('[role="listbox"]').forEach((element) => element.remove()); +}); + +/** An editor named as the page's text blocks name theirs. */ +function mount(source: string): TestEditor { + mounted = mountEditor(source); + const editable = mounted.editor.view.dom; + editable.setAttribute('role', 'textbox'); + editable.setAttribute('aria-multiline', 'true'); + editable.setAttribute('aria-label', 'Text'); + return mounted; +} + +describe('WP4’s interface', () => { + for (const theme of THEMES) { + it(`the formatting bar passes axe in the ${theme} theme`, async () => { + document.documentElement.dataset.theme = theme; + showFormattingBar(new DOMRect(200, 200, 80, 20)); + const bar = await screen.findByRole('toolbar', { name: 'Formatting' }); + expect(bar.querySelectorAll('button').length).toBeGreaterThanOrEqual(2); + await expectNoAxeViolations(bar); + }); + + it(`the fold button passes axe in the ${theme} theme`, async () => { + document.documentElement.dataset.theme = theme; + const page = mount('## Light reactions\n\nText'); + expect(page.root.querySelector('button[aria-expanded="true"]')).not.toBeNull(); + await expectNoAxeViolations(page.root); + }); + + it(`the slash menu passes axe in the ${theme} theme`, async () => { + document.documentElement.dataset.theme = theme; + const page = mount('[]'); + page.editor.commands.focus(); + typeInto(page.editor, '/'); + await vi.waitFor(() => expect(document.querySelector('[role="listbox"]')).not.toBeNull(), { timeout: 10_000 }); + expect(document.querySelectorAll('[role="listbox"]')).toHaveLength(1); + await expectNoAxeViolations(document.querySelector('[role="listbox"]')!); + await expectNoAxeViolations(page.root); + }); + + it(`the link popover passes axe in the ${theme} theme`, async () => { + document.documentElement.dataset.theme = theme; + const page = mount('a [b] c'); + const closed = openLinkPopover(page.editor); + const dialog = await screen.findByRole('dialog', { name: 'Link' }); + await expectNoAxeViolations(dialog); + const field = dialog.querySelector('input')!; + field.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await closed; + }); + + it(`the text styles editor passes axe in the ${theme} theme`, async () => { + const { container } = renderUi( {}} />, { + theme, + }); + await expectNoAxeViolations(container); + }); + + it(`the editing settings pass axe in the ${theme} theme`, async () => { + const { container } = renderUi( + <> + + + , + { theme }, + ); + expect(screen.getByRole('switch', { name: 'Markdown shortcuts' })).toBeTruthy(); + await expectNoAxeViolations(container); + }); + } +}); diff --git a/app/src/features/page/formattingBar/bar.module.css b/app/src/features/page/formattingBar/bar.module.css new file mode 100644 index 000000000..425734ec8 --- /dev/null +++ b/app/src/features/page/formattingBar/bar.module.css @@ -0,0 +1,48 @@ +/* The formatting bar: a raised row of 44 px buttons above or below a touch or pen selection, in the top layer. */ +@layer components { + .bar { + position: fixed; + inset: auto; + display: flex; + gap: var(--space-1); + margin: 0; + padding: var(--space-1); + color: var(--color-text-primary); + background: var(--color-surface-raised); + border: 1px solid var(--color-border-control); + border-radius: var(--radius-md); + box-shadow: var(--elevation-2); + } + + .button { + min-inline-size: 44px; + min-block-size: 44px; + padding-inline: var(--space-2); + color: inherit; + background: transparent; + border: 0; + border-radius: var(--radius-sm); + cursor: pointer; + } + + .button:hover { + background: var(--color-surface-hover); + } + + .button[aria-pressed='true'] { + background: var(--color-surface-selected); + } + + .button:focus-visible { + outline: var(--size-focus-ring-width) solid var(--color-focus-ring); + outline-offset: calc(-1 * var(--size-focus-ring-width)); + } + + @media (forced-colors: active) { + .button[aria-pressed='true'] { + forced-color-adjust: none; + color: HighlightText; + background: Highlight; + } + } +} diff --git a/app/src/features/page/formattingBar/bar.ts b/app/src/features/page/formattingBar/bar.ts new file mode 100644 index 000000000..4c744b5a1 --- /dev/null +++ b/app/src/features/page/formattingBar/bar.ts @@ -0,0 +1,136 @@ +// The formatting bar (ARCHITECTURE.md section 22.6; owner: WP4). After a touch or pen selection in a text box, +// or any selection with the setting at Always, a small toolbar appears above the selection, or below it when +// there's no room. It holds Bold, Italic, Highlight, Link, and More, with 44 px targets, in the top layer. +// +// Escape, scrolling, or a tap elsewhere closes it. Arrow keys move between its buttons, and the editor keeps the +// selection, because pressing a button never takes focus from the text. +import { executeCommand } from '../../../commands/registry'; +import type { CommandId } from '../../../commands/types'; +import { commands } from '../../../registries'; +import { t } from '../../../strings/t'; +import { openMenu } from '../../../ui'; +import type { MenuItemSpec } from '../../../ui'; +import styles from './bar.module.css'; + +const BUTTONS: readonly CommandId[] = ['format.bold', 'format.italic', 'format.highlight', 'format.link']; +const MORE: readonly CommandId[] = [ + 'format.underline', + 'format.strike', + 'format.code', + 'format.textColor', + 'format.clear', + 'block.turnInto', +]; +/** The gap between the selection and the bar. */ +const GAP = 8; + +let open: { close(): void } | null = null; + +function label(id: CommandId): string { + const def = commands.get(id); + return def ? t(def.title) : id; +} + +function moreItems(): MenuItemSpec[] { + return MORE.filter((id) => commands.get(id)).map((id) => ({ + id, + label: label(id), + onSelect: () => void executeCommand(id, undefined, 'commandBar'), + })); +} + +function makeButton(text: string, onPress: (element: HTMLButtonElement) => void): HTMLButtonElement { + const element = document.createElement('button'); + element.type = 'button'; + element.className = styles.button; + element.textContent = text; + element.tabIndex = -1; + // Pressing a button keeps focus, and so the selection, in the text. + element.addEventListener('pointerdown', (event) => event.preventDefault()); + element.addEventListener('click', () => onPress(element)); + return element; +} + +function commandButton(id: CommandId, onPress: () => void): HTMLButtonElement { + const element = makeButton(label(id), onPress); + element.dataset.command = id; + const checked = commands.get(id)?.checked; + if (checked) element.setAttribute('aria-pressed', String(checked({} as never))); + return element; +} + +/** Above the selection, or below it when the bar wouldn't fit above. */ +function place(bar: HTMLElement, rect: DOMRect): void { + const height = bar.getBoundingClientRect().height || 44; + const above = rect.top - height - GAP; + bar.style.insetBlockStart = `${above >= 0 ? above : rect.bottom + GAP}px`; + bar.style.insetInlineStart = `${Math.max(GAP, rect.left)}px`; + bar.dataset.placement = above >= 0 ? 'above' : 'below'; +} + +/** Arrow keys move between the bar's buttons; Escape closes it. */ +function onKey(bar: HTMLElement, event: KeyboardEvent, close: () => void): void { + if (event.key === 'Escape') { + event.preventDefault(); + close(); + return; + } + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + const buttons = [...bar.querySelectorAll('button')]; + const at = buttons.indexOf(document.activeElement as HTMLButtonElement); + if (at < 0) return; + event.preventDefault(); + const last = buttons.length - 1; + const next = { ArrowLeft: at - 1, ArrowRight: at + 1, Home: 0, End: last }[event.key] ?? at; + buttons[(next + buttons.length) % buttons.length].focus(); +} + +/** Shows the bar for the selection at `rect`. Opening another closes this one. */ +export function showFormattingBar(rect: DOMRect): { close(): void } { + open?.close(); + const bar = document.createElement('div'); + bar.className = styles.bar; + bar.setAttribute('role', 'toolbar'); + bar.setAttribute('aria-label', t('editor.bar.label')); + bar.setAttribute('popover', 'manual'); + const close = () => { + bar.remove(); + document.removeEventListener('keydown', escape, true); + document.removeEventListener('pointerdown', outside, true); + window.removeEventListener('scroll', close, true); + if (open === handle) open = null; + }; + const run = (id: CommandId) => () => { + void executeCommand(id, undefined, 'commandBar'); + close(); + }; + const more = makeButton(t('editor.bar.more'), async (element) => { + const chosen = await openMenu({ label: t('editor.bar.more'), items: moreItems(), anchor: element }); + if (chosen) close(); + }); + more.setAttribute('aria-haspopup', 'menu'); + const buttons = BUTTONS.filter((id) => commands.get(id)).map((id) => commandButton(id, run(id))); + bar.append(...buttons, more); + buttons[0]?.setAttribute('tabindex', '0'); + bar.addEventListener('keydown', (event) => onKey(bar, event, close)); + const escape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !bar.contains(event.target as Node)) close(); + }; + const outside = (event: PointerEvent) => { + if (!bar.contains(event.target as Node)) close(); + }; + document.body.append(bar); + (bar as HTMLElement & { showPopover?(): void }).showPopover?.(); + place(bar, rect); + document.addEventListener('keydown', escape, true); + document.addEventListener('pointerdown', outside, true); + window.addEventListener('scroll', close, true); + const handle = { close }; + open = handle; + return handle; +} + +/** Closes the bar, if it's open. */ +export function closeFormattingBar(): void { + open?.close(); +} diff --git a/app/src/features/page/formattingBar/commands.test.ts b/app/src/features/page/formattingBar/commands.test.ts new file mode 100644 index 000000000..7c624a891 --- /dev/null +++ b/app/src/features/page/formattingBar/commands.test.ts @@ -0,0 +1,297 @@ +// @vitest-environment jsdom +// Every page command WP4 registers, run through the command registry on a real page over the memory service. Each +// case checks the Markdown after the command and that one page undo brings the text back. Key cases check which +// command a chord reaches in an editor, in both shortcut sets. A coverage test fails on a command without a case. +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import '../registrations/editor'; +import '../registrations/sync'; +import { commandForKey } from '../../../commands/dispatcher'; +import { executeCommand } from '../../../commands/registry'; +import { typeInto } from '../../../editor/commands/testing'; +import { createMarkdownCache, serializeTextBlock } from '../../../editor/markdown'; +import { commands } from '../../../registries'; +import { DEFAULT_SETTINGS, settingsStore } from '../../../state/settings'; +import { PAGE_KEYS } from '../keys'; +import type { PageCommandId } from '../keys'; +import { selectOnPage } from '../seams/selectionStore'; +import { md } from '../test/builders'; +import { textPageFixture } from '../test/fixtures'; +import { cleanupPages, renderPage } from '../test/harness'; +import type { PageHarness } from '../test/harness'; + +/** A blank line between two blocks of Markdown. */ +const BLANK = String.fromCharCode(10, 10); + +afterEach(async () => { + await cleanupPages(); + settingsStore.set({ settings: DEFAULT_SETTINGS, readOnly: false }); + vi.useRealTimers(); +}); + +const cache = createMarkdownCache(); + +/** A page with one text block holding `before`, its selection marked as in `md`, with the editor focused. */ +async function open(before: string): Promise<{ page: PageHarness; block: string }> { + const marked = md(before); + const fixture = textPageFixture(serializeTextBlock(marked.doc, cache)); + const block = fixture.page.blocks[0].id; + const page = await renderPage({ fixture }); + const editor = page.mounted.pool.mount( + block, + { kind: 'selection', anchor: marked.anchor, head: marked.head }, + 'target', + ); + editor?.commands.focus(); + return { page, block }; +} + +function editorMarkdown(page: PageHarness, block: string): string { + return serializeTextBlock(page.mounted.pool.editor(block)!.state.doc, cache); +} + +interface Case { + before: string; + after: string; + args?: unknown; + /** Blocks selected as objects, for pageObject commands. */ + objects?: boolean; +} + +/** Runs the case, checks the Markdown the service holds, then undoes and checks the text is back. */ +async function expectCase(id: string, { before, after, args, objects }: Case): Promise { + const { page, block } = await open(before); + const original = editorMarkdown(page, block); + if (objects) selectOnPage({ blocks: [block], strokes: [] }); + expect(await executeCommand(id as PageCommandId, args), `${id} ran`).toBe(true); + await page.mounted.sync.flushAll('command'); + expect(page.markdown(block), id).toBe(after); + await page.mounted.sync.undo(); + expect(editorMarkdown(page, block), `${id} undone`).toBe(original); +} + +const NOW = new Date('2026-09-30T14:05:00'); + +const CASES: Partial> = { + 'format.bold': { before: 'a [b] c', after: 'a **b** c' }, + 'format.italic': { before: 'a [b] c', after: 'a *b* c' }, + 'format.underline': { before: 'a [b] c', after: 'a b c' }, + 'format.strike': { before: 'a [b] c', after: 'a ~~b~~ c' }, + 'format.highlight': { before: 'a [b] c', after: 'a ==b== c' }, + 'format.code': { before: 'a [b] c', after: 'a `b` c' }, + 'format.subscript': { before: 'H[2]O', after: 'H2O' }, + 'format.superscript': { before: 'x[2]', after: 'x2' }, + 'format.larger': { before: 'a [b] c', after: 'a b c' }, + 'format.smaller': { before: 'a [b] c', after: 'a b c' }, + 'format.textColor': { before: 'a [b] c', after: 'a b c', args: { color: 'fern' } }, + 'format.textSize': { before: 'a [b] c', after: 'a b c', args: { size: 'xlarge' } }, + 'format.clear': { before: 'a **[b]** c', after: 'a b c' }, + 'format.clearAll': { before: '# **[Title]**', after: 'Title' }, + 'format.link': { before: 'a [b] c', after: 'a [b](https://example.com) c', args: { href: 'example.com' } }, + 'block.normal': { before: '## [Title]', after: 'Title' }, + 'block.heading1': { before: '[Title]', after: '# Title' }, + 'block.heading2': { before: '[Title]', after: '## Title' }, + 'block.heading3': { before: '[Title]', after: '### Title' }, + 'block.heading4': { before: '[Title]', after: '#### Title' }, + 'block.heading5': { before: '[Title]', after: '##### Title' }, + 'block.heading6': { before: '[Title]', after: '###### Title' }, + 'block.bulletList': { before: 'one[]', after: '- one' }, + 'block.orderedList': { before: 'one[]', after: '1. one' }, + 'block.checklist': { before: 'one[]', after: '- [ ] one' }, + 'block.todoCycle': { before: '- [ ] one[]', after: '- [x] one' }, + 'block.toggleCheck': { before: '- [x] one[]', after: '- [ ] one' }, + 'block.quote': { before: 'one[]', after: '> one' }, + 'block.codeBlock': { before: '**one**[]', after: '```\none\n```' }, + 'block.callout': { before: 'one[]', after: '> [!note]\n> one' }, + 'block.divider': { before: 'one[]', after: 'one\n\n---' }, + 'block.turnInto': { before: '- one[]', after: '### one', args: { kind: 'heading3' } }, + 'insert.date': { before: 'On []', after: 'On Sep 30, 2026' }, + 'insert.time': { before: 'At []', after: 'At 2:05 PM' }, + 'insert.dateTime': { before: 'At []', after: 'At Sep 30, 2026 2:05 PM' }, + 'outline.moveUp': { before: '- one\n- t[]wo', after: '- two\n- one' }, + 'outline.moveDown': { before: '- o[]ne\n- two', after: '- two\n- one' }, + 'outline.promote': { before: '## T[]itle', after: '# Title' }, + 'outline.demote': { before: '## T[]itle', after: '### Title' }, + 'outline.fold': { before: '## T[]itle\n\nBody', after: '## Title\n\nBody' }, + 'outline.unfold': { before: '## T[]itle\n\nBody', after: '## Title\n\nBody' }, + 'outline.showAll': { before: '## T[]itle\n\nBody', after: '## Title\n\nBody' }, + ...Object.fromEntries( + [1, 2, 3, 4, 5, 6, 7, 8, 9].map((level) => [ + `outline.showLevel${level}`, + { before: '## T[]itle\n\nBody', after: '## Title\n\nBody' }, + ]), + ), + 'text.setColor': { + before: 'one []two', + after: 'one two', + args: { color: 'brick' }, + objects: true, + }, +}; + +/** WP4's commands: every PAGE_KEYS entry in its sections of PLAN.md section 3.10. */ +const WP4 = (Object.keys(PAGE_KEYS) as PageCommandId[]).filter((id) => + /^(format|block|outline|insert\.(date|time|dateTime)$|text\.setColor$)/.test(id), +); + +describe('WP4’s page commands', () => { + // The first command loads the editor's chunk, which takes a while under load. + beforeAll(async () => { + await import('./commands'); + await open('warm up'); + await cleanupPages(); + }, 60_000); + + it('are all registered, each with a case', () => { + const registered = WP4.filter((id) => commands.get(id)); + expect(WP4.filter((id) => !CASES[id])).toEqual([]); + expect(registered).toEqual(WP4); + }); + + for (const [id, testCase] of Object.entries(CASES)) { + it(`${id} changes the text as one undo step`, async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); + await expectCase(id, testCase); + }); + } +}); + +describe('the formatting toggles', () => { + it('report whether they are on, and announce the change', async () => { + const { page } = await open('a **[b]** c'); + expect(commands.get('format.bold')?.checked?.({} as never)).toBe(true); + expect(commands.get('format.italic')?.checked?.({} as never)).toBe(false); + await executeCommand('format.bold'); + expect(commands.get('format.bold')?.checked?.({} as never)).toBe(false); + expect(page.mounted.pool.active()).not.toBeNull(); + }); + + it('do nothing in code, so their keys go on', async () => { + await open('```\nco[d]e\n```'); + expect(commands.get('format.bold')?.enabled?.({ source: 'keyboard' } as never)).toBe(false); + }); +}); + +const SHIFTED: Record = { + '>': ['Period', '.'], + '<': ['Comma', ','], + '+': ['Equal', '='], + _: ['Minus', '-'], + '}': ['BracketRight', ']'], + '{': ['BracketLeft', '['], +}; +const PLAIN: Record = { + '.': 'Period', + ',': 'Comma', + '=': 'Equal', + '-': 'Minus', + '/': 'Slash', + '\\': 'Backslash', + ']': 'BracketRight', + '[': 'BracketLeft', +}; +const ARROWS: Record = { Up: 'ArrowUp', Down: 'ArrowDown', Left: 'ArrowLeft', Right: 'ArrowRight' }; +const US_SHIFTED_DIGITS = ')!@#$%^&*('; + +/** The key and code a US keyboard reports for a chord's key, adding Shift where the key needs it. */ +function usKey(key: string, mods: Set): { key: string; code: string } { + if (SHIFTED[key]) { + mods.add('Shift'); + return { key, code: SHIFTED[key][0] }; + } + if (PLAIN[key]) return { key, code: PLAIN[key] }; + if (/^[A-Z]$/.test(key)) return { key: mods.has('Shift') ? key : key.toLowerCase(), code: `Key${key}` }; + if (/^\d$/.test(key)) { + return { key: mods.has('Shift') ? US_SHIFTED_DIGITS[Number(key)] : key, code: `Digit${key}` }; + } + if (ARROWS[key]) return { key: ARROWS[key], code: ARROWS[key] }; + return { key, code: key }; +} + +/** A key press on a US keyboard for a chord in canonical form, aimed at `target`. */ +function usPress(chord: string, target: Element) { + const cut = chord.endsWith('++') ? chord.length - 1 : chord.lastIndexOf('+') + 1; + const mods = new Set( + chord + .slice(0, Math.max(0, cut - 1)) + .split('+') + .filter(Boolean), + ); + const { key, code } = usKey(chord.slice(cut), mods); + return { + key, + code, + ctrlKey: mods.has('Ctrl'), + altKey: mods.has('Alt'), + shiftKey: mods.has('Shift'), + metaKey: false, + isComposing: false, + repeat: false, + getModifierState: () => false, + target, + }; +} + +/** Each chord of WP4's registered commands in a set, with the command it should reach. */ +function keyed(set: 'keys' | 'oneNoteKeys'): (readonly [PageCommandId, string])[] { + return WP4.filter((id) => commands.get(id)).flatMap((id) => { + const spec = PAGE_KEYS[id] as { keys: readonly string[]; oneNoteKeys?: readonly string[] }; + const chords = set === 'keys' ? spec.keys : (spec.oneNoteKeys ?? spec.keys); + return chords.map((chord) => [id, chord] as const); + }); +} + +describe('the keys', () => { + for (const preset of ['default', 'onenote'] as const) { + it(`reach their commands from a text editor in the ${preset} set`, async () => { + settingsStore.set({ settings: { ...DEFAULT_SETTINGS, keymap: { preset } }, readOnly: false }); + const { page, block } = await open('- [ ] [task]'); + const target = page.mounted.pool.editor(block)!.view.dom; + const reached = keyed(preset === 'default' ? 'keys' : 'oneNoteKeys').map(([id, chord]) => [ + chord, + commandForKey(usPress(chord, target))?.id ?? null, + id, + ]); + expect(reached.filter(([, got, id]) => got !== id)).toEqual([]); + }); + } + + it('let Ctrl+K open the palette at a bare caret outside a link', async () => { + const { page, block } = await open('a []b'); + const target = page.mounted.pool.editor(block)!.view.dom; + expect(commandForKey(usPress('Ctrl+K', target))?.id).not.toBe('format.link'); + }); +}); + +describe('automatic changes', () => { + it('undo in one step, leaving the typed characters', async () => { + const { page, block } = await open('First line[]'); + const editor = page.mounted.pool.editor(block)!; + editor.commands.splitBlock(); + typeInto(editor, '## Head'); + await page.mounted.sync.flushAll('command'); + expect(page.sent().map((batch) => batch.coalesce?.kind ?? null)).toEqual(['typing', null, 'typing']); + const heading = ['First line', '## Head'].join(BLANK); + expect(page.markdown(block)).toBe(heading); + await page.mounted.sync.undo(); + expect(page.markdown(block)).toBe(['First line', '##'].join(BLANK)); + // The heading goes back to the characters as typed: a paragraph that starts with the marker. + await page.mounted.sync.undo(); + expect(editor.state.doc.lastChild?.type.name).toBe('paragraph'); + expect(editor.state.doc.lastChild?.textContent).toBe('## '); + await page.mounted.sync.undo(); + expect(page.markdown(block)).toBe('First line'); + }); + + it('keep an AutoCorrect replacement apart from the typing before it', async () => { + const { page, block } = await open('[]'); + const editor = page.mounted.pool.editor(block)!; + typeInto(editor, 'teh cat'); + await page.mounted.sync.flushAll('command'); + expect(page.markdown(block)).toBe('the cat'); + await page.mounted.sync.undo(); + expect(editor.state.doc.textContent).toBe('the '); + await page.mounted.sync.undo(); + expect(editor.state.doc.textContent).toBe('teh '); + }); +}); diff --git a/app/src/features/page/formattingBar/commands.ts b/app/src/features/page/formattingBar/commands.ts new file mode 100644 index 000000000..7bb536ebd --- /dev/null +++ b/app/src/features/page/formattingBar/commands.ts @@ -0,0 +1,242 @@ +// How WP4's page commands run (owner: WP4). The start-up registrations load this module on first use. It runs the +// editor command on the target editor, and opens the pickers that commands without arguments need. The pickers are +// text color, text size, highlight color, Turn into, and the link popover. +import type { Editor } from '@tiptap/core'; +import { tokens } from '../../../theme/tokens'; +import { + currentTextSize, + highlightAt, + runEditorCommand, + textColorAt, + commandApplies, +} from '../../../editor/commands/catalog'; +import type { EditorCommandArgs } from '../../../editor/commands/catalog'; +import { linkAt } from '../../../editor/commands/links'; +import { runCommand } from '../../../editor/commands/command'; +import { BLOCK_KINDS } from '../../../editor/commands/turnInto'; +import type { BlockKind } from '../../../editor/commands/turnInto'; +import { HIGHLIGHT_COLORS, TEXT_SIZES, linkKind, schemeOf } from '../../../editor/schema/constants'; +import { blockKindAt } from '../../../editor/commands/state'; +import type { MenuAnchor, MenuItemSpec } from '../../../ui'; +import type { TextSizeName } from '../../../editor/schema/specs'; +import type { NodeId } from '../../../services/notes/types'; +import { openMenu, showToast } from '../../../ui'; +import { t } from '../../../strings/t'; +import type { MessageKey } from '../../../strings/t'; +import type { CommandContext } from '../../../commands/types'; +import { shownPool } from '../pool/shown'; +import { pageSelection } from '../seams/selectionStore'; +import { targetEditor } from './target'; + +/** The caret's place on screen, or the focused control (a bar button), for a picker to open at. */ +function anchorFor(editor: Editor): MenuAnchor { + const active = document.activeElement; + if (active instanceof HTMLElement && !editor.view.dom.contains(active) && active !== document.body) return active; + try { + const at = editor.view.coordsAtPos(editor.state.selection.head); + return { x: at.left, y: at.bottom }; + } catch { + return editor.view.dom; + } +} + +async function pick(editor: Editor, label: string, items: MenuItemSpec[]): Promise { + return openMenu({ label, items, anchor: anchorFor(editor), returnFocus: editor.view.dom }); +} + +const radio = (id: string, label: string, checked: boolean): MenuItemSpec => ({ id, label, kind: 'radio', checked }); + +/** The pens in token order, then Automatic (no color). Custom colors come from the text styles dialog. */ +export function textColorItems(current: string | null): MenuItemSpec[] { + const pens = tokens.ink.pens.map((pen) => pen.name.toLowerCase()); + return [ + ...pens.map((pen) => radio(pen, t(`commands.colors.${pen}` as MessageKey), current === pen)), + { ...radio('none', t('editor.colorMenu.automatic'), current === null), separatorBefore: true }, + { id: 'custom', label: t('editor.colorMenu.custom'), kind: 'radio', checked: current?.startsWith('#') ?? false }, + ]; +} + +/** A color from the text color menu: a pen, null for Automatic, or a custom one asked for. */ +async function chosenColor(chosen: string, current: string | null): Promise { + if (chosen === 'none') return null; + if (chosen !== 'custom') return chosen; + const { chooseCustomColor } = await import('../styles/CustomColor'); + return (await chooseCustomColor(current?.startsWith('#') ? current : '#')) ?? undefined; +} + +export function highlightItems(current: string | null | undefined): MenuItemSpec[] { + return [ + radio('honey', t('editor.highlightColors.honey'), current === null), + ...HIGHLIGHT_COLORS.map((color) => radio(color, t(`editor.highlightColors.${color}`), current === color)), + { ...radio('none', t('editor.highlightColors.none'), current === undefined), separatorBefore: true }, + ]; +} + +export function textSizeItems(current: string | null): MenuItemSpec[] { + const sizes = ['small', 'normal', ...TEXT_SIZES.filter((size) => size !== 'small')] as const; + return sizes.map((size) => radio(size, t(`editor.sizes.${size}`), (current ?? 'normal') === size)); +} + +const KIND_TITLES: Record = { + paragraph: 'editor.commands.normal', + heading1: 'editor.commands.heading1', + heading2: 'editor.commands.heading2', + heading3: 'editor.commands.heading3', + heading4: 'editor.commands.heading4', + heading5: 'editor.commands.heading5', + heading6: 'editor.commands.heading6', + bulletList: 'editor.commands.bulletList', + orderedList: 'editor.commands.orderedList', + checklist: 'editor.commands.checklist', + quote: 'editor.commands.quote', + callout: 'editor.commands.callout', + codeBlock: 'editor.commands.codeBlock', +}; + +export function turnIntoItems(current: BlockKind | null): MenuItemSpec[] { + return BLOCK_KINDS.map((kind) => radio(kind, t(KIND_TITLES[kind]), current === kind)); +} + +/** + * Holds what is typed while a popover loads. Without it, the first letters of an address typed straight after + * Ctrl+K reach the text and replace the selected words. The popover takes the letters into its field. + */ +function holdTyping(): () => string { + let held = ''; + const hold = (event: KeyboardEvent) => { + if (event.ctrlKey || event.metaKey || event.altKey || event.isComposing) return; + if (event.key.length === 1) held += event.key; + else if (event.key === 'Backspace') held = held.slice(0, -1); + else if (event.key !== 'Enter') return; + event.preventDefault(); + event.stopImmediatePropagation(); + }; + const stop = () => window.removeEventListener('keydown', hold, { capture: true }); + window.addEventListener('keydown', hold, { capture: true }); + // Typing is never held for long, even if the popover never opens. + const timer = setTimeout(stop, 2_000); + let released = false; + return () => { + clearTimeout(timer); + stop(); + if (released) return ''; + released = true; + return held; + }; +} + +async function chooseAndRun(editor: Editor, id: string, args: EditorCommandArgs): Promise { + if (id === 'format.textColor' && args.color === undefined) { + const current = textColorAt(editor.state); + const chosen = await pick(editor, t('editor.colorMenu.label'), textColorItems(current)); + const color = chosen ? await chosenColor(chosen, current) : undefined; + if (color !== undefined) runEditorCommand(editor, id, { color }); + return; + } + if (id === 'format.textSize' && args.size === undefined) { + const chosen = await pick(editor, t('editor.sizeMenu'), textSizeItems(currentTextSize(editor.state))); + if (chosen) runEditorCommand(editor, id, { size: chosen === 'normal' ? null : (chosen as TextSizeName) }); + return; + } + if (id === 'block.turnInto' && !args.kind) { + const chosen = await pick(editor, t('editor.turnIntoMenu'), turnIntoItems(blockKindAt(editor.state))); + if (chosen) runEditorCommand(editor, id, { kind: chosen as BlockKind }); + return; + } + if (id === 'format.link' && args.href === undefined) { + const typed = holdTyping(); + try { + const { openLinkPopover } = await import('../linkPopover/LinkPopover'); + return await openLinkPopover(editor, typed); + } finally { + typed(); + } + } + runEditorCommand(editor, id, args); +} + +/** Opens the highlight color menu and applies the color chosen. */ +export async function chooseHighlight(editor: Editor): Promise { + const chosen = await pick(editor, t('editor.highlightMenu'), highlightItems(highlightAt(editor.state))); + if (!chosen) return; + runEditorCommand(editor, 'format.highlight', { color: chosen === 'honey' ? null : chosen }); +} + +/** Runs a WP4 page command on the target editor. */ +export async function runFormatting(id: string, args: EditorCommandArgs | undefined): Promise { + const editor = targetEditor(); + if (!editor) return; + await chooseAndRun(editor, id, args ?? {}); +} + +/** Whether a command would change the target editor's document now. */ +export function formattingApplies(id: string, args?: EditorCommandArgs): boolean { + const editor = targetEditor(); + return editor !== null && commandApplies(id, editor.state, args); +} + +/** Sets the text color of every selected text block, as one command per block. */ +export async function setBlocksColor(color: string | null | undefined, ctx: CommandContext): Promise { + const pool = shownPool.get(); + const blocks = pageSelection.get().blocks; + if (!pool || blocks.length === 0) return; + let value = color; + if (value === undefined) { + const anchor = document.activeElement instanceof HTMLElement ? document.activeElement : document.body; + const chosen = await openMenu({ label: t('editor.colorMenu.label'), items: textColorItems(null), anchor }); + value = chosen ? await chosenColor(chosen, null) : undefined; + if (value === undefined) return; + } + for (const block of blocks) { + const editor = pool.editor(block) ?? pool.mount(block, null, 'target'); + if (!editor) continue; + runCommand( + editor, + (state, dispatch) => { + const type = state.schema.marks.textColor; + const tr = state.tr; + if (value === null) tr.removeMark(0, state.doc.content.size, type); + else tr.addMark(0, state.doc.content.size, type.create({ color: value })); + if (!tr.docChanged) return false; + dispatch?.(tr); + return true; + }, + { focus: false }, + ); + } + ctx.announce(t('editor.announce.block', { name: t('editor.commands.textColor') })); +} + +/** The link under the target editor's caret, for the link menu. */ +function targetLink(): { href: string; editor: Editor } | null { + const editor = targetEditor(); + const link = editor ? linkAt(editor.state) : null; + return editor && link ? { href: link.href, editor } : null; +} + +/** Opens the link at the caret: web and mail links in the browser, OpenNote links in the app; others never. */ +export async function openLinkAtCaret(ctx: CommandContext): Promise { + const link = targetLink(); + if (!link) return; + const kind = linkKind(link.href); + if (kind === 'inert' || kind === 'asset') { + showToast({ message: t('editor.link.inert', { scheme: schemeOf(link.href) ?? '' }) }); + return; + } + if (kind === 'opennote') { + const page = /^opennote:(?:\/\/)?page\/([^#?]+)/i.exec(link.href)?.[1]; + if (page) ctx.navigate({ view: 'workspace', notebookId: null, sectionId: null, pageId: page as NodeId }); + else showToast({ message: t('editor.link.inert', { scheme: 'opennote' }) }); + return; + } + await ctx.platform.shell.openExternal({ kind: 'link', url: link.href }); +} + +export async function copyLinkAtCaret(ctx: CommandContext): Promise { + const link = targetLink(); + if (!link) return; + await navigator.clipboard.writeText(link.href); + ctx.announce(t('editor.link.copied')); +} + +export { linkAt }; diff --git a/app/src/features/page/formattingBar/crossBlock.test.ts b/app/src/features/page/formattingBar/crossBlock.test.ts new file mode 100644 index 000000000..de7726bc6 --- /dev/null +++ b/app/src/features/page/formattingBar/crossBlock.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +// Keys between blocks on a real page: Escape and Ctrl+A select blocks as objects, arrows cross flowing blocks and +// stay in floating ones, Shift+Arrow escalates, and Backspace merges into the text block above in one batch. +import type { Editor } from '@tiptap/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../registrations/editor'; +import { ESCAPE_HINT_ID } from '../../../editor/extensions/crossBlock'; +import { pageSelection } from '../seams/selectionStore'; +import { textPageFixture } from '../test/fixtures'; +import type { PageFixture } from '../test/fixtures'; +import { cleanupPages, renderPage } from '../test/harness'; + +afterEach(async () => { + await cleanupPages(); + pageSelection.set({ blocks: [], strokes: [] }); +}); + +/** A page of flowing text blocks, one per string; `floating` gives the second one a frame. */ +function blocksFixture(texts: readonly string[], floating = false): PageFixture { + const fixture = textPageFixture(texts[0]); + const blocks = texts.map((markdown, index) => { + const one = fixture.page.blocks[0]; + const frame = floating && index === 1 ? { x: 400, y: 300, w: 200 } : one.frame; + const id = `${one.id.slice(0, -1)}${index + 1}`; + return { ...one, id, order: `a000${index}`, data: { markdown }, ...(frame ? { frame } : {}) }; + }); + return { page: { ...fixture.page, blocks } }; +} + +async function open(texts: readonly string[], floating = false) { + const fixture = blocksFixture(texts, floating); + const page = await renderPage({ fixture }); + const ids = fixture.page.blocks.map((block) => block.id); + // The pool mounts editors as they are needed, so the test asks for each one it presses keys in. + const editor = (index: number) => page.mounted.pool.mount(ids[index], null, 'focus')!; + return { page, ids, editor }; +} + +function press(editor: Editor, key: string, init: KeyboardEventInit = {}): boolean { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }); + return editor.view.someProp('handleKeyDown', (handle) => handle(editor.view, event)) ?? false; +} + +describe('keys between blocks', () => { + it('describe every editor by the Escape hint', async () => { + const { editor } = await open(['one']); + expect(editor(0).view.dom.getAttribute('aria-describedby')).toBe(ESCAPE_HINT_ID); + expect(document.getElementById(ESCAPE_HINT_ID)?.textContent).toBe('Press Escape, then Tab, to leave the text box.'); + }); + + it('select the block as an object on Escape', async () => { + const { ids, editor } = await open(['one', 'two']); + expect(press(editor(1), 'Escape')).toBe(true); + expect(pageSelection.get().blocks).toEqual([ids[1]]); + }); + + it('select the text on Ctrl+A, then every block', async () => { + const { ids, editor } = await open(['one', 'two']); + editor(0).commands.setTextSelection(2); + press(editor(0), 'a', { ctrlKey: true }); + expect(pageSelection.get().blocks).toEqual([]); + editor(0).commands.selectAll(); + expect(press(editor(0), 'a', { ctrlKey: true })).toBe(true); + expect(pageSelection.get().blocks).toEqual(ids); + }); + + it('move down into the next flowing block from the last line', async () => { + const { editor } = await open(['one', 'two']); + editor(0).commands.focus('end', { scrollIntoView: false }); + // jsdom has no layout, so the caret counts as on the last line. + editor(0).view.endOfTextblock = () => true; + expect(press(editor(0), 'ArrowDown')).toBe(true); + expect(document.activeElement).toBe(editor(1).view.dom); + }); + + it('select both blocks on Shift+Arrow past the edge', async () => { + const { ids, editor } = await open(['one', 'two']); + editor(1).commands.focus('start', { scrollIntoView: false }); + expect(press(editor(1), 'ArrowUp', { shiftKey: true })).toBe(true); + expect(pageSelection.get().blocks).toEqual(ids); + }); + + it('stay inside a floating text box', async () => { + const { editor } = await open(['one', 'two'], true); + editor(1).commands.focus('end', { scrollIntoView: false }); + expect(press(editor(1), 'ArrowUp')).toBe(false); + }); + + it('merge into the text block above on Backspace at the start, in one batch', async () => { + const { page, ids, editor } = await open(['one', 'two']); + editor(1).commands.focus('start', { scrollIntoView: false }); + expect(press(editor(1), 'Backspace')).toBe(true); + expect(pageSelection.get().blocks).toEqual([]); + await vi.waitFor(() => expect(editor(0).state.doc.textContent).toBe('onetwo')); + await page.mounted.sync.flushAll('command'); + expect(page.markdown(ids[0])).toBe('onetwo'); + const batch = page.sent().find((sent) => sent.edits.some((edit) => edit.edit === 'deleteBlocks')); + // The text goes as a splice when the core takes them, and as the whole text otherwise. + const textEdit: unknown = expect.stringMatching(/^(setText|spliceText)$/); + expect(batch?.edits.map((edit) => edit.edit)).toEqual([textEdit, 'deleteBlocks']); + }); +}); diff --git a/app/src/features/page/formattingBar/dateLine.test.ts b/app/src/features/page/formattingBar/dateLine.test.ts new file mode 100644 index 000000000..7c396e193 --- /dev/null +++ b/app/src/features/page/formattingBar/dateLine.test.ts @@ -0,0 +1,50 @@ +// The date line under a new page's title: an ordinary text block with the date and time in small text, added +// once to an empty page, and not at all when Settings turns it off. +import { afterEach, describe, expect, it } from 'vitest'; +import { initFlags } from '../../../app/flags'; +import type { NodeId } from '../../../services/notes/types'; +import { createMemoryPageService } from '../../../services/pages/memory'; +import { DEFAULT_SETTINGS, settingsStore } from '../../../state/settings'; +import { installPages } from '../runtime'; +import { textPageFixture } from '../test/fixtures'; +import { addDateLine, dateLineMarkdown } from './dateLine'; + +const NOW = new Date('2026-09-30T14:05:00'); +let uninstall: (() => void) | null = null; + +afterEach(() => { + uninstall?.(); + settingsStore.set({ settings: DEFAULT_SETTINGS, readOnly: false }); +}); + +function emptyPage() { + const fixture = textPageFixture(''); + fixture.page.blocks = []; + const service = createMemoryPageService([fixture]); + uninstall = installPages({ pages: service }); + initFlags('dev', { 'page.editor': true }); + return { service, id: fixture.page.id }; +} + +describe('the date line', () => { + it('is the date and time in small text', () => { + expect(dateLineMarkdown(NOW)).toBe('Sep 30, 2026 2:05 PM'); + }); + + it('goes into a new, empty page as its first text block', async () => { + const { service, id } = emptyPage(); + await addDateLine(id as unknown as NodeId, NOW); + const edits = service.sent(id).flatMap((batch) => batch.edits); + expect(edits).toMatchObject([ + { edit: 'insertBlock', block: { type: 'text', data: { markdown: dateLineMarkdown(NOW) } } }, + ]); + }); + + it('stays out when Settings turns it off', async () => { + const { service, id } = emptyPage(); + const editing = { ...DEFAULT_SETTINGS.editing, newPageDateTime: false }; + settingsStore.set({ settings: { ...DEFAULT_SETTINGS, editing }, readOnly: false }); + await addDateLine(id as unknown as NodeId, NOW); + expect(service.sent(id)).toEqual([]); + }); +}); diff --git a/app/src/features/page/formattingBar/dateLine.ts b/app/src/features/page/formattingBar/dateLine.ts new file mode 100644 index 000000000..9d4604a43 --- /dev/null +++ b/app/src/features/page/formattingBar/dateLine.ts @@ -0,0 +1,27 @@ +// The date and time under a new page's title (ARCHITECTURE.md section 18.3; owner: WP4). A new page gets a first +// flowing text block with the date and time in small text. It is ordinary, editable text, so it appears in page.md. +import { isEnabled } from '../../../app/flags'; +import { dateTimeText } from '../../../editor/commands/insertDate'; +import { newId } from '../../../editor/ids'; +import type { NodeId } from '../../../services/notes/types'; +import { getSettings } from '../../../state/settings'; +import { pagesClient } from '../runtime'; +import type { PageId } from '../../../services/pages/types'; + +/** The Markdown of the date line for the moment `now`. */ +export function dateLineMarkdown(now: Date = new Date()): string { + return `${dateTimeText('dateTime', now)}`; +} + +/** Adds the date line to a page that was just created, when the setting and the flag are on. */ +export async function addDateLine(pageId: NodeId, now: Date = new Date()): Promise { + if (!getSettings().editing.newPageDateTime || !isEnabled('page.typingHelpers') || !isEnabled('page.editor')) return; + const page = await pagesClient().open(pageId as unknown as PageId, { viewport: null }); + try { + if (page.initial.blocks.length > 0) return; + const block = { id: newId(), type: 'text', data: { markdown: dateLineMarkdown(now) } }; + await page.send({ edits: [{ edit: 'insertBlock', block }] }); + } finally { + await page.close(); + } +} diff --git a/app/src/features/page/formattingBar/folds.ts b/app/src/features/page/formattingBar/folds.ts new file mode 100644 index 000000000..e44272c75 --- /dev/null +++ b/app/src/features/page/formattingBar/folds.ts @@ -0,0 +1,52 @@ +// Fold state in pageViews (ARCHITECTURE.md section 18.2; owner: WP4). Heading and list folds are device-local view +// state, per page, keyed by block, text, and occurrence. Editors ask for their block's folds as they mount and +// report changes; this keeps the page's folds and writes them to Phase 2's device state. +import { getLocation } from '../../../app/location'; +import { commandContext } from '../../../commands/registry'; +import type { FoldKey } from '../../../editor/commands/fold'; +import type { FoldsDetail, FoldsRequestDetail } from '../../../editor/commands/state'; +import type { Platform } from '../../../platform/types'; + +const saved = new Map(); + +function shownPage(): string | null { + const location = getLocation(); + return location.view === 'workspace' ? location.pageId : null; +} + +function platform(): Platform | null { + try { + return commandContext('menu').platform; + } catch { + return null; + } +} + +function foldsOf(page: string): FoldKey[] { + const known = saved.get(page); + if (known) return known; + const stored = platform()?.boot.state.pageViews[page]?.folds ?? []; + saved.set(page, [...stored]); + return saved.get(page)!; +} + +/** Answers an editor's request with the folds saved for its block on the shown page. */ +export function provideFolds(detail: FoldsRequestDetail): void { + const page = shownPage(); + if (!page) return; + const keys = foldsOf(page).filter((key) => key.block === detail.block); + if (keys.length > 0) detail.provide(keys); +} + +/** Saves a block's folds for the shown page. */ +export function saveFolds(detail: FoldsDetail): void { + const page = shownPage(); + const client = platform(); + if (!page) return; + const folds = [...foldsOf(page).filter((key) => key.block !== detail.block), ...detail.keys]; + saved.set(page, folds); + if (!client) return; + const existing = client.boot.state.pageViews[page]; + const view = existing ? { folds } : { scrollX: 0, scrollY: 0, zoom: 1, view: null, folds, at: Date.now() }; + client.state.update({ pageViews: { [page]: view } }); +} diff --git a/app/src/features/page/formattingBar/layouts.test.ts b/app/src/features/page/formattingBar/layouts.test.ts new file mode 100644 index 000000000..f5c534b66 --- /dev/null +++ b/app/src/features/page/formattingBar/layouts.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +// Spike S7's layout fixtures through the dispatcher, in a text box with Phase 4's commands registered. AltGr text +// wins over Ctrl+Alt shortcuts, and an exact key beats a physical key, in both shortcut sets. +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import '../registrations/editor'; +import '../registrations/sync'; +import { commandForKey } from '../../../commands/dispatcher'; +import { LAYOUT_PRESSES } from '../../../commands/fixtures/layouts'; +import { chord, defineCommand } from '../../../commands/registry'; +import { commands } from '../../../registries'; +import { DEFAULT_SETTINGS, settingsStore } from '../../../state/settings'; +import { textPageFixture } from '../test/fixtures'; +import { cleanupPages, renderPage } from '../test/harness'; + +let stop: (() => void) | null = null; + +beforeAll(() => { + // Phase 2's shell registers the shortcut list on Ctrl+/; these tests mount no shell. + if (!commands.get('app.shortcuts')) { + stop = commands.register( + defineCommand({ + id: 'app.shortcuts', + title: 'commands.app.shortcuts', + category: 'help', + keys: [chord('Ctrl+/')], + allowInTextInput: true, + run: () => {}, + }), + ); + } +}); +afterAll(() => stop?.()); +afterEach(async () => { + await cleanupPages(); + settingsStore.set({ settings: DEFAULT_SETTINGS, readOnly: false }); +}); + +describe('keyboard layouts', () => { + for (const press of LAYOUT_PRESSES) { + it(`${press.layout}: ${press.pressed} ${press.expect ? `runs ${press.expect}` : 'types text'}`, async () => { + if (press.preset) + settingsStore.set({ settings: { ...DEFAULT_SETTINGS, keymap: { preset: press.preset } }, readOnly: false }); + const fixture = textPageFixture('- [ ] task'); + const block = fixture.page.blocks[0].id; + const page = await renderPage({ fixture }); + const editor = page.mounted.pool.mount(block, { kind: 'selection', anchor: 7, head: 11 }, 'target')!; + const altGraph = press.altGraph ?? false; + const found = commandForKey({ + key: press.key, + code: press.code, + ctrlKey: (press.ctrl ?? false) || altGraph, + altKey: (press.alt ?? false) || altGraph, + shiftKey: press.shift ?? false, + metaKey: false, + isComposing: false, + repeat: false, + getModifierState: (name: string) => name === 'AltGraph' && altGraph, + target: editor.view.dom, + }); + expect(found?.id ?? null).toBe(press.expect); + }); + } +}); diff --git a/app/src/features/page/formattingBar/merge.ts b/app/src/features/page/formattingBar/merge.ts new file mode 100644 index 000000000..473516058 --- /dev/null +++ b/app/src/features/page/formattingBar/merge.ts @@ -0,0 +1,33 @@ +// Backspace at the start of a flowing text block merges it into the text block above (ARCHITECTURE.md section 8.4; +// owner: WP4). The editor asks with MERGE_BLOCKS_EVENT, because merging needs the pool and the sync queue. The +// result is one batch: the text edit on the block above, with deleteBlocks for the merged block riding along. +import { Fragment } from '@tiptap/pm/model'; +import { TextSelection } from '@tiptap/pm/state'; +import { canJoin } from '@tiptap/pm/transform'; +import type { MergeBlocksDetail } from '../../../editor/commands/state'; +import { META_COMMAND } from '../../../editor/meta'; +import { shownPool } from '../pool/shown'; +import { appendToNextBatch } from '../sync/shown'; + +/** Moves `block`'s content to the end of `previous`, joining the two textblocks that meet. */ +export function mergeIntoPrevious({ block, previous }: MergeBlocksDetail): boolean { + const pool = shownPool.get(); + const above = pool?.editor(previous) ?? pool?.mount(previous, null, 'target') ?? null; + const below = pool?.editor(block) ?? null; + if (!above || !below || above.isDestroyed || below.isDestroyed) return false; + const { state } = above; + const end = state.doc.content.size; + // Each editor builds its own schema, so the content moves through JSON. + const content = Fragment.fromJSON(state.schema, below.state.doc.content.toJSON()); + const tr = state.tr.insert(end, content); + const $join = tr.doc.resolve(end); + const textblocks = Boolean($join.nodeBefore?.isTextblock && $join.nodeAfter?.isTextblock); + const joins = textblocks && canJoin(tr.doc, end); + if (joins) tr.join(end); + const caret = joins ? end - 1 : end + 1; + tr.setSelection(TextSelection.near(tr.doc.resolve(Math.min(caret, tr.doc.content.size)))); + appendToNextBatch(previous, [{ edit: 'deleteBlocks', blocks: [block] }]); + above.view.dispatch(tr.setMeta(META_COMMAND, true)); + above.commands.focus(undefined, { scrollIntoView: false }); + return true; +} diff --git a/app/src/features/page/formattingBar/plugins.bench.test.ts b/app/src/features/page/formattingBar/plugins.bench.test.ts new file mode 100644 index 000000000..1c282eb6f --- /dev/null +++ b/app/src/features/page/formattingBar/plugins.bench.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +// What Phase 4's plugins cost per transaction on the 20-page flat note and outline (PLAN.md section 8.6). Typing +// replays a keystroke at a time, with an Enter every twenty, and the 95th percentile of applying each transaction +// stays within the 1.5 ms budget, with a margin for a loaded test machine. +import { EditorState, TextSelection } from '@tiptap/pm/state'; +import type { Transaction } from '@tiptap/pm/state'; +import { afterEach, describe, expect, it } from 'vitest'; +import { pageFixture } from '../../../services/pages/fixtures'; +import type { Editor } from '@tiptap/core'; +import { testHost } from '../../../editor/commands/testing'; +import { parseTextBlock } from '../../../editor/markdown'; +import { createBlockEditor } from '../../../editor/extensions/kit'; + +let mounted: Editor | null = null; +afterEach(() => { + mounted?.destroy(); + mounted = null; +}); + +/** The budget, with room for a test machine shared by other work. */ +const BUDGET_MS = 1.5 * 3; + +function p95(samples: number[]): number { + const sorted = [...samples].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length * 0.95)]; +} + +/** Applies typing transactions to the state alone, which is what plugins add to; the view is the same for all. */ +function measure(state: EditorState, keystrokes: number): number[] { + let current = state; + const middle = Math.floor(current.doc.content.size / 2); + current = current.apply(current.tr.setSelection(TextSelection.near(current.doc.resolve(middle)))); + const samples: number[] = []; + for (let i = 0; i < keystrokes; i += 1) { + const tr: Transaction = i % 20 === 19 ? current.tr.split(current.selection.from) : current.tr.insertText('a'); + const started = performance.now(); + current = current.apply(tr); + samples.push(performance.now() - started); + } + return samples; +} + +describe('Phase 4’s plugins', () => { + for (const name of ['twentyPage', 'twentyPageOutline'] as const) { + it(`cost little per transaction on the ${name} fixture`, () => { + const markdown = String(pageFixture(name).page.blocks[0].data.markdown); + const root = document.body.appendChild(document.createElement('div')); + mounted = createBlockEditor(root, parseTextBlock(markdown), { kind: 'text', block: 'b1', host: testHost() }); + const samples = measure(mounted.state, 400); + console.info(`${name}: p95 ${p95(samples.slice(50)).toFixed(3)} ms per transaction`); + expect(p95(samples.slice(50))).toBeLessThan(BUDGET_MS); + }, 60_000); + } +}); diff --git a/app/src/features/page/formattingBar/target.ts b/app/src/features/page/formattingBar/target.ts new file mode 100644 index 000000000..275d6fa22 --- /dev/null +++ b/app/src/features/page/formattingBar/target.ts @@ -0,0 +1,36 @@ +// The editor that formatting commands act on (owner: WP4): the focused text editor. While focus is in the command +// bar, the palette, or a menu, it is the editor that had focus last. This module is light, because the start-up +// registrations read it for `when` and checked states. +import type { Editor } from '@tiptap/core'; +import { shownPool } from '../pool/shown'; +import type { EditorPool } from '../pool/pool'; + +let watched: EditorPool | null = null; +let last: string | null = null; +let stop: (() => void) | null = null; + +function watch(): void { + const pool = shownPool.get(); + if (pool === watched) return; + stop?.(); + stop = null; + last = null; + watched = pool; + if (pool) stop = pool.onActiveChange((block) => void (block !== null && (last = block))); +} + +const usable = (editor: Editor | null | undefined): Editor | null => + editor && !editor.isDestroyed && editor.isEditable ? editor : null; + +/** The editor a formatting command runs on, or null when no text editor is or was active on the shown page. */ +export function targetEditor(): Editor | null { + watch(); + const pool = shownPool.get(); + if (!pool) return null; + const active = pool.active(); + if (active) { + last = active.block; + return usable(active.editor); + } + return last === null ? null : usable(pool.editor(last)); +} diff --git a/app/src/features/page/history/CompareView.tsx b/app/src/features/page/history/CompareView.tsx new file mode 100644 index 000000000..e60c2ef83 --- /dev/null +++ b/app/src/features/page/history/CompareView.tsx @@ -0,0 +1,197 @@ +// The compare view (ARCHITECTURE.md section 21): static DOM through the page's schema. Added words are underlined in +// and removed words struck through in , each with hidden "Added:" or "Removed:" text, because screen +// readers don't all announce those elements; colors pair with the lines, so color is never the only signal. +// Unchanged paragraphs collapse into buttons. Each change has its restore action. +import { useEffect, useRef, useState } from 'react'; +import type { BlockChange, PageDiff, ParagraphChange } from '../../../editor/diff/pageDiff'; +import type { WordPart } from '../../../editor/diff/myers'; +import { parseTextBlock } from '../../../editor/markdown'; +import { renderStatic } from '../../../editor/schema/dom'; +import { t } from '../../../strings/t'; +import { Button } from '../../../ui'; +import styles from './history.module.css'; + +export interface CompareActions { + restoreParagraph(block: string, change: ParagraphChange, all: readonly ParagraphChange[]): void; + restoreBlock(block: string): void; +} + +function Static({ markdown }: { markdown: string }) { + const ref = useRef(null); + useEffect(() => { + if (!ref.current) return; + ref.current.replaceChildren(); + renderStatic(parseTextBlock(markdown), ref.current); + }, [markdown]); + return
; +} + +function Marked({ kind, children }: { kind: 'added' | 'removed'; children: React.ReactNode }) { + const label = ( + {t(kind === 'added' ? 'history.compare.added' : 'history.compare.removed')} + ); + return kind === 'added' ? ( + + {label} + {children} + + ) : ( + + {label} + {children} + + ); +} + +function Words({ parts }: { parts: readonly WordPart[] }) { + return ( +

+ {parts.map((part, index) => + part.kind === 'same' ? ( + {part.text} + ) : ( + + {part.text} + + ), + )} +

+ ); +} + +function Unchanged({ items }: { items: readonly ParagraphChange[] }) { + const [open, setOpen] = useState(false); + if (open) + return ( + <> + {items.map((item, index) => ( + + ))} + + ); + return ( + + ); +} + +function Paragraph({ change, onRestore }: { change: ParagraphChange; onRestore: (() => void) | null }) { + const body = + change.status === 'changed' && change.words ? ( + + ) : ( + + + + ); + return ( +
+ {body} + {onRestore && ( + + )} +
+ ); +} + +/** A text block's paragraphs, with runs of unchanged ones collapsed. */ +function TextChanges({ block, actions }: { block: Extract; actions: CompareActions }) { + const groups: (ParagraphChange | ParagraphChange[])[] = []; + for (const change of block.paragraphs) { + const last = groups.at(-1); + if (change.status === 'same' && Array.isArray(last)) last.push(change); + else groups.push(change.status === 'same' ? [change] : change); + } + const live = block.status !== 'removed'; + return ( + <> + {groups.map((group, index) => + Array.isArray(group) ? ( + + ) : ( + actions.restoreParagraph(block.id, group, block.paragraphs) : null} + /> + ), + )} + + ); +} + +function describe(block: BlockChange): string { + if (block.kind === 'table') { + const { cells, rows } = block; + return t('history.compare.table', { cells: cells.length, added: rows.added, removed: rows.removed }); + } + if (block.kind === 'image') { + const notes = block.notes.map((note) => t(`history.compare.${note}`)).join(', '); + const name = t('history.compare.image', { alt: block.alt }); + return notes ? `${name}. ${t('history.compare.imageNotes', { notes })}` : name; + } + if (block.kind === 'other') return t('history.compare.other', { type: block.type }); + return ''; +} + +function BlockView({ block, actions }: { block: BlockChange; actions: CompareActions }) { + const status = + block.status === 'added' + ? t('history.compare.blockAdded') + : block.status === 'removed' + ? t('history.compare.blockRemoved') + : null; + const restorable = block.status === 'removed' || (block.status === 'changed' && block.kind !== 'text'); + return ( +
+ {(status || block.moved) && ( +

+ {[status, block.moved ? t('history.compare.moved') : null].filter(Boolean).join('. ')} +

+ )} + {block.kind === 'text' ? ( + + ) : ( +
+

{describe(block)}

+
+ )} + {restorable && ( + + )} +
+ ); +} + +export function CompareView({ diff, actions }: { diff: PageDiff; actions: CompareActions }) { + const shown = diff.blocks.filter((block) => block.status !== 'same' || block.moved); + return ( +
+

+ {shown.length || diff.ink ? t('history.compare.summary', { ...diff.counts }) : t('history.compare.noChanges')} +

+ {diff.ink &&

{t('history.compare.ink', diff.ink)}

} + {shown.map((block) => ( + + ))} +
+ ); +} + +/** F8 and Shift+F8: focuses the next or previous change after focus. Returns false when there is none. */ +export function moveToChange(root: Element, direction: 1 | -1): { index: number; total: number } | null { + const changes = [...root.querySelectorAll('[data-change]')]; + if (!changes.length) return null; + const focused = changes.findIndex((change) => change.contains(document.activeElement)); + const current = focused >= 0 ? focused : direction === 1 ? -1 : changes.length; + const next = current + direction; + if (next < 0 || next >= changes.length) return null; + changes[next].focus(); + changes[next].scrollIntoView({ block: 'nearest' }); + return { index: next + 1, total: changes.length }; +} diff --git a/app/src/features/page/history/HistoryPanel.tsx b/app/src/features/page/history/HistoryPanel.tsx new file mode 100644 index 000000000..116d056c0 --- /dev/null +++ b/app/src/features/page/history/HistoryPanel.tsx @@ -0,0 +1,273 @@ +// The Page history panel (ARCHITECTURE.md section 21): versions newest first, with date and time, the reason in plain +// words, device, and name. The newest is the current page, saved when the panel opens. Choosing a version compares it +// with the current page, and "Compare with" picks another. Versions can be named, restored, or restored as a copy, +// and the page's history deleted. +import { useCallback, useEffect, useState } from 'react'; +import { XIcon } from '@phosphor-icons/react/dist/csr/X'; +import { diffPages } from '../../../editor/diff/pageDiff'; +import type { PageDiff } from '../../../editor/diff/pageDiff'; +import type { OpenPage, VersionInfo } from '../../../services/pages/types'; +import { t } from '../../../strings/t'; +import { announce, Button, confirm, IconButton, Switch, TextField } from '../../../ui'; +import { historyAdmin } from './admin'; +import { CompareView } from './CompareView'; +import type { CompareActions } from './CompareView'; +import styles from './history.module.css'; +import { restoreBlocks, restoreParagraph, restoreVersion } from './restore'; + +const REASONS = new Set([ + 'beforeEdit', + 'closed', + 'interval', + 'exit', + 'named', + 'beforeRestore', + 'beforeUpgrade', + 'beforeLargeDelete', + 'beforeRepair', + 'conflict', + 'recovered', +]); + +const when = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }); + +export function reasonText(reason: string): string { + return REASONS.has(reason) ? t(`history.reasons.${reason as 'closed'}`) : t('history.reasons.unknown'); +} + +function versionLabel(version: VersionInfo): string { + return t('history.version', { when: when.format(new Date(version.savedAt)), device: version.device }); +} + +export interface HistoryPanelProps { + page: OpenPage; + title: string; + onClose(): void; +} + +type Listing = { state: 'loading' } | { state: 'failed' } | { state: 'ready'; versions: VersionInfo[] }; + +async function listVersions(page: OpenPage): Promise { + try { + await page.saveNow().catch(() => undefined); + const versions = [...(await page.history.list())].sort((a, b) => b.savedAt.localeCompare(a.savedAt)); + return { state: 'ready', versions }; + } catch { + return { state: 'failed' }; + } +} + +/** The page's versions, newest first, saved first; reload lists them again after a change. */ +function useVersions(page: OpenPage): [Listing, () => void] { + const [listing, setListing] = useState({ state: 'loading' }); + const [round, setRound] = useState(0); + useEffect(() => { + let current = true; + void listVersions(page).then((next) => current && setListing(next)); + return () => { + current = false; + }; + }, [page, round]); + return [listing, useCallback(() => setRound((value) => value + 1), [])]; +} + +function NameForm({ page, version, onDone }: { page: OpenPage; version: VersionInfo; onDone(): void }) { + const [name, setName] = useState(version.name ?? ''); + const [keep, setKeep] = useState(version.keep || version.name !== null); + const save = async () => { + await page.history.name(version.revision, name.trim() || null, keep).catch(() => undefined); + announce(t('history.name.done')); + onDone(); + }; + return ( +
{ + event.preventDefault(); + void save(); + }} + > + + +
+ + +
+ + ); +} + +async function deleteHistory(page: OpenPage, title: string): Promise { + const admin = historyAdmin(); + const notebook = admin?.notebookOf(page.id); + if (!admin || !notebook) return false; + const yes = await confirm({ + title: t('history.delete.title', { name: title }), + body: t('history.delete.page'), + confirmLabel: t('history.delete.confirm'), + cancelLabel: t('history.delete.cancel'), + danger: true, + }); + if (!yes) return false; + try { + await admin.deleteHistory(notebook, { kind: 'page', id: page.id }, true); + announce(t('history.delete.done')); + return true; + } catch { + announce(t('history.delete.failed'), 'assertive'); + return false; + } +} + +function Compare(props: { page: OpenPage; versions: VersionInfo[]; older: string; onBack(): void; onChanged(): void }) { + const { page, versions, older, onBack, onChanged } = props; + const [newer, setNewer] = useState(versions[0].revision); + const [result, setResult] = useState<{ key: string; diff: PageDiff } | null>(null); + const key = `${older} ${newer}`; + const diff = result?.key === key ? result.diff : null; + const [naming, setNaming] = useState(false); + useEffect(() => { + let current = true; + void Promise.all([page.history.open(older), page.history.open(newer)]) + .then(([before, after]) => current && setResult({ key, diff: diffPages(before, after) })) + .catch( + () => + current && + setResult({ key, diff: { blocks: [], counts: { added: 0, removed: 0, changed: 0, moved: 0 }, ink: null } }), + ); + return () => { + current = false; + }; + }, [page, older, newer, key]); + const version = versions.find((item) => item.revision === older)!; + const isCurrent = newer === versions[0].revision; + const actions: CompareActions = { + restoreParagraph: (block, change, all) => + void restoreParagraph(block, change, all).then((ok) => (ok ? onChanged() : undefined)), + restoreBlock: (block) => void restoreBlocks(page, older, [block]).then((ok) => (ok ? onChanged() : undefined)), + }; + return ( +
+ +

+ {t('history.compare.title', { + older: versionLabel(version), + newer: isCurrent ? t('history.current') : versionLabel(versions.find((item) => item.revision === newer)!), + })} +

+ +
+ + + +
+ {naming && setNaming(false)} />} + {diff ? ( + undefined, restoreBlock: actions.restoreBlock }} + /> + ) : ( +

{t('history.loading')}

+ )} +
+ ); +} + +function VersionList(props: { versions: VersionInfo[]; onChoose(revision: string): void }) { + return ( +
    + {props.versions.map((version, index) => ( +
  • + +
  • + ))} +
+ ); +} + +export function HistoryPanel({ page, title, onClose }: HistoryPanelProps) { + const [listing, reload] = useVersions(page); + const [older, setOlder] = useState(null); + const [naming, setNaming] = useState(false); + const versions = listing.state === 'ready' ? listing.versions : []; + return ( + + ); +} diff --git a/app/src/features/page/history/admin.ts b/app/src/features/page/history/admin.ts new file mode 100644 index 000000000..1a1f6e642 --- /dev/null +++ b/app/src/features/page/history/admin.ts @@ -0,0 +1,21 @@ +// Deleting history (ARCHITECTURE.md section 21) goes through history_delete, which needs the notebook a page is in. +// The shell installs both once the core's notebook commands are wired; until then the panel offers no delete. +import type { HistoryAdmin, PageId } from '../../../services/pages/types'; + +export interface HistoryAdminSeam extends HistoryAdmin { + /** The notebook that holds a page, or null when it isn't known. */ + notebookOf(page: PageId): string | null; +} + +let installed: HistoryAdminSeam | null = null; + +export function installHistoryAdmin(admin: HistoryAdminSeam | null): () => void { + installed = admin; + return () => { + if (installed === admin) installed = null; + }; +} + +export function historyAdmin(): HistoryAdminSeam | null { + return installed; +} diff --git a/app/src/features/page/history/chunk.ts b/app/src/features/page/history/chunk.ts new file mode 100644 index 000000000..195624705 --- /dev/null +++ b/app/src/features/page/history/chunk.ts @@ -0,0 +1,63 @@ +// The page history chunk (ARCHITECTURE.md section 21): the panel beside the shown page and the commands' work. +// Next change and Previous change move through the comparison. The panel closes when the page changes. +import { createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import type { Root } from 'react-dom/client'; +import { t } from '../../../strings/t'; +import { announce } from '../../../ui'; +import { shownViewport } from '../viewport/viewport'; +import { moveToChange } from './CompareView'; +import { HistoryPanel } from './HistoryPanel'; +import styles from './history.module.css'; +import { shownPage } from './shown'; + +let panel: { host: HTMLElement; root: Root; returnTo: HTMLElement | null } | null = null; +let stopWatching: (() => void) | null = null; + +/** Opens the Page history panel for the shown page, or focuses it when it's open. */ +export function openHistory(title = ''): void { + const page = shownPage.get(); + const viewport = shownViewport.get(); + const parent = viewport?.viewport.parentElement; + if (!page || !parent) return; + stopWatching ??= shownPage.subscribe(() => closeHistory()); + if (!panel?.host.isConnected) { + panel?.root.unmount(); + const host = parent.appendChild(document.createElement('div')); + host.className = styles.host; + const returnTo = document.activeElement instanceof HTMLElement ? document.activeElement : null; + panel = { host, root: createRoot(host), returnTo }; + panel.root.render(createElement(HistoryPanel, { page, title, onClose: closeHistory })); + } + const host = panel.host; + requestAnimationFrame(() => host.querySelector('button:not(:disabled)')?.focus()); +} + +export function closeHistory(): void { + if (!panel) return; + const { host, root, returnTo } = panel; + panel = null; + const hadFocus = host.contains(document.activeElement); + root.unmount(); + host.remove(); + if (hadFocus && returnTo?.isConnected) returnTo.focus({ preventScroll: true }); +} + +export function isHistoryOpen(): boolean { + return panel?.host.isConnected ?? false; +} + +/** F8 and Shift+F8 in a comparison. */ +export function moveThroughChanges(direction: 1 | -1): void { + const compare = panel?.host.querySelector('[data-compare]'); + if (!compare) return; + const moved = moveToChange(compare, direction); + announce(moved ? t('history.compare.change', moved) : t('history.compare.noMore')); +} + +/** Tests start over. */ +export function resetHistory(): void { + closeHistory(); + stopWatching?.(); + stopWatching = null; +} diff --git a/app/src/features/page/history/history.module.css b/app/src/features/page/history/history.module.css new file mode 100644 index 000000000..3a1477a51 --- /dev/null +++ b/app/src/features/page/history/history.module.css @@ -0,0 +1,180 @@ +/* Page history and compare versions (ARCHITECTURE.md section 21). */ +@layer components { + .host { + position: absolute; + inset-block: 0; + inset-inline-end: 0; + z-index: var(--layer-page-chrome); + inline-size: min(30rem, 100%); + } + + .panel { + display: grid; + grid-template-rows: auto 1fr; + block-size: 100%; + overflow: auto; + border-inline-start: 1px solid var(--color-border-subtle); + background: var(--color-surface-raised); + box-shadow: var(--elevation-2); + } + + .header { + display: flex; + gap: var(--space-2); + align-items: center; + justify-content: space-between; + padding: var(--space-2) var(--space-3); + border-block-end: 1px solid var(--color-border-subtle); + } + + .title, + .subtitle { + margin: 0; + font-size: var(--type-subtitle-size); + line-height: var(--type-subtitle-line); + } + + .body { + display: grid; + gap: var(--space-3); + align-content: start; + padding: var(--space-3); + } + + .versions { + display: grid; + gap: var(--space-1); + margin: 0; + padding: 0; + list-style: none; + } + + .version { + display: grid; + gap: var(--space-1); + inline-size: 100%; + min-block-size: var(--size-target-min); + padding: var(--space-2); + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-primary); + font: inherit; + text-align: start; + } + + .version:hover:not(:disabled) { + background: var(--color-surface-hover); + } + + .version:disabled { + background: var(--color-surface-selected); + } + + .when { + font-weight: 600; + } + + .reason, + .name, + .note, + .summary { + color: var(--color-text-secondary); + font-size: var(--type-small-size); + } + + .note, + .summary { + margin: 0; + } + + .field { + display: flex; + gap: var(--space-2); + align-items: center; + } + + .select { + min-block-size: var(--size-target-min); + padding-inline: var(--space-2); + border: 1px solid var(--color-border-control); + border-radius: var(--radius-sm); + background: var(--color-surface-page); + color: var(--color-text-primary); + font: inherit; + } + + .actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + } + + .form { + display: grid; + gap: var(--space-2); + } + + .compare { + display: grid; + gap: var(--space-3); + } + + .block { + display: grid; + gap: var(--space-2); + padding-block-start: var(--space-2); + border-block-start: 1px solid var(--color-border-subtle); + } + + .change { + display: grid; + gap: var(--space-1); + border-radius: var(--radius-sm); + } + + .change:focus-visible { + outline: var(--size-focus-ring-width) solid var(--color-focus-ring); + outline-offset: var(--size-focus-ring-offset); + } + + .static > :first-child, + .words { + margin-block-start: 0; + } + + .static > :last-child, + .words { + margin-block-end: 0; + } + + .ins { + color: var(--color-status-success); + text-decoration: underline; + text-decoration-thickness: 2px; + } + + .del { + color: var(--color-status-danger); + text-decoration: line-through; + } + + .ins .static, + .del .static { + color: inherit; + } + + .unchanged, + .restore { + justify-self: start; + } + + .hidden { + position: absolute; + inline-size: 1px; + block-size: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } +} diff --git a/app/src/features/page/history/history.test.tsx b/app/src/features/page/history/history.test.tsx new file mode 100644 index 000000000..f0ade212d --- /dev/null +++ b/app/src/features/page/history/history.test.tsx @@ -0,0 +1,94 @@ +// Page history in the browser: listing versions, comparing with and , F8, and restoring one paragraph +// and one table in one step each. The page's history is a fake with two versions. +import { screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import type { PageJson, VersionInfo } from '../../../services/pages/types'; +import { expectNoAxeViolations } from '../../../test'; +import { cleanupPages, renderPage } from '../test/harness'; +import type { PageHarness } from '../test/harness'; +import { textPageFixture } from '../test/fixtures'; +import { moveThroughChanges, openHistory, resetHistory } from './chunk'; + +const TEXT = '01k6f0000000000000000t0001'; +const TABLE = '01k6f0000000000000000t0009'; + +const version = (revision: string, savedAt: string, reason: string): VersionInfo => ({ + revision, + savedAt, + reason, + device: 'Laptop', + name: null, + keep: false, +}); + +function withHistory(page: PageHarness, older: PageJson) { + const current = structuredClone(page.page.initial); + const history = page.page.history; + vi.spyOn(history, 'list').mockResolvedValue([ + version('r1', '2026-10-01T08:00:00.000Z', 'closed'), + version('r2', '2026-10-01T09:00:00.000Z', 'interval'), + ]); + vi.spyOn(history, 'open').mockImplementation((revision) => Promise.resolve(revision === 'r1' ? older : current)); + return { + restoreBlocks: vi.spyOn(history, 'restoreBlocks').mockResolvedValue({} as never), + name: vi.spyOn(history, 'name').mockResolvedValue(), + }; +} + +function olderVersion(page: PageHarness, markdown: string): PageJson { + const older = structuredClone(page.page.initial); + older.blocks[0] = { ...older.blocks[0], data: { markdown } }; + const table = { ...older.blocks[0], id: TABLE, type: 'table', order: 'a5', data: { columns: [], rows: [] } }; + older.blocks.push(table); + return older; +} + +afterEach(async () => { + resetHistory(); + await cleanupPages(); +}); + +describe('page history', () => { + it('lists versions with their reasons, compares with marked words, and passes axe', async () => { + const page = await renderPage({ + fixture: textPageFixture('The quick fox.\n\nKept as is.'), + flags: { 'page.history': true }, + }); + withHistory(page, olderVersion(page, 'The slow fox.\n\nKept as is.\n\nA lost line here.')); + openHistory('Biology'); + const panel = await screen.findByRole('complementary', { name: 'Page history' }); + const list = await within(panel).findByRole('list', { name: 'Versions' }); + expect(await within(list).findByText('Saved when you closed the page')).toBeTruthy(); + await expectNoAxeViolations(panel); + await userEvent.click((await within(list).findAllByRole('button'))[1]); + await expect.poll(() => panel.querySelector('ins')?.textContent).toBe('Added: quick'); + expect(panel.querySelector('del')?.textContent).toBe('Removed: slow'); + expect(within(panel).getByRole('button', { name: '1 unchanged paragraph' })).toBeTruthy(); + expect(within(panel).getByRole('button', { name: 'Restore this block' })).toBeTruthy(); + await expectNoAxeViolations(panel); + moveThroughChanges(1); + expect(document.activeElement?.closest('[data-change]')?.textContent).toContain('quick'); + moveThroughChanges(1); + expect(document.activeElement?.textContent).toContain('A lost line here.'); + }); + + it('restores one paragraph with one edit, and one table with one restore', async () => { + const page = await renderPage({ + fixture: textPageFixture('The quick fox.\n\nKept as is.'), + flags: { 'page.history': true }, + }); + const calls = withHistory(page, olderVersion(page, 'The slow fox.\n\nKept as is.\n\nA lost line here.')); + openHistory('Biology'); + const panel = await screen.findByRole('complementary', { name: 'Page history' }); + const list = await within(panel).findByRole('list', { name: 'Versions' }); + await userEvent.click((await within(list).findAllByRole('button'))[1]); + const restores = await within(panel).findAllByRole('button', { name: 'Restore this paragraph' }); + const before = page.sent().length; + await userEvent.click(restores[0]); + await expect.poll(() => page.mounted.pool.editor(TEXT)?.getText()).toContain('The slow fox.'); + expect(page.sent().length - before).toBe(1); + await userEvent.click(within(panel).getByRole('button', { name: 'Restore this block' })); + await expect.poll(() => calls.restoreBlocks.mock.calls).toEqual([['r1', [TABLE]]]); + }); +}); diff --git a/app/src/features/page/history/restore.ts b/app/src/features/page/history/restore.ts new file mode 100644 index 000000000..3c5f1ccbb --- /dev/null +++ b/app/src/features/page/history/restore.ts @@ -0,0 +1,83 @@ +// Restoring parts of an older version (ARCHITECTURE.md section 21). A paragraph whose block still exists replaces its +// aligned paragraph, or goes in after its aligned predecessor, with one text edit: one undo step. Whole blocks, such +// as tables, images, or a deleted text block, come back through history_restore_blocks, one transaction in the core. +// The whole version comes back through history_restore, in place or as a copy. +import type { Node as PMNode } from '@tiptap/pm/model'; +import type { ParagraphChange } from '../../../editor/diff/pageDiff'; +import { parseTextBlock } from '../../../editor/markdown'; +import { META_COMMAND } from '../../../editor/meta'; +import type { BlockId, OpenPage } from '../../../services/pages/types'; +import { t } from '../../../strings/t'; +import { announce } from '../../../ui'; +import { shownPool } from '../pool/shown'; +import { shownQueue } from '../sync/shown'; + +/** Where the top-level node `index` starts and ends in `doc`, or the end of the doc for one past the last. */ +function childRange(doc: PMNode, index: number): { from: number; to: number } | null { + if (index > doc.childCount) return null; + let from = 0; + for (let i = 0; i < index; i += 1) from += doc.child(i).nodeSize; + return { from, to: index < doc.childCount ? from + doc.child(index).nodeSize : from }; +} + +/** The current index the older paragraph goes in at: its pair's, or right after its nearest earlier kept paragraph. */ +function targetIndex(change: ParagraphChange, all: readonly ParagraphChange[]): { index: number; replace: boolean } { + if (change.after) return { index: change.after.index, replace: true }; + const at = all.indexOf(change); + const earlier = all + .slice(0, at) + .reverse() + .find((item) => item.after); + return { index: earlier?.after ? earlier.after.index + 1 : 0, replace: false }; +} + +/** Puts an older paragraph back into its text block with one edit. Resolves false when the page changed meanwhile. */ +export async function restoreParagraph( + block: BlockId, + change: ParagraphChange, + all: readonly ParagraphChange[], +): Promise { + const pool = shownPool.get(); + const editor = pool && (pool.editor(block) ?? pool.mount(block, null, 'target')); + if (!editor || !change.before) return false; + const { index, replace } = targetIndex(change, all); + const { doc } = editor.state; + const range = childRange(doc, index); + const stale = replace && (index >= doc.childCount || doc.child(index).textContent !== change.after?.node.textContent); + if (!range || stale) { + announce(t('history.restore.gone')); + return false; + } + const older = editor.schema.nodeFromJSON(parseTextBlock(change.before.markdown).toJSON()); + const tr = editor.state.tr.replaceWith(range.from, replace ? range.to : range.from, older.content); + editor.view.dispatch(tr.setMeta(META_COMMAND, true)); + await shownQueue.get()?.flushAll('command'); + announce(t('history.restore.done')); + return true; +} + +/** Brings blocks back from `revision` as they were, in one transaction. */ +export async function restoreBlocks(page: OpenPage, revision: string, blocks: readonly BlockId[]): Promise { + try { + await shownQueue.get()?.flushAll('command'); + await page.history.restoreBlocks(revision, blocks); + announce(t('history.restore.done')); + return true; + } catch { + announce(t('history.restore.failed'), 'assertive'); + return false; + } +} + +/** Restores the whole version, in place or as a new page. */ +export async function restoreVersion(page: OpenPage, revision: string, asCopy: boolean): Promise { + try { + await shownQueue.get()?.flushAll('command'); + await page.history.restore(revision, asCopy); + announce(t(asCopy ? 'history.restore.copyDone' : 'history.restore.done')); + return true; + } catch { + announce(t('history.restore.failed'), 'assertive'); + return false; + } +} diff --git a/app/src/features/page/history/shown.ts b/app/src/features/page/history/shown.ts new file mode 100644 index 000000000..68b1d1e7e --- /dev/null +++ b/app/src/features/page/history/shown.ts @@ -0,0 +1,5 @@ +// The open page that is shown, for page history (owner: WP7). mount.ts sets it beside the other shown stores. +import type { OpenPage } from '../../../services/pages/types'; +import { createStore } from '../../../state/store'; + +export const shownPage = createStore(null, 'shown open page'); diff --git a/app/src/features/page/images/AltTextDialog.tsx b/app/src/features/page/images/AltTextDialog.tsx new file mode 100644 index 000000000..29a4337d9 --- /dev/null +++ b/app/src/features/page/images/AltTextDialog.tsx @@ -0,0 +1,113 @@ +// The "Alt text" dialog (Phase 4 ARCHITECTURE.md section 20.2): a multi-line description of up to 2,000 +// characters and a "Decorative" checkbox that disables it. Save sends one patchBlock. Phase 5's drawing dialog +// uses the same component. +import { useId, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { t } from '../../../strings/t'; +import { Dialog } from '../../../ui'; +import type { DialogAction } from '../../../ui'; +import type { ImageHandle } from '../blocks/imageBlock'; +import styles from './dialog.module.css'; + +export const MAX_ALT_LENGTH = 2000; + +export interface AltTextValue { + alt: string; + decorative: boolean; +} + +export interface AltTextDialogProps { + initial: AltTextValue; + onSave(value: AltTextValue): void; + onCancel(): void; + returnFocus?: () => HTMLElement | null; +} + +export function AltTextDialog({ initial, onSave, onCancel, returnFocus }: AltTextDialogProps) { + const [alt, setAlt] = useState(initial.alt); + const [decorative, setDecorative] = useState(initial.decorative); + const field = useRef(null); + const id = useId(); + const save = () => onSave({ alt: alt.trim(), decorative }); + const actions: DialogAction[] = [ + { id: 'cancel', label: t('common.cancel'), variant: 'secondary', leastDestructive: true, onPress: onCancel }, + { id: 'save', label: t('images.altDialog.save'), variant: 'primary', onPress: save }, + ]; + return ( + +
+ + + + + diff --git a/spikes/results/s2-speech.json b/spikes/results/s2-speech.json new file mode 100644 index 000000000..3bdf6eac6 --- /dev/null +++ b/spikes/results/s2-speech.json @@ -0,0 +1,141 @@ +{ + "spike": "s2-speech", + "at": "2026-10-03T09:26:03.141Z", + "os": "Windows 10.0.26200", + "browser": "154.0.4258.48", + "text": "The quick brown fox jumps over the lazy dog, then rests in the warm afternoon sun.", + "voices": 3, + "local": [ + { + "name": "Microsoft David - English (United States)", + "lang": "en-US", + "default": true + }, + { + "name": "Microsoft Mark - English (United States)", + "lang": "en-US", + "default": false + }, + { + "name": "Microsoft Zira - English (United States)", + "lang": "en-US", + "default": false + } + ], + "online": 0, + "boundaries": [ + { + "name": "sentence", + "charIndex": 0, + "charLength": 0, + "ms": 1197 + }, + { + "name": "word", + "charIndex": 0, + "charLength": 3, + "ms": 1335 + }, + { + "name": "word", + "charIndex": 4, + "charLength": 5, + "ms": 1483 + }, + { + "name": "word", + "charIndex": 10, + "charLength": 5, + "ms": 1733 + }, + { + "name": "word", + "charIndex": 16, + "charLength": 3, + "ms": 2083 + }, + { + "name": "word", + "charIndex": 20, + "charLength": 5, + "ms": 2404 + }, + { + "name": "word", + "charIndex": 26, + "charLength": 4, + "ms": 3702 + }, + { + "name": "word", + "charIndex": 31, + "charLength": 3, + "ms": 4001 + }, + { + "name": "word", + "charIndex": 35, + "charLength": 4, + "ms": 4102 + }, + { + "name": "word", + "charIndex": 40, + "charLength": 3, + "ms": 4451 + }, + { + "name": "word", + "charIndex": 45, + "charLength": 4, + "ms": 5252 + }, + { + "name": "word", + "charIndex": 50, + "charLength": 5, + "ms": 5452 + }, + { + "name": "word", + "charIndex": 56, + "charLength": 2, + "ms": 5802 + }, + { + "name": "word", + "charIndex": 59, + "charLength": 3, + "ms": 5902 + }, + { + "name": "word", + "charIndex": 63, + "charLength": 4, + "ms": 6001 + }, + { + "name": "word", + "charIndex": 68, + "charLength": 9, + "ms": 6301 + }, + { + "name": "word", + "charIndex": 78, + "charLength": 4, + "ms": 6852 + } + ], + "paused": { + "speaking": true, + "paused": true, + "boundariesDuringPause": 3 + }, + "resumed": { + "paused": false, + "boundariesAfter": 7 + }, + "ended": true, + "error": null +} diff --git a/tests/e2e/page/clipboard.ts b/tests/e2e/page/clipboard.ts new file mode 100644 index 000000000..9008e30cf --- /dev/null +++ b/tests/e2e/page/clipboard.ts @@ -0,0 +1,62 @@ +// Shared steps for the page E2E specs that use the real Windows clipboard: load a corpus case with clipset, open +// the seeded Mitosis page, and focus its text box. +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { ROOT } from '../harness.ts'; +import type { Browser } from '../harness.ts'; + +/** Puts a corpus case (tests/fixtures/clipboard//) on the clipboard with every format it has. */ +export function clipset(name: string): void { + execFileSync( + 'powershell', + [ + '-NoProfile', + '-STA', + '-ExecutionPolicy', + 'Bypass', + '-File', + join(ROOT, 'tests', 'e2e', 'tools', 'clipset', 'clipset.ps1'), + join(ROOT, 'tests', 'fixtures', 'clipboard', ...name.split('/')), + ], + { stdio: 'inherit' }, + ); +} + +/** Clicks the row of a tree whose label reads `name`, once it shows. */ +async function clickRow(browser: Browser, tree: string, name: string): Promise { + await browser.waitUntil( + () => + browser.execute( + (tree, name) => { + const rows = document.querySelectorAll(`[role="tree"][aria-label="${tree}"] [role="treeitem"]`); + const row = [...rows].find( + (candidate) => + document.getElementById(candidate.getAttribute('aria-labelledby') ?? '')?.textContent === name, + ); + (row as HTMLElement | undefined)?.click(); + return Boolean(row); + }, + tree, + name, + ), + { timeout: 20_000 }, + ); +} + +/** Opens the seeded Mitosis page and puts the caret at the end of its text box. */ +export async function openTextBox(browser: Browser) { + await clickRow(browser, 'Notebooks', 'Lectures'); + await clickRow(browser, 'Pages', 'Mitosis'); + const box = browser.$('[role="textbox"][aria-label="Text"]'); + await box.waitForExist({ timeout: 20_000 }); + await box.click(); + await browser.keys(['Control', 'End']); + return box; +} + +/** The page's text, with each block on its own line. */ +export function pageText(browser: Browser): Promise { + return browser.execute(() => + [...document.querySelectorAll('[data-block-id]')].map((block) => (block as HTMLElement).innerText).join('\n'), + ); +} diff --git a/tests/e2e/page/contractEntry.ts b/tests/e2e/page/contractEntry.ts new file mode 100644 index 000000000..8c3427cbf --- /dev/null +++ b/tests/e2e/page/contractEntry.ts @@ -0,0 +1,46 @@ +// The page service contract inside the real app (owner: WP2). page.contract.spec.ts bundles this file and runs it in +// the app's webview, against Phase 3's core through the Tauri adapter. Each case gets pages of its own, seeded with +// CONTRACT_PAGE's title and blocks by a client the case never sees, so its undo stack starts empty. +import { createCoreClient } from '../../../app/src/core/client'; +import type { CoreInvoke } from '../../../app/src/core/client'; +import type { ImagesClient } from '../../../app/src/platform/types'; +import { CONTRACT_CASES, CONTRACT_PAGE } from '../../../app/src/services/pages/contractCases'; +import { createTauriPageService } from '../../../app/src/services/pages/tauri'; +import type { PageService } from '../../../app/src/services/pages/types'; + +export interface CaseResult { + name: string; + error: string | null; +} + +/** A service where CONTRACT_PAGE's ID opens a fresh core page holding its title and blocks. */ +async function seededService(invoke: CoreInvoke, run: string): Promise { + const service = createTauriPageService(createCoreClient({ invoke }), {} as ImagesClient); + const fresh = `${CONTRACT_PAGE.id}-${run}`; + const seed = await service.open(fresh, { viewport: null }); + await seed.send({ + edits: [ + { edit: 'setPage', title: CONTRACT_PAGE.title }, + ...CONTRACT_PAGE.blocks.map((block) => ({ + edit: 'insertBlock' as const, + block: { id: block.id, type: block.type, data: block.data }, + })), + ], + }); + await seed.close(); + return { open: (pageId, options) => service.open(pageId === CONTRACT_PAGE.id ? fresh : pageId, options) }; +} + +export async function runContract(invoke: CoreInvoke): Promise { + const results: CaseResult[] = []; + const stamp = Date.now().toString(36); + for (const [index, contractCase] of CONTRACT_CASES.entries()) { + try { + await contractCase.run(() => seededService(invoke, `${stamp}-${index}`)); + results.push({ name: contractCase.name, error: null }); + } catch (error) { + results.push({ name: contractCase.name, error: error instanceof Error ? error.message : String(error) }); + } + } + return results; +} diff --git a/tests/e2e/page/helpers.ts b/tests/e2e/page/helpers.ts new file mode 100644 index 000000000..8cec30230 --- /dev/null +++ b/tests/e2e/page/helpers.ts @@ -0,0 +1,46 @@ +// Shared steps for the page E2E specs (WP7): a profile with flags on, and opening Mitosis's text box. + +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Browser } from '../harness.ts'; + +/** A profile whose settings turn on the flags a spec needs. */ +export function flaggedProfile(prefix: string, flags: Record): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + mkdirSync(join(dir, 'roaming'), { recursive: true }); + mkdirSync(join(dir, 'local'), { recursive: true }); + const settings = { schemaVersion: 1, minWriterSchema: 1, experimental: { flags } }; + writeFileSync(join(dir, 'roaming', 'settings.json'), JSON.stringify(settings)); + writeFileSync(join(dir, 'local', 'state.json'), JSON.stringify({ stateVersion: 1, setup: { status: 'done' } })); + return dir; +} + +/** Opens Lectures, then Mitosis, and puts the caret at the end of its first text box. */ +export async function openMitosisText(browser: Browser): Promise { + for (const [tree, name] of [ + ['Notebooks', 'Lectures'], + ['Pages', 'Mitosis'], + ]) { + await browser.waitUntil( + () => + browser.execute( + (tree, name) => { + const rows = document.querySelectorAll(`[role="tree"][aria-label="${tree}"] [role="treeitem"]`); + const row = [...rows].find( + (item) => document.getElementById(item.getAttribute('aria-labelledby') ?? '')?.textContent === name, + ); + (row as HTMLElement | undefined)?.click(); + return Boolean(row); + }, + tree, + name, + ), + { timeout: 20_000 }, + ); + } + const box = browser.$('[role="textbox"][aria-label="Text"]'); + await box.waitForExist({ timeout: 20_000 }); + await box.click(); + await browser.keys(['Control', 'End']); +} diff --git a/tests/e2e/page/page.blocks.select.spec.ts b/tests/e2e/page/page.blocks.select.spec.ts new file mode 100644 index 000000000..7319b62f0 --- /dev/null +++ b/tests/e2e/page/page.blocks.select.spec.ts @@ -0,0 +1,37 @@ +// Selecting blocks in the real app (Phase 4 PLAN.md section 7.5): a marquee on empty page selects the boxes it +// touches and shows the count badge, and Delete removes them with an Undo toast that brings them back. + +import assert from 'node:assert/strict'; +import { after, describe, it } from 'node:test'; +import { skipReason } from '../harness.ts'; +import type { AppSession } from '../harness.ts'; +import { clientPoint, drag, makeBox, openEmptyPage, textBoxes } from './pageSession.ts'; + +describe('selecting blocks', { skip: skipReason() }, () => { + let session: AppSession | undefined; + + after(async () => { + await session?.close(); + }); + + it('selects boxes with a marquee and deletes them with an Undo toast', async () => { + session = await openEmptyPage(); + const { browser } = session; + await makeBox(browser, 200, 300, 'One'); + await makeBox(browser, 420, 320, 'Two'); + await browser.keys('Escape'); + await browser.keys('Escape'); + await drag(browser, await clientPoint(browser, 150, 260), await clientPoint(browser, 700, 420)); + const badge = await browser.execute(() => + [...document.querySelectorAll('[aria-hidden="true"] *')] + .map((element) => element.textContent) + .find((text) => text?.endsWith('selected')), + ); + assert.equal(badge, '2 selected'); + await browser.keys('Delete'); + await browser.waitUntil(async () => (await textBoxes(browser)).length === 0, { timeout: 5_000 }); + const undo = await browser.$('button=Undo'); + await undo.click(); + await browser.waitUntil(async () => (await textBoxes(browser)).length === 2, { timeout: 5_000 }); + }); +}); diff --git a/tests/e2e/page/page.contract.spec.ts b/tests/e2e/page/page.contract.spec.ts new file mode 100644 index 000000000..d86000357 --- /dev/null +++ b/tests/e2e/page/page.contract.spec.ts @@ -0,0 +1,47 @@ +// The page service contract against Phase 3's real core (PLAN.md section 6.5; owner: WP2). The same cases as the +// memory service's Vitest run are bundled with the Tauri adapter and run inside the app's webview. +// Run with: node --test tests/e2e/page/page.contract.spec.ts + +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { after, describe, it } from 'node:test'; +import { build } from 'rolldown'; +import { launchApp, ROOT, skipReason } from '../harness.ts'; +import type { AppSession } from '../harness.ts'; +import type { CaseResult } from './contractEntry.ts'; + +/** contractEntry.ts as one script that defines window.__OPENNOTE_CONTRACT__. */ +async function bundle(): Promise { + const result = await build({ + input: join(ROOT, 'tests', 'e2e', 'page', 'contractEntry.ts'), + output: { format: 'iife', name: '__OPENNOTE_CONTRACT__' }, + write: false, + }); + return result.output[0].code; +} + +describe('the page service contract against the core', { skip: skipReason() }, () => { + let session: AppSession | undefined; + + after(async () => { + await session?.close(); + }); + + it('passes every case of describePageService', async () => { + const script = await bundle(); + session = await launchApp(); + await session.browser.execute(`${script}; window.__OPENNOTE_CONTRACT__ = __OPENNOTE_CONTRACT__;`); + const results = (await session.browser.execute(() => { + const win = window as unknown as { + __OPENNOTE_CONTRACT__: { runContract(invoke: unknown): Promise }; + __TAURI_INTERNALS__: { invoke(command: string, args?: unknown): Promise }; + }; + return win.__OPENNOTE_CONTRACT__.runContract((command: string, args?: unknown) => + win.__TAURI_INTERNALS__.invoke(command, args), + ); + })) as CaseResult[]; + const failed = results.filter((result) => result.error !== null); + assert.ok(results.length > 0, 'the cases ran'); + assert.deepEqual(failed, [], failed.map((result) => `${result.name}: ${result.error}`).join('\n')); + }); +}); diff --git a/tests/e2e/page/page.crash.spec.ts b/tests/e2e/page/page.crash.spec.ts new file mode 100644 index 000000000..84f894b10 --- /dev/null +++ b/tests/e2e/page/page.crash.spec.ts @@ -0,0 +1,97 @@ +// Killing the app while typing loses nothing typed more than a second before (PLAN.md section 6.5; owner: WP2). Each +// run types into a page, kills the app's process at a random moment, starts it again on the same profile, and +// checks the text. OPENNOTE_CRASH_RUNS sets the number of runs: 50 per pull request and 200 nightly. +// Run with: node --test tests/e2e/page/page.crash.spec.ts + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { describe, it } from 'node:test'; +import { launchApp, skipReason, tools } from '../harness.ts'; +import type { Browser } from '../harness.ts'; + +const RUNS = Number(process.env.OPENNOTE_CRASH_RUNS ?? 50); +/** Typing this long before the kill must survive it (BRAND.md's crash promise). */ +const SAFE_MS = 1000; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Kills the newest app process with no chance to flush, as a power cut or a crash does. */ +function killApp(): void { + const exe = basename(tools().exe ?? 'opennote.exe'); + const script = + `Get-Process -Name '${exe.replace(/\.exe$/i, '')}' -ErrorAction SilentlyContinue | ` + + 'Sort-Object StartTime -Descending | Select-Object -First 1 | Stop-Process -Force'; + execFileSync('powershell.exe', ['-NoProfile', '-Command', script]); +} + +async function openTextBox(browser: Browser) { + for (const [tree, name] of [ + ['Notebooks', 'Lectures'], + ['Pages', 'Mitosis'], + ]) { + await browser.waitUntil( + () => + browser.execute( + (tree, name) => { + const rows = document.querySelectorAll(`[role="tree"][aria-label="${tree}"] [role="treeitem"]`); + const row = [...rows].find( + (candidate) => + document.getElementById(candidate.getAttribute('aria-labelledby') ?? '')?.textContent === name, + ); + (row as HTMLElement | undefined)?.click(); + return Boolean(row); + }, + tree, + name, + ), + { timeout: 20_000 }, + ); + } + const box = browser.$('[role="textbox"][aria-label="Text"]'); + await box.waitForExist({ timeout: 20_000 }); + return box; +} + +describe('typing survives a crash', { skip: skipReason() }, () => { + for (let run = 1; run <= RUNS; run++) { + it(`keeps what was typed a second before the kill, run ${run}`, async () => { + const profileDir = mkdtempSync(join(tmpdir(), 'opennote-e2e-crash-')); + try { + const first = await launchApp({ profileDir }); + const box = await openTextBox(first.browser); + await box.click(); + const marker = `R${run}:`; + const typed: { at: number; char: string }[] = []; + const killAt = Date.now() + 1500 + Math.random() * 2500; + for (let i = 0; Date.now() < killAt; i++) { + const char = String(i % 10); + await first.browser.keys(i === 0 ? marker + char : char); + typed.push({ at: Date.now(), char: i === 0 ? marker + char : char }); + } + killApp(); + const killed = Date.now(); + await first.close().catch(() => undefined); + await sleep(500); + const safe = typed + .filter((entry) => entry.at <= killed - SAFE_MS) + .map((entry) => entry.char) + .join(''); + const again = await launchApp({ profileDir }); + try { + const text = await (await openTextBox(again.browser)).getText(); + assert.ok( + text.includes(safe), + `run ${run}: "${safe}" was typed a second before the kill, but the page has "${text}"`, + ); + } finally { + await again.close(); + } + } finally { + rmSync(profileDir, { recursive: true, force: true }); + } + }); + } +}); diff --git a/tests/e2e/page/page.freeform.pointer.spec.ts b/tests/e2e/page/page.freeform.pointer.spec.ts new file mode 100644 index 000000000..390f6b54c --- /dev/null +++ b/tests/e2e/page/page.freeform.pointer.spec.ts @@ -0,0 +1,42 @@ +// Freeform pages by pointer in the real app (Phase 4 PLAN.md section 7.5): a click on empty page places a caret +// that becomes a text box when typed in, a click that types nothing leaves nothing behind, and the grip moves a box. + +import assert from 'node:assert/strict'; +import { after, describe, it } from 'node:test'; +import { skipReason } from '../harness.ts'; +import type { AppSession } from '../harness.ts'; +import { clickAt, clientPoint, drag, makeBox, openEmptyPage, textBoxes } from './pageSession.ts'; + +describe('freeform pages by pointer', { skip: skipReason() }, () => { + let session: AppSession | undefined; + + after(async () => { + await session?.close(); + }); + + it('makes a text box where the page is clicked, and only once something is typed', async () => { + session = await openEmptyPage(); + const { browser } = session; + await clickAt(browser, await clientPoint(browser, 500, 300)); + await clickAt(browser, await clientPoint(browser, 600, 500)); + assert.deepEqual(await textBoxes(browser), [], 'an untouched caret leaves nothing behind'); + await makeBox(browser, 400, 260, 'Stomata'); + const [box] = await textBoxes(browser); + assert.equal(box?.name, 'Text box 1 of 1'); + assert.equal(box?.left, 400); + }); + + it('moves a text box by its grip', async () => { + const { browser } = session!; + const [before] = await textBoxes(browser); + const grip = await browser.execute(() => { + const handle = document.querySelector('[data-handle="grip"]'); + const rect = handle?.getBoundingClientRect(); + return rect ? { x: rect.left + 12, y: rect.top + rect.height / 2 } : null; + }); + assert.ok(grip, 'the edited box shows its grip'); + await drag(browser, grip, { x: grip.x + 80, y: grip.y + 40 }); + await browser.waitUntil(async () => (await textBoxes(browser))[0]?.left === before!.left + 80, { timeout: 5_000 }); + assert.equal((await textBoxes(browser))[0]?.top, before!.top + 40); + }); +}); diff --git a/tests/e2e/page/page.history.spec.ts b/tests/e2e/page/page.history.spec.ts new file mode 100644 index 000000000..ccd8471a6 --- /dev/null +++ b/tests/e2e/page/page.history.spec.ts @@ -0,0 +1,49 @@ +// Page history in the real app (Phase 4 PLAN.md section 11): after an edit and a save, the panel lists the earlier +// version, compares it with and , and restores the old paragraph. + +import assert from 'node:assert/strict'; +import { rmSync } from 'node:fs'; +import { after, describe, it } from 'node:test'; +import { launchApp, skipReason } from '../harness.ts'; +import type { AppSession, Browser } from '../harness.ts'; +import { flaggedProfile, openMitosisText } from './helpers.ts'; + +/** Runs a command from the palette by its title. */ +async function runCommand(browser: Browser, title: string): Promise { + await browser.keys(['Control', 'Shift', 'p']); + const input = browser.$('[role="combobox"]'); + await input.waitForExist({ timeout: 5_000 }); + await browser.keys(title); + await browser.keys(['Enter']); +} + +describe('page history', { skip: skipReason() }, () => { + const profileDir = flaggedProfile('opennote-e2e-history-', { 'page.history': true }); + let session: AppSession | undefined; + + after(async () => { + await session?.close(); + rmSync(profileDir, { recursive: true, force: true }); + }); + + it('compares with an earlier version and restores a paragraph', async () => { + session = await launchApp({ profileDir }); + const { browser } = session; + await openMitosisText(browser); + await browser.keys(['Enter', ...'An earlier line.']); + await browser.keys(['Control', 's']); + await browser.keys(['Shift', 'Home', 'Backspace', ...'A later line.']); + await runCommand(browser, 'Page history'); + const panel = browser.$('aside[aria-label="Page history"]'); + await panel.waitForExist({ timeout: 5_000 }); + const versions = await panel.$$('ul[aria-label="Versions"] button:not([disabled])'); + if (!versions.length) { + assert.fail('The panel lists no earlier version after a save.'); + } + await versions[0].click(); + await panel.$('del').waitForExist({ timeout: 5_000 }); + await panel.$('button=Restore this paragraph').click(); + const box = browser.$('[role="textbox"][aria-label="Text"]'); + await browser.waitUntil(async () => (await box.getText()).includes('An earlier line.'), { timeout: 5_000 }); + }); +}); diff --git a/tests/e2e/page/page.images.spec.ts b/tests/e2e/page/page.images.spec.ts new file mode 100644 index 000000000..b38afb3b9 --- /dev/null +++ b/tests/e2e/page/page.images.spec.ts @@ -0,0 +1,48 @@ +// Images in the real app (Phase 4 ARCHITECTURE.md section 26, `page.images`): a screenshot on the clipboard pastes +// as an image block whose size came from the shell's probe, and it survives a restart through the asset scheme. +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, it } from 'node:test'; +import { launchApp, skipReason } from '../harness.ts'; +import type { AppSession } from '../harness.ts'; +import { clipset, openTextBox } from './clipboard.ts'; + +const skip = skipReason() || (process.platform === 'win32' ? false : 'The clipboard tool needs Windows.'); + +describe('images on the real clipboard', { skip }, () => { + const profileDir = mkdtempSync(join(tmpdir(), 'opennote-e2e-images-')); + let session: AppSession | undefined; + + after(async () => { + await session?.close(); + rmSync(profileDir, { recursive: true, force: true }); + }); + + const imageSize = (session: AppSession) => + session.browser.execute(() => { + const image = document.querySelector('[data-block-id] img'); + return image ? { width: image.getAttribute('width'), height: image.getAttribute('height') } : null; + }); + + it('pastes a screenshot as an image block that loads and survives a restart', async () => { + session = await launchApp({ profileDir }); + await openTextBox(session.browser); + clipset('image/screenshot'); + await session.browser.keys(['Control', 'v']); + await session.browser.keys(['Control']); + await session.browser.waitUntil(async () => (await imageSize(session!)) !== null, { timeout: 15_000 }); + const loaded = await session.browser.waitUntil( + () => session!.browser.execute(() => document.querySelector('[data-block-id] img')?.complete), + { timeout: 10_000 }, + ); + assert.ok(loaded, 'the image loaded through opennote-asset'); + const size = await imageSize(session); + await session.close(); + session = await launchApp({ profileDir }); + await openTextBox(session.browser); + await session.browser.waitUntil(async () => (await imageSize(session!)) !== null, { timeout: 15_000 }); + assert.deepEqual(await imageSize(session), size); + }); +}); diff --git a/tests/e2e/page/page.keepBelow.spec.ts b/tests/e2e/page/page.keepBelow.spec.ts new file mode 100644 index 000000000..47d660d24 --- /dev/null +++ b/tests/e2e/page/page.keepBelow.spec.ts @@ -0,0 +1,35 @@ +// Keep-below in the real app (Phase 4 PLAN.md section 7.5): typing new lines into a text box moves the box just +// below it down by the same amount, and undo puts both back in one step. + +import assert from 'node:assert/strict'; +import { after, describe, it } from 'node:test'; +import { skipReason } from '../harness.ts'; +import type { AppSession } from '../harness.ts'; +import { clickAt, clientPoint, makeBox, openEmptyPage, textBoxes } from './pageSession.ts'; + +describe('keep-below', { skip: skipReason() }, () => { + let session: AppSession | undefined; + + after(async () => { + await session?.close(); + }); + + it('moves the box below a growing box, and undoes both together', async () => { + session = await openEmptyPage(); + const { browser } = session; + await makeBox(browser, 100, 200, 'Above'); + const height = await browser.execute( + () => + document.querySelector('[aria-label="Text box 1 of 1"]')!.closest('[data-block-id]')! + .offsetHeight, + ); + await makeBox(browser, 100, 200 + height + 10, 'Below'); + await clickAt(browser, await clientPoint(browser, 110, 210)); + await browser.keys(['End', 'Enter', 'A', 'n', 'o', 't', 'h', 'e', 'r']); + await browser.waitUntil(async () => (await textBoxes(browser))[1]!.top > 200 + height + 10, { timeout: 5_000 }); + const moved = (await textBoxes(browser))[1]!.top - (200 + height + 10); + assert.ok(moved > 10, 'the lower box moved down by the new line'); + await browser.keys(['Control', 'z']); + await browser.waitUntil(async () => (await textBoxes(browser))[1]!.top === 200 + height + 10, { timeout: 5_000 }); + }); +}); diff --git a/tests/e2e/page/page.objects.keyboard.spec.ts b/tests/e2e/page/page.objects.keyboard.spec.ts new file mode 100644 index 000000000..38807f8bb --- /dev/null +++ b/tests/e2e/page/page.objects.keyboard.spec.ts @@ -0,0 +1,46 @@ +// Objects from the keyboard in the real app (Phase 4 PLAN.md section 7.5): Escape selects the text box, arrows +// move it, Tab moves between boxes in reading order, and focus, the selection, and the caret survive a nudge that +// changes the reading order. + +import assert from 'node:assert/strict'; +import { after, describe, it } from 'node:test'; +import { skipReason } from '../harness.ts'; +import type { AppSession, Browser } from '../harness.ts'; +import { makeBox, openEmptyPage, textBoxes } from './pageSession.ts'; + +const focused = (browser: Browser) => + browser.execute(() => { + const element = document.activeElement as HTMLElement | null; + return { block: element?.dataset.blockId ?? null, label: element?.getAttribute('aria-label') ?? null }; + }); + +describe('objects from the keyboard', { skip: skipReason() }, () => { + let session: AppSession | undefined; + + after(async () => { + await session?.close(); + }); + + it('selects with Escape, moves with arrows, and keeps focus through a reading order change', async () => { + session = await openEmptyPage(); + const { browser } = session; + await makeBox(browser, 100, 300, 'Upper'); + await makeBox(browser, 100, 330, 'Lower'); + await browser.keys('Escape'); + const wrapper = await focused(browser); + assert.equal(wrapper.label, 'Lower', 'Escape focuses the box as an object'); + for (let i = 0; i < 6; i += 1) await browser.keys('ArrowUp'); + await browser.waitUntil(async () => (await textBoxes(browser))[0]?.text === 'Lower', { timeout: 5_000 }); + assert.deepEqual(await focused(browser), wrapper, 'focus stayed on the moved box'); + await browser.keys('Enter'); + await browser.keys(' first'); + await browser.waitUntil(async () => (await textBoxes(browser))[0]?.text === 'Lower first', { timeout: 5_000 }); + }); + + it('moves between boxes with Tab in reading order', async () => { + const { browser } = session!; + await browser.keys('Escape'); + await browser.keys('Tab'); + assert.equal((await focused(browser)).label, 'Upper'); + }); +}); diff --git a/tests/e2e/page/page.paste.spec.ts b/tests/e2e/page/page.paste.spec.ts new file mode 100644 index 000000000..270437f0d --- /dev/null +++ b/tests/e2e/page/page.paste.spec.ts @@ -0,0 +1,74 @@ +// Paste through the real Windows clipboard (Phase 4 ARCHITECTURE.md section 26, `page.paste`): clipset loads every +// format a program writes, Ctrl+V pastes it, and the page shows what the corpus expects. Ctrl+Shift+V keeps text +// plain, and PDF line joining undoes in one step. +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { launchApp, skipReason } from '../harness.ts'; +import type { AppSession } from '../harness.ts'; +import { clipset, openTextBox, pageText } from './clipboard.ts'; + +const skip = skipReason() || (process.platform === 'win32' ? false : 'The clipboard tool needs Windows.'); + +describe('paste on the real clipboard', { skip }, () => { + let session: AppSession | undefined; + + before(async () => { + session = await launchApp(); + }); + after(async () => { + await session?.close(); + }); + + const paste = async (name: string, keys: string[] = ['Control', 'v']) => { + const browser = session!.browser; + await openTextBox(browser); + await browser.keys('Enter'); + clipset(name); + await browser.keys(keys); + await browser.keys(['Control']); + }; + + it('keeps Word structure and colors', async () => { + await paste('word/basic'); + await session!.browser.waitUntil(async () => (await pageText(session!.browser)).includes('Weigh each one'), { + timeout: 10_000, + }); + const colored = await session!.browser.execute(() => !!document.querySelector('[data-block-id] span[data-color]')); + assert.ok(colored, 'a Word text color became a pen color'); + }); + + it('keeps OneNote To Do tags as checklist items', async () => { + await paste('onenote/basic'); + await session!.browser.waitUntil(async () => (await pageText(session!.browser)).includes('Book the room'), { + timeout: 10_000, + }); + const checked = await session!.browser.execute(() => document.querySelectorAll('li[data-checked="true"]').length); + assert.ok(checked > 0, 'the checked To Do tag came through'); + }); + + it('pastes a web table as a table block, without the page colors', async () => { + await paste('web/wikipedia-table'); + await session!.browser.waitUntil(async () => (await pageText(session!.browser)).includes('Mercury'), { + timeout: 10_000, + }); + const text = await pageText(session!.browser); + assert.ok(!text.includes('[edit]'), 'the edit link was left out'); + }); + + it('pastes plain text with Ctrl+Shift+V', async () => { + await paste('markdown/basic', ['Control', 'Shift', 'v']); + await session!.browser.waitUntil(async () => (await pageText(session!.browser)).includes('#'), { + timeout: 10_000, + }); + }); + + it('joins PDF lines as a step of its own', async () => { + await paste('plain/pdf'); + const browser = session!.browser; + await browser.waitUntil(async () => (await pageText(browser)).length > 0, { timeout: 10_000 }); + const joined = await pageText(browser); + await browser.keys(['Control', 'z']); + await browser.keys(['Control']); + await browser.waitUntil(async () => (await pageText(browser)) !== joined, { timeout: 5_000 }); + }); +}); diff --git a/tests/e2e/page/page.readAloud.spec.ts b/tests/e2e/page/page.readAloud.spec.ts new file mode 100644 index 000000000..e8d4e450d --- /dev/null +++ b/tests/e2e/page/page.readAloud.spec.ts @@ -0,0 +1,49 @@ +// Read aloud in the real app, nightly (Phase 4 PLAN.md section 11; spike S2): WebView2 lists local voices, Ctrl+Shift+U +// starts reading, the spoken word is highlighted from boundary events, and pause holds the highlight still. Runners +// without a local voice skip. + +import assert from 'node:assert/strict'; +import { rmSync } from 'node:fs'; +import { after, describe, it } from 'node:test'; +import { launchApp, skipReason } from '../harness.ts'; +import type { AppSession, Browser } from '../harness.ts'; +import { flaggedProfile, openMitosisText } from './helpers.ts'; + +const spoken = (browser: Browser) => + browser.execute(() => [...(CSS.highlights.get('read-aloud') ?? [])].map((range) => range.toString()).join('')); + +describe('read aloud', { skip: skipReason() }, () => { + const profileDir = flaggedProfile('opennote-e2e-read-aloud-', { 'editor.readAloud': true }); + let session: AppSession | undefined; + + after(async () => { + await session?.close(); + rmSync(profileDir, { recursive: true, force: true }); + }); + + it('reads with a local voice and highlights each word', async (context) => { + session = await launchApp({ profileDir }); + const { browser } = session; + const local = await browser.executeAsync((done: (count: number) => void) => { + const count = () => speechSynthesis.getVoices().filter((voice) => voice.localService).length; + if (count()) done(count()); + else speechSynthesis.addEventListener('voiceschanged', () => done(count()), { once: true }); + setTimeout(() => done(count()), 3_000); + }); + if (!local) { + context.skip('No local voices on this runner.'); + return; + } + await openMitosisText(browser); + await browser.keys(['Control', 'Home']); + await browser.keys(['Control', 'Shift', 'u']); + await browser.$('[role="region"][aria-label="Read aloud"]').waitForExist({ timeout: 5_000 }); + await browser.waitUntil(async () => (await spoken(browser)).length > 0, { timeout: 10_000 }); + await browser.keys(['Control', 'Shift', 'u']); + const paused = await spoken(browser); + await browser.pause(1_000); + assert.equal(await spoken(browser), paused, 'the highlight holds while paused'); + await browser.keys(['Control', 'Shift', 'u']); + await browser.waitUntil(async () => (await spoken(browser)) !== paused, { timeout: 10_000 }); + }); +}); diff --git a/tests/e2e/page/page.spelling.spec.ts b/tests/e2e/page/page.spelling.spec.ts new file mode 100644 index 000000000..e8b705a6e --- /dev/null +++ b/tests/e2e/page/page.spelling.spec.ts @@ -0,0 +1,44 @@ +// Spell check in the real app (Phase 4 PLAN.md section 11): typed misspellings get squiggles from the Windows spelling +// service within a second after typing pauses, and F7 opens the spelling menu, where Enter takes the first suggestion. +// Runners without an English spell checker skip the check rather than fail. + +import assert from 'node:assert/strict'; +import { rmSync } from 'node:fs'; +import { after, describe, it } from 'node:test'; +import { launchApp, skipReason } from '../harness.ts'; +import type { AppSession, Browser } from '../harness.ts'; +import { flaggedProfile, openMitosisText } from './helpers.ts'; + +const squiggles = (browser: Browser) => + browser.execute(() => [...(CSS.highlights.get('spelling-error') ?? [])].map((range) => range.toString())); + +describe('spell check', { skip: skipReason() }, () => { + const profileDir = flaggedProfile('opennote-e2e-spelling-', { 'editor.spelling': true }); + let session: AppSession | undefined; + + after(async () => { + await session?.close(); + rmSync(profileDir, { recursive: true, force: true }); + }); + + it('underlines a misspelling and fixes it from F7', async (context) => { + session = await launchApp({ profileDir }); + const { browser } = session; + await openMitosisText(browser); + await browser.keys(' I recieve mail'); + const found = await browser + .waitUntil(async () => (await squiggles(browser)).includes('recieve'), { timeout: 3_000 }) + .catch(() => false); + if (!found) { + context.skip('No English spell checker on this runner.'); + return; + } + await browser.keys(['F7']); + const menu = browser.$('[role="menu"][aria-label="Spelling"]'); + await menu.waitForExist({ timeout: 5_000 }); + await browser.keys(['Enter']); + const box = browser.$('[role="textbox"][aria-label="Text"]'); + await browser.waitUntil(async () => (await box.getText()).includes('I receive mail'), { timeout: 5_000 }); + assert.ok(!(await squiggles(browser)).includes('recieve')); + }); +}); diff --git a/tests/e2e/page/page.tables.ts b/tests/e2e/page/page.tables.ts new file mode 100644 index 000000000..03de91332 --- /dev/null +++ b/tests/e2e/page/page.tables.ts @@ -0,0 +1,92 @@ +// Tables in the real app (Phase 4 PLAN.md section 10.5). Insert a table from the palette, type its header cells +// with Tab between them, and add a row with Ctrl+Enter. The table is a real
, named by its header cells, +// and it survives a restart. It skips where the WebDriver tools aren't installed, like the smoke specs. + +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, it } from 'node:test'; +import { launchApp, skipReason } from '../harness.ts'; +import type { AppSession, Browser } from '../harness.ts'; + +/** Clicks the row of a tree whose label reads `name`, once it shows. */ +async function clickRow(browser: Browser, tree: string, name: string): Promise { + await browser.waitUntil( + () => + browser.execute( + (tree, name) => { + const rows = document.querySelectorAll(`[role="tree"][aria-label="${tree}"] [role="treeitem"]`); + const row = [...rows].find( + (candidate) => + document.getElementById(candidate.getAttribute('aria-labelledby') ?? '')?.textContent === name, + ); + (row as HTMLElement | undefined)?.click(); + return Boolean(row); + }, + tree, + name, + ), + { timeout: 20_000 }, + ); +} + +async function openMitosis(browser: Browser): Promise { + await clickRow(browser, 'Notebooks', 'Lectures'); + await clickRow(browser, 'Pages', 'Mitosis'); +} + +/** Runs a command by its title from the command palette. */ +async function runCommand(browser: Browser, title: string): Promise { + await browser.keys(['Control', 'k']); + const input = browser.$('[role="combobox"]'); + await input.waitForExist({ timeout: 5_000 }); + await input.setValue(title); + await browser.keys('Enter'); +} + +/** The table's name and its rows' cell text. */ +function readTable(browser: Browser): Promise<{ name: string | null; rows: string[][] } | null> { + return browser.execute(() => { + const table = document.querySelector('[data-block-id] table'); + if (!table) return null; + const rows = [...table.querySelectorAll('tr')].map((row) => + [...row.querySelectorAll('th, td')].map((cell) => cell.textContent ?? ''), + ); + return { name: table.closest('[role="group"]')?.getAttribute('aria-label') ?? null, rows }; + }); +} + +describe('tables in a page', { skip: skipReason() }, () => { + const profileDir = mkdtempSync(join(tmpdir(), 'opennote-e2e-tables-')); + let session: AppSession | undefined; + + after(async () => { + await session?.close(); + rmSync(profileDir, { recursive: true, force: true }); + }); + + it('inserts a table, types its cells, adds a row, and keeps it all after a restart', async () => { + session = await launchApp({ profileDir }); + const { browser } = session; + await openMitosis(browser); + const box = browser.$('[role="textbox"][aria-label="Text"]'); + await box.waitForExist({ timeout: 20_000 }); + await box.click(); + await runCommand(browser, 'Insert table'); + await browser.waitUntil(async () => (await readTable(browser)) !== null, { timeout: 10_000 }); + await browser.keys(['S', 't', 'a', 'g', 'e', 'Tab', 'W', 'h', 'e', 'r', 'e']); + await browser.keys(['Control', 'Enter']); + await browser.waitUntil(async () => (await readTable(browser))?.rows.length === 4, { timeout: 5_000 }); + const table = await readTable(browser); + assert.equal(table?.name, 'Table: Stage, Where'); + assert.deepEqual(table?.rows[0].slice(0, 2), ['Stage', 'Where']); + await session.close(); + session = await launchApp({ profileDir }); + await openMitosis(session.browser); + await session.browser.waitUntil(async () => (await readTable(session!.browser)) !== null, { timeout: 20_000 }); + const again = await readTable(session.browser); + assert.equal(again?.rows.length, 4, 'the added row came back after the restart'); + assert.deepEqual(again?.rows[0].slice(0, 2), ['Stage', 'Where']); + }); +}); diff --git a/tests/e2e/page/pageSession.ts b/tests/e2e/page/pageSession.ts new file mode 100644 index 000000000..5057ae35c --- /dev/null +++ b/tests/e2e/page/pageSession.ts @@ -0,0 +1,96 @@ +// Shared steps for WP3's page specs (Phase 4 PLAN.md section 7.5): open the empty Mitosis page in the real app, +// make floating text boxes on it, and read blocks back by their accessible names. + +import { launchApp } from '../harness.ts'; +import type { AppSession, Browser, LaunchOptions } from '../harness.ts'; + +/** Clicks the row of a tree whose label reads `name`, once it shows. */ +async function clickRow(browser: Browser, tree: string, name: string): Promise { + await browser.waitUntil( + () => + browser.execute( + (tree, name) => { + const rows = document.querySelectorAll(`[role="tree"][aria-label="${tree}"] [role="treeitem"]`); + const row = [...rows].find( + (candidate) => + document.getElementById(candidate.getAttribute('aria-labelledby') ?? '')?.textContent === name, + ); + (row as HTMLElement | undefined)?.click(); + return Boolean(row); + }, + tree, + name, + ), + { timeout: 20_000 }, + ); +} + +/** Launches the app and opens the empty Mitosis page, waiting for its text. */ +export async function openEmptyPage(options: LaunchOptions = {}): Promise { + const session = await launchApp(options); + await clickRow(session.browser, 'Notebooks', 'Lectures'); + await clickRow(session.browser, 'Pages', 'Mitosis'); + await session.browser.$('[role="textbox"][aria-label="Page text"]').waitForExist({ timeout: 20_000 }); + return session; +} + +/** The client point of a page-unit point, through the page's own layers. */ +export async function clientPoint(browser: Browser, x: number, y: number): Promise<{ x: number; y: number }> { + return browser.execute( + (x, y) => { + // The title band is the world's first child, so its grandparent is the world. + const world = document.querySelector('h1[data-page-title]')!.parentElement!.parentElement!; + const box = world.getBoundingClientRect(); + const zoom = box.width / world.offsetWidth; + return { x: box.left + x * zoom, y: box.top + y * zoom }; + }, + x, + y, + ); +} + +/** Presses and releases the mouse at a client point, with moves between, through WebDriver actions. */ +export async function drag(browser: Browser, from: { x: number; y: number }, to: { x: number; y: number }) { + await browser + .action('pointer', { parameters: { pointerType: 'mouse' } }) + .move({ x: Math.round(from.x), y: Math.round(from.y) }) + .down({ button: 0 }) + .move({ x: Math.round((from.x + to.x) / 2), y: Math.round((from.y + to.y) / 2), duration: 100 }) + .move({ x: Math.round(to.x), y: Math.round(to.y), duration: 100 }) + .up({ button: 0 }) + .perform(); +} + +/** Clicks at a client point. */ +export async function clickAt(browser: Browser, point: { x: number; y: number }): Promise { + await browser + .action('pointer', { parameters: { pointerType: 'mouse' } }) + .move({ x: Math.round(point.x), y: Math.round(point.y) }) + .down({ button: 0 }) + .up({ button: 0 }) + .perform(); +} + +/** The floating text boxes, by name, with their page-unit positions from their inline styles. */ +export async function textBoxes( + browser: Browser, +): Promise<{ name: string; text: string; left: number; top: number }[]> { + return browser.execute(() => + [...document.querySelectorAll('[role="textbox"][aria-label^="Text box"]')].map((root) => { + const wrapper = root.closest('[data-block-id]')!; + return { + name: root.getAttribute('aria-label') ?? '', + text: root.textContent ?? '', + left: parseFloat(wrapper.style.left), + top: parseFloat(wrapper.style.top), + }; + }), + ); +} + +/** Makes a floating text box at a page-unit point by clicking there and typing. */ +export async function makeBox(browser: Browser, x: number, y: number, text: string): Promise { + await clickAt(browser, await clientPoint(browser, x, y)); + await browser.keys(text); + await browser.waitUntil(async () => (await textBoxes(browser)).some((box) => box.text === text), { timeout: 5_000 }); +} diff --git a/tests/e2e/smoke/page.restart.spec.ts b/tests/e2e/smoke/page.restart.spec.ts new file mode 100644 index 000000000..3b9aba950 --- /dev/null +++ b/tests/e2e/smoke/page.restart.spec.ts @@ -0,0 +1,83 @@ +// Typed text in the real app reaches Phase 3's core and survives a restart (Phase 4 PLAN.md section 4.4): open +// a page, type into its text box, close the app, start it again on the same profile, and find the text. + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { after, describe, it } from 'node:test'; +import { launchApp, skipReason, tools } from '../harness.ts'; +import type { AppSession, Browser } from '../harness.ts'; + +const TEXT = 'Typed before a restart'; + +/** Clicks the row of a tree whose label reads `name`, once it shows. */ +async function clickRow(browser: Browser, tree: string, name: string): Promise { + await browser.waitUntil( + () => + browser.execute( + (tree, name) => { + const rows = document.querySelectorAll(`[role="tree"][aria-label="${tree}"] [role="treeitem"]`); + const row = [...rows].find( + (candidate) => + document.getElementById(candidate.getAttribute('aria-labelledby') ?? '')?.textContent === name, + ); + (row as HTMLElement | undefined)?.click(); + return Boolean(row); + }, + tree, + name, + ), + { timeout: 20_000 }, + ); +} + +async function openMitosis(browser: Browser): Promise { + await clickRow(browser, 'Notebooks', 'Lectures'); + await clickRow(browser, 'Pages', 'Mitosis'); +} + +/** + * Closes the app's window as its close button does, and waits for the process to go. Rust holds the close until the + * interface has flushed the page, so the process leaves only after the typed text is with the core. Ending the + * WebDriver session instead stops the app at once, and text typed in the last moments is lost, as in a crash. + */ +function closeWindow(): void { + const name = basename(tools().exe ?? 'opennote.exe').replace(/\.exe$/i, ''); + const script = + `$app = Get-Process -Name '${name}' -ErrorAction SilentlyContinue | Sort-Object StartTime -Descending | ` + + 'Select-Object -First 1; if (-not $app) { exit 2 }; [void]$app.CloseMainWindow(); ' + + '[void]$app.WaitForExit(20000); exit [int](-not $app.HasExited)'; + execFileSync('powershell.exe', ['-NoProfile', '-Command', script]); +} + +const textBox = (browser: Browser) => browser.$('[role="textbox"][aria-label="Page text"]'); + +describe('typed text survives a restart', { skip: skipReason() }, () => { + const profileDir = mkdtempSync(join(tmpdir(), 'opennote-e2e-page-')); + let session: AppSession | undefined; + + after(async () => { + await session?.close(); + rmSync(profileDir, { recursive: true, force: true }); + }); + + it('keeps what was typed into a page', async () => { + session = await launchApp({ profileDir }); + await openMitosis(session.browser); + const box = textBox(session.browser); + await box.waitForExist({ timeout: 20_000 }); + await box.click(); + await session.browser.keys(TEXT); + await session.browser.waitUntil(async () => (await box.getText()).includes(TEXT), { timeout: 5_000 }); + closeWindow(); + await session.close(); + session = await launchApp({ profileDir }); + await openMitosis(session.browser); + const again = textBox(session.browser); + await again.waitForExist({ timeout: 20_000 }); + const text = await again.getText(); + assert.ok(text.includes(TEXT), `the text came back after the restart, but the page has "${text.slice(0, 200)}"`); + }); +}); diff --git a/tests/e2e/tools/clipset/clipset.ps1 b/tests/e2e/tools/clipset/clipset.ps1 new file mode 100644 index 000000000..ee2cc7902 --- /dev/null +++ b/tests/e2e/tools/clipset/clipset.ps1 @@ -0,0 +1,75 @@ +# clipset loads one clipboard corpus case onto the real Windows clipboard, with every format the source program +# would write (Phase 4 ARCHITECTURE.md section 26). A case is a folder of tests/fixtures/clipboard//. +# Its fragment.html goes on as CF_HTML ("HTML Format"), with a header that names the source address. +# Its text.txt goes on as CF_UNICODETEXT. +# Its facts.json names what the shell reads besides the text, such as { "sourceUrl": "...", "hasOneNote": true }. +# Its image.png goes on as a bitmap, for image-only pastes. +# Run it in a single-threaded apartment: powershell -NoProfile -STA -File clipset.ps1 . +param([Parameter(Mandatory = $true)][string]$Case) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$folder = (Resolve-Path -LiteralPath $Case).Path +$data = New-Object System.Windows.Forms.DataObject +$facts = @{} +$factsFile = Join-Path $folder 'facts.json' +if (Test-Path -LiteralPath $factsFile) { + $parsed = Get-Content -LiteralPath $factsFile -Raw -Encoding UTF8 | ConvertFrom-Json + foreach ($property in $parsed.PSObject.Properties) { $facts[$property.Name] = $property.Value } +} + +# CF_HTML: a header of byte offsets into the UTF-8 bytes, then the document (the Windows HTML clipboard format). +function ConvertTo-CfHtml([string]$html, [string]$sourceUrl) { + if ($html -notmatch '') { + $html = "$html" + } + $template = "Version:0.9`r`nStartHTML:{0:D10}`r`nEndHTML:{1:D10}`r`nStartFragment:{2:D10}`r`nEndFragment:{3:D10}`r`n" + if ($sourceUrl) { $template += "SourceURL:$sourceUrl`r`n" } + $utf8 = New-Object System.Text.UTF8Encoding($false) + $headerLength = $utf8.GetByteCount(($template -f 0, 0, 0, 0)) + $before = $html.Substring(0, $html.IndexOf('') + ''.Length) + $fragmentEnd = $html.IndexOf('') + $startFragment = $headerLength + $utf8.GetByteCount($before) + $endFragment = $headerLength + $utf8.GetByteCount($html.Substring(0, $fragmentEnd)) + $endHtml = $headerLength + $utf8.GetByteCount($html) + $header = $template -f $headerLength, $endHtml, $startFragment, $endFragment + return $utf8.GetBytes($header + $html) +} + +$htmlFile = Join-Path $folder 'fragment.html' +if (Test-Path -LiteralPath $htmlFile) { + $html = Get-Content -LiteralPath $htmlFile -Raw -Encoding UTF8 + $bytes = ConvertTo-CfHtml $html $facts['sourceUrl'] + # A stream goes on the clipboard as its raw bytes, so the offsets stay true. + $data.SetData('HTML Format', (New-Object System.IO.MemoryStream(, $bytes))) +} + +$textFile = Join-Path $folder 'text.txt' +if (Test-Path -LiteralPath $textFile) { + $text = Get-Content -LiteralPath $textFile -Raw -Encoding UTF8 + $data.SetData([System.Windows.Forms.DataFormats]::UnicodeText, $text.Replace("`r`n", "`n").Replace("`n", "`r`n")) +} + +if ($facts['hasOneNote']) { + # OneNote writes its own formats beside the HTML; the shell only checks that one is there. + $data.SetData('OneNote 2016 Internal Format', (New-Object System.IO.MemoryStream(, [byte[]](0, 1, 2, 3)))) +} + +$imageFile = Join-Path $folder 'image.png' +if (Test-Path -LiteralPath $imageFile) { + $data.SetImage([System.Drawing.Image]::FromFile($imageFile)) +} + +# Copy = true keeps the data after this process exits; retry while another program holds the clipboard. +for ($attempt = 0; $attempt -lt 10; $attempt++) { + try { + [System.Windows.Forms.Clipboard]::SetDataObject($data, $true) + exit 0 + } catch { + Start-Sleep -Milliseconds 100 + } +} +Write-Error 'The clipboard stayed busy.' +exit 1 diff --git a/tests/fixtures/clipboard/excel/basic/expected.md b/tests/fixtures/clipboard/excel/basic/expected.md new file mode 100644 index 000000000..b21a73323 --- /dev/null +++ b/tests/fixtures/clipboard/excel/basic/expected.md @@ -0,0 +1,7 @@ + + +| | | | +| --- | --- | --- | +| Item | Qty | Price | +| Pens | 12 | 1.5 | +| Paper | 3 | 4.25 | diff --git a/tests/fixtures/clipboard/excel/basic/fragment.html b/tests/fixtures/clipboard/excel/basic/fragment.html new file mode 100644 index 000000000..39a539b20 --- /dev/null +++ b/tests/fixtures/clipboard/excel/basic/fragment.html @@ -0,0 +1,45 @@ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
ItemQtyPrice
Pens121.5
Paper34.25
+ + + + diff --git a/tests/fixtures/clipboard/excel/basic/text.txt b/tests/fixtures/clipboard/excel/basic/text.txt new file mode 100644 index 000000000..72f2bc0eb --- /dev/null +++ b/tests/fixtures/clipboard/excel/basic/text.txt @@ -0,0 +1 @@ +Item Qty Price \ No newline at end of file diff --git a/tests/fixtures/clipboard/gdocs/basic/expected.md b/tests/fixtures/clipboard/gdocs/basic/expected.md new file mode 100644 index 000000000..2a34afd7e --- /dev/null +++ b/tests/fixtures/clipboard/gdocs/basic/expected.md @@ -0,0 +1,13 @@ + + +## Project plan + +Ship the **first** *milestone* by Friday + +- Design + + - Sketch screens + +- Build + +[the plan](https://example.com/plan) diff --git a/tests/fixtures/clipboard/gdocs/basic/fragment.html b/tests/fixtures/clipboard/gdocs/basic/fragment.html new file mode 100644 index 000000000..0c5f950a6 --- /dev/null +++ b/tests/fixtures/clipboard/gdocs/basic/fragment.html @@ -0,0 +1 @@ +

Project plan

Ship the first milestone by Friday

  • Design

    • Sketch screens

  • Build


the plan

diff --git a/tests/fixtures/clipboard/gdocs/basic/text.txt b/tests/fixtures/clipboard/gdocs/basic/text.txt new file mode 100644 index 000000000..60939f5ed --- /dev/null +++ b/tests/fixtures/clipboard/gdocs/basic/text.txt @@ -0,0 +1 @@ +Project plan \ No newline at end of file diff --git a/tests/fixtures/clipboard/image/screenshot/image.png b/tests/fixtures/clipboard/image/screenshot/image.png new file mode 100644 index 000000000..aacdcf4ab Binary files /dev/null and b/tests/fixtures/clipboard/image/screenshot/image.png differ diff --git a/tests/fixtures/clipboard/markdown/basic/expected.md b/tests/fixtures/clipboard/markdown/basic/expected.md new file mode 100644 index 000000000..6be5ff49a --- /dev/null +++ b/tests/fixtures/clipboard/markdown/basic/expected.md @@ -0,0 +1,23 @@ + + +# Field notes + +Saw **three** birds near the [pond](https://example.com/pond). + +- [ ] Log the sightings +- [x] Charge the camera + + + +| Bird | Count | +| --- | --- | +| Heron | 1 | +| Duck | 2 | + + + +> Quiet morning. + +```js +console.log('birds'); +``` diff --git a/tests/fixtures/clipboard/markdown/basic/text.txt b/tests/fixtures/clipboard/markdown/basic/text.txt new file mode 100644 index 000000000..46dc27476 --- /dev/null +++ b/tests/fixtures/clipboard/markdown/basic/text.txt @@ -0,0 +1,17 @@ +# Field notes + +Saw **three** birds near the [pond](https://example.com/pond). + +- [ ] Log the sightings +- [x] Charge the camera + +| Bird | Count | +|------|------:| +| Heron | 1 | +| Duck | 2 | + +> Quiet morning. + +```js +console.log('birds'); +``` diff --git a/tests/fixtures/clipboard/onenote/basic/expected.md b/tests/fixtures/clipboard/onenote/basic/expected.md new file mode 100644 index 000000000..ebf76d60a --- /dev/null +++ b/tests/fixtures/clipboard/onenote/basic/expected.md @@ -0,0 +1,11 @@ + + +**Meeting notes** + +- [ ] Send the agenda +- [x] Book the room + +*Bring* the printouts + +- Budget review +- Hiring diff --git a/tests/fixtures/clipboard/onenote/basic/facts.json b/tests/fixtures/clipboard/onenote/basic/facts.json new file mode 100644 index 000000000..237d65bb5 --- /dev/null +++ b/tests/fixtures/clipboard/onenote/basic/facts.json @@ -0,0 +1 @@ +{ "hasOneNote": true } diff --git a/tests/fixtures/clipboard/onenote/basic/fragment.html b/tests/fixtures/clipboard/onenote/basic/fragment.html new file mode 100644 index 000000000..49c81d2fa --- /dev/null +++ b/tests/fixtures/clipboard/onenote/basic/fragment.html @@ -0,0 +1,38 @@ + + + + + + + + + + +
+ +
+
+

Meeting notes

+
+
+ +
+

To Do Send the agenda

+

To Do (checked) Book the room

+

Bring the printouts

+
+ +
+
    +
  • Budget review

  • +
  • Hiring

  • +
+
+ +
+ + + + diff --git a/tests/fixtures/clipboard/onenote/basic/text.txt b/tests/fixtures/clipboard/onenote/basic/text.txt new file mode 100644 index 000000000..1f9e6baf6 --- /dev/null +++ b/tests/fixtures/clipboard/onenote/basic/text.txt @@ -0,0 +1 @@ +Meeting notes \ No newline at end of file diff --git a/tests/fixtures/clipboard/plain/pdf/expected.md b/tests/fixtures/clipboard/plain/pdf/expected.md new file mode 100644 index 000000000..e59f66e0c --- /dev/null +++ b/tests/fixtures/clipboard/plain/pdf/expected.md @@ -0,0 +1,15 @@ + + +Photosynthesis converts light energy into chemical en- + +ergy that the cell can store. In the light reactions, + +pigments in the thylakoid membranes absorb photons and + +pass the energy along a chain of carriers. The carbon + +fixation cycle then uses that energy to build sugars + +from carbon dioxide, which the plant stores as starch + +or exports to the growing tissues of the shoot. diff --git a/tests/fixtures/clipboard/plain/pdf/text.txt b/tests/fixtures/clipboard/plain/pdf/text.txt new file mode 100644 index 000000000..a7ebe0db6 --- /dev/null +++ b/tests/fixtures/clipboard/plain/pdf/text.txt @@ -0,0 +1,7 @@ +Photosynthesis converts light energy into chemical en- +ergy that the cell can store. In the light reactions, +pigments in the thylakoid membranes absorb photons and +pass the energy along a chain of carriers. The carbon +fixation cycle then uses that energy to build sugars +from carbon dioxide, which the plant stores as starch +or exports to the growing tissues of the shoot. diff --git a/tests/fixtures/clipboard/plain/prose/expected.md b/tests/fixtures/clipboard/plain/prose/expected.md new file mode 100644 index 000000000..a648b4225 --- /dev/null +++ b/tests/fixtures/clipboard/plain/prose/expected.md @@ -0,0 +1,7 @@ + + +Cells divide in two ways. Mitosis makes two identical cells, and meiosis makes four cells with half the chromosomes. + +Both start after the DNA is copied. The cell checks the copy before it divides. + +Read more at https://example.org/cell-division before the lab. diff --git a/tests/fixtures/clipboard/plain/prose/text.txt b/tests/fixtures/clipboard/plain/prose/text.txt new file mode 100644 index 000000000..3aa6efd47 --- /dev/null +++ b/tests/fixtures/clipboard/plain/prose/text.txt @@ -0,0 +1,5 @@ +Cells divide in two ways. Mitosis makes two identical cells, and meiosis makes four cells with half the chromosomes. + +Both start after the DNA is copied. The cell checks the copy before it divides. + +Read more at https://example.org/cell-division before the lab. diff --git a/tests/fixtures/clipboard/plain/url/expected.md b/tests/fixtures/clipboard/plain/url/expected.md new file mode 100644 index 000000000..72e196613 --- /dev/null +++ b/tests/fixtures/clipboard/plain/url/expected.md @@ -0,0 +1,3 @@ + + +https://example.org/cell-division?part=2#mitosis diff --git a/tests/fixtures/clipboard/plain/url/text.txt b/tests/fixtures/clipboard/plain/url/text.txt new file mode 100644 index 000000000..ed9f80971 --- /dev/null +++ b/tests/fixtures/clipboard/plain/url/text.txt @@ -0,0 +1 @@ +https://example.org/cell-division?part=2#mitosis \ No newline at end of file diff --git a/tests/fixtures/clipboard/vscode/basic/expected.md b/tests/fixtures/clipboard/vscode/basic/expected.md new file mode 100644 index 000000000..57f32ac18 --- /dev/null +++ b/tests/fixtures/clipboard/vscode/basic/expected.md @@ -0,0 +1,6 @@ + + +``` +const total = 3; +console.log(total); +``` diff --git a/tests/fixtures/clipboard/vscode/basic/fragment.html b/tests/fixtures/clipboard/vscode/basic/fragment.html new file mode 100644 index 000000000..6d65b4204 --- /dev/null +++ b/tests/fixtures/clipboard/vscode/basic/fragment.html @@ -0,0 +1 @@ +
const total = 3;
console.log(total);
diff --git a/tests/fixtures/clipboard/vscode/basic/text.txt b/tests/fixtures/clipboard/vscode/basic/text.txt new file mode 100644 index 000000000..cf056b70c --- /dev/null +++ b/tests/fixtures/clipboard/vscode/basic/text.txt @@ -0,0 +1,2 @@ +const total = 3; +console.log(total); \ No newline at end of file diff --git a/tests/fixtures/clipboard/web/basic/expected.md b/tests/fixtures/clipboard/web/basic/expected.md new file mode 100644 index 000000000..53232539b --- /dev/null +++ b/tests/fixtures/clipboard/web/basic/expected.md @@ -0,0 +1,37 @@ + + +## How bees find flowers + +Bees use **scent** and *color*. Read the [pollination](https://en.example.org/wiki/Pollination) article, [the survey](https://example.org/bees), or this. + + + +![A bee on a flower](https://en.example.org/images/bee.jpg) + + + +A bee + +- Scent + +- Color + + - Ultraviolet + +> Nature is not a place to visit. + +```python +for bee in hive: + bee.fly() +``` + + + +| Bee | Trips | +| --- | --- | +| Worker | 12 | + + + +Text with a\ +line break and a mark. diff --git a/tests/fixtures/clipboard/web/basic/facts.json b/tests/fixtures/clipboard/web/basic/facts.json new file mode 100644 index 000000000..94c19ef67 --- /dev/null +++ b/tests/fixtures/clipboard/web/basic/facts.json @@ -0,0 +1 @@ +{ "sourceUrl": "https://en.example.org/wiki/Bees" } diff --git a/tests/fixtures/clipboard/web/basic/fragment.html b/tests/fixtures/clipboard/web/basic/fragment.html new file mode 100644 index 000000000..4654904ae --- /dev/null +++ b/tests/fixtures/clipboard/web/basic/fragment.html @@ -0,0 +1,2 @@ +

How bees find flowers

Bees use scent and color. Read the pollination article, the survey, or this.

A bee on a flower
A bee
  • Scent
  • Color
    • Ultraviolet
Nature is not a place to visit.
for bee in hive:
+    bee.fly()
BeeTrips
Worker12

hidden text

Text with a
line break and a mark.

diff --git a/tests/fixtures/clipboard/web/basic/text.txt b/tests/fixtures/clipboard/web/basic/text.txt new file mode 100644 index 000000000..d103e8fc1 --- /dev/null +++ b/tests/fixtures/clipboard/web/basic/text.txt @@ -0,0 +1 @@ +How bees find flowers \ No newline at end of file diff --git a/tests/fixtures/clipboard/web/security/expected.md b/tests/fixtures/clipboard/web/security/expected.md new file mode 100644 index 000000000..631004f55 --- /dev/null +++ b/tests/fixtures/clipboard/web/security/expected.md @@ -0,0 +1,15 @@ + + +Click here or there. + +A data link, a [file server](ftp://files.example.com/a.txt), and [an app link](obsidian://open?vault=notes). + +A local file + + + +![A remote picture](https://example.com/remote.png) + + + +Styled diff --git a/tests/fixtures/clipboard/web/security/fragment.html b/tests/fixtures/clipboard/web/security/fragment.html new file mode 100644 index 000000000..5af7a0be3 --- /dev/null +++ b/tests/fixtures/clipboard/web/security/fragment.html @@ -0,0 +1,8 @@ + +

Click here or there.

+ +

A data link, a file server, and an app link.

+

A local file A remote picture

+ +

Styled

+ diff --git a/tests/fixtures/clipboard/web/security/text.txt b/tests/fixtures/clipboard/web/security/text.txt new file mode 100644 index 000000000..eab81d87d --- /dev/null +++ b/tests/fixtures/clipboard/web/security/text.txt @@ -0,0 +1 @@ +Click here or there. \ No newline at end of file diff --git a/tests/fixtures/clipboard/web/wikipedia-table/expected.md b/tests/fixtures/clipboard/web/wikipedia-table/expected.md new file mode 100644 index 000000000..47d434154 --- /dev/null +++ b/tests/fixtures/clipboard/web/wikipedia-table/expected.md @@ -0,0 +1,17 @@ + + +## Planets + +The [planets](https://en.wikipedia.org/wiki/Planet) of the [Solar System](https://en.wikipedia.org/wiki/Solar_System), in order from the **Sun**: + + + +| Planet | Distance (AU) | Moons | +| --- | --- | --- | +| [Mercury]() | 0.39 | 0 | +| [Venus](https://en.wikipedia.org/wiki/Venus) | 0.72 | 0 | +| [Earth](https://en.wikipedia.org/wiki/Earth) | 1.00 | 1 | + + + +Each planet keeps its own orbit. diff --git a/tests/fixtures/clipboard/web/wikipedia-table/facts.json b/tests/fixtures/clipboard/web/wikipedia-table/facts.json new file mode 100644 index 000000000..602c1edc9 --- /dev/null +++ b/tests/fixtures/clipboard/web/wikipedia-table/facts.json @@ -0,0 +1 @@ +{ "sourceUrl": "https://en.wikipedia.org/wiki/Solar_System" } diff --git a/tests/fixtures/clipboard/web/wikipedia-table/fragment.html b/tests/fixtures/clipboard/web/wikipedia-table/fragment.html new file mode 100644 index 000000000..0a5bf9da9 --- /dev/null +++ b/tests/fixtures/clipboard/web/wikipedia-table/fragment.html @@ -0,0 +1,27 @@ + +

Planets[edit]

+

The planets of the Solar System, in order from the Sun[1]:

+ + + + + + + + + + + + + + + + + + + + + +
PlanetDistance (AU)Moons
Mercury0.390
Venus0.720
Earth1.001
+

Each planet keeps its own orbit.

+ diff --git a/tests/fixtures/clipboard/web/wikipedia-table/text.txt b/tests/fixtures/clipboard/web/wikipedia-table/text.txt new file mode 100644 index 000000000..10fdcb729 --- /dev/null +++ b/tests/fixtures/clipboard/web/wikipedia-table/text.txt @@ -0,0 +1,7 @@ +Planets[edit] +The planets of the Solar System, in order from the Sun[1]: +Planet Distance (AU) Moons +Mercury 0.39 0 +Venus 0.72 0 +Earth 1.00 1 +Each planet keeps its own orbit. \ No newline at end of file diff --git a/tests/fixtures/clipboard/word/basic/expected.md b/tests/fixtures/clipboard/word/basic/expected.md new file mode 100644 index 000000000..ace993838 --- /dev/null +++ b/tests/fixtures/clipboard/word/basic/expected.md @@ -0,0 +1,28 @@ + + +# Lab report + +Results were **clearly** better, see [the data](https://example.com/data). + +- Measure the samples + + - Weigh each one + - Record the mass in **grams** + +- Clean up + +1. First step +2. Second step + +H2O, x2, underlined, ~~struck~~, and ==highlighted==. + + + +![A chart of the masses](file:///C:/Users/me/AppData/Local/Temp/msohtmlclip1/01/clip_image001.png) + + + +| | | +| --- | --- | +| **Sample** | **Mass (g)** | +| A1 | 12.5 | diff --git a/tests/fixtures/clipboard/word/basic/fragment.html b/tests/fixtures/clipboard/word/basic/fragment.html new file mode 100644 index 000000000..f5800092f --- /dev/null +++ b/tests/fixtures/clipboard/word/basic/fragment.html @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + +

Lab report

+ +

Results were +clearly better, see the data.

+ +

 

+ +

·          +Measure the samples

+ +

o       +Weigh each one

+ +

o       +Record the mass in grams

+ +

·          +Clean up

+ +

1.        +First step

+ +

2.        +Second step

+ +

H2O, x2, underlined, struck, and highlighted.

+ +

A chart of the masses

+ + + + + + + + + + +
+

Sample

+
+

Mass (g)

+
+

A1

+
+

12.5

+
+ + + + + diff --git a/tests/fixtures/clipboard/word/basic/text.txt b/tests/fixtures/clipboard/word/basic/text.txt new file mode 100644 index 000000000..c6c938028 --- /dev/null +++ b/tests/fixtures/clipboard/word/basic/text.txt @@ -0,0 +1 @@ +Lab report \ No newline at end of file diff --git a/tests/perf/typing/conditions.ts b/tests/perf/typing/conditions.ts new file mode 100644 index 000000000..60d563559 --- /dev/null +++ b/tests/perf/typing/conditions.ts @@ -0,0 +1,311 @@ +// The typing benchmark's conditions (Phase 4 ARCHITECTURE.md sections 24.2 and 26.4): each opens a seeded page in +// the web build and puts the caret where the keys go. The web platform serves the page fixture the address names +// (?fixture=twentyPage) as every page, so each condition opens Lectures, then Membranes. + +import { expect } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import type { PageFixtureName } from '../../../app/src/services/pages/fixtures'; + +export type Caret = 'start' | 'middle' | 'end'; + +export interface Condition { + readonly id: string; + readonly description: string; + readonly fixture: PageFixtureName; + /** Milliseconds between keys. Default 120, as ADR 0005's harness sends them. */ + readonly gapMs?: number; + /** Starts the browser with its accessibility tree on, as a UI Automation client such as Narrator does. */ + readonly accessibility?: boolean; + /** Puts the caret where typing goes, after the page is open. */ + prepare(page: Page): Promise; +} + +/** The text blocks' editing roots. Each is static text until a press or focus mounts its editor in place. */ +const TEXT_BLOCKS = '[data-scope="editor"][data-block]'; + +/** + * Waits until the page is ready for a person to type in. A text box exists as an empty wrapper well before its + * text is drawn: blocks draw when they are near the viewport or in idle time. The chunks the page's commands wait + * for load in idle time too. A slow machine can be seconds from either when the first box appears. + */ +async function waitForPage(page: Page): Promise { + await page.locator('html[data-page-commands="ready"]').waitFor({ state: 'attached', timeout: 30_000 }); + await page + .waitForFunction( + (selector) => { + const blocks = [...document.querySelectorAll(selector)]; + return blocks.length > 0 && blocks.every((block) => block.firstChild !== null); + }, + TEXT_BLOCKS, + { timeout: 30_000 }, + ) + .catch(async (error: unknown) => { + const drawn = await page.evaluate((selector) => { + const blocks = [...document.querySelectorAll(selector)]; + return `${blocks.filter((block) => block.firstChild !== null).length} of ${blocks.length}`; + }, TEXT_BLOCKS); + throw new Error(`The page's text blocks were not all drawn (${drawn}).`, { cause: error }); + }); +} + +/** Opens the page fixture's copy of Membranes and waits until its text is drawn and its commands are loaded. */ +export async function openFixture(page: Page, fixture: PageFixtureName): Promise { + await page.goto(`/?fixture=${fixture}`); + await page.getByRole('tree', { name: 'Notebooks' }).getByRole('treeitem', { name: 'Lectures' }).click(); + await page.getByRole('tree', { name: 'Pages' }).getByRole('treeitem', { name: 'Membranes' }).click(); + await page + .getByRole('textbox', { name: /^(Page text|Text box 1)/ }) + .first() + .waitFor(); + await waitForPage(page); +} + +/** + * The text block with the most text: the 20-page note on every 20-page fixture. The page title is a text box too, + * but it isn't a block, and it is what a search over every text box finds while the note is still undrawn. + */ +async function longestBox(page: Page): Promise { + const id = await page.evaluate((selector) => { + const blocks = [...document.querySelectorAll(selector)]; + const lengths = blocks.map((block) => block.textContent?.length ?? 0); + return blocks[lengths.indexOf(Math.max(...lengths))]?.dataset.block ?? null; + }, TEXT_BLOCKS); + if (id === null) throw new Error('The page has no text block.'); + return page.locator(`${TEXT_BLOCKS}[data-block=${JSON.stringify(id)}]`); +} + +const frame = (page: Page) => page.evaluate(() => new Promise((done) => requestAnimationFrame(done))); + +/** + * Focuses a text box and puts the caret at its start, middle, or end, after a space for the middle so typing + * makes a new word. The editor reads the DOM selection, as it does after a click. It returns once the box's editor + * is mounted and holds both focus and the caret, and says so if it can't. + */ +export async function placeCaret(page: Page, box: Locator, caret: Caret): Promise { + await box.click({ position: { x: 8, y: 8 } }); + // The press mounts the editor in place of the static text, which replaces the text nodes the caret goes in. + await expect(box, 'The press did not mount the text box editor').toHaveAttribute('contenteditable', 'true', { + timeout: 15_000, + }); + await box.evaluate((root, caret) => { + (root as HTMLElement).focus(); + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const nodes: Text[] = []; + while (walker.nextNode()) nodes.push(walker.currentNode as Text); + let node = nodes[0]; + let offset = 0; + if (caret === 'end') { + node = nodes[nodes.length - 1]; + offset = node.length; + } else if (caret === 'middle') { + const total = nodes.reduce((sum, text) => sum + text.length, 0); + let left = total / 2; + for (const text of nodes) { + node = text; + if (left < text.length) break; + left -= text.length; + } + const space = node.data.indexOf(' ', Math.floor(left)); + offset = space < 0 ? node.length : space + 1; + } + getSelection()!.collapse(node, offset); + node.parentElement!.scrollIntoView({ block: 'center' }); + }, caret); + await frame(page); + await frame(page); + const placed = await box.evaluate( + (root) => root.contains(getSelection()?.anchorNode ?? null) && root.contains(document.activeElement), + ); + if (!placed) throw new Error(`The caret is not in the text box it was placed in: ${await caretContext(page)}`); +} + +/** Where the caret sits, for the message of a wait that ran out. */ +async function caretContext(page: Page): Promise { + return page.evaluate(() => { + const node = getSelection()?.anchorNode; + const element = node?.nodeType === Node.ELEMENT_NODE ? (node as Element) : (node?.parentElement ?? null); + const block = element?.closest('p, li, pre, h1, h2, h3, h4, h5, h6, td, th'); + return JSON.stringify({ + editable: element?.closest('[contenteditable="true"]') !== null, + inTitle: element?.closest('[data-page-title]') !== null, + block: block?.tagName ?? null, + textEnd: block?.textContent?.slice(-60) ?? null, + codeBlocks: document.querySelectorAll('pre code').length, + }); + }); +} + +/** + * Puts the caret at the end of the 20-page note and adds a paragraph there by typing, then waits until the caret is + * in it. Keys sent before then land at the end of the note's last paragraph, where a block shortcut can't apply. + */ +async function atEndOfNote(page: Page): Promise { + await placeCaret(page, await longestBox(page), 'end'); + await page.keyboard.press('Enter'); + await page + .waitForFunction( + () => { + const node = getSelection()?.anchorNode; + const element = node?.nodeType === Node.ELEMENT_NODE ? (node as Element) : node?.parentElement; + const paragraph = element?.closest('p'); + return paragraph !== null && paragraph !== undefined && paragraph.textContent === ''; + }, + null, + { timeout: 15_000 }, + ) + .catch(async (error: unknown) => { + throw new Error(`Enter did not put the caret in a new paragraph: ${await caretContext(page)}`, { cause: error }); + }); +} + +/** Presses page zoom keys until the page's scale passes `target`. */ +async function zoomTo(page: Page, target: number): Promise { + const scale = () => + page.evaluate(() => { + const world = document.querySelector('h1[data-page-title]')!.parentElement!.parentElement!; + return world.getBoundingClientRect().width / world.offsetWidth; + }); + const key = target < 1 ? 'Control+Alt+Minus' : 'Control+Alt+Equal'; + for (let i = 0; i < 12; i += 1) { + const now = await scale(); + if (target < 1 ? now <= target + 0.001 : now >= target - 0.001) return; + await page.keyboard.press(key); + await frame(page); + } + throw new Error(`Page zoom stopped short of ${target * 100}%.`); +} + +const inNote = (caret: Caret) => async (page: Page) => placeCaret(page, await longestBox(page), caret); + +export const CONDITIONS: readonly Condition[] = [ + { id: 'short', description: 'A short note (control)', fixture: 'short', prepare: inNote('end') }, + { + id: 'flat.start', + description: '20-page note, caret at the start', + fixture: 'twentyPage', + prepare: inNote('start'), + }, + { + id: 'flat.middle', + description: '20-page note, caret in the middle', + fixture: 'twentyPage', + prepare: inNote('middle'), + }, + { id: 'flat.end', description: '20-page note, caret at the end', fixture: 'twentyPage', prepare: inNote('end') }, + { + id: 'outline', + description: '20-page outline (about 1,200 items, 4 levels), middle', + fixture: 'twentyPageOutline', + prepare: inNote('middle'), + }, + { + id: 'callout', + description: '20-page note inside one callout, middle', + fixture: 'twentyPageCallout', + prepare: inNote('middle'), + }, + { + id: 'freeform', + description: 'Freeform page with 8 text boxes, in the 20-page box', + fixture: 'freeform8', + prepare: inNote('middle'), + }, + { + id: 'table', + description: 'A table cell after the 20-page note', + fixture: 'twentyPage', + prepare: async (page) => { + await atEndOfNote(page); + await page.keyboard.type('/table'); + await page.getByRole('listbox').waitFor(); + await page.keyboard.press('Enter'); + const cell = page.getByRole('group', { name: 'Table' }).locator('[contenteditable="true"]').first(); + await cell.waitFor(); + await page.keyboard.type('x'); + }, + }, + { + id: 'code', + description: 'A highlighted TypeScript code block after the 20-page note', + fixture: 'twentyPage', + prepare: async (page) => { + await atEndOfNote(page); + await page.keyboard.type('```ts '); + await page + .locator('pre code') + .last() + .waitFor({ state: 'attached', timeout: 15_000 }) + .catch(async (error: unknown) => { + throw new Error(`The fence made no code block: ${await caretContext(page)}`, { cause: error }); + }); + await page.keyboard.type('const value = compute(1, 2);'); + await page.keyboard.press('Enter'); + }, + }, + { + id: 'spelling', + description: '20-page note with spell check on and 200 errors in view', + fixture: 'twentyPage', + prepare: async (page) => { + await placeCaret(page, await longestBox(page), 'middle'); + // 40 short paragraphs with 5 misspellings each, pasted as plain text where the caret is: a screenful. + await page.evaluate(() => { + const words = ['teh', 'recieve', 'wich', 'untill']; + const paragraphs = Array.from({ length: 40 }, (_, p) => + Array.from({ length: 5 }, (_, i) => `a ${words[(p + i) % 4]} word`).join(' '), + ); + const data = new DataTransfer(); + data.setData('text/plain', paragraphs.join('\n\n')); + const paste = new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }); + document.activeElement!.dispatchEvent(paste); + }); + // Squiggles come after typing pauses for 500 ms. + await page.waitForFunction(() => (CSS.highlights.get('spelling-error')?.size ?? 0) >= 200, null, { + timeout: 10_000, + }); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('End'); + await page.keyboard.press('Space'); + }, + }, + { + id: 'burst', + description: '20-page note, bursts of 30 keys a second', + fixture: 'twentyPage', + gapMs: 33, + prepare: inNote('middle'), + }, + { + id: 'screenReader', + description: '20-page note, screen reader on and the accessibility tree built', + fixture: 'twentyPage', + accessibility: true, + prepare: async (page) => { + await page.evaluate(() => + (window as unknown as { __OPENNOTE_TEST__: { setOs(os: object): void } }).__OPENNOTE_TEST__.setOs({ + screenReader: true, + }), + ); + await placeCaret(page, await longestBox(page), 'middle'); + }, + }, + { + id: 'zoom50', + description: '20-page note at 50% page zoom', + fixture: 'twentyPage', + prepare: async (page) => { + await placeCaret(page, await longestBox(page), 'middle'); + await zoomTo(page, 0.5); + }, + }, + { + id: 'zoom200', + description: '20-page note at 200% page zoom', + fixture: 'twentyPage', + prepare: async (page) => { + await placeCaret(page, await longestBox(page), 'middle'); + await zoomTo(page, 2); + }, + }, +]; diff --git a/tests/perf/typing/keyTimer.ts b/tests/perf/typing/keyTimer.ts new file mode 100644 index 000000000..94f1f1c34 --- /dev/null +++ b/tests/perf/typing/keyTimer.ts @@ -0,0 +1,90 @@ +// The in-page measures of the typing benchmark (Phase 4 ARCHITECTURE.md sections 24.2 and 24.5), installed before +// any of the app's scripts so its listeners run first. +// +// - Painted: ADR 0005's KeyTimer. From each keydown's time stamp to a message posted from the next +// requestAnimationFrame callback; the message runs after that frame's style, layout, and paint. +// - Script: from the start of the keydown's first listener to the end of the key's synchronous work. That is the +// last of the microtasks queued from our own MutationObserver, `input`, and `keyup` callbacks. Our observer is +// created before the editor's, so its microtask runs after ProseMirror has read the change and dispatched. +// - Long animation frames: Chromium's long-animation-frame entries (frames over 50 ms) while keys are measured. + +import type { Page } from '@playwright/test'; + +export interface KeySamples { + painted: number[]; + script: number[]; + longFrames: number; +} + +interface TimerState extends KeySamples { + recording: boolean; +} + +declare global { + interface Window { + __keyTimer?: TimerState; + } +} + +function install(): void { + const state: TimerState = { painted: [], script: [], longFrames: 0, recording: false }; + window.__keyTimer = state; + let started = 0; + let last = 0; + const touch = () => queueMicrotask(() => (last = performance.now())); + new MutationObserver(touch).observe(document, { subtree: true, childList: true, characterData: true }); + window.addEventListener('input', touch); + window.addEventListener('keyup', touch); + window.addEventListener( + 'keydown', + (event) => { + if (!state.recording) return; + started = performance.now(); + last = started; + requestAnimationFrame(() => { + // The key's synchronous work is over by the next frame, which also waits for it. + const script = last - started; + const channel = new MessageChannel(); + channel.port1.onmessage = () => { + state.painted.push(performance.now() - event.timeStamp); + state.script.push(script); + }; + channel.port2.postMessage(null); + }); + }, + { capture: true }, + ); + try { + new PerformanceObserver((list) => { + if (state.recording) state.longFrames += list.getEntries().length; + }).observe({ type: 'long-animation-frame' }); + } catch { + // Browsers without long-animation-frame entries report none. + } +} + +/** Installs the measures; call before the first navigation. */ +export async function installKeyTimer(page: Page): Promise { + await page.addInitScript(install); +} + +/** Starts or stops recording; starting clears what was recorded. */ +export async function recordKeys(page: Page, on: boolean): Promise { + await page.evaluate((on) => { + const state = window.__keyTimer!; + if (on) Object.assign(state, { painted: [], script: [], longFrames: 0 }); + state.recording = on; + }, on); +} + +export async function readKeys(page: Page): Promise { + return page.evaluate(() => { + const { painted, script, longFrames } = window.__keyTimer!; + return { painted: [...painted], script: [...script], longFrames }; + }); +} + +export function percentile(values: readonly number[], p: number): number { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))]; +} diff --git a/tests/perf/typing/motion.spec.ts b/tests/perf/typing/motion.spec.ts new file mode 100644 index 000000000..746c60f40 --- /dev/null +++ b/tests/perf/typing/motion.spec.ts @@ -0,0 +1,151 @@ +// The typing benchmark's motion mode (Phase 4 ARCHITECTURE.md section 24.2): zooming and panning the freeform page +// with 8 text boxes, one of them the 20-page note, by mouse wheel, Ctrl+wheel, touch pan, and touch pinch. Each +// gesture records the time of every animation frame while it runs, and reports frames a second, the 95th +// percentile frame interval, and the longest run of dropped frames. The budget is 60 frames a second with never +// two dropped frames in a row; OPENNOTE_TYPING_GATE=1 fails a gesture that drops two in a row. Results go to +// results/motion.json. + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect, test } from '@playwright/test'; +import type { CDPSession, Page } from '@playwright/test'; +import { openFixture } from './conditions'; +import { percentile } from './keyTimer'; + +const RESULTS = join(import.meta.dirname, 'results'); +const gate = process.env.OPENNOTE_TYPING_GATE === '1'; + +interface Gesture { + id: string; + description: string; + run(page: Page, client: CDPSession, center: { x: number; y: number }): Promise; +} + +async function wheel(page: Page, center: { x: number; y: number }, dy: number, steps: number) { + await page.mouse.move(center.x, center.y); + for (let i = 0; i < steps; i += 1) { + await page.mouse.wheel(0, dy); + await page.waitForTimeout(16); + } +} + +const GESTURES: readonly Gesture[] = [ + { + id: 'wheel.pan', + description: 'Mouse wheel scrolling down and up', + run: async (page, _client, center) => { + await wheel(page, center, 100, 30); + await wheel(page, center, -100, 30); + }, + }, + { + id: 'wheel.zoom', + description: 'Ctrl+wheel zooming out and in', + run: async (page, _client, center) => { + await page.keyboard.down('Control'); + await wheel(page, center, 100, 15); + await wheel(page, center, -100, 15); + await page.keyboard.up('Control'); + }, + }, + { + id: 'touch.pan', + description: 'A one-finger touch drag down and back', + run: async (_page, client, center) => { + for (const yDistance of [-600, 600]) { + await client.send('Input.synthesizeScrollGesture', { + x: center.x, + y: center.y, + yDistance, + speed: 1200, + gestureSourceType: 'touch', + repeatCount: 1, + }); + } + }, + }, + { + id: 'touch.pinch', + description: 'A two-finger pinch out and in', + run: async (_page, client, center) => { + for (const scaleFactor of [2, 0.5]) { + await client.send('Input.synthesizePinchGesture', { + x: center.x, + y: center.y, + scaleFactor, + relativeSpeed: 400, + gestureSourceType: 'touch', + }); + } + }, + }, +]; + +interface MotionResult { + id: string; + description: string; + frames: number; + fps: number; + intervalP95: number; + dropped: number; + longestDropRun: number; +} + +const results: MotionResult[] = []; + +test.describe('motion', () => { + for (const gesture of GESTURES) { + test(`${gesture.id}: ${gesture.description}`, async ({ page }) => { + test.setTimeout(90_000); + await openFixture(page, 'freeform8'); + await page.waitForTimeout(1_000); + const client = await page.context().newCDPSession(page); + // Over the 20-page box, where a person's hand would be. + const box = (await page.getByRole('textbox', { name: /^Text box 1 / }).boundingBox())!; + const area = { x: box.x + box.width / 2, y: Math.min(box.y + 300, 600) }; + await page.evaluate(() => { + const times: number[] = []; + (window as unknown as { __frames: number[] }).__frames = times; + const tick = (time: number) => { + times.push(time); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); + const start = await page.evaluate(() => performance.now()); + await gesture.run(page, client, area); + const end = await page.evaluate(() => performance.now()); + const times = await page.evaluate( + ([from, to]) => (window as unknown as { __frames: number[] }).__frames.filter((t) => t >= from && t <= to), + [start, end], + ); + const intervals = times.slice(1).map((time, i) => time - times[i]); + // Dropped frames count against the budget's 60 Hz display, even on a faster one: a 40 ms gap is two. + const missed = intervals.map((interval) => Math.max(0, Math.round(interval / (1000 / 60)) - 1)); + const result: MotionResult = { + id: gesture.id, + description: gesture.description, + frames: times.length, + fps: Number(((times.length - 1) / ((times[times.length - 1] - times[0]) / 1000)).toFixed(1)), + intervalP95: Number(percentile(intervals, 95).toFixed(1)), + dropped: missed.reduce((sum, count) => sum + count, 0), + longestDropRun: Math.max(0, ...missed), + }; + results.push(result); + console.log( + `${gesture.id.padEnd(12)} ${result.fps} fps over ${result.frames} frames, interval p95 ` + + `${result.intervalP95} ms, ${result.dropped} dropped, at most ${result.longestDropRun} in a row`, + ); + expect(result.frames).toBeGreaterThan(10); + if (gate) expect(result.longestDropRun, 'dropped frames in a row').toBeLessThanOrEqual(1); + }); + } + + test.afterAll(() => { + mkdirSync(RESULTS, { recursive: true }); + writeFileSync( + join(RESULTS, 'motion.json'), + `${JSON.stringify({ when: new Date().toISOString(), gestures: results }, null, 2)}\n`, + ); + }); +}); diff --git a/tests/perf/typing/playwright.config.ts b/tests/perf/typing/playwright.config.ts new file mode 100644 index 000000000..92ab07815 --- /dev/null +++ b/tests/perf/typing/playwright.config.ts @@ -0,0 +1,34 @@ +// The typing benchmark's pull request run (Phase 4 ARCHITECTURE.md section 24.5; owner after WP0: WP8): the in-page +// measure in Playwright against the test build. WP0 runs it once on the short fixture so the job exists; WP8 adds +// the conditions, the throttling, the comparison with main, and the reference laptop's nightly run. + +import { join } from 'node:path'; +import { defineConfig } from '@playwright/test'; +import { browserChannel } from '../../browser'; + +const ROOT = join(import.meta.dirname, '..', '..', '..'); +const PORT = Number(process.env.OPENNOTE_TYPING_PORT ?? 4183); +const BASE_URL = process.env.OPENNOTE_UI_URL ?? `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: import.meta.dirname, + outputDir: join(import.meta.dirname, 'results'), + retries: 0, + workers: 1, + reporter: 'list', + use: { + baseURL: BASE_URL, + browserName: 'chromium', + channel: browserChannel(), + viewport: { width: 1440, height: 900 }, + }, + webServer: process.env.OPENNOTE_UI_URL + ? undefined + : { + command: `npx vite preview --config app/vite.config.ts --mode test --host 127.0.0.1 --port ${PORT} --strictPort`, + url: BASE_URL, + cwd: ROOT, + reuseExistingServer: false, + timeout: 60_000, + }, +}); diff --git a/tests/perf/typing/typing.spec.ts b/tests/perf/typing/typing.spec.ts new file mode 100644 index 000000000..43b5b9bb2 --- /dev/null +++ b/tests/perf/typing/typing.spec.ts @@ -0,0 +1,177 @@ +// The typing benchmark (Phase 4 ARCHITECTURE.md sections 24.2, 24.5, and 26.4) on the web build. Each condition +// opens its page fresh and places the caret. It sends 10 warm-up keys, then 100 measured keys, through the DevTools +// Protocol, 120 ms apart (33 ms in the burst condition). It reports painted p50, p95, and maximum, script per key, +// and long animation frames, in results/typing.json. +// +// Environment: +// - OPENNOTE_TYPING_GATE=1 fails a condition whose painted p95 is over 16 ms: the exit gate and the nightly run on +// the reference laptop. Script per key is reported against its 4.5 ms budget. +// - OPENNOTE_TYPING_THROTTLE=calibrated (or a number) slows the CPU to the reference laptop, as Phase 2's perf +// project does, for runs on faster or slower machines. +// - OPENNOTE_TYPING_BASELINE= fails a condition whose painted p95 is more than 10% worse. +// - OPENNOTE_TYPING_ONLY= runs only those conditions. + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect, test } from '@playwright/test'; +import type { Page, PlaywrightWorkerArgs, TestInfo } from '@playwright/test'; +import { CONDITIONS, openFixture } from './conditions'; +import type { Condition } from './conditions'; +import { installKeyTimer, percentile, readKeys, recordKeys } from './keyTimer'; + +const WARM_UP = 10; +const KEYS = 100; +const PAINTED_BUDGET_MS = 16; +/** The fixed benchmark's time on the reference laptop, in ms (tests/ui/perf/tree.perf.spec.ts). */ +const REFERENCE_BENCHMARK_MS = 60; +/** What the keys type: words and spaces, so word-end checks such as AutoCorrect run too. */ +const TEXT = 'the quick brown fox jumps over a lazy dog '; + +const RESULTS = join(import.meta.dirname, 'results'); +const only = process.env.OPENNOTE_TYPING_ONLY?.split(',').map((id) => id.trim()); +const gate = process.env.OPENNOTE_TYPING_GATE === '1'; + +export interface ConditionResult { + id: string; + description: string; + keys: number; + gapMs: number; + throttle: number; + painted: { p50: number; p95: number; max: number }; + script: { p50: number; p95: number; max: number }; + longFrames: number; +} + +const results: ConditionResult[] = []; + +async function throttle(page: Page): Promise { + const setting = process.env.OPENNOTE_TYPING_THROTTLE; + if (!setting) return 1; + let rate = Number(setting); + if (setting === 'calibrated') { + const ms = await page.evaluate(() => { + const start = performance.now(); + let total = 0; + for (let round = 0; round < 40; round += 1) { + const items = Array.from({ length: 20_000 }, (_, i) => (i * 7919) % 10_007); + items.sort((a, b) => a - b); + total += items[round]; + } + return total >= 0 ? performance.now() - start : 0; + }); + rate = Math.max(1, REFERENCE_BENCHMARK_MS / Math.max(1, ms)); + } + const client = await page.context().newCDPSession(page); + await client.send('Emulation.setCPUThrottlingRate', { rate }); + return rate; +} + +/** Sends one key per gap on a fixed schedule, so a slow key doesn't stretch the gaps after it. */ +async function typeOnSchedule(page: Page, count: number, gapMs: number, offset: number): Promise { + const start = Date.now(); + for (let i = 0; i < count; i += 1) { + const wait = start + i * gapMs - Date.now(); + if (wait > 0) await page.waitForTimeout(wait); + const character = TEXT[(offset + i) % TEXT.length]; + await page.keyboard.press(character === ' ' ? 'Space' : character); + } +} + +const summary = (values: readonly number[]) => ({ + p50: percentile(values, 50), + p95: percentile(values, 95), + max: Math.max(...values), +}); + +/** Opens the condition's page, in a browser with its accessibility tree on when the condition asks for that. */ +async function pageFor( + condition: Condition, + shared: Page, + playwright: PlaywrightWorkerArgs['playwright'], + info: TestInfo, +) { + if (!condition.accessibility) return shared; + // What a UI Automation client such as Narrator does to Chromium: build the accessibility tree and keep it. + const { channel, baseURL, viewport } = info.project.use; + const browser = await playwright.chromium.launch({ channel, args: ['--force-renderer-accessibility'] }); + return browser.newPage({ baseURL, viewport }); +} + +/** Types the warm-up and measured keys on the prepared page and returns what the key timer recorded. */ +async function measure(page: Page, condition: Condition) { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + await installKeyTimer(page); + await openFixture(page, condition.fixture); + // The page's own idle work (mounting, warming caches) finishes before keys start. + await page.waitForTimeout(1_000); + await condition.prepare(page); + const rate = await throttle(page); + const gapMs = condition.gapMs ?? 120; + await typeOnSchedule(page, WARM_UP, gapMs, 0); + await page.waitForTimeout(500); + await recordKeys(page, true); + await typeOnSchedule(page, KEYS, gapMs, WARM_UP); + await page.waitForTimeout(250); + await recordKeys(page, false); + const samples = await readKeys(page); + expect(errors).toEqual([]); + expect(samples.painted).toHaveLength(KEYS); + const result: ConditionResult = { + id: condition.id, + description: condition.description, + keys: KEYS, + gapMs, + throttle: Number(rate.toFixed(2)), + painted: summary(samples.painted), + script: summary(samples.script), + longFrames: samples.longFrames, + }; + return result; +} + +/** Logs the result, then holds it to the budget and to main's numbers when the run asks for either. */ +function check(result: ConditionResult): void { + const ms = (value: number) => `${value.toFixed(1)} ms`; + console.log( + `${result.id.padEnd(12)} painted p50 ${ms(result.painted.p50)}, p95 ${ms(result.painted.p95)}, ` + + `max ${ms(result.painted.max)}; script p95 ${ms(result.script.p95)}; long frames ${result.longFrames}`, + ); + if (gate) expect(result.painted.p95, 'painted p95').toBeLessThanOrEqual(PAINTED_BUDGET_MS); + const baselinePath = process.env.OPENNOTE_TYPING_BASELINE; + if (baselinePath && existsSync(baselinePath)) { + const baseline = (JSON.parse(readFileSync(baselinePath, 'utf8')) as { conditions: ConditionResult[] }).conditions; + const before = baseline.find((entry) => entry.id === result.id); + if (before) expect(result.painted.p95, 'painted p95 against main').toBeLessThanOrEqual(before.painted.p95 * 1.1); + } +} + +test.describe('typing', () => { + for (const condition of CONDITIONS) { + if (only && !only.includes(condition.id)) continue; + test(`${condition.id}: ${condition.description}`, async ({ page: shared, playwright }, info) => { + test.setTimeout(120_000); + const page = await pageFor(condition, shared, playwright, info); + try { + const result = await measure(page, condition); + results.push(result); + check(result); + } finally { + if (page !== shared) await page.context().browser()?.close(); + } + }); + } + + test.afterAll(() => { + mkdirSync(RESULTS, { recursive: true }); + const file = join(RESULTS, 'typing.json'); + // Runs of a few conditions add to the file rather than replace the others. + const before = existsSync(file) + ? (JSON.parse(readFileSync(file, 'utf8')) as { conditions?: ConditionResult[] }) + : {}; + const kept = (before.conditions ?? []).filter((entry) => !results.some((result) => result.id === entry.id)); + const order = CONDITIONS.map((condition) => condition.id); + const conditions = [...kept, ...results].sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)); + writeFileSync(file, `${JSON.stringify({ when: new Date().toISOString(), conditions }, null, 2)}\n`); + }); +}); diff --git a/tests/ui/a11y/page.aria.spec.ts b/tests/ui/a11y/page.aria.spec.ts new file mode 100644 index 000000000..bdb113e37 --- /dev/null +++ b/tests/ui/a11y/page.aria.spec.ts @@ -0,0 +1,66 @@ +// Static and mounted blocks read the same to a screen reader (Phase 4 ARCHITECTURE.md sections 7.2 and 26.1): +// the sampler page's text has one accessibility tree before its editor mounts and after, so mounting in place +// never changes what a screen reader is reading. The focus walk reaches the text from the page title. + +import type { Page } from '@playwright/test'; +import { expect, test } from '../fixtures'; + +test.use({ + boot: { + state: { + location: { view: 'workspace', notebookId: 'n-biology', sectionId: 's-lectures', pageId: 'p-membranes' }, + }, + } as never, +}); + +/** The text's accessibility tree before its editor mounts and after. */ +async function staticAndMounted(page: Page): Promise<[string, string]> { + await page.goto('/?fixture=sampler'); + const text = page.getByRole('textbox', { name: 'Page text' }); + await expect(text).toBeVisible(); + await expect(text).not.toHaveAttribute('contenteditable', 'true'); + const before = await text.ariaSnapshot(); + await text.click({ position: { x: 4, y: 4 } }); + await expect(text).toHaveAttribute('contenteditable', 'true'); + return [before, await text.ariaSnapshot()]; +} + +/** The tree without what only an editor draws: fold buttons, callout menus, checkboxes, and callout names. */ +const words = (tree: string) => + tree + .split('\n') + .filter((line) => !/^\s*- '?(button|checkbox) /.test(line)) + .map((line) => line.replace(/'note "[^"]*"':/, 'note:')) + // Static math is generated text; an editor's math view puts the same source in a code element. + .map((line) => line.replace(/^(\s*- )code: (\$.*)$/, '$1text: $2')) + .join('\n'); + +test('the sampler text reads the same, in the same structure, static and mounted', async ({ page }) => { + const [before, after] = await staticAndMounted(page); + expect(words(after)).toBe(words(before)); +}); + +// Known gaps, left to the schema's owner (docs/perf/phase-4.md): static task items show their state only as a +// drawn box, with no checkbox, and static callouts have no name. When both are fixed, this test passes and +// Playwright reports it, so the expectation is removed. +test('the sampler text has the same accessibility tree static and mounted, controls aside', async ({ page }) => { + test.fail(); + const [before, after] = await staticAndMounted(page); + const controls = (tree: string) => + tree + .split('\n') + .filter((line) => !/^\s*- '?button /.test(line)) + .join('\n'); + expect(controls(after)).toBe(controls(before)); +}); + +test('Tab reaches the page text from the title, and Escape leaves it', async ({ page }) => { + await page.goto('/?fixture=sampler'); + const title = page.getByRole('textbox', { name: 'Page title' }); + await title.click(); + await page.keyboard.press('Tab'); + const text = page.getByRole('textbox', { name: 'Page text' }); + await expect(text).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(text).not.toBeFocused(); +}); diff --git a/tests/ui/behavior/page.keyboard.spec.ts b/tests/ui/behavior/page.keyboard.spec.ts new file mode 100644 index 000000000..326cad00a --- /dev/null +++ b/tests/ui/behavior/page.keyboard.spec.ts @@ -0,0 +1,98 @@ +// Writing and formatting a page from the keyboard (Phase 4 ARCHITECTURE.md section 26.3, page.write.keyboard and +// page.format.keyboard), on the web build. Once the page is open, every step is a key, and each test checks the +// Markdown the page service holds, which is what the core saves as page.md. The E2E versions against the desktop +// app add a restart; see docs/perf/phase-4.md. + +import type { Page } from '@playwright/test'; +import { expect, test } from '../fixtures'; + +/** The text block's Markdown as the page service holds it. */ +function held(page: Page): Promise { + return page.evaluate(() => { + const hooks = ( + window as unknown as { __OPENNOTE_TEST__: { pagesHeld(): { blocks: { data: { markdown?: string } }[] } } } + ).__OPENNOTE_TEST__; + return hooks.pagesHeld().blocks[0]?.data.markdown; + }); +} + +/** + * Waits until the page has loaded the chunks its commands need. A command used sooner runs after the keys typed + * behind it, which no run on a fast machine shows. + */ +async function waitForCommands(page: Page): Promise { + await page.locator('html[data-page-commands="ready"]').waitFor({ state: 'attached' }); +} + +/** Opens Membranes, which starts empty, and puts the caret in its text. */ +async function openEmptyPage(page: Page): Promise { + await page.goto('/'); + await page.getByRole('tree', { name: 'Notebooks' }).getByRole('treeitem', { name: 'Lectures' }).click(); + await page.getByRole('tree', { name: 'Pages' }).getByRole('treeitem', { name: 'Membranes' }).click(); + await page.getByRole('textbox', { name: 'Page title' }).click(); + await page.keyboard.press('Tab'); + await expect(page.getByRole('textbox', { name: 'Page text' })).toBeFocused(); + await waitForCommands(page); +} + +/** Types a line of steps: text, or a key in braces, such as "{Enter}". */ +async function keys(page: Page, steps: string): Promise { + for (const part of steps.split(/(\{[^}]+\})/).filter(Boolean)) { + if (part.startsWith('{')) await page.keyboard.press(part.slice(1, -1)); + else await page.keyboard.type(part); + } +} + +test('writes a page with Markdown shortcuts, each one undoable back to the typed characters', async ({ page }) => { + await openEmptyPage(page); + // Undo right after a shortcut keeps what was typed, as text. + await keys(page, '# {Control+z}x{Enter}- {Control+z}y{Enter}'); + await expect.poll(() => held(page)).toBe('\\# x\n\n\\- y'); + await keys(page, '{Control+a}{Delete}'); + await keys(page, '# Cell division{Enter}Cells divide in two ways.{Enter}'); + await keys(page, '- Mitosis{Enter}{Tab}Two cells{Enter}{Shift+Tab}Meiosis{Enter}{Enter}'); + await keys(page, '[ ] Read the chapter{Control+Enter}{Enter}{Enter}'); + await keys(page, '> A quote{Enter}{Enter}> [!tip] Remember{Enter}Check the copy.{Enter}{Enter}'); + await keys(page, '```python print(1){ArrowDown}---{Enter}The end.'); + await expect + .poll(() => held(page)) + .toBe( + [ + '# Cell division', + 'Cells divide in two ways.', + '- Mitosis\n\n - Two cells\n\n- Meiosis', + '- [x] Read the chapter', + '> A quote', + '> [!tip] Remember\n> Check the copy.', + '```python\nprint(1)\n```', + '---', + 'The end.', + ].join('\n\n'), + ); +}); + +test('formats with keys: marks, a link, headings, an outline move, a date, and AutoCorrect', async ({ page }) => { + await openEmptyPage(page); + await keys(page, 'Normal {Control+b}bold{Control+b} {Control+i}italic{Control+i} '); + await keys(page, '{Control+u}under{Control+u} {Control+Shift+S}gone{Control+Shift+S} '); + await keys(page, '{Control+Shift+H}marked{Control+Shift+H} {Control+e}code{Control+e}{Enter}'); + await keys(page, 'Heading{Control+Alt+2}{Enter}Plain{Control+Alt+1}{Control+Shift+N}{Enter}'); + // The address is typed straight after Ctrl+K, before the link field has loaded. + await keys(page, 'Visit site{Control+Shift+ArrowLeft}{Control+k}example.com{Enter}{End}{Enter}'); + await keys(page, '- one{Enter}two{Alt+Shift+ArrowUp}{Control+End}{Enter}{Enter}'); + // AutoCorrect fixes "teh", and one undo puts back what was typed. + await keys(page, 'I saw teh {Control+z}cat{Enter}Due {Alt+Shift+D}'); + await expect + .poll(() => held(page)) + .toBe( + [ + 'Normal **bold** *italic* under ~~gone~~ ==marked== `code`', + '## Heading', + 'Plain', + 'Visit [site](https://example.com)', + '- two\n- one', + 'I saw teh cat', + 'Due Sep 30, 2026', + ].join('\n\n'), + ); +}); diff --git a/tests/ui/behavior/page.spec.ts b/tests/ui/behavior/page.spec.ts index 835572140..d9804e051 100644 --- a/tests/ui/behavior/page.spec.ts +++ b/tests/ui/behavior/page.spec.ts @@ -1,12 +1,31 @@ -// The page placeholder in the production build: a prompt with no page open, then the page's title as a heading. +// The page view in the production build: a prompt with no page open, then the page's title as a heading and a text +// box that typing reaches through the web platform's page service. import { expect, test } from '../fixtures'; -test('shows a prompt, then the open page as a heading', async ({ page }) => { +test('shows a prompt, then the open page with a text box to type into', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { level: 1, name: 'No page open' })).toBeVisible(); await page.getByRole('tree', { name: 'Notebooks' }).getByRole('treeitem', { name: 'Lectures' }).click(); await page.getByRole('tree', { name: 'Pages' }).getByRole('treeitem', { name: 'Membranes' }).click(); await expect(page.getByRole('heading', { level: 1, name: 'Membranes' })).toBeVisible(); - await expect(page.getByText(/Writing and drawing on pages arrive in a later version/)).toBeVisible(); + const box = page.getByRole('textbox', { name: 'Text' }); + await box.click(); + await page.keyboard.type('Osmosis moves water'); + await expect(box).toHaveText('Osmosis moves water'); + // Typing reaches the service in batches (the first words, then splices), so the test reads the page it holds. + const held = () => + page.evaluate(() => { + const hooks = (window as unknown as { __OPENNOTE_TEST__: Record unknown> }).__OPENNOTE_TEST__; + return JSON.stringify(hooks.pagesHeld()); + }); + await expect.poll(held).toContain('"markdown":"Osmosis moves water"'); +}); + +test('shows every page as the sampler with ?fixture=sampler', async ({ page }) => { + await page.goto('/?fixture=sampler'); + await page.getByRole('tree', { name: 'Notebooks' }).getByRole('treeitem', { name: 'Lectures' }).click(); + await page.getByRole('tree', { name: 'Pages' }).getByRole('treeitem', { name: 'Membranes' }).click(); + await expect(page.getByText('Every kind of text')).toBeVisible(); + await expect(page.getByRole('group', { name: 'Table' })).toBeVisible(); }); diff --git a/tests/ui/behavior/page.touch-pen.spec.ts b/tests/ui/behavior/page.touch-pen.spec.ts new file mode 100644 index 000000000..bf733c6c1 --- /dev/null +++ b/tests/ui/behavior/page.touch-pen.spec.ts @@ -0,0 +1,135 @@ +// Touch and pen on a freeform page (Phase 4 ARCHITECTURE.md section 26.3, page.touch-pen), on the web build with +// touch and pen input sent through the DevTools Protocol. A tap places the caret, a one-finger drag pans without +// making anything, a pinch zooms, and a pen drag on empty page draws a marquee and never pans. Long presses need +// Windows' own gesture recognizer, so the desktop version and the manual checklist cover them. + +import type { CDPSession, Page } from '@playwright/test'; +import { expect, test } from '../fixtures'; + +interface Point { + x: number; + y: number; +} + +/** How far the page is scrolled: its nearest scrolling ancestor's offsets. */ +function scrolled(page: Page): Promise<[number, number]> { + return page.evaluate(() => { + let element: HTMLElement | null = document.querySelector('h1[data-page-title]'); + while (element && element.scrollHeight <= element.clientHeight) element = element.parentElement; + return [element?.scrollLeft ?? 0, element?.scrollTop ?? 0]; + }); +} + +/** The page's world: where it sits on screen and its scale. */ +function world(page: Page): Promise<{ left: number; top: number; scale: number }> { + return page.evaluate(() => { + const element = document.querySelector('h1[data-page-title]')!.parentElement!.parentElement!; + const box = element.getBoundingClientRect(); + return { left: box.left, top: box.top, scale: box.width / element.offsetWidth }; + }); +} + +const boxes = (page: Page) => page.locator('[role="textbox"][aria-label^="Text box"]').count(); + +async function touch( + client: CDPSession, + type: 'touchStart' | 'touchMove' | 'touchEnd', + points: Point[], +): Promise { + await client.send('Input.dispatchTouchEvent', { + type, + touchPoints: points.map((point, id) => ({ x: point.x, y: point.y, id })), + }); +} + +/** Moves one or two fingers in steps from `from` to `to`, then lifts them. */ +async function gesture(client: CDPSession, from: Point[], to: Point[], steps = 10): Promise { + await touch(client, 'touchStart', from); + for (let step = 1; step <= steps; step += 1) { + const at = from.map((start, i) => ({ + x: start.x + ((to[i].x - start.x) * step) / steps, + y: start.y + ((to[i].y - start.y) * step) / steps, + })); + await touch(client, 'touchMove', at); + } + await touch(client, 'touchEnd', []); +} + +async function pen(client: CDPSession, from: Point, to: Point): Promise { + const send = (type: 'mousePressed' | 'mouseMoved' | 'mouseReleased', point: Point, buttons: number) => + client.send('Input.dispatchMouseEvent', { + type, + x: point.x, + y: point.y, + button: 'left', + buttons, + clickCount: 1, + pointerType: 'pen', + }); + await send('mousePressed', from, 1); + for (let step = 1; step <= 10; step += 1) { + await send( + 'mouseMoved', + { x: from.x + ((to.x - from.x) * step) / 10, y: from.y + ((to.y - from.y) * step) / 10 }, + 1, + ); + } + await send('mouseReleased', to, 0); +} + +test.use({ hasTouch: true }); + +test.describe('touch and pen', () => { + let client: CDPSession; + /** An empty spot of the page, below the boxes. */ + let empty: Point; + + test.beforeEach(async ({ page }) => { + await page.goto('/?fixture=freeform8'); + await page.getByRole('tree', { name: 'Notebooks' }).getByRole('treeitem', { name: 'Lectures' }).click(); + await page.getByRole('tree', { name: 'Pages' }).getByRole('treeitem', { name: 'Membranes' }).click(); + await expect(page.getByRole('textbox', { name: /^Text box 2 / })).toBeVisible(); + client = await page.context().newCDPSession(page); + const second = (await page.getByRole('textbox', { name: /^Text box 2 / }).boundingBox())!; + empty = { x: second.x + second.width + 20, y: second.y + second.height + 60 }; + }); + + test('a tap places the caret in a text box', async ({ page }) => { + const target = page.getByRole('textbox', { name: /^Text box 2 / }); + const box = (await target.boundingBox())!; + const at = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + await touch(client, 'touchStart', [at]); + await touch(client, 'touchEnd', []); + await expect(target).toBeFocused(); + }); + + test('a one-finger drag pans without making anything, and a pinch zooms', async ({ page }) => { + const count = await boxes(page); + const before = await world(page); + await gesture(client, [empty], [{ x: empty.x - 150, y: empty.y - 200 }]); + await expect.poll(async () => (await world(page)).top).toBeLessThan(before.top - 50); + expect(await boxes(page)).toBe(count); + const scale = (await world(page)).scale; + await gesture( + client, + [ + { x: empty.x - 40, y: empty.y }, + { x: empty.x + 40, y: empty.y }, + ], + [ + { x: empty.x - 160, y: empty.y }, + { x: empty.x + 160, y: empty.y }, + ], + ); + await expect.poll(async () => (await world(page)).scale).toBeGreaterThan(scale * 1.5); + expect(await boxes(page)).toBe(count); + }); + + test('a pen drag on empty page draws a marquee and never pans', async ({ page }) => { + const before = await scrolled(page); + const second = (await page.getByRole('textbox', { name: /^Text box 2 / }).boundingBox())!; + await pen(client, empty, { x: second.x + 20, y: second.y + 20 }); + await expect(page.getByText(/^\d+ selected$/)).toBeVisible(); + expect(await scrolled(page)).toEqual(before); + }); +}); diff --git a/tests/ui/behavior/page.walk.spec.ts b/tests/ui/behavior/page.walk.spec.ts new file mode 100644 index 000000000..df98c5cb1 --- /dev/null +++ b/tests/ui/behavior/page.walk.spec.ts @@ -0,0 +1,116 @@ +// One walk through typed notes on the web build (Phase 4 exit, WP8): Markdown shortcuts and the formatting bar, +// paste from a document, a code block, an image from the file picker, and a table, then undo and redo. Each step +// checks the Markdown the page service holds, which is what the core saves. The web platform keeps pages in +// memory, so persistence across a restart is page.restart's job in the E2E smoke set. + +import type { Page } from '@playwright/test'; +import { expect, test } from '../fixtures'; + +// A 1 by 1 PNG. +const PIXEL = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64', +); + +test.use({ boot: { settings: { editing: { formattingBar: 'always' } } } as never }); + +/** The page as the service holds it: each block's Markdown, or its type. */ +function held(page: Page): Promise { + return page.evaluate(() => { + const hooks = ( + window as unknown as { + __OPENNOTE_TEST__: { pagesHeld(): { blocks: { type: string; data: { markdown?: string } }[] } }; + } + ).__OPENNOTE_TEST__; + return hooks.pagesHeld().blocks.map((block) => block.data.markdown ?? block.type); + }); +} + +/** Pastes clipboard data the way Ctrl+V delivers it. */ +async function paste(page: Page, data: Record): Promise { + await page.evaluate((data) => { + const transfer = new DataTransfer(); + for (const [type, value] of Object.entries(data)) transfer.setData(type, value); + const event = new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true, cancelable: true }); + document.activeElement!.dispatchEvent(event); + }, data); +} + +/** Picks a slash menu item by typing its name. */ +async function slash(page: Page, name: string): Promise { + await page.keyboard.type(`/${name}`); + await expect(page.getByRole('listbox')).toBeVisible(); + await page.keyboard.press('Enter'); +} + +test('writes, formats, pastes, inserts, and undoes on a page', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto('/'); + await page.getByRole('tree', { name: 'Notebooks' }).getByRole('treeitem', { name: 'Lectures' }).click(); + await page.getByRole('tree', { name: 'Pages' }).getByRole('treeitem', { name: 'Membranes' }).click(); + await page.getByRole('textbox', { name: 'Text' }).click(); + // Commands used before their chunks load run after the keys typed behind them. + await page.locator('html[data-page-commands="ready"]').waitFor({ state: 'attached' }); + const { keyboard } = page; + + // Markdown shortcuts as you type. + await keyboard.type('# Membranes\nCells keep **apart** and water moves.'); + await expect.poll(() => held(page)).toEqual(['# Membranes\n\nCells keep **apart** and water moves.']); + + // The formatting bar, set to show for every selection. + for (let i = 0; i < 'moves.'.length; i += 1) await keyboard.press('Shift+ArrowLeft'); + await page.getByRole('toolbar').getByRole('button', { name: 'Italic' }).click(); + await expect.poll(() => held(page)).toEqual(['# Membranes\n\nCells keep **apart** and water *moves.*']); + + // Paste from a document: HTML becomes blocks and marks. + await page.getByRole('textbox', { name: 'Text' }).click(); + await keyboard.press('Control+End'); + await keyboard.press('Enter'); + await paste(page, { + 'text/html': '

From Word

  • one
  • two
', + 'text/plain': 'From Word\none\ntwo', + }); + await expect.poll(async () => (await held(page))[0]).toContain('From **Word**\n\n- one\n- two'); + + // A code block with a language, from three backticks. + await keyboard.press('Enter'); + await keyboard.press('Enter'); + await keyboard.type('```js const x = 1;'); + await expect.poll(async () => (await held(page))[0]).toContain('```js\nconst x = 1;\n```'); + await keyboard.press('ArrowDown'); + + // An image from the file picker, through the slash menu. + const chooser = page.waitForEvent('filechooser'); + await slash(page, 'image'); + await (await chooser).setFiles({ name: 'cell.png', mimeType: 'image/png', buffer: PIXEL }); + await expect.poll(() => held(page)).toContain('image'); + // The page holds the image before the view has finished with it; the view then selects it and takes the focus. A + // click and keys sent before that land on the image, not the text. + await expect(page.getByRole('button', { name: 'Crop image' })).toBeVisible(); + + // A table, through the slash menu, typed into. + await page.getByRole('textbox', { name: 'Text' }).last().click(); + await keyboard.press('Control+End'); + await slash(page, 'table'); + const table = page.getByRole('group', { name: 'Table' }); + await expect(table).toBeVisible(); + await keyboard.type('Head'); + await expect.poll(async () => JSON.stringify(await held(page))).toContain('table'); + + // Undo takes the cell's words back off, and redo brings them back. + // The cell's words reach the service in the next flush, 150 to 300 ms after typing stops. + const heldPage = () => + page.evaluate(() => + JSON.stringify( + (window as unknown as { __OPENNOTE_TEST__: { pagesHeld(): unknown } }).__OPENNOTE_TEST__.pagesHeld(), + ), + ); + await expect.poll(heldPage).toContain('"markdown":"Head"'); + await keyboard.press('Control+z'); + await expect.poll(() => table.textContent()).not.toContain('Head'); + await keyboard.press('Control+y'); + await expect.poll(() => table.textContent()).toContain('Head'); + await expect.poll(heldPage).toContain('"markdown":"Head"'); + expect(errors).toEqual([]); +}); diff --git a/tests/ui/screens/page.ts b/tests/ui/screens/page.ts new file mode 100644 index 000000000..16d57c613 --- /dev/null +++ b/tests/ui/screens/page.ts @@ -0,0 +1,36 @@ +// The page view's screen states (Phase 4 PLAN.md section 12; owner: WP8). The sampler page holds every node, mark, +// callout type, a table, code, and images, so axe, the focus walk, and the screenshots see each one, in static +// blocks and with the text's editor mounted. + +import type { BootOverrides } from '../../../app/src/boot/defaults'; +import { defineScreens } from '../screens'; + +const membranes = { + state: { + location: { view: 'workspace', notebookId: 'n-biology', sectionId: 's-lectures', pageId: 'p-membranes' }, + }, +} as BootOverrides; + +export default defineScreens([ + { + id: 'page.sampler', + description: 'The sampler page in static blocks', + path: '/?fixture=sampler', + boot: membranes, + prepare: async (page) => { + await page.getByText('Every kind of text').waitFor(); + }, + }, + { + id: 'page.sampler.editing', + description: 'The sampler page with its text editor mounted and the caret in it', + path: '/?fixture=sampler', + boot: membranes, + sizes: ['compact', 'wide'], + prepare: async (page) => { + const text = page.getByRole('textbox', { name: 'Page text' }); + await text.click({ position: { x: 4, y: 4 } }); + await page.locator('[role="textbox"][aria-label="Page text"][contenteditable="true"]').waitFor(); + }, + }, +]);