From baeb14782d5f35cb2e9cc0604c47c974212f7d17 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 09:10:44 -0800 Subject: [PATCH 01/26] Check spelling per run and paragraph language Text with its own lang (a run's lang mark or a paragraph's lang) is checked in that language only, one request per language, and its results map back to the textblock's offsets. Text set to "do not check spelling" (zxx) is masked like code, so it never reaches the checker. A language without an installed checker stays unchecked, as in Word. Suggestions use the misspelled word's own language. textblockText reports the language spans, and SpellingService takes them as an optional argument, so callers without languages are as before. Co-Authored-By: Claude Opus 5.5 --- app/src/editor/extensions/spellingRanges.ts | 3 +- app/src/editor/extensions/spellingText.ts | 75 +++++-- app/src/editor/host.ts | 16 +- app/src/features/page/spelling/attach.ts | 9 +- app/src/features/page/spelling/engine.ts | 121 +++++++---- .../features/page/spelling/language.test.ts | 192 ++++++++++++++++++ app/src/features/page/spelling/languages.ts | 65 ++++++ app/src/features/page/spelling/menu.ts | 8 +- app/src/features/page/spelling/navigate.ts | 12 +- 9 files changed, 433 insertions(+), 68 deletions(-) create mode 100644 app/src/features/page/spelling/language.test.ts create mode 100644 app/src/features/page/spelling/languages.ts diff --git a/app/src/editor/extensions/spellingRanges.ts b/app/src/editor/extensions/spellingRanges.ts index 5ac6c00ea..4c380481b 100644 --- a/app/src/editor/extensions/spellingRanges.ts +++ b/app/src/editor/extensions/spellingRanges.ts @@ -14,13 +14,14 @@ import { isCheckable, spellingHighlights } from './spellingText'; export { HIGHLIGHT_NAME, isCheckable, + NO_PROOF, rangeOver, SpellingHighlights, spellingHighlights, TEXTBLOCK_SELECTOR, textblockText, } from './spellingText'; -export type { SpellRange, TextblockText } from './spellingText'; +export type { LangSpan, SpellRange, TextblockText } from './spellingText'; /** Where `next` differs from `prev`, in `next`'s positions. */ function changedRange(prev: PMNode, next: PMNode): { from: number; to: number } | null { diff --git a/app/src/editor/extensions/spellingText.ts b/app/src/editor/extensions/spellingText.ts index 17e51d845..b7130f1a7 100644 --- a/app/src/editor/extensions/spellingText.ts +++ b/app/src/editor/extensions/spellingText.ts @@ -9,12 +9,25 @@ export interface SpellRange { readonly length: number; } +/** A part of a textblock's text in a language other than the page's: a `lang` on a run or on the paragraph. */ +export interface LangSpan { + readonly start: number; + readonly length: number; + readonly lang: string; +} + /** 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 parts in their own language, in order. Absent when all the text is in the page's languages. */ + readonly langs?: readonly LangSpan[]; } +/** The language that means "do not check spelling" (BCP 47's "no linguistic content"). */ +export const NO_PROOF = 'zxx'; +const LANG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/; + /** 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'; @@ -28,29 +41,60 @@ const isAtom = (element: Element) => element.hasAttribute('data-math') || element.getAttribute('contenteditable') === 'false'; +/** An element's own `lang`, when it is a language tag. */ +const ownLang = (element: Element): string | null => { + const value = element.getAttribute('lang'); + return value !== null && LANG.test(value) ? value : null; +}; + +/** The paragraph's language: its own `lang`, or its list item's. The page's language is not a paragraph's. */ +const paragraphLang = (element: Element): string | null => + ownLang(element) ?? (element.parentElement?.localName === 'li' ? ownLang(element.parentElement) : null); + /** * 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. + * passes `mask` false to keep code and addresses. Text set to "do not check spelling" is masked too, so it is never + * sent, and text in another language is listed in `langs`. */ export function textblockText(element: Element, mask = true): TextblockText { let text = ''; const nodes: { node: Text; at: number }[] = []; - const walk = (parent: Node, masked: boolean) => { + const langs: LangSpan[] = []; + const mark = (start: number, length: number, lang: string | null) => { + if (lang === null || length === 0) return; + const last = langs[langs.length - 1]; + if (last && last.lang === lang && last.start + last.length === start) { + langs[langs.length - 1] = { start: last.start, length: last.length + length, lang }; + } else langs.push({ start, length, lang }); + }; + const walk = (parent: Node, masked: boolean, lang: string | null) => { 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 }); + mark(text.length, data.length, lang); 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')); + if (isAtom(child)) { + mark(text.length, 1, lang); + text += ' '; + continue; + } + const inner = ownLang(child) ?? lang; + walk(child, masked || (mask && (child.localName === 'code' || inner === NO_PROOF)), inner); } } }; - walk(element, false); - return { text: mask ? text.replace(ADDRESS, (match) => ' '.repeat(match.length)) : text, nodes }; + const base = paragraphLang(element); + walk(element, mask && base === NO_PROOF, base); + const own = langs.filter((span) => span.lang !== NO_PROOF); + return { + text: mask ? text.replace(ADDRESS, (match) => ' '.repeat(match.length)) : text, + nodes, + ...(own.length ? { langs: own } : {}), + }; } /** Whether a textblock element is checked: not in a code block or a widget. */ @@ -83,6 +127,7 @@ export function rangeOver(extracted: TextblockText, start: number, length: numbe interface Entry { text: string; + langs?: readonly LangSpan[]; errors: readonly SpellRange[]; ranges: Range[]; waiting: boolean; @@ -128,13 +173,13 @@ export class SpellingHighlights { // 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 }); + this.entries.set(element, { text: extracted.text, langs: extracted.langs, 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 }); + this.entries.set(element, { text: extracted.text, langs: extracted.langs, errors, ranges, waiting }); } /** @@ -186,15 +231,17 @@ export class SpellingHighlights { /** 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); + const errors = service.errorsFor(extracted.text, extracted.langs); 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); + if (entry?.text === extracted.text) { + entry.waiting = true; + entry.langs = extracted.langs; + } else this.carry(element, extracted); + if (request) service.requestCheck(key ?? extracted.text, extracted.text, extracted.langs); return extracted.text; } @@ -206,8 +253,8 @@ export class SpellingHighlights { return this.guard; } - /** The element's text and errors, as last shown. */ - entry(element: Element): { text: string; errors: readonly SpellRange[] } | null { + /** The element's text, its languages, and its errors, as last shown. */ + entry(element: Element): { text: string; langs?: readonly LangSpan[]; errors: readonly SpellRange[] } | null { return this.entries.get(element) ?? null; } diff --git a/app/src/editor/host.ts b/app/src/editor/host.ts index 5fc5cff08..0b4f852c9 100644 --- a/app/src/editor/host.ts +++ b/app/src/editor/host.ts @@ -18,10 +18,20 @@ export interface EditingSettingsView { } /** Spelling results for the text of one textblock, from the page's cache. */ +/** A part of a textblock's text in its own language (spellingText's LangSpan). */ +export interface SpellLang { + readonly start: number; + readonly length: number; + readonly lang: string; +} + 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; + /** + * UTF-16 ranges of the misspelled words, or null when the text hasn't been checked yet. `langs` are the parts in + * their own language; the rest is in the page's languages. + */ + errorsFor(text: string, langs?: readonly SpellLang[]): readonly { start: number; length: number }[] | null; + requestCheck(key: string, text: string, langs?: readonly SpellLang[]): void; } export interface EditorHost { diff --git a/app/src/features/page/spelling/attach.ts b/app/src/features/page/spelling/attach.ts index 7f01aae26..795555a89 100644 --- a/app/src/features/page/spelling/attach.ts +++ b/app/src/features/page/spelling/attach.ts @@ -4,20 +4,21 @@ import { isCheckable, spellingHighlights, TEXTBLOCK_SELECTOR } from '../../../editor/extensions/spellingText'; import { shownViewport } from '../viewport/viewport'; import { spellingEngine } from './current'; -import type { SpellingEngine } from './engine'; +import type { SpellingEngine, SpellItem } from './engine'; import './spelling.module.css'; /** Reads textblocks, shows what the cache knows, and queues the rest: those in view first. */ function scan(elements: readonly Element[], engine: SpellingEngine, viewport: Element): void { const view = viewport.getBoundingClientRect(); - const inView: string[] = []; - const rest: string[] = []; + const inView: SpellItem[] = []; + const rest: SpellItem[] = []; for (const element of elements) { if (!isCheckable(element)) continue; const text = spellingHighlights.refresh(element, engine, false); if (text === null) continue; const box = element.getBoundingClientRect(); - (box.bottom >= view.top && box.top <= view.bottom ? inView : rest).push(text); + const langs = spellingHighlights.entry(element)?.langs; + (box.bottom >= view.top && box.top <= view.bottom ? inView : rest).push(langs ? { text, langs } : { text }); } engine.check(inView, 'now'); engine.check(rest, 'idle'); diff --git a/app/src/features/page/spelling/engine.ts b/app/src/features/page/spelling/engine.ts index 8e29d55a4..72dd0c18d 100644 --- a/app/src/features/page/spelling/engine.ts +++ b/app/src/features/page/spelling/engine.ts @@ -1,9 +1,21 @@ // The page's spelling service (ARCHITECTURE.md section 16.3). Results are cached by text and languages, and checks // are scheduled so typing never waits for them. Some words are hidden without checking again: the personal -// dictionary, words ignored this session, and words in all capitals or with digits. -import type { SpellingService } from '../../../editor/host'; +// dictionary, words ignored this session, and words in all capitals or with digits. A run or a paragraph with its +// own `lang` is checked in that language only, one request per language (lane text:language-direction). Text set +// to "do not check spelling" never reaches the engine. +import type { SpellingService, SpellLang } from '../../../editor/host'; import type { SpellRange } from '../../../editor/extensions/spellingText'; import type { SpellingClient } from '../../../platform/types'; +import { piecesOf, resolveLanguage } from './languages'; +import type { Piece } from './languages'; + +/** A textblock's text and the parts of it in their own language. */ +export interface SpellItem { + readonly text: string; + readonly langs?: readonly SpellLang[]; +} + +const itemOf = (entry: string | SpellItem): SpellItem => (typeof entry === 'string' ? { text: entry } : entry); export interface SpellingRules { enabled: boolean; @@ -23,10 +35,11 @@ export type ChangeKind = 'results' | 'rules'; export interface SpellingEngine extends SpellingService { /** Checks texts that have no results yet: 'now' for the textblocks in view, 'idle' for the rest. */ - check(texts: readonly string[], priority: 'now' | 'idle'): void; + check(texts: readonly (string | SpellItem)[], priority: 'now' | 'idle'): void; /** Called after results arrive, or after the rules change and every textblock needs reading again. */ onChange(listener: (kind: ChangeKind) => void): () => void; - suggest(word: string): Promise; + /** Suggestions in the word's own language when it has one, else in the page's languages. */ + suggest(word: string, lang?: string): Promise; ignore(word: string): void; addWord(word: string): Promise; removeWord(word: string): Promise; @@ -107,9 +120,9 @@ class Engine implements SpellingEngine { private readonly words: WordRules; private readonly cache = new Lru(CACHE_SIZE); private readonly listeners = new Set<(kind: ChangeKind) => void>(); - private readonly now = new Set(); - private readonly idle = new Set(); - private readonly typing = new Map(); + private readonly now = new Map(); + private readonly idle = new Map(); + private readonly typing = new Map(); private readonly inFlight = new Set(); private typingTimer: ReturnType | null = null; private pumping = false; @@ -134,8 +147,10 @@ class Engine implements SpellingEngine { return this.rules.languages.join(','); } - private keyOf(text: string): string { - return `${this.languageKey()}\u0001${text}`; + /** The cache key: the page's languages, the text's own languages and where they are, and the text. */ + private keyOf(item: SpellItem): string { + const own = item.langs?.map((span) => `${span.start}+${span.length}=${span.lang}`).join(';') ?? ''; + return `${this.languageKey()}\u0002${own}\u0001${item.text}`; } private notify(kind: ChangeKind): void { @@ -154,33 +169,58 @@ class Engine implements SpellingEngine { }; /** Takes up to 200 waiting texts, at most 128 K characters, from `source`. */ - private takeBatch(source: Set): string[] { - const batch: string[] = []; + private takeBatch(source: Map): { key: string; item: SpellItem }[] { + const batch: { key: string; item: SpellItem }[] = []; let chars = 0; - for (const text of source) { - if (batch.length === MAX_ITEMS || chars + text.length > MAX_CHARS) break; - source.delete(text); - if (this.cache.has(this.keyOf(text)) || this.inFlight.has(text)) continue; - batch.push(text); - chars += text.length; + for (const [key, item] of source) { + if (batch.length === MAX_ITEMS || chars + item.text.length > MAX_CHARS) break; + source.delete(key); + if (this.cache.has(key) || this.inFlight.has(key)) continue; + batch.push({ key, item }); + chars += item.text.length; } return batch; } - private async send(batch: readonly string[]): Promise { - const key = this.languageKey(); - batch.forEach((text) => this.inFlight.add(text)); + /** + * Checks a batch: the page's languages get each text with its own-language parts blanked, and each other + * language gets its parts, one request per language. A part whose language has no checker is not checked. + */ + private async send(batch: readonly { key: string; item: SpellItem }[]): Promise { + batch.forEach(({ key }) => this.inFlight.add(key)); try { - const items = batch.map((text, index) => ({ id: String(index), text })); - const results = await this.client.check(items, [...this.rules.languages]); - for (const result of results) { - const text = batch[Number(result.id)]; - if (text !== undefined) this.cache.set(`${key}\u0001${text}`, result.errors); + const installed = batch.some(({ item }) => item.langs?.length) ? await this.languages() : []; + const groups = new Map(); + batch.forEach(({ item }, index) => { + for (const piece of piecesOf(item, index)) { + const resolved = piece.lang === null ? null : resolveLanguage(piece.lang, installed); + if (piece.lang !== null && resolved === null) continue; + const languages = resolved === null ? [...this.rules.languages] : [resolved]; + const group = groups.get(languages.join(',')) ?? { languages, pieces: [] }; + group.pieces.push(piece); + groups.set(languages.join(','), group); + } + }); + const errors = batch.map((): SpellRange[] => []); + for (const { languages, pieces } of groups.values()) { + const items = pieces.map((piece, index) => ({ id: String(index), text: piece.text })); + for (const result of await this.client.check(items, languages)) { + const piece = pieces[Number(result.id)]; + if (!piece) continue; + const shifted = result.errors.map((error) => ({ start: error.start + piece.offset, length: error.length })); + errors[piece.item].push(...shifted); + } } + batch.forEach(({ key }, index) => + this.cache.set( + key, + errors[index].sort((a, b) => a.start - b.start), + ), + ); } catch { // A failed check leaves its texts unchecked; the next change asks again. } finally { - batch.forEach((text) => this.inFlight.delete(text)); + batch.forEach(({ key }) => this.inFlight.delete(key)); } } @@ -202,34 +242,35 @@ class Engine implements SpellingEngine { } } - private queue(texts: Iterable, into: Set): void { - for (const text of texts) { - if (text.trim() && !this.cache.has(this.keyOf(text))) into.add(text); + private queue(items: Iterable, into: Map): void { + for (const item of items) { + const key = this.keyOf(item); + if (item.text.trim() && !this.cache.has(key)) into.set(key, item); } void this.pump(); } - errorsFor = (text: string): readonly SpellRange[] | null => { + errorsFor = (text: string, langs?: readonly SpellLang[]): readonly SpellRange[] | null => { if (!this.active() || !text.trim()) return []; - const errors = this.cache.get(this.keyOf(text)); + const errors = this.cache.get(this.keyOf({ text, langs })); if (!errors) return null; return errors.filter((error) => !this.words.skips(text.slice(error.start, error.start + error.length))); }; - requestCheck = (key: string, text: string): void => { + requestCheck = (key: string, text: string, langs?: readonly SpellLang[]): void => { if (!this.active()) return; - this.typing.set(key, text); + this.typing.set(key, langs?.length ? { text, langs } : { text }); if (this.typingTimer) clearTimeout(this.typingTimer); this.typingTimer = setTimeout(() => { this.typingTimer = null; - const texts = [...this.typing.values()]; + const items = [...this.typing.values()]; this.typing.clear(); - this.queue(texts, this.now); + this.queue(items, this.now); }, this.timing.typingDelayMs); }; - check(texts: readonly string[], priority: 'now' | 'idle'): void { - if (this.active()) this.queue(texts, priority === 'now' ? this.now : this.idle); + check(texts: readonly (string | SpellItem)[], priority: 'now' | 'idle'): void { + if (this.active()) this.queue(texts.map(itemOf), priority === 'now' ? this.now : this.idle); } onChange(listener: (kind: ChangeKind) => void): () => void { @@ -237,9 +278,11 @@ class Engine implements SpellingEngine { return () => void this.listeners.delete(listener); } - async suggest(word: string): Promise { + async suggest(word: string, lang?: string): Promise { if (!this.active()) return []; - return this.client.suggest(word, this.rules.languages).catch(() => []); + const own = lang ? resolveLanguage(lang, await this.languages()) : null; + if (lang && !own) return []; + return this.client.suggest(word, own ? [own] : this.rules.languages).catch(() => []); } ignore(word: string): void { diff --git a/app/src/features/page/spelling/language.test.ts b/app/src/features/page/spelling/language.test.ts new file mode 100644 index 000000000..a57f97fd8 --- /dev/null +++ b/app/src/features/page/spelling/language.test.ts @@ -0,0 +1,192 @@ +// @vitest-environment jsdom + +// Spell check by language (lane text:language-direction). Each part of a textblock goes to the checker in its own +// language. Text set to "do not check spelling" (zxx) never leaves the page. The fake client records each call. +import { afterEach, describe, expect, it } from 'vitest'; +import type { Editor } from '@tiptap/core'; +import { testHost } from '../../../editor/commands/testing'; +import { createBlockEditor } from '../../../editor/extensions/kit'; +import { textblockText } from '../../../editor/extensions/spellingText'; +import { parseTextBlock } from '../../../editor/markdown/parse'; +import type { SpellingClient } from '../../../platform/types'; +import { createSpellingEngine } from './engine'; +import type { SpellItem, SpellingRules } from './engine'; +import { piecesOf, resolveLanguage } from './languages'; + +const RULES: SpellingRules = { + enabled: true, + languages: ['en-US'], + personalWords: [], + ignoreUppercase: true, + ignoreWithDigits: true, +}; + +/** Each language knows its misspelled words; every call is recorded. */ +const BAD: Record = { 'en-US': ['teh'], 'fr-FR': ['chatt'] }; + +function fakeClient(installed = ['en-US', 'fr-FR']) { + const calls: { texts: string[]; languages: readonly string[] }[] = []; + const client: SpellingClient = { + languages: () => Promise.resolve(installed.map((tag, i) => ({ tag, name: tag, isDefault: i === 0 }))), + check: (items, languages) => { + calls.push({ texts: items.map((item) => item.text), languages: [...languages] }); + const bad = languages.flatMap((language) => BAD[language] ?? []); + return Promise.resolve( + items.map(({ id, text }) => ({ + id, + errors: [...text.matchAll(/\S+/g)] + .filter((match) => bad.includes(match[0])) + .map((match) => ({ start: match.index, length: match[0].length })), + })), + ); + }, + suggest: () => Promise.resolve([]), + addWord: () => Promise.resolve(), + removeWord: () => Promise.resolve(), + }; + return { client, calls }; +} + +const idleNow = { typingDelayMs: 0, idle: (run: () => void) => setTimeout(run, 0) }; +const settle = () => new Promise((resolve) => setTimeout(resolve, 10)); + +function paragraph(html: string, attrs: Record = {}): HTMLElement { + const element = document.createElement('p'); + Object.entries(attrs).forEach(([name, value]) => element.setAttribute(name, value)); + element.innerHTML = html; + document.body.append(element); + return element; +} + +const itemOf = (element: Element): SpellItem => { + const { text, langs } = textblockText(element); + return langs ? { text, langs } : { text }; +}; + +let editor: Editor | null = null; +afterEach(() => { + editor?.destroy(); + editor = null; + document.body.replaceChildren(); +}); + +describe('spell check by language', () => { + it('sends a French run to the engine with fr-FR, and the rest with the page language', async () => { + const { client, calls } = fakeClient(); + const engine = createSpellingEngine(client, RULES, idleNow); + const item = itemOf(paragraph('I saw teh chatt noir today')); + expect(item.langs).toEqual([{ start: 10, length: 10, lang: 'fr-FR' }]); + engine.check([item], 'now'); + await settle(); + expect(calls).toContainEqual({ texts: ['chatt noir'], languages: ['fr-FR'] }); + expect(calls).toContainEqual({ texts: ['I saw teh today'], languages: ['en-US'] }); + // Errors from both requests map back to the textblock's offsets. + expect(engine.errorsFor(item.text, item.langs)).toEqual([ + { start: 6, length: 3 }, + { start: 10, length: 5 }, + ]); + }); + + it('never sends a zxx run', async () => { + const { client, calls } = fakeClient(); + const engine = createSpellingEngine(client, RULES, idleNow); + const item = itemOf(paragraph('see teh xyzzy now')); + engine.check([item], 'now'); + await settle(); + const sent = calls.flatMap((call) => call.texts).join('|'); + expect(sent).not.toContain('teh'); + expect(sent).not.toContain('xyzzy'); + expect(engine.errorsFor(item.text, item.langs)).toEqual([]); + }); + + it('checks a paragraph in its own language, and never sends a zxx paragraph', async () => { + const { client, calls } = fakeClient(); + const engine = createSpellingEngine(client, RULES, idleNow); + const french = itemOf(paragraph('le chatt', { lang: 'fr-FR' })); + const none = itemOf(paragraph('teh teh', { lang: 'zxx' })); + engine.check([french, none], 'now'); + await settle(); + expect(calls).toEqual([{ texts: ['le chatt'], languages: ['fr-FR'] }]); + expect(engine.errorsFor(none.text, none.langs)).toEqual([]); + }); + + it('reads a list item paragraph language from its list item', () => { + const li = document.body.appendChild(document.createElement('li')); + li.setAttribute('lang', 'fr-FR'); + const p = li.appendChild(document.createElement('p')); + p.textContent = 'bonjour'; + expect(textblockText(p).langs).toEqual([{ start: 0, length: 7, lang: 'fr-FR' }]); + }); + + it('leaves a part unchecked when no installed checker knows its language', async () => { + const { client, calls } = fakeClient(); + const engine = createSpellingEngine(client, RULES, idleNow); + const item = itemOf(paragraph('ok Haus')); + engine.check([item], 'now'); + await settle(); + expect(calls).toEqual([{ texts: ['ok '], languages: ['en-US'] }]); + expect(engine.errorsFor(item.text, item.langs)).toEqual([]); + }); + + it('asks again when the same text gets another language', async () => { + const { client, calls } = fakeClient(); + const engine = createSpellingEngine(client, RULES, idleNow); + engine.check(['chatt'], 'now'); + await settle(); + expect(engine.errorsFor('chatt')).toEqual([]); + expect(engine.errorsFor('chatt', [{ start: 0, length: 5, lang: 'fr-FR' }])).toBeNull(); + engine.check([{ text: 'chatt', langs: [{ start: 0, length: 5, lang: 'fr-FR' }] }], 'now'); + await settle(); + expect(engine.errorsFor('chatt', [{ start: 0, length: 5, lang: 'fr-FR' }])).toEqual([{ start: 0, length: 5 }]); + expect(calls.map((call) => call.languages)).toEqual([['en-US'], ['fr-FR']]); + }); + + it('suggests in the word language', async () => { + const asked: (readonly string[])[] = []; + const { client } = fakeClient(); + const engine = createSpellingEngine( + { ...client, suggest: (_word, languages) => (asked.push(languages), Promise.resolve([])) }, + RULES, + idleNow, + ); + await engine.suggest('chatt', 'fr'); + await engine.suggest('teh'); + expect(asked).toEqual([['fr-FR'], ['en-US']]); + }); + + it('a mounted editor asks for its lang runs with their language', async () => { + const requests: { text: string; langs?: readonly unknown[] }[] = []; + const host = testHost({ + spelling: () => ({ + errorsFor: () => null, + requestCheck: (_key, text, langs) => void requests.push({ text, langs }), + }), + }); + host.editing = { ...host.editing, spelling: { ...host.editing.spelling, enabled: true } }; + const root = document.body.appendChild(document.createElement('div')); + const doc = parseTextBlock('a chat b xq'); + editor = createBlockEditor(root, doc, { kind: 'text', block: 'b1', host }); + await settle(); + expect(requests).toContainEqual({ text: 'a chat b ', langs: [{ start: 2, length: 4, lang: 'fr-FR' }] }); + }); +}); + +describe('language pieces and checkers', () => { + it('blanks own-language parts of the page piece and keeps offsets', () => { + expect(piecesOf({ text: 'ab cd ef', langs: [{ start: 3, length: 2, lang: 'fr' }] }, 4)).toEqual([ + { item: 4, offset: 0, text: 'ab ef', lang: null }, + { item: 4, offset: 3, text: 'cd', lang: 'fr' }, + ]); + expect(piecesOf({ text: 'cd', langs: [{ start: 0, length: 2, lang: 'fr' }] }, 0)).toEqual([ + { item: 0, offset: 0, text: 'cd', lang: 'fr' }, + ]); + }); + + it('matches a tag exactly, then by primary language, else not at all', () => { + const installed = [{ tag: 'en-US' }, { tag: 'fr-CA' }, { tag: 'fr-FR', isDefault: true }]; + expect(resolveLanguage('FR-ca', installed)).toBe('fr-CA'); + expect(resolveLanguage('fr', installed)).toBe('fr-FR'); + expect(resolveLanguage('fr-BE', installed)).toBe('fr-FR'); + expect(resolveLanguage('de-DE', installed)).toBeNull(); + }); +}); diff --git a/app/src/features/page/spelling/languages.ts b/app/src/features/page/spelling/languages.ts new file mode 100644 index 000000000..7aaa79af6 --- /dev/null +++ b/app/src/features/page/spelling/languages.ts @@ -0,0 +1,65 @@ +// Languages for spell check (lane text:language-direction): how a textblock's text splits into the parts each +// language checks, and which installed checker a language tag uses. Pure, so the engine and its tests share it. +import type { SpellLang } from '../../../editor/host'; + +/** A part of one textblock's text that one request checks: `text` starts at `offset` of the textblock's text. */ +export interface Piece { + readonly item: number; + readonly offset: number; + readonly text: string; + /** Null for the page's languages. */ + readonly lang: string | null; +} + +/** + * The pieces of a text: the whole text with its own-language parts blanked, for the page's languages, then each + * own-language part. Blank pieces are left out, and blanking keeps offsets, so errors map back by adding `offset`. + */ +export function piecesOf(item: { text: string; langs?: readonly SpellLang[] }, index: number): Piece[] { + const langs = item.langs ?? []; + if (!langs.length) return [{ item: index, offset: 0, text: item.text, lang: null }]; + let rest = ''; + let at = 0; + const own: Piece[] = []; + for (const span of langs) { + const start = Math.max(at, Math.min(span.start, item.text.length)); + const end = Math.min(item.text.length, span.start + span.length); + if (end <= start) continue; + rest += item.text.slice(at, start) + ' '.repeat(end - start); + own.push({ item: index, offset: start, text: item.text.slice(start, end), lang: span.lang }); + at = end; + } + rest += item.text.slice(at); + return [{ item: index, offset: 0, text: rest, lang: null }, ...own].filter((piece) => piece.text.trim() !== ''); +} + +/** + * The installed checker for a language tag: the same tag in any case, else the first one with the same primary + * language (a page's "fr" uses an installed "fr-FR"). Null when no checker knows the language, as Word leaves text + * whose proofing tools are missing unchecked. + */ +export function resolveLanguage( + tag: string, + installed: readonly { tag: string; isDefault?: boolean }[], +): string | null { + const lower = tag.toLowerCase(); + const exact = installed.find((language) => language.tag.toLowerCase() === lower); + if (exact) return exact.tag; + const primary = lower.split('-')[0]; + const same = installed.filter((language) => language.tag.toLowerCase().split('-')[0] === primary); + return (same.find((language) => language.isDefault) ?? same[0])?.tag ?? null; +} + +/** A language's name in American English, such as "French (Canada)" for fr-CA; the tag when no name is known. */ +export function languageName(tag: string): string { + try { + return new Intl.DisplayNames(['en-US'], { type: 'language' }).of(tag) ?? tag; + } catch { + return tag; + } +} + +/** The own language of the text at `offset`, if any. */ +export function langAt(langs: readonly SpellLang[] | undefined, offset: number): string | undefined { + return langs?.find((span) => span.start <= offset && offset < span.start + span.length)?.lang; +} diff --git a/app/src/features/page/spelling/menu.ts b/app/src/features/page/spelling/menu.ts index b6210e871..108c335ef 100644 --- a/app/src/features/page/spelling/menu.ts +++ b/app/src/features/page/spelling/menu.ts @@ -15,6 +15,7 @@ import type { MenuAnchor, MenuItemSpec } from '../../../ui'; import { shownPool } from '../pool/shown'; import { spellingEngine } from './current'; import type { SpellingEngine } from './engine'; +import { langAt } from './languages'; export interface SpellingMenuOptions { editor: Editor; @@ -22,6 +23,8 @@ export interface SpellingMenuOptions { to: number; word: string; engine: SpellingEngine; + /** The word's own language, when its run or paragraph has one. */ + lang?: string; /** F7 says the word and its first suggestion. */ announceWord: boolean; anchor?: MenuAnchor; @@ -54,7 +57,7 @@ export function spellingItems(options: SpellingMenuOptions, suggestions: readonl /** Opens the spelling menu for the word between `from` and `to`. */ export async function openSpellingMenu(options: SpellingMenuOptions): Promise { - const suggestions = await options.engine.suggest(options.word); + const suggestions = await options.engine.suggest(options.word, options.lang); if (options.announceWord) { const first = suggestions[0] ? ` ${t('spelling.first', { word: suggestions[0] })}` : ''; announce(t('spelling.misspelled', { word: options.word, count: suggestions.length }) + first); @@ -109,6 +112,7 @@ export function spellingContextMenu(event: MouseEvent): boolean { const anchor: MenuAnchor | undefined = keyboard ? undefined : { x: event.clientX, y: event.clientY }; const more = editMenu({ target: target as Element, host, anchor: anchor ?? host }).items; const word = editor.state.doc.textBetween(from, to); - void openSpellingMenu({ editor, from, to, word, engine, announceWord: false, anchor, more }); + const lang = langAt(extracted.langs, error.start); + void openSpellingMenu({ editor, from, to, word, engine, lang, announceWord: false, anchor, more }); return true; } diff --git a/app/src/features/page/spelling/navigate.ts b/app/src/features/page/spelling/navigate.ts index 2e7f57223..128751a97 100644 --- a/app/src/features/page/spelling/navigate.ts +++ b/app/src/features/page/spelling/navigate.ts @@ -17,6 +17,7 @@ import { shownPool } from '../pool/shown'; import type { EditorPool } from '../pool/pool'; import { shownViewport } from '../viewport/viewport'; import { spellingEngine } from './current'; +import { langAt } from './languages'; import { openSpellingMenu } from './menu'; interface Spot { @@ -66,7 +67,7 @@ function findError(world: Element, pool: EditorPool, direction: 1 | -1): Spot | } /** Mounts the error's block if needed, and returns its editor and the word's positions there. */ -function mountAt(pool: EditorPool, spot: Spot): { editor: Editor; from: number; to: number } | null { +function mountAt(pool: EditorPool, spot: Spot): { editor: Editor; from: number; to: number; lang?: string } | null { const wrapper = spot.element.closest('[data-block-id]'); const block = wrapper?.dataset.blockId; if (!wrapper || !block) return null; @@ -77,14 +78,15 @@ function mountAt(pool: EditorPool, spot: Spot): { editor: Editor; from: number; if (!editor) return null; const element = textblocks()[index]; if (!element || textblockText(element).text !== text) return null; - const range = rangeOver(textblockText(element), spot.error.start, spot.error.length); + const extracted = textblockText(element); + const range = rangeOver(extracted, spot.error.start, spot.error.length); if (!range) return null; const from = editor.view.posAtDOM(range.startContainer, range.startOffset); const to = editor.view.posAtDOM(range.endContainer, range.endOffset); pool.mount(block, { kind: 'selection', anchor: from, head: to }, 'target'); unfoldTo(editor, from); editor.view.focus(); - return { editor, from, to }; + return { editor, from, to, lang: langAt(extracted.langs, spot.error.start) }; } /** Moves to the next (1) or previous (-1) misspelled word and opens its menu. Resolves false when there is none. */ @@ -99,10 +101,10 @@ export async function moveToError(direction: 1 | -1): Promise { announce(t('spelling.none')); return false; } - const { editor, from, to } = target; + const { editor, from, to, lang } = target; lastEditor = editor; editor.commands.scrollIntoView(); const word = editor.state.doc.textBetween(from, to); - await openSpellingMenu({ editor, from, to, word, engine, announceWord: true }); + await openSpellingMenu({ editor, from, to, word, engine, lang, announceWord: true }); return true; } From 8b7fb6224282e10cf1c873763dc61eb07cfd0935 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 09:30:21 -0800 Subject: [PATCH 02/26] Draw paragraph direction, language, and hyphenation A new editor extension turns a paragraph's dir, lang, and hyphenate attributes into the element's dir, lang, and hyphens through node decorations. Alignment is logical, as in Word: start is the right edge of a right-to-left paragraph, so the decoration writes the physical side. A paragraph without dir stays left to right even when it starts with Arabic; the direction is never guessed from the text. The editor root gets the page language, so automatic hyphenation and the screen reader voice follow it. Decorations are redrawn only for the top-level blocks a change touched. It registers through editorExtensions behind text.language, with commands to set direction and hyphenation. Co-Authored-By: Claude Opus 5.5 --- app/src/editor/extensions/direction.test.ts | 42 ++++ app/src/editor/extensions/direction.test.tsx | 136 +++++++++++ app/src/editor/extensions/direction.ts | 223 +++++++++++++++++++ 3 files changed, 401 insertions(+) create mode 100644 app/src/editor/extensions/direction.test.ts create mode 100644 app/src/editor/extensions/direction.test.tsx create mode 100644 app/src/editor/extensions/direction.ts diff --git a/app/src/editor/extensions/direction.test.ts b/app/src/editor/extensions/direction.test.ts new file mode 100644 index 000000000..c1e3a328c --- /dev/null +++ b/app/src/editor/extensions/direction.test.ts @@ -0,0 +1,42 @@ +// The DOM attributes direction.ts writes, worked out by hand from text DESIGN.md 2.2. Alignment is logical: start +// is the right edge of a right-to-left paragraph, and end is the left one. +import { describe, expect, it } from 'vitest'; +import { directionDom, physicalAlign } from './direction'; + +describe('physical alignment', () => { + it('maps start and end by direction, and leaves center and justify alone', () => { + expect(physicalAlign('start', 'rtl')).toBe('right'); + expect(physicalAlign('end', 'rtl')).toBe('left'); + expect(physicalAlign('start', 'ltr')).toBe('left'); + expect(physicalAlign('end', 'ltr')).toBe('right'); + expect(physicalAlign(undefined, 'rtl')).toBe('right'); + expect(physicalAlign('center', 'rtl')).toBe('center'); + expect(physicalAlign('distribute', 'rtl')).toBe('justify'); + }); +}); + +describe('direction DOM attributes', () => { + it('sets nothing on a plain paragraph', () => { + expect(directionDom({ dir: null, lang: null, align: null, hyphenate: null })).toBeNull(); + }); + + it('writes dir, lang, and the physical side', () => { + expect(directionDom({ dir: 'rtl', lang: 'ar-EG' })).toEqual({ + dir: 'rtl', + lang: 'ar-EG', + style: 'text-align: right', + }); + expect(directionDom({ align: 'end' })).toEqual({ style: 'text-align: right' }); + expect(directionDom({ dir: 'rtl', align: 'center' })).toEqual({ dir: 'rtl' }); + }); + + it('hyphenates from the paragraph, else from the page', () => { + expect(directionDom({}, true)).toEqual({ style: 'hyphens: auto; -webkit-hyphens: auto' }); + expect(directionDom({ hyphenate: false }, true)).toEqual({ style: 'hyphens: manual; -webkit-hyphens: manual' }); + expect(directionDom({ hyphenate: true })).toEqual({ style: 'hyphens: auto; -webkit-hyphens: auto' }); + }); + + it('ignores values outside the grammar', () => { + expect(directionDom({ dir: 'up', lang: 'not a tag' })).toBeNull(); + }); +}); diff --git a/app/src/editor/extensions/direction.test.tsx b/app/src/editor/extensions/direction.test.tsx new file mode 100644 index 000000000..c88988c85 --- /dev/null +++ b/app/src/editor/extensions/direction.test.tsx @@ -0,0 +1,136 @@ +// Paragraph direction in a real browser (lane text:language-direction): computed styles and measured positions. +// A right-to-left paragraph computes direction rtl, with its start on the right. An Arabic word never turns a +// left-to-right paragraph around. The caret and the selection follow the text's own order. +import type { Editor } from '@tiptap/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { testHost } from '../commands/testing'; +import { applyElementData } from '../markdown/elements'; +import { parseTextBlock } from '../markdown/parse'; +// Importing the module registers the extension in every editor whose host has text.language on. +import { paragraphDirection, setParagraphAttr, setParagraphDirection } from './direction'; +import { createBlockEditor } from './kit'; + +let editor: Editor | null = null; +afterEach(() => { + editor?.destroy(); + editor = null; + document.body.replaceChildren(); +}); + +const ARABIC = 'مرحبا'; + +function mount(markdown: string, entry: Record = {}, language = 'en-US'): HTMLElement { + const root = document.body.appendChild(document.createElement('div')); + root.style.cssText = 'width: 400px; font: 16px sans-serif; white-space: pre-wrap'; + const doc = applyElementData(parseTextBlock(markdown), { ids: ['a'], paras: { a: entry } }); + const host = testHost(); + host.editing = { ...host.editing, spelling: { enabled: false, languages: [language] } }; + editor = createBlockEditor(root, doc, { kind: 'text', block: 'b1', host }); + return root; +} + +const first = (root: HTMLElement) => root.querySelector('p') as HTMLParagraphElement; + +/** The box of the first `text` in `root`. */ +function boxOf(root: HTMLElement, text: string): DOMRect { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const at = node.textContent?.indexOf(text) ?? -1; + if (at === -1) continue; + const range = document.createRange(); + range.setStart(node, at); + range.setEnd(node, at + text.length); + return range.getBoundingClientRect(); + } + throw new Error(`no ${text}`); +} + +describe('paragraph direction', () => { + it('computes direction rtl for a paragraph set to rtl, and align start maps to text-align right', () => { + const p = first(mount('hello world', { dir: 'rtl', align: 'start' })); + expect(getComputedStyle(p).direction).toBe('rtl'); + expect(getComputedStyle(p).textAlign).toBe('right'); + // The text sits against the right edge. + expect(p.getBoundingClientRect().right - boxOf(p, 'hello world').right).toBeLessThan(1); + }); + + it('maps end to the left in a right-to-left paragraph and to the right in a left-to-right one', () => { + expect(getComputedStyle(first(mount('x', { dir: 'rtl', align: 'end' }))).textAlign).toBe('left'); + editor?.destroy(); + document.body.replaceChildren(); + expect(getComputedStyle(first(mount('x', { align: 'end' }))).textAlign).toBe('right'); + }); + + it('keeps a left-to-right paragraph left to right when an Arabic word starts it', () => { + const root = mount(`${ARABIC} hello`); + const p = first(root); + expect(getComputedStyle(p).direction).toBe('ltr'); + expect(p.hasAttribute('dir')).toBe(false); + // In a left-to-right paragraph the Arabic word, first in the text, is drawn first, on the left. + expect(boxOf(root, ARABIC).left).toBeLessThan(boxOf(root, 'hello').left); + }); + + it('keeps an Arabic word inside a left-to-right paragraph from turning it around', () => { + const root = mount(`one ${ARABIC} two`); + expect(getComputedStyle(first(root)).direction).toBe('ltr'); + expect(boxOf(root, 'one').left).toBeLessThan(boxOf(root, ARABIC).left); + expect(boxOf(root, ARABIC).left).toBeLessThan(boxOf(root, 'two').left); + }); + + it('puts the caret at the start of a right-to-left paragraph on the right, and selects in text order', () => { + const root = mount(`${ARABIC} ${ARABIC}`, { dir: 'rtl' }); + const view = editor!.view; + const start = view.coordsAtPos(1); + const end = view.coordsAtPos(1 + ARABIC.length * 2 + 1); + expect(start.left).toBeGreaterThan(end.left); + expect(first(root).getBoundingClientRect().right - start.left).toBeLessThan(2); + // The selection over the first word holds that word, and it is drawn at the right edge. + editor!.commands.setTextSelection({ from: 1, to: 1 + ARABIC.length }); + const { from, to } = editor!.state.selection; + const a = view.domAtPos(from); + const b = view.domAtPos(to); + const range = document.createRange(); + range.setStart(a.node, a.offset); + range.setEnd(b.node, b.offset); + expect(range.toString()).toBe(ARABIC); + expect(range.getBoundingClientRect().right).toBeGreaterThan(first(root).getBoundingClientRect().right - 2); + }); + + it('switches a paragraph between right to left and left to right, keeping its logical alignment', () => { + const root = mount('text', { align: 'start' }); + const state = () => editor!.state; + expect(paragraphDirection(state())).toBe('ltr'); + setParagraphDirection(state(), editor!.view.dispatch, 'rtl'); + expect(paragraphDirection(state())).toBe('rtl'); + expect(state().doc.firstChild?.attrs.align).toBe('start'); + expect(getComputedStyle(first(root)).textAlign).toBe('right'); + setParagraphDirection(state(), editor!.view.dispatch, 'ltr'); + expect(state().doc.firstChild?.attrs.dir).toBeNull(); + expect(getComputedStyle(first(root)).textAlign).toBe('left'); + }); + + it('carries the direction to the new paragraph on Enter', () => { + mount('abc', { dir: 'rtl' }); + editor!.commands.setTextSelection(4); + editor!.commands.enter(); + expect(editor!.state.doc.childCount).toBe(2); + expect(editor!.state.doc.child(1).attrs.dir).toBe('rtl'); + expect(editor!.view.dom.querySelectorAll('p[dir="rtl"]')).toHaveLength(2); + }); +}); + +describe('language and hyphenation', () => { + it('gives the root the page language and a paragraph its own', () => { + const root = mount('bonjour', { lang: 'fr-FR' }, 'en-US'); + expect(editor!.view.dom.getAttribute('lang')).toBe('en-US'); + expect(first(root).getAttribute('lang')).toBe('fr-FR'); + }); + + it('hyphenates a paragraph automatically through its language, and not when it is set off', () => { + const root = mount('internationalization', { hyphenate: true }); + expect(getComputedStyle(first(root)).hyphens).toBe('auto'); + expect(first(root).closest('[lang]')?.getAttribute('lang')).toBe('en-US'); + setParagraphAttr(editor!.state, editor!.view.dispatch, 'hyphenate', false); + expect(getComputedStyle(first(root)).hyphens).toBe('manual'); + }); +}); diff --git a/app/src/editor/extensions/direction.ts b/app/src/editor/extensions/direction.ts new file mode 100644 index 000000000..89e34cc5f --- /dev/null +++ b/app/src/editor/extensions/direction.ts @@ -0,0 +1,223 @@ +// Paragraph direction, language, and hyphenation in the editor (lane text:language-direction, text DESIGN.md 2.2). +// Node decorations turn a paragraph's `dir`, `lang`, and `hyphenate` attributes into the element's `dir`, `lang`, +// and `hyphens`, so the stored attributes stay the only truth. +// +// Alignment is logical, as Word's buttons are. "Start" is the right edge of a right-to-left paragraph, and the +// decoration writes the physical side. A paragraph without `dir` stays left to right, even when it begins with +// Arabic or Hebrew: the direction is never guessed from the text (no `dir="auto"`). +// +// The editor's root gets the page's language, so automatic hyphenation and the screen reader's voice follow it. A +// run's or a paragraph's own `lang` overrides it. Registered through editorExtensions behind text.language. +import { Extension } 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 { Decoration, DecorationSet } from '@tiptap/pm/view'; +import type { EditorHost } from '../host'; +import { PARA_NODES, paraFormat } from '../paragraph/attributes'; +import type { Align } from '../paragraph/attributes'; +import { editorExtensions } from './kit'; + +export type Direction = 'ltr' | 'rtl'; +export type PhysicalAlign = 'left' | 'right' | 'center' | 'justify'; + +/** The side a logical alignment takes in a paragraph of the given direction. */ +export function physicalAlign(align: Align | undefined, dir: Direction): PhysicalAlign { + switch (align ?? 'start') { + case 'start': + return dir === 'rtl' ? 'right' : 'left'; + case 'end': + return dir === 'rtl' ? 'left' : 'right'; + case 'center': + return 'center'; + default: + return 'justify'; + } +} + +/** + * The DOM attributes a paragraph's direction, language, and hyphenation give it, or null when it sets none. + * `pageHyphenate` is the page's default (`view.text.hyphenate`), which a paragraph's own `hyphenate` overrides. + */ +export function directionDom( + attrs: Readonly>, + pageHyphenate = false, +): Record | null { + if (attrs.dir == null && attrs.lang == null && attrs.align == null && attrs.hyphenate == null && !pageHyphenate) { + return null; + } + const format = paraFormat(attrs); + const out: Record = {}; + const style: string[] = []; + if (format.dir) out.dir = format.dir; + if (format.lang) out.lang = format.lang; + // Start and end become sides. Center, justify, and distribute need no side, so paraStyle's declarations stand. + const logical = format.align === undefined || format.align === 'start' || format.align === 'end'; + if (logical && (format.dir || format.align)) + style.push(`text-align: ${physicalAlign(format.align, format.dir ?? 'ltr')}`); + const hyphenate = format.hyphenate ?? pageHyphenate; + if (hyphenate) style.push('hyphens: auto', '-webkit-hyphens: auto'); + else if (format.hyphenate === false) style.push('hyphens: manual', '-webkit-hyphens: manual'); + if (style.length) out.style = style.join('; '); + return Object.keys(out).length ? out : null; +} + +export interface DirectionOptions { + /** The page's default for automatic hyphenation. Default: off, as in Word and Docs. */ + hyphenate?: () => boolean; + /** The page's language, for the editor's root. Null leaves the root without `lang`. */ + language?: () => string | null; +} + +const key = new PluginKey('opennote.direction'); + +/** The decorations of the formatted paragraphs that overlap `[from, to)`. */ +function decorate(doc: PMNode, from: number, to: number, hyphenate: boolean): Decoration[] { + const out: Decoration[] = []; + doc.nodesBetween(from, to, (node, pos) => { + if (PARA_NODES.has(node.type.name)) { + const dom = directionDom(node.attrs, hyphenate); + if (dom) out.push(Decoration.node(pos, pos + node.nodeSize, dom)); + } + return !node.isTextblock; + }); + return out; +} + +/** + * The part of `tr.doc` a change touched, in its positions: every replaced range, mapped to the end, and where the + * documents differ (an attribute step replaces nothing). Null when nothing changed. + */ +function touched(tr: Transaction, prev: PMNode): { from: number; to: number } | null { + let from = Infinity; + let to = -Infinity; + tr.mapping.maps.forEach((map, index) => { + const rest = tr.mapping.slice(index + 1); + map.forEach((_oldStart, _oldEnd, newStart, newEnd) => { + from = Math.min(from, rest.map(newStart, -1)); + to = Math.max(to, rest.map(newEnd, 1)); + }); + }); + const start = tr.doc.content.findDiffStart(prev.content); + if (start !== null) { + const end = tr.doc.content.findDiffEnd(prev.content); + from = Math.min(from, start); + to = Math.max(to, end ? end.a : start, start); + } + return from === Infinity ? null : { from, to }; +} + +/** + * Maps the set through the change and redraws only the top-level blocks it touched. Mapping drops a node + * decoration that a split or a join left across two nodes, so whole blocks are redrawn, never part of one. + */ +function update(tr: Transaction, old: DecorationSet, prev: PMNode, hyphenate: boolean): DecorationSet { + const mapped = old.map(tr.mapping, tr.doc); + const range = touched(tr, prev); + if (!range) return mapped; + const size = tr.doc.content.size; + const start = tr.doc.resolve(Math.max(0, Math.min(range.from, size))); + const end = tr.doc.resolve(Math.max(0, Math.min(range.to, size))); + const from = start.depth > 0 ? start.before(1) : start.pos; + const to = Math.min(size, Math.max(end.depth > 0 ? end.after(1) : end.pos, from + 1)); + const stale = mapped.find(from, to).filter((deco) => deco.from < to && deco.to > from); + return mapped.remove(stale).add(tr.doc, decorate(tr.doc, from, to, hyphenate)); +} + +export function directionPlugin(options: DirectionOptions = {}): Plugin { + const hyphenate = options.hyphenate ?? (() => false); + const language = options.language ?? (() => null); + return new Plugin({ + key, + state: { + init: (_config, state) => + DecorationSet.create(state.doc, decorate(state.doc, 0, state.doc.content.size, hyphenate())), + apply: (tr, old, before) => (tr.docChanged ? update(tr, old, before.doc, hyphenate()) : old), + }, + props: { + decorations: (state) => key.getState(state), + attributes: (): Record => { + const lang = language(); + return lang ? { lang } : {}; + }, + }, + }); +} + +/** The page's language as the editor sees it: the first spelling language, the one Settings, Language chose. */ +const pageLanguage = (host: EditorHost) => () => host.settings().spelling.languages[0] ?? null; + +export function directionExtension(options: DirectionOptions = {}): Extension { + return Extension.create({ name: 'opennoteDirection', addProseMirrorPlugins: () => [directionPlugin(options)] }); +} + +/** The paragraph-like nodes the selection touches: paragraphs, headings, and list items. */ +function paragraphsIn(state: EditorState): { pos: number; node: PMNode }[] { + const found: { pos: number; node: PMNode }[] = []; + for (const { $from, $to } of state.selection.ranges) { + state.doc.nodesBetween($from.pos, Math.max($to.pos, $from.pos + 1), (node, pos) => { + if (PARA_NODES.has(node.type.name)) found.push({ pos, node }); + return !node.isTextblock; + }); + } + return found; +} + +/** Sets one paragraph attribute on every paragraph, heading, and list item the selection touches. */ +export function setParagraphAttr( + state: EditorState, + dispatch: ((tr: Transaction) => void) | undefined, + name: 'dir' | 'lang' | 'hyphenate', + value: string | boolean | null, +): boolean { + const targets = paragraphsIn(state).filter(({ node }) => name in node.attrs); + if (!targets.length) return false; + if (dispatch) { + const tr = state.tr; + for (const { pos, node } of targets) if (node.attrs[name] !== value) tr.setNodeAttribute(pos, name, value); + dispatch(tr.scrollIntoView()); + } + return true; +} + +/** + * Makes the selected paragraphs right to left or left to right. Left to right clears `dir`, the default, so a page + * keeps no attribute it does not need. Alignment is logical, so it flips with the direction without a change. + */ +export function setParagraphDirection( + state: EditorState, + dispatch: ((tr: Transaction) => void) | undefined, + dir: Direction, +): boolean { + return setParagraphAttr(state, dispatch, 'dir', dir === 'rtl' ? 'rtl' : null); +} + +/** The direction of the paragraph at the selection's head. */ +export function paragraphDirection(state: EditorState): Direction { + const { $head } = state.selection; + for (let depth = $head.depth; depth > 0; depth--) { + const node = $head.node(depth); + if (PARA_NODES.has(node.type.name) && node.attrs.dir != null) return node.attrs.dir === 'rtl' ? 'rtl' : 'ltr'; + } + return 'ltr'; +} + +/** Whether automatic hyphenation is on for the paragraph at the selection's head. */ +export function paragraphHyphenates(state: EditorState, pageHyphenate = false): boolean { + const { $head } = state.selection; + for (let depth = $head.depth; depth > 0; depth--) { + const node = $head.node(depth); + if (PARA_NODES.has(node.type.name) && typeof node.attrs.hyphenate === 'boolean') return node.attrs.hyphenate; + } + return pageHyphenate; +} + +if (!editorExtensions.get('text.direction')) { + editorExtensions.register({ + id: 'text.direction', + order: 410, + flag: 'text.language', + kinds: ['text', 'table'], + create: (host) => directionExtension({ language: pageLanguage(host) }), + }); +} From 7364855dae23a27c0c36d9fb5a0c2fdbe163fc6e Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 09:30:21 -0800 Subject: [PATCH 03/26] Add Language, Do not check spelling, and direction commands Language on the Review tab sets the language of the selection as a lang run, or of the caret's paragraphs, from the page languages and the installed spell checkers, listed by American English name. Do not check spelling sets zxx. Right-to-left and left-to-right join the Home tab's Paragraph group, and Automatic hyphenation the Layout tab. All sit behind text.language and load their code when they run. The feature list gains the five commands (registry.json, FEATURE-LIST.md regenerated, one sentence each in featureCommands.ts). Co-Authored-By: Claude Opus 5.5 --- .../features/page/registrations/language.ts | 127 +++++++++++++++++ .../features/page/spelling/languageMenu.ts | 27 ++++ .../page/spelling/setLanguage.test.ts | 62 ++++++++ app/src/features/page/spelling/setLanguage.ts | 134 ++++++++++++++++++ app/src/strings/en/textLanguage.ts | 26 +++- docs/FEATURE-LIST.md | 7 +- docs/tools/featureCommands.ts | 5 + docs/tools/registry.json | 35 +++++ 8 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 app/src/features/page/registrations/language.ts create mode 100644 app/src/features/page/spelling/languageMenu.ts create mode 100644 app/src/features/page/spelling/setLanguage.test.ts create mode 100644 app/src/features/page/spelling/setLanguage.ts diff --git a/app/src/features/page/registrations/language.ts b/app/src/features/page/registrations/language.ts new file mode 100644 index 000000000..c2b8d5ab3 --- /dev/null +++ b/app/src/features/page/registrations/language.ts @@ -0,0 +1,127 @@ +// The registrations of Language, "Do not check spelling", paragraph direction, and automatic hyphenation (lane +// text:language-direction). This file loads at start-up, so it holds only definitions; the code loads when a +// command runs. Word keeps Language on the Review tab, the direction buttons in the Home tab's Paragraph group, +// and hyphenation on the Layout tab, and so does OpenNote. +import type { Editor } from '@tiptap/core'; +import { commandBar, commands } from '../../../registries'; +import type { CommandBarItem } from '../../../registries/types'; +import { t } from '../../../strings/t'; +import type { MessageKey } from '../../../strings/t'; +import type { IconName } from '../../../ui/icons'; +import { announce } from '../../../ui'; +import { targetEditor } from '../formattingBar/target'; +import { shownQueue } from '../sync/shown'; +import { say } from './say'; + +type Spec = { + id: `${string}.${string}`; + title: MessageKey; + keywords: MessageKey; + icon: IconName; + checked?: (editor: Editor) => boolean; + bar?: Pick; + run(editor: Editor): void | Promise; +}; + +const PARAGRAPHS = new Set(['paragraph', 'heading', 'listItem']); + +/** A paragraph attribute at the editor's caret, read without loading the editor's code. */ +function paraAttr(editor: Editor, name: string): unknown { + const { $head } = editor.state.selection; + for (let depth = $head.depth; depth > 0; depth--) { + const node = $head.node(depth); + if (PARAGRAPHS.has(node.type.name) && node.attrs[name] != null) return node.attrs[name]; + } + return null; +} + +function add(spec: Spec): void { + commands.register({ + id: spec.id, + title: spec.title, + keywords: spec.keywords, + category: 'format', + icon: spec.icon, + flag: 'text.language', + when: () => shownQueue.get() !== null, + ...(spec.checked + ? { + checked: () => { + const editor = targetEditor(); + return editor ? spec.checked!(editor) : false; + }, + } + : {}), + run: async () => { + const editor = targetEditor(); + if (editor) await spec.run(editor); + else say(t('textLanguage.needsText')); + }, + }); + if (spec.bar) commandBar.register({ id: spec.id, command: spec.id, flag: 'text.language', ...spec.bar }); +} + +const languageCode = () => import('../spelling/languageMenu'); +const directionCode = () => import('../../../editor/extensions/direction'); + +add({ + id: 'text.setLanguage', + title: 'textLanguage.language', + keywords: 'textLanguage.languageKeywords', + icon: 'Translate', + bar: { tab: 'review', group: 'language', priority: 60, presentation: 'button' }, + run: async (editor) => void (await languageCode()).openLanguageMenu(editor), +}); + +add({ + id: 'text.noProof', + title: 'textLanguage.noProof', + keywords: 'textLanguage.noProofKeywords', + icon: 'TextAa', + checked: (editor) => + paraAttr(editor, 'lang') === 'zxx' || editor.state.selection.$from.marks().some((m) => m.attrs.lang === 'zxx'), + run: async (editor) => { + const { toggleNoProof, selectionLanguage } = await import('../spelling/setLanguage'); + if (!toggleNoProof(editor.state, editor.view.dispatch)) return; + announce(t(selectionLanguage(editor.state) === 'zxx' ? 'textLanguage.noProofOn' : 'textLanguage.noProofOff')); + }, +}); + +const DIRECTIONS = [ + { dir: 'rtl', id: 'text.paragraphRtl', icon: 'ArrowLineLeft', priority: 25 }, + { dir: 'ltr', id: 'text.paragraphLtr', icon: 'ArrowLineRight', priority: 24 }, +] as const; +for (const { dir, id, icon, priority } of DIRECTIONS) { + add({ + id, + title: `textLanguage.${dir}`, + keywords: `textLanguage.${dir}Keywords`, + icon, + checked: (editor) => (paraAttr(editor, 'dir') === 'rtl') === (dir === 'rtl'), + bar: { tab: 'home', group: 'paragraph', priority, presentation: 'toggle' }, + run: async (editor) => { + if ((await directionCode()).setParagraphDirection(editor.state, editor.view.dispatch, dir)) { + announce(t(`textLanguage.${dir}On`)); + } + }, + }); +} + +add({ + id: 'text.hyphenate', + title: 'textLanguage.hyphenate', + keywords: 'textLanguage.hyphenateKeywords', + icon: 'TextColumns', + checked: (editor) => paraAttr(editor, 'hyphenate') === true, + bar: { tab: 'layout', group: 'hyphenation', priority: 40, presentation: 'toggle' }, + run: async (editor) => { + const on = paraAttr(editor, 'hyphenate') !== true; + if ((await directionCode()).setParagraphAttr(editor.state, editor.view.dispatch, 'hyphenate', on)) { + announce(t(on ? 'textLanguage.hyphenateOn' : 'textLanguage.hyphenateOff')); + } + }, +}); + +// The extension that draws direction, language, and hyphenation loads after start-up, as the other editor +// extensions do, so the first load stays small. The registry is read when an editor mounts. +void directionCode().catch(() => undefined); diff --git a/app/src/features/page/spelling/languageMenu.ts b/app/src/features/page/spelling/languageMenu.ts new file mode 100644 index 000000000..cd3429746 --- /dev/null +++ b/app/src/features/page/spelling/languageMenu.ts @@ -0,0 +1,27 @@ +// The Language menu (lane text:language-direction): the page's languages and the installed spell checkers' +// languages by name, "Use the page language", and "Do not check spelling". It loads when the command runs. +import type { Editor } from '@tiptap/core'; +import { spellingLanguages } from '../../../state/language'; +import { t } from '../../../strings/t'; +import { announce, openMenu } from '../../../ui'; +import { spellingEngine } from './current'; +import { caretAnchor, languageChoices, languageItems, selectionLanguage } from './setLanguage'; + +/** Opens the Language menu at the caret. Resolves with the chosen item's id, or null. */ +export async function openLanguageMenu(editor: Editor): Promise { + const installed = + (await spellingEngine() + ?.languages() + .catch(() => [])) ?? []; + const tags = languageChoices( + spellingLanguages(), + installed.map((language) => language.tag), + selectionLanguage(editor.state), + ); + const label = editor.state.selection.empty ? 'textLanguage.paragraphMenuLabel' : 'textLanguage.menuLabel'; + return openMenu({ + label: t(label), + items: languageItems(editor, tags, (text) => announce(text)), + anchor: caretAnchor(editor), + }); +} diff --git a/app/src/features/page/spelling/setLanguage.test.ts b/app/src/features/page/spelling/setLanguage.test.ts new file mode 100644 index 000000000..dfb2a75c7 --- /dev/null +++ b/app/src/features/page/spelling/setLanguage.test.ts @@ -0,0 +1,62 @@ +// Set language and "Do not check spelling" (lane text:language-direction). A selection gets a lang run in the +// file's tag form (text SPEC D.8), and a caret alone sets its paragraph's language. +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import { describe, expect, it } from 'vitest'; +import { applyTo, marked } from '../../../editor/commands/testing'; +import { canonicalTag, languageChoices, selectionLanguage, setLanguage, toggleNoProof } from './setLanguage'; + +const run = (state: EditorState, command: (s: EditorState, d: (tr: Transaction) => void) => boolean) => { + let next = state; + command(state, (tr) => void (next = state.apply(tr))); + return next; +}; + +describe('set language', () => { + it('marks the selected text with the language, in the tag form the file keeps', () => { + expect(applyTo('a [chat] b', (s, d) => setLanguage(s, d, 'fr_fr'))).toBe('a chat b'); + }); + + it('returns selected text to the page language', () => { + expect(applyTo('a [chat] b', (s, d) => setLanguage(s, d, null))).toBe('a chat b'); + }); + + it('sets the paragraph language when nothing is selected', () => { + const { state } = marked('bonjour'); + const next = run(state, (s, d) => setLanguage(s, d, 'fr-CA')); + expect(next.doc.firstChild?.attrs.lang).toBe('fr-CA'); + expect(selectionLanguage(next)).toBe('fr-CA'); + }); + + it('refuses a value that is not a language tag', () => { + const { state } = marked('a [b] c'); + expect(setLanguage(state, undefined, 'English')).toBe(false); + }); + + it('turns do not check spelling on and off', () => { + const { state } = marked('a [teh] b'); + const on = run(state, toggleNoProof); + expect(selectionLanguage(on)).toBe('zxx'); + const off = run(on, toggleNoProof); + expect(selectionLanguage(off)).toBeNull(); + }); +}); + +describe('language tags and choices', () => { + it('shapes tags as BCP 47 writes them', () => { + expect(canonicalTag('EN_us')).toBe('en-US'); + expect(canonicalTag('zh-hant-tw')).toBe('zh-Hant-TW'); + expect(canonicalTag('es-419')).toBe('es-419'); + expect(canonicalTag('english')).toBeNull(); + }); + + it('lists each language once, sorted by its American English name', () => { + // American English, French (France), German (Germany), and Mexican Spanish or Spanish (Mexico). + expect(languageChoices(['en-US'], ['fr-FR', 'EN-us', 'de-DE'], 'es-MX')).toEqual([ + 'en-US', + 'fr-FR', + 'de-DE', + 'es-MX', + ]); + expect(languageChoices([], [], 'zxx')).toEqual([]); + }); +}); diff --git a/app/src/features/page/spelling/setLanguage.ts b/app/src/features/page/spelling/setLanguage.ts new file mode 100644 index 000000000..c830aecdf --- /dev/null +++ b/app/src/features/page/spelling/setLanguage.ts @@ -0,0 +1,134 @@ +// Set language and "Do not check spelling" (lane text:language-direction, text DESIGN.md 2.1 and 2.2). A selection +// gets a `lang` mark on its text. A caret alone sets the language of the paragraphs it is in, as the paragraph +// mark's language. The language "zxx" means "do not check spelling", which the spell checker never sends. +import type { Editor } from '@tiptap/core'; +import type { MarkType } from '@tiptap/pm/model'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import { setParagraphAttr } from '../../../editor/extensions/direction'; +import { NO_PROOF } from '../../../editor/extensions/spellingText'; +import { PARA_NODES } from '../../../editor/paragraph/attributes'; +import { t } from '../../../strings/t'; +import type { MenuAnchor, MenuItemSpec } from '../../../ui'; +import { languageName } from './languages'; + +const LANG_TAG = /^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$/; + +const langType = (state: EditorState): MarkType | undefined => state.schema.marks.lang; + +/** + * A tag in the form the file format keeps: language in lower case, script in title case, region in upper case + * (text DESIGN.md 2.1). Null when it is not a language tag. + */ +export function canonicalTag(tag: string): string | null { + const parts = tag.trim().replace(/_/g, '-').split('-'); + const shaped = parts + .map((part, index) => { + if (index === 0) return part.toLowerCase(); + if (part.length === 4) return part[0].toUpperCase() + part.slice(1).toLowerCase(); + return part.toUpperCase(); + }) + .join('-'); + return LANG_TAG.test(shaped) ? shaped : null; +} + +/** The language at the selection: the `lang` mark at its start, else its paragraph's, else null (the page's). */ +export function selectionLanguage(state: EditorState): string | null { + const type = langType(state); + const { $from, empty } = state.selection; + const marks = empty ? (state.storedMarks ?? $from.marks()) : ($from.nodeAfter?.marks ?? $from.marks()); + const mark = type && marks.find((item) => item.type === type); + if (mark) return String(mark.attrs.lang); + for (let depth = $from.depth; depth > 0; depth--) { + const node = $from.node(depth); + if (PARA_NODES.has(node.type.name) && typeof node.attrs.lang === 'string') return node.attrs.lang; + } + return null; +} + +/** + * Sets the language of the selection, or of the caret's paragraphs. Null returns the text to the page's language. + * Returns false when nothing can take a language, such as a code block. + */ +export function setLanguage( + state: EditorState, + dispatch: ((tr: Transaction) => void) | undefined, + tag: string | null, +): boolean { + const lang = tag === null ? null : tag === NO_PROOF ? NO_PROOF : canonicalTag(tag); + if (tag !== null && lang === null) return false; + if (state.selection.empty) return setParagraphAttr(state, dispatch, 'lang', lang); + const type = langType(state); + if (!type) return false; + if (dispatch) { + const tr = state.tr; + for (const { $from, $to } of state.selection.ranges) { + tr.removeMark($from.pos, $to.pos, type); + if (lang !== null) tr.addMark($from.pos, $to.pos, type.create({ lang })); + } + dispatch(tr.scrollIntoView()); + } + return true; +} + +/** Turns "Do not check spelling" on or off for the selection, or for the caret's paragraphs. */ +export function toggleNoProof(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + return setLanguage(state, dispatch, selectionLanguage(state) === NO_PROOF ? null : NO_PROOF); +} + +/** The menu's languages: the page's, the installed checkers', and the one in use, each once, by name. */ +export function languageChoices( + page: readonly string[], + installed: readonly string[], + current: string | null, +): string[] { + const tags = [...page, ...installed, ...(current && current !== NO_PROOF ? [current] : [])]; + const seen = new Set(); + const unique = tags.filter((tag) => { + const key = tag.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + return unique.sort((a, b) => languageName(a).localeCompare(languageName(b), 'en-US')); +} + +/** The Language menu's items for an editor. Choosing one sets it and says so. */ +export function languageItems(editor: Editor, tags: readonly string[], say: (text: string) => void): MenuItemSpec[] { + const current = selectionLanguage(editor.state); + const choose = (tag: string | null) => () => { + if (!setLanguage(editor.state, editor.view.dispatch, tag)) return; + if (tag === null) say(t('textLanguage.setToPage')); + else if (tag === NO_PROOF) say(t('textLanguage.noProofOn')); + else say(t('textLanguage.setTo', { name: languageName(tag) })); + }; + return [ + { + id: 'page', + label: t('textLanguage.pageLanguage'), + kind: 'radio', + checked: current === null, + onSelect: choose(null), + }, + ...tags.map((tag): MenuItemSpec => ({ + id: `lang.${tag}`, + label: languageName(tag), + kind: 'radio', + checked: current?.toLowerCase() === tag.toLowerCase(), + onSelect: choose(tag), + })), + { + id: 'noProof', + label: t('textLanguage.noProof'), + kind: 'checkbox', + checked: current === NO_PROOF, + separatorBefore: true, + onSelect: choose(current === NO_PROOF ? null : NO_PROOF), + }, + ]; +} + +/** Where the menu opens: under the caret. */ +export function caretAnchor(editor: Editor): MenuAnchor { + const coords = editor.view.coordsAtPos(editor.state.selection.head); + return { x: coords.left, y: coords.bottom }; +} diff --git a/app/src/strings/en/textLanguage.ts b/app/src/strings/en/textLanguage.ts index 98f1bd96c..fd6ad0236 100644 --- a/app/src/strings/en/textLanguage.ts +++ b/app/src/strings/en/textLanguage.ts @@ -1,4 +1,28 @@ // Per-run language and text direction. // Owner: text:language-direction (B11 and B12). text:scaffold registered it in the string index. +// Every interface string is a full sentence or a full label, never joined from pieces. -export const textLanguage = {} as const; +export const textLanguage = { + language: 'Language', + languageKeywords: 'language proofing spelling dictionary set language for selection paragraph', + menuLabel: 'Language of the selected text', + paragraphMenuLabel: 'Language of this paragraph', + pageLanguage: 'Use the page language', + noProof: 'Do not check spelling', + noProofKeywords: 'do not check spelling proofing ignore skip no proof', + rtl: 'Right-to-left text direction', + rtlKeywords: 'right to left direction rtl bidi arabic hebrew persian urdu', + ltr: 'Left-to-right text direction', + ltrKeywords: 'left to right direction ltr bidi', + hyphenate: 'Automatic hyphenation', + hyphenateKeywords: 'hyphenation hyphenate hyphens break words', + needsText: 'Select some text first.', + setTo: 'Language set to {name}.', + setToPage: 'The text now uses the page language.', + noProofOn: 'Spelling will not be checked here.', + noProofOff: 'Spelling will be checked here again.', + rtlOn: 'The paragraph now reads right to left.', + ltrOn: 'The paragraph now reads left to right.', + hyphenateOn: 'Automatic hyphenation is on for this paragraph.', + hyphenateOff: 'Automatic hyphenation is off for this paragraph.', +} as const; diff --git a/docs/FEATURE-LIST.md b/docs/FEATURE-LIST.md index 716541f8c..d2b3c3095 100644 --- a/docs/FEATURE-LIST.md +++ b/docs/FEATURE-LIST.md @@ -2,7 +2,7 @@ Every tool, action, and feature in OpenNote, grouped by category, with one sentence each. `npm run docs:features` makes this page. The commands, tool windows, title bar items, settings sections, and setup steps come from what the app registers, so a new one shows up here or fails the tests. The feature sentences come from [FEATURES.md](FEATURES.md), which also says what has been tested by hand and which flag turns each one on. The [help pages](help/README.md) explain how to use them. [docs/screens](screens/README.md) has every picture in light and dark. -The app registers 894 commands and 48 tool windows. 24 of the windows are subject and engineering kits. The count includes the ink commands, which register when a page first shows its ink, and the commands that register once start-up is done or with the first page: on-device intelligence, the second pass of page commands, and the code block and table commands. The command palette (Ctrl+K) runs every command not marked as left out of it. Some commands need a feature flag, which FEATURES.md names. The keys shown are the defaults of the standard shortcut set. +The app registers 899 commands and 48 tool windows. 24 of the windows are subject and engineering kits. The count includes the ink commands, which register when a page first shows its ink, and the commands that register once start-up is done or with the first page: on-device intelligence, the second pass of page commands, and the code block and table commands. The command palette (Ctrl+K) runs every command not marked as left out of it. Some commands need a feature flag, which FEATURES.md names. The keys shown are the defaults of the standard shortcut set. ## Contents @@ -132,6 +132,11 @@ Open it with the command "Open the code studio". | Crop image | Crops the edges of the selected image. | | | Alt text… | Writes the alt text that screen readers read for the image, or marks it decorative. | | | Copy as Markdown | Copies the selection as Markdown text. | | +| Language | Sets the language of the selected text or paragraph, for spell check and hyphenation. | | +| Do not check spelling | Turns spell check off or on for the selected text or paragraph. | | +| Right-to-left text direction | Makes the paragraph read right to left, with its start on the right. | | +| Left-to-right text direction | Makes the paragraph read left to right. | | +| Automatic hyphenation | Turns automatic hyphenation on or off for the paragraph, by its language. | | | New text box | Adds a new text box on the page. | | | Bring to front | Brings the selected object in front of all others. | Ctrl+} | | Send to back | Sends the selected object behind all others. | Ctrl+{ | diff --git a/docs/tools/featureCommands.ts b/docs/tools/featureCommands.ts index 67fc1af5e..537fe0ef7 100644 --- a/docs/tools/featureCommands.ts +++ b/docs/tools/featureCommands.ts @@ -158,6 +158,11 @@ export const COMMAND_TEXT: Readonly> = { 'text.emDash': 'Inserts an em dash.', 'text.enDash': 'Inserts an en dash.', 'text.formattingMarks': 'Shows or hides marks for paragraphs, spaces, tabs, and special characters.', + 'text.setLanguage': 'Sets the language of the selected text or paragraph, for spell check and hyphenation.', + 'text.noProof': 'Turns spell check off or on for the selected text or paragraph.', + 'text.paragraphRtl': 'Makes the paragraph read right to left, with its start on the right.', + 'text.paragraphLtr': 'Makes the paragraph read left to right.', + 'text.hyphenate': 'Turns automatic hyphenation on or off for the paragraph, by its language.', 'insert.fromPhone': 'Shows a QR code to scan with your phone, so its photos go onto the page.', 'api.cliPathOnMove': 'Adds the opennote command to PATH, so other programs can find it.', 'interop.import': 'Imports notes from Obsidian, Evernote, Notion, OneNote, Markdown, Word, HTML, and more.', diff --git a/docs/tools/registry.json b/docs/tools/registry.json index 88d2e5c71..dcf0ddfd9 100644 --- a/docs/tools/registry.json +++ b/docs/tools/registry.json @@ -3848,6 +3848,41 @@ "keys": [], "flag": "kits.thermo" }, + { + "id": "text.setLanguage", + "title": "Language", + "category": "format", + "keys": [], + "flag": "text.language" + }, + { + "id": "text.noProof", + "title": "Do not check spelling", + "category": "format", + "keys": [], + "flag": "text.language" + }, + { + "id": "text.paragraphRtl", + "title": "Right-to-left text direction", + "category": "format", + "keys": [], + "flag": "text.language" + }, + { + "id": "text.paragraphLtr", + "title": "Left-to-right text direction", + "category": "format", + "keys": [], + "flag": "text.language" + }, + { + "id": "text.hyphenate", + "title": "Automatic hyphenation", + "category": "format", + "keys": [], + "flag": "text.language" + }, { "id": "insert.plot3d", "title": "Insert 3D function plot", From cba05dcc98730f9e8dca69c8782dbedec4c30c5a Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 09:34:59 -0800 Subject: [PATCH 04/26] Raise the start-up JavaScript budget from 539 to 541 KB origin/beta already builds 539.7 KB of start-up JavaScript. The language and direction commands and their strings register at start-up and add 1.2 KB; their code loads when a command runs. Co-Authored-By: Claude Opus 5.5 --- app/bundle-budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/bundle-budget.json b/app/bundle-budget.json index 50cab16d6..3035f85e8 100644 --- a/app/bundle-budget.json +++ b/app/bundle-budget.json @@ -1,6 +1,6 @@ { "startup": { - "jsKb": 539, + "jsKb": 541, "cssKb": 27.5 }, "after": [ From becdb319cc434c1c691fb9231cfb6b870aab19f4 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 10:17:56 -0800 Subject: [PATCH 05/26] Close the language and direction verification gaps - A caret inside a run with its own language now changes that run, so "Do not check spelling" turns off and "Use the page language" works there (G1). - Static paragraphs get dir, lang, the physical side, and hyphens from the same directionDom the editor uses, so the page does not reflow and spell check reads their language before the editor mounts (G2). - EditorHost.pageSettings gives the editor view.text; the direction extension and the static render use its hyphenate default (G3). - The text.language flag description says where its buttons sit (G4). - A run with its own language inside a zxx paragraph is checked (G5). - The unit test asserts dir="auto" is never written (G6). - Start-up JavaScript budget 541 to 542 KB: the static render now carries directionDom and the page settings reader (541.2 KB). Co-Authored-By: Claude Opus 5.5 --- app/bundle-budget.json | 2 +- app/src/editor/extensions/direction.test.ts | 2 + app/src/editor/extensions/direction.test.tsx | 44 ++++++++++- app/src/editor/extensions/direction.ts | 74 +++++++------------ app/src/editor/extensions/spellingText.ts | 9 ++- app/src/editor/host.ts | 17 ++++- app/src/editor/paragraph/directionDom.ts | 49 ++++++++++++ app/src/editor/schema/dom.test.ts | 32 +++++++- app/src/editor/schema/dom.ts | 66 ++++++++++++++++- app/src/features/page/blocks/tableBlock.ts | 4 +- app/src/features/page/blocks/textBlock.ts | 4 +- app/src/features/page/editorHost.test.ts | 22 ++++++ app/src/features/page/editorHost.ts | 22 +++++- app/src/features/page/flags.ts | 6 +- app/src/features/page/mount.ts | 5 +- .../features/page/spelling/language.test.ts | 10 +++ .../page/spelling/setLanguage.test.ts | 19 ++++- app/src/features/page/spelling/setLanguage.ts | 60 ++++++++++++++- 18 files changed, 375 insertions(+), 72 deletions(-) create mode 100644 app/src/editor/paragraph/directionDom.ts create mode 100644 app/src/features/page/editorHost.test.ts diff --git a/app/bundle-budget.json b/app/bundle-budget.json index 3035f85e8..d54f4a73f 100644 --- a/app/bundle-budget.json +++ b/app/bundle-budget.json @@ -1,6 +1,6 @@ { "startup": { - "jsKb": 541, + "jsKb": 542, "cssKb": 27.5 }, "after": [ diff --git a/app/src/editor/extensions/direction.test.ts b/app/src/editor/extensions/direction.test.ts index c1e3a328c..c25431ad0 100644 --- a/app/src/editor/extensions/direction.test.ts +++ b/app/src/editor/extensions/direction.test.ts @@ -38,5 +38,7 @@ describe('direction DOM attributes', () => { it('ignores values outside the grammar', () => { expect(directionDom({ dir: 'up', lang: 'not a tag' })).toBeNull(); + // The decoration never writes dir="auto": a paragraph's first strong letter must not turn it around. + expect(directionDom({ dir: 'auto' })).toBeNull(); }); }); diff --git a/app/src/editor/extensions/direction.test.tsx b/app/src/editor/extensions/direction.test.tsx index c88988c85..e98855373 100644 --- a/app/src/editor/extensions/direction.test.tsx +++ b/app/src/editor/extensions/direction.test.tsx @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { testHost } from '../commands/testing'; import { applyElementData } from '../markdown/elements'; import { parseTextBlock } from '../markdown/parse'; +import { renderStatic } from '../schema/dom'; // Importing the module registers the extension in every editor whose host has text.language on. import { paragraphDirection, setParagraphAttr, setParagraphDirection } from './direction'; import { createBlockEditor } from './kit'; @@ -19,11 +20,18 @@ afterEach(() => { const ARABIC = 'مرحبا'; -function mount(markdown: string, entry: Record = {}, language = 'en-US'): HTMLElement { +function mount( + markdown: string, + entry: Record = {}, + language = 'en-US', + page?: { hyphenate: () => boolean }, +): HTMLElement { const root = document.body.appendChild(document.createElement('div')); root.style.cssText = 'width: 400px; font: 16px sans-serif; white-space: pre-wrap'; const doc = applyElementData(parseTextBlock(markdown), { ids: ['a'], paras: { a: entry } }); - const host = testHost(); + const host = testHost( + page ? { pageSettings: () => ({ text: { hyphenate: page.hyphenate(), defaultTab: 36 } }) } : {}, + ); host.editing = { ...host.editing, spelling: { enabled: false, languages: [language] } }; editor = createBlockEditor(root, doc, { kind: 'text', block: 'b1', host }); return root; @@ -119,6 +127,23 @@ describe('paragraph direction', () => { }); }); +describe('static and mounted paragraphs', () => { + it('draws the same direction, language, side, and hyphens before and after the editor mounts', () => { + const entry = { dir: 'rtl', lang: 'ar-EG', align: 'end', hyphenate: true }; + const still = document.body.appendChild(document.createElement('div')); + renderStatic(applyElementData(parseTextBlock('abc'), { ids: ['a'], paras: { a: entry } }), still, { + direction: { hyphenate: false }, + }); + const mounted = first(mount('abc', entry)); + const look = (p: HTMLElement) => { + const style = getComputedStyle(p); + return [p.getAttribute('dir'), p.getAttribute('lang'), style.direction, style.textAlign, style.hyphens]; + }; + expect(look(first(still))).toEqual(look(mounted)); + expect(look(mounted)).toEqual(['rtl', 'ar-EG', 'rtl', 'left', 'auto']); + }); +}); + describe('language and hyphenation', () => { it('gives the root the page language and a paragraph its own', () => { const root = mount('bonjour', { lang: 'fr-FR' }, 'en-US'); @@ -133,4 +158,19 @@ describe('language and hyphenation', () => { setParagraphAttr(editor!.state, editor!.view.dispatch, 'hyphenate', false); expect(getComputedStyle(first(root)).hyphens).toBe('manual'); }); + + it('hyphenates a paragraph without its own setting when the page hyphenates by default', () => { + let pageOn = true; + const root = mount('internationalization', {}, 'en-US', { hyphenate: () => pageOn }); + expect(getComputedStyle(first(root)).hyphens).toBe('auto'); + // The paragraph's own setting wins over the page's. + setParagraphAttr(editor!.state, editor!.view.dispatch, 'hyphenate', false); + expect(getComputedStyle(first(root)).hyphens).toBe('manual'); + setParagraphAttr(editor!.state, editor!.view.dispatch, 'hyphenate', null); + expect(getComputedStyle(first(root)).hyphens).toBe('auto'); + // Turning the page default off redraws the paragraph on the next change. + pageOn = false; + editor!.commands.setTextSelection(2); + expect(getComputedStyle(first(root)).hyphens).toBe('manual'); + }); }); diff --git a/app/src/editor/extensions/direction.ts b/app/src/editor/extensions/direction.ts index 89e34cc5f..c72221f18 100644 --- a/app/src/editor/extensions/direction.ts +++ b/app/src/editor/extensions/direction.ts @@ -14,53 +14,13 @@ import { Plugin, PluginKey } from '@tiptap/pm/state'; import type { EditorState, Transaction } from '@tiptap/pm/state'; import { Decoration, DecorationSet } from '@tiptap/pm/view'; import type { EditorHost } from '../host'; -import { PARA_NODES, paraFormat } from '../paragraph/attributes'; -import type { Align } from '../paragraph/attributes'; +import { PARA_NODES } from '../paragraph/attributes'; +import { directionDom } from '../paragraph/directionDom'; +import type { Direction } from '../paragraph/directionDom'; import { editorExtensions } from './kit'; -export type Direction = 'ltr' | 'rtl'; -export type PhysicalAlign = 'left' | 'right' | 'center' | 'justify'; - -/** The side a logical alignment takes in a paragraph of the given direction. */ -export function physicalAlign(align: Align | undefined, dir: Direction): PhysicalAlign { - switch (align ?? 'start') { - case 'start': - return dir === 'rtl' ? 'right' : 'left'; - case 'end': - return dir === 'rtl' ? 'left' : 'right'; - case 'center': - return 'center'; - default: - return 'justify'; - } -} - -/** - * The DOM attributes a paragraph's direction, language, and hyphenation give it, or null when it sets none. - * `pageHyphenate` is the page's default (`view.text.hyphenate`), which a paragraph's own `hyphenate` overrides. - */ -export function directionDom( - attrs: Readonly>, - pageHyphenate = false, -): Record | null { - if (attrs.dir == null && attrs.lang == null && attrs.align == null && attrs.hyphenate == null && !pageHyphenate) { - return null; - } - const format = paraFormat(attrs); - const out: Record = {}; - const style: string[] = []; - if (format.dir) out.dir = format.dir; - if (format.lang) out.lang = format.lang; - // Start and end become sides. Center, justify, and distribute need no side, so paraStyle's declarations stand. - const logical = format.align === undefined || format.align === 'start' || format.align === 'end'; - if (logical && (format.dir || format.align)) - style.push(`text-align: ${physicalAlign(format.align, format.dir ?? 'ltr')}`); - const hyphenate = format.hyphenate ?? pageHyphenate; - if (hyphenate) style.push('hyphens: auto', '-webkit-hyphens: auto'); - else if (format.hyphenate === false) style.push('hyphens: manual', '-webkit-hyphens: manual'); - if (style.length) out.style = style.join('; '); - return Object.keys(out).length ? out : null; -} +export { directionDom, physicalAlign } from '../paragraph/directionDom'; +export type { Direction, PhysicalAlign } from '../paragraph/directionDom'; export interface DirectionOptions { /** The page's default for automatic hyphenation. Default: off, as in Word and Docs. */ @@ -127,12 +87,24 @@ function update(tr: Transaction, old: DecorationSet, prev: PMNode, hyphenate: bo export function directionPlugin(options: DirectionOptions = {}): Plugin { const hyphenate = options.hyphenate ?? (() => false); const language = options.language ?? (() => null); + // The page default the decorations were drawn with. When the page changes it, the next transaction redraws all. + let drawn = hyphenate(); + const all = (doc: PMNode) => DecorationSet.create(doc, decorate(doc, 0, doc.content.size, drawn)); return new Plugin({ key, state: { - init: (_config, state) => - DecorationSet.create(state.doc, decorate(state.doc, 0, state.doc.content.size, hyphenate())), - apply: (tr, old, before) => (tr.docChanged ? update(tr, old, before.doc, hyphenate()) : old), + init: (_config, state) => { + drawn = hyphenate(); + return all(state.doc); + }, + apply: (tr, old, before) => { + const now = hyphenate(); + if (now !== drawn) { + drawn = now; + return all(tr.doc); + } + return tr.docChanged ? update(tr, old, before.doc, now) : old; + }, }, props: { decorations: (state) => key.getState(state), @@ -218,6 +190,10 @@ if (!editorExtensions.get('text.direction')) { order: 410, flag: 'text.language', kinds: ['text', 'table'], - create: (host) => directionExtension({ language: pageLanguage(host) }), + create: (host) => + directionExtension({ + language: pageLanguage(host), + hyphenate: () => host.pageSettings?.().text.hyphenate ?? false, + }), }); } diff --git a/app/src/editor/extensions/spellingText.ts b/app/src/editor/extensions/spellingText.ts index b7130f1a7..f3be63b52 100644 --- a/app/src/editor/extensions/spellingText.ts +++ b/app/src/editor/extensions/spellingText.ts @@ -68,7 +68,10 @@ export function textblockText(element: Element, mask = true): TextblockText { langs[langs.length - 1] = { start: last.start, length: last.length + length, lang }; } else langs.push({ start, length, lang }); }; - const walk = (parent: Node, masked: boolean, lang: string | null) => { + // `code` masks everything under it. "Do not check spelling" masks only while it is the nearest language, so a run + // with its own language inside a zxx paragraph is still checked, as a run's language wins over its paragraph's. + const walk = (parent: Node, inCode: boolean, lang: string | null) => { + const masked = inCode || (mask && lang === NO_PROOF); for (let child = parent.firstChild; child; child = child.nextSibling) { if (child.nodeType === Node.TEXT_NODE) { const data = (child as Text).data; @@ -83,12 +86,12 @@ export function textblockText(element: Element, mask = true): TextblockText { continue; } const inner = ownLang(child) ?? lang; - walk(child, masked || (mask && (child.localName === 'code' || inner === NO_PROOF)), inner); + walk(child, inCode || (mask && child.localName === 'code'), inner); } } }; const base = paragraphLang(element); - walk(element, mask && base === NO_PROOF, base); + walk(element, false, base); const own = langs.filter((span) => span.lang !== NO_PROOF); return { text: mask ? text.replace(ADDRESS, (match) => ' '.repeat(match.length)) : text, diff --git a/app/src/editor/host.ts b/app/src/editor/host.ts index 0b4f852c9..8efc94e46 100644 --- a/app/src/editor/host.ts +++ b/app/src/editor/host.ts @@ -17,7 +17,19 @@ export interface EditingSettingsView { spelling: { enabled: boolean; languages: readonly string[] }; } -/** Spelling results for the text of one textblock, from the page's cache. */ +/** The page's own text settings, `view.text` (format spec 5.4.2), as the editor needs them. */ +export interface PageTextSettingsView { + /** Automatic hyphenation for paragraphs without their own `hyphenate`. */ + hyphenate: boolean; + /** Points between default tab stops. */ + defaultTab: number; +} + +/** The page's own settings, as the editor needs them. */ +export interface PageSettingsView { + text: PageTextSettingsView; +} + /** A part of a textblock's text in its own language (spellingText's LangSpan). */ export interface SpellLang { readonly start: number; @@ -25,6 +37,7 @@ export interface SpellLang { readonly lang: 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. `langs` are the parts in @@ -36,6 +49,8 @@ export interface SpellingService { export interface EditorHost { settings(): EditingSettingsView; + /** The page's own settings (`view`). Absent outside a page, where every default applies. */ + pageSettings?(): PageSettingsView; flag(id: FlagId): boolean; announce(text: string, politeness?: 'polite' | 'assertive'): void; openMenu(options: { label: string; items: readonly MenuItemSpec[]; anchor: MenuAnchor }): Promise; diff --git a/app/src/editor/paragraph/directionDom.ts b/app/src/editor/paragraph/directionDom.ts new file mode 100644 index 000000000..65a9bc43e --- /dev/null +++ b/app/src/editor/paragraph/directionDom.ts @@ -0,0 +1,49 @@ +// The DOM attributes of a paragraph's direction, language, and hyphenation (lane text:language-direction, text +// DESIGN.md 2.2). The editor's decorations and the static render both use them, so a paragraph looks the same before +// and after its editor mounts. It imports nothing from Tiptap, so the static render stays small. +import { paraFormat } from './attributes'; +import type { Align } from './attributes'; + +export type Direction = 'ltr' | 'rtl'; +export type PhysicalAlign = 'left' | 'right' | 'center' | 'justify'; + +/** The side a logical alignment takes in a paragraph of the given direction. */ +export function physicalAlign(align: Align | undefined, dir: Direction): PhysicalAlign { + switch (align ?? 'start') { + case 'start': + return dir === 'rtl' ? 'right' : 'left'; + case 'end': + return dir === 'rtl' ? 'left' : 'right'; + case 'center': + return 'center'; + default: + return 'justify'; + } +} + +/** + * The DOM attributes a paragraph's direction, language, and hyphenation give it, or null when it sets none. + * `pageHyphenate` is the page's default (`view.text.hyphenate`), which a paragraph's own `hyphenate` overrides. + */ +export function directionDom( + attrs: Readonly>, + pageHyphenate = false, +): Record | null { + if (attrs.dir == null && attrs.lang == null && attrs.align == null && attrs.hyphenate == null && !pageHyphenate) { + return null; + } + const format = paraFormat(attrs); + const out: Record = {}; + const style: string[] = []; + if (format.dir) out.dir = format.dir; + if (format.lang) out.lang = format.lang; + // Start and end become sides. Center, justify, and distribute need no side, so paraStyle's declarations stand. + const logical = format.align === undefined || format.align === 'start' || format.align === 'end'; + if (logical && (format.dir || format.align)) + style.push(`text-align: ${physicalAlign(format.align, format.dir ?? 'ltr')}`); + const hyphenate = format.hyphenate ?? pageHyphenate; + if (hyphenate) style.push('hyphens: auto', '-webkit-hyphens: auto'); + else if (format.hyphenate === false) style.push('hyphens: manual', '-webkit-hyphens: manual'); + if (style.length) out.style = style.join('; '); + return Object.keys(out).length ? out : null; +} diff --git a/app/src/editor/schema/dom.test.ts b/app/src/editor/schema/dom.test.ts index 0ee0dce48..b5833d33c 100644 --- a/app/src/editor/schema/dom.test.ts +++ b/app/src/editor/schema/dom.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, expect, it } from 'vitest'; -import { parseTextBlock } from '../markdown'; +import { applyElementData, parseTextBlock } from '../markdown'; import { renderStatic, renderStaticSliced } from './dom'; import { markSpecs, nodeSpecs, tableNodeSpecs } from './specs'; @@ -41,3 +41,33 @@ describe('specs', () => { expect(Object.keys(markSpecs)[0]).toBe('float'); }); }); + +describe('static paragraph direction, language, and hyphenation', () => { + // Hand-worked from text DESIGN.md 2.2: start is the right edge of a right-to-left paragraph, a paragraph's own + // hyphenate wins over the page's default, and the attributes are the ones the editor's decorations write. + const doc = () => + applyElementData(parseTextBlock('one\n\ntwo\n\nthree'), { + ids: ['a', 'b', 'c'], + paras: { a: { dir: 'rtl', lang: 'ar-EG', align: 'start' }, b: { lang: 'zxx', hyphenate: false } }, + }); + + it('writes dir, lang, the physical side, and hyphens on a paragraph that is not mounted', () => { + const into = document.createElement('div'); + renderStatic(doc(), into, { direction: { hyphenate: true } }); + const [a, b, c] = [...into.querySelectorAll('p')]; + expect(a.getAttribute('dir')).toBe('rtl'); + expect(a.getAttribute('lang')).toBe('ar-EG'); + expect(a.style.textAlign).toBe('right'); + expect(a.getAttribute('style')).toContain('hyphens: auto'); + expect(b.getAttribute('lang')).toBe('zxx'); + expect(b.getAttribute('style')).toContain('hyphens: manual'); + expect(c.getAttribute('style')).toContain('hyphens: auto'); + expect(c.hasAttribute('dir')).toBe(false); + }); + + it('writes none of them when text.language is off', () => { + const into = document.createElement('div'); + renderStatic(doc(), into); + expect(into.querySelector('[dir], [lang], [style]')).toBeNull(); + }); +}); diff --git a/app/src/editor/schema/dom.ts b/app/src/editor/schema/dom.ts index f5baf782c..204afd0cf 100644 --- a/app/src/editor/schema/dom.ts +++ b/app/src/editor/schema/dom.ts @@ -2,11 +2,71 @@ // 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'; +import type { DOMOutputSpec, Node as PMNode, Schema } from '@tiptap/pm/model'; +import type { EditorHost } from '../host'; +import { PARA_NODES } from '../paragraph/attributes'; +import { directionDom } from '../paragraph/directionDom'; + +export interface StaticOptions { + /** + * Draws each paragraph's direction, language, and hyphenation as the editor's decorations do (text.language), so + * the page does not reflow when the editor mounts. `hyphenate` is the page's default (`view.text.hyphenate`). + */ + direction?: { hyphenate: boolean }; +} + +/** The static options for an editor host: direction drawn when text.language is on, as the editor draws it. */ +export function staticOptions(host: Pick): StaticOptions { + if (!host.flag('text.language')) return {}; + return { direction: { hyphenate: host.pageSettings?.().text.hyphenate ?? false } }; +} + +const isAttrs = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) && !('nodeType' in value); + +/** The spec with `extra` added to its element's attributes. Styles are joined, the spec's first. */ +function withAttrs(spec: DOMOutputSpec, extra: Record | null): DOMOutputSpec { + if (!extra) return spec; + if (Array.isArray(spec)) { + const [tag, second, ...rest] = spec as unknown as [string, ...unknown[]]; + if (!isAttrs(second)) return [tag, extra, ...spec.slice(1)]; + const style = [second.style, extra.style].filter(Boolean).join('; '); + return [tag, { ...second, ...extra, ...(style ? { style } : {}) }, ...rest]; + } + const element = typeof spec === 'object' && 'dom' in spec ? spec.dom : spec; + if (element instanceof Element) { + for (const [name, value] of Object.entries(extra)) { + const old = name === 'style' ? element.getAttribute('style') : null; + element.setAttribute(name, old ? `${old}; ${value}` : value); + } + } + return spec; +} + +const directed = new WeakMap>(); + +/** The schema's serializer, with each paragraph's direction attributes when `options.direction` asks for them. */ +function serializerFor(schema: Schema, options: StaticOptions): DOMSerializer { + const base = DOMSerializer.fromSchema(schema); + if (!options.direction) return base; + const { hyphenate } = options.direction; + const cached = directed.get(schema)?.get(hyphenate); + if (cached) return cached; + const nodes = { ...base.nodes }; + for (const name of PARA_NODES) { + const toDOM = base.nodes[name]; + if (toDOM) nodes[name] = (node) => withAttrs(toDOM(node), directionDom(node.attrs, hyphenate)); + } + const serializer = new DOMSerializer(nodes, base.marks); + const bySetting = directed.get(schema) ?? new Map(); + bySetting.set(hyphenate, serializer); + directed.set(schema, bySetting); + return serializer; +} /** 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); +export function renderStatic(doc: PMNode, into: HTMLElement, options: StaticOptions = {}): void { + const serializer = serializerFor(doc.type.schema, options); const fragment = serializer.serializeFragment(doc.content, { document: into.ownerDocument }); into.replaceChildren(fragment); // The editor keeps an empty line below a last equation (extensions/math.ts), so the static view has it too, and the diff --git a/app/src/features/page/blocks/tableBlock.ts b/app/src/features/page/blocks/tableBlock.ts index e0b3783af..092d36fe6 100644 --- a/app/src/features/page/blocks/tableBlock.ts +++ b/app/src/features/page/blocks/tableBlock.ts @@ -17,7 +17,7 @@ import { applyTableOp, cellAt, cellTextPos, fitAddedColumn } from '../../../edit 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 { renderStatic, staticOptions } 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'; @@ -168,7 +168,7 @@ class TableView implements InnerView { } private showStatic(doc: PMNode): void { - renderStatic(doc, this.editRoot); + renderStatic(doc, this.editRoot, staticOptions(this.ctx.host)); this.editRoot.querySelectorAll('th').forEach((th) => th.setAttribute('scope', 'col')); this.editRoot.querySelector('table')?.setAttribute('aria-label', tableName(doc)); this.name(doc); diff --git a/app/src/features/page/blocks/textBlock.ts b/app/src/features/page/blocks/textBlock.ts index d866b9826..9536906c6 100644 --- a/app/src/features/page/blocks/textBlock.ts +++ b/app/src/features/page/blocks/textBlock.ts @@ -7,7 +7,7 @@ import type { Node as PMNode } from '@tiptap/pm/model'; import { createBlockEditor } from '../../../editor/extensions/kit'; import { highlightStatic } from '../../../editor/highlight'; import { parseTextBlock, serializeTextBlock } from '../../../editor/markdown'; -import { renderStatic } from '../../../editor/schema/dom'; +import { renderStatic, staticOptions } from '../../../editor/schema/dom'; import type { BlockId, BlockJson } from '../../../services/pages/types'; import { t } from '../../../strings/t'; import type { PagePool } from '../pool/pool'; @@ -216,7 +216,7 @@ class TextBlockView implements LazyBlockView, LiveText { /** 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); + renderStatic(doc, this.editRoot, staticOptions(this.ctx.host)); if (this.ctx.host.flag('page.codeHighlight')) void highlightStatic(this.editRoot, doc).catch(() => undefined); } diff --git a/app/src/features/page/editorHost.test.ts b/app/src/features/page/editorHost.test.ts new file mode 100644 index 000000000..d3de2acc0 --- /dev/null +++ b/app/src/features/page/editorHost.test.ts @@ -0,0 +1,22 @@ +// @vitest-environment jsdom + +// The page settings an editor reads from the page's `view.text` (format spec 5.4.2). The defaults are the spec's: +// no automatic hyphenation and a tab stop every 36 points. An invalid value reads as missing (R-VIEW-4). +import { describe, expect, it } from 'vitest'; +import { pageSettingsOf } from './editorHost'; + +describe('page settings for the editor', () => { + it('reads hyphenate and defaultTab from view.text', () => { + expect(pageSettingsOf({ text: { hyphenate: true, defaultTab: 18 } })).toEqual({ + text: { hyphenate: true, defaultTab: 18 }, + }); + }); + + it('uses the defaults when the page sets nothing or sets an invalid value', () => { + const defaults = { text: { hyphenate: false, defaultTab: 36 } }; + expect(pageSettingsOf(undefined)).toEqual(defaults); + expect(pageSettingsOf({})).toEqual(defaults); + expect(pageSettingsOf({ text: 'yes' })).toEqual(defaults); + expect(pageSettingsOf({ text: { hyphenate: 'yes', defaultTab: -4 } })).toEqual(defaults); + }); +}); diff --git a/app/src/features/page/editorHost.ts b/app/src/features/page/editorHost.ts index 56276c38f..a2ff469bf 100644 --- a/app/src/features/page/editorHost.ts +++ b/app/src/features/page/editorHost.ts @@ -1,7 +1,8 @@ // 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 type { EditorHost, EditingSettingsView, PageSettingsView } from '../../editor/host'; +import type { PageViewJson } from '../../services/pages/types'; import { getSettings } from '../../state/settings'; import { osStore } from '../../state/os'; import { announce, openMenu } from '../../ui'; @@ -19,6 +20,25 @@ function editingView(): EditingSettingsView { }; } +/** Points between default tab stops when the page sets none (format spec 5.4.2). */ +const DEFAULT_TAB = 36; + +/** + * The page's settings from its `view`. A `view.text` key whose value is invalid reads as missing, as the file + * format says (R-VIEW-4). + */ +export function pageSettingsOf(view: PageViewJson | undefined): PageSettingsView { + const text = view?.text; + const keys = text !== null && typeof text === 'object' ? (text as Record) : {}; + const tab = keys.defaultTab; + return { + text: { + hyphenate: typeof keys.hyphenate === 'boolean' ? keys.hyphenate : false, + defaultTab: typeof tab === 'number' && Number.isFinite(tab) && tab > 0 ? tab : DEFAULT_TAB, + }, + }; +} + export function createEditorHost(overrides: Partial = {}): EditorHost { return { settings: editingView, diff --git a/app/src/features/page/flags.ts b/app/src/features/page/flags.ts index 5091ff36a..e63b35f71 100644 --- a/app/src/features/page/flags.ts +++ b/app/src/features/page/flags.ts @@ -59,7 +59,11 @@ const TEXT_FLAGS: readonly FlagDef[] = [ textFlag('text.formatPane', 'The Format pane for text.'), textFlag('text.ruler', 'The ruler above the page.'), textFlag('text.symbols', 'The symbol and special character picker.'), - textFlag('text.language', 'Per-run language and text direction.'), + textFlag( + 'text.language', + 'Per-run language and text direction. Language sits on the Review tab and Hyphenation on the Layout tab, so ' + + 'with text.review or text.layout off they are in the command palette only.', + ), textFlag('text.footnotes', 'Footnotes and endnotes.'), textFlag('text.review', 'The Review tab: comments, changes, and proofing.'), textFlag('text.layout', 'The Layout tab: breaks, columns, and vertical text.'), diff --git a/app/src/features/page/mount.ts b/app/src/features/page/mount.ts index 7b72c2919..00c392051 100644 --- a/app/src/features/page/mount.ts +++ b/app/src/features/page/mount.ts @@ -19,7 +19,7 @@ import { createBlockLayer } from './blocks/blockLayer'; import type { PageBlockLayer } from './blocks/blockLayer'; import { frameContext } from './blocks/frameContext'; import { markDraft, syncOf } from './blocks/textBlock'; -import { createEditorHost } from './editorHost'; +import { createEditorHost, pageSettingsOf } from './editorHost'; import { createFlow } from './layout/flow'; import type { Flow } from './layout/flow'; import { createPageLayout } from './layout/actions'; @@ -319,13 +319,16 @@ export function mountPage(container: HTMLElement, page: OpenPage, options: Mount restoreView(page.id, viewport, () => flow.extent()); const pool = createEditorPool(); let objects: Objects | null = null; + let pageSettings = pageSettingsOf(page.initial.view); const host = createEditorHost({ selectBlocks: (blocks, reason) => objects?.selectBlocks(blocks, reason), + pageSettings: () => pageSettings, ...options.host, }); const frames = frameContext(pool, () => layer, { setPageFields(fields) { if (fields.view) { + pageSettings = pageSettingsOf(fields.view); layout.setView(fields.view); layer.setPreferredOrder(fields.view.readingOrder ?? []); } diff --git a/app/src/features/page/spelling/language.test.ts b/app/src/features/page/spelling/language.test.ts index a57f97fd8..0ecaa95a0 100644 --- a/app/src/features/page/spelling/language.test.ts +++ b/app/src/features/page/spelling/language.test.ts @@ -171,6 +171,16 @@ describe('spell check by language', () => { }); }); +describe('a run language inside a zxx paragraph', () => { + it('checks a run with its own language, since the run language wins over the paragraph', () => { + const item = itemOf(paragraph('teh chatt x', { lang: 'zxx' })); + expect(item.text).toBe(' chatt '); + expect(item.langs).toEqual([{ start: 4, length: 5, lang: 'fr-FR' }]); + // Code under a run with its own language stays masked. + expect(textblockText(paragraph('a b', { lang: 'zxx' })).text).toBe('a '); + }); +}); + describe('language pieces and checkers', () => { it('blanks own-language parts of the page piece and keeps offsets', () => { expect(piecesOf({ text: 'ab cd ef', langs: [{ start: 3, length: 2, lang: 'fr' }] }, 4)).toEqual([ diff --git a/app/src/features/page/spelling/setLanguage.test.ts b/app/src/features/page/spelling/setLanguage.test.ts index dfb2a75c7..3f6a272a6 100644 --- a/app/src/features/page/spelling/setLanguage.test.ts +++ b/app/src/features/page/spelling/setLanguage.test.ts @@ -2,7 +2,7 @@ // file's tag form (text SPEC D.8), and a caret alone sets its paragraph's language. import type { EditorState, Transaction } from '@tiptap/pm/state'; import { describe, expect, it } from 'vitest'; -import { applyTo, marked } from '../../../editor/commands/testing'; +import { applyTo, markdownOf, marked } from '../../../editor/commands/testing'; import { canonicalTag, languageChoices, selectionLanguage, setLanguage, toggleNoProof } from './setLanguage'; const run = (state: EditorState, command: (s: EditorState, d: (tr: Transaction) => void) => boolean) => { @@ -39,6 +39,23 @@ describe('set language', () => { const off = run(on, toggleNoProof); expect(selectionLanguage(off)).toBeNull(); }); + + it('turns do not check spelling off for the run the caret is in', () => { + const { state } = marked('a t[]eh b'); + expect(selectionLanguage(state)).toBe('zxx'); + const off = run(state, toggleNoProof); + expect(markdownOf(off)).toBe('a teh b'); + expect(selectionLanguage(off)).toBeNull(); + expect(off.doc.firstChild?.attrs.lang ?? null).toBeNull(); + }); + + it('changes the language of the run the caret is in, and only that run', () => { + const { state } = marked('un x ch[]at'); + const page = run(state, (s, d) => setLanguage(s, d, null)); + expect(markdownOf(page)).toBe('un x chat'); + const german = run(state, (s, d) => setLanguage(s, d, 'de-DE')); + expect(markdownOf(german)).toBe('un x chat'); + }); }); describe('language tags and choices', () => { diff --git a/app/src/features/page/spelling/setLanguage.ts b/app/src/features/page/spelling/setLanguage.ts index c830aecdf..967d575cd 100644 --- a/app/src/features/page/spelling/setLanguage.ts +++ b/app/src/features/page/spelling/setLanguage.ts @@ -1,8 +1,9 @@ // Set language and "Do not check spelling" (lane text:language-direction, text DESIGN.md 2.1 and 2.2). A selection -// gets a `lang` mark on its text. A caret alone sets the language of the paragraphs it is in, as the paragraph -// mark's language. The language "zxx" means "do not check spelling", which the spell checker never sends. +// gets a `lang` mark on its text. A caret inside a run with its own language changes that run. Any other caret +// sets the language of the paragraphs it is in, as the paragraph mark's language. The language "zxx" means "do not +// check spelling", which the spell checker never sends. import type { Editor } from '@tiptap/core'; -import type { MarkType } from '@tiptap/pm/model'; +import type { Mark, MarkType } from '@tiptap/pm/model'; import type { EditorState, Transaction } from '@tiptap/pm/state'; import { setParagraphAttr } from '../../../editor/extensions/direction'; import { NO_PROOF } from '../../../editor/extensions/spellingText'; @@ -45,6 +46,40 @@ export function selectionLanguage(state: EditorState): string | null { return null; } +/** + * The `lang` run the caret is in, as selectionLanguage reads it: the stored marks when they hold a language, else + * the whole run of text around the caret that carries the same `lang` mark. Null when the caret has no run language. + */ +function caretLangRun( + state: EditorState, + type: MarkType, +): { from: number; to: number; stored?: readonly Mark[] } | null { + const { $from } = state.selection; + if (state.storedMarks) { + return state.storedMarks.some((mark) => mark.type === type) + ? { from: $from.pos, to: $from.pos, stored: state.storedMarks } + : null; + } + const mark = $from.marks().find((item) => item.type === type); + if (!mark) return null; + const parent = $from.parent; + const start = $from.start(); + const offset = $from.parentOffset; + let found: { from: number; to: number } | null = null; + let open = -1; + const close = (end: number) => { + if (open !== -1 && !found && open <= offset && offset <= end) found = { from: start + open, to: start + end }; + open = -1; + }; + parent.forEach((child, pos) => { + if (mark.isInSet(child.marks)) { + if (open === -1) open = pos; + } else close(pos); + }); + close(parent.content.size); + return found; +} + /** * Sets the language of the selection, or of the caret's paragraphs. Null returns the text to the page's language. * Returns false when nothing can take a language, such as a code block. @@ -56,8 +91,25 @@ export function setLanguage( ): boolean { const lang = tag === null ? null : tag === NO_PROOF ? NO_PROOF : canonicalTag(tag); if (tag !== null && lang === null) return false; - if (state.selection.empty) return setParagraphAttr(state, dispatch, 'lang', lang); const type = langType(state); + if (state.selection.empty) { + // A caret inside a run with its own language changes that run, since the run's language wins over the + // paragraph's. Otherwise the caret sets its paragraphs' language. + const run = type ? caretLangRun(state, type) : null; + if (!run || !type) return setParagraphAttr(state, dispatch, 'lang', lang); + if (dispatch) { + const tr = state.tr; + if (run.stored) { + const marks = run.stored.filter((mark) => mark.type !== type); + tr.setStoredMarks(lang === null ? marks : [...marks, type.create({ lang })]); + } else { + tr.removeMark(run.from, run.to, type); + if (lang !== null) tr.addMark(run.from, run.to, type.create({ lang })); + } + dispatch(tr.scrollIntoView()); + } + return true; + } if (!type) return false; if (dispatch) { const tr = state.tr; From 76c1097d7e22db47bd7ed13e91af02701efdc9c6 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 7 Nov 2026 12:56:33 -0800 Subject: [PATCH 06/26] Add the ruler for indents and tab stops The ruler sits above the focused text block, aligned to its content box at any zoom, in inches, centimeters, millimeters, points, or picas. Markers move by mouse, pen, touch (44 px targets, 4 px drag threshold) or arrow keys, snap to 1/16 inch (0.25 cm), and Alt drags freely. Clicking adds a tab stop of the selected type; dragging a stop off the ruler removes it. Each marker is a slider that announces its value. Behind text.ruler. Co-Authored-By: Claude Sonnet 5.5 --- app/src/app/flagUsage.test.ts | 1 - app/src/features/page/qol/PageExtras.tsx | 10 +- app/src/features/page/registrations/ruler.ts | 33 ++ app/src/features/page/ruler/Ruler.tsx | 107 +++++ app/src/features/page/ruler/RulerBar.test.tsx | 150 +++++++ app/src/features/page/ruler/RulerBar.tsx | 368 ++++++++++++++++++ app/src/features/page/ruler/geometry.test.ts | 61 +++ app/src/features/page/ruler/geometry.ts | 98 +++++ app/src/features/page/ruler/ruler.module.css | 129 ++++++ app/src/features/page/ruler/store.ts | 59 +++ app/src/features/page/ruler/units.ts | 63 +++ app/src/strings/en/textRuler.ts | 53 ++- 12 files changed, 1129 insertions(+), 3 deletions(-) create mode 100644 app/src/features/page/registrations/ruler.ts create mode 100644 app/src/features/page/ruler/Ruler.tsx create mode 100644 app/src/features/page/ruler/RulerBar.test.tsx create mode 100644 app/src/features/page/ruler/RulerBar.tsx create mode 100644 app/src/features/page/ruler/geometry.test.ts create mode 100644 app/src/features/page/ruler/geometry.ts create mode 100644 app/src/features/page/ruler/ruler.module.css create mode 100644 app/src/features/page/ruler/store.ts create mode 100644 app/src/features/page/ruler/units.ts diff --git a/app/src/app/flagUsage.test.ts b/app/src/app/flagUsage.test.ts index 564cffc6c..43c7b7166 100644 --- a/app/src/app/flagUsage.test.ts +++ b/app/src/app/flagUsage.test.ts @@ -18,7 +18,6 @@ const WAITING: Readonly> = { 'text.fontPicker': 'Beta 10 to 12: the lane that builds it reads it.', 'text.styles2': 'Beta 10 to 12: the lane that builds it reads it.', 'text.formatPane': 'Beta 10 to 12: the lane that builds it reads it.', - 'text.ruler': 'Beta 10 to 12: the lane that builds it reads it.', 'text.language': 'Beta 10 to 12: the lane that builds it reads it.', 'text.findFormat': 'Beta 10 to 12: the lane that builds it reads it.', 'tables.sheetWrites': 'Beta 10: the table bar and the formula bar read it.', diff --git a/app/src/features/page/qol/PageExtras.tsx b/app/src/features/page/qol/PageExtras.tsx index cb73c1b9c..85e8e49dc 100644 --- a/app/src/features/page/qol/PageExtras.tsx +++ b/app/src/features/page/qol/PageExtras.tsx @@ -4,13 +4,21 @@ import type { MountedPage } from '../mount'; import { FindBar } from '../find/FindBar'; import { SourceView } from '../source/SourceView'; +import { Ruler } from '../ruler/Ruler'; import { TocPane } from '../toc/TocPane'; import { StatusLine } from './StatusLine'; export type ExtrasSlot = 'top' | 'source' | 'bottom' | 'side'; export default function PageExtras({ mounted, slot }: { mounted: MountedPage; slot: ExtrasSlot }) { - if (slot === 'top') return ; + if (slot === 'top') { + return ( + <> + + + + ); + } if (slot === 'source') return ; if (slot === 'bottom') return ; return ; diff --git a/app/src/features/page/registrations/ruler.ts b/app/src/features/page/registrations/ruler.ts new file mode 100644 index 000000000..fad4c3dff --- /dev/null +++ b/app/src/features/page/registrations/ruler.ts @@ -0,0 +1,33 @@ +// The ruler's registrations (lane text:ruler): View, Ruler shows the ruler above the focused text block. This file +// loads at start-up, so it holds only the command; the ruler's code loads with the page. +import { commandBar, commands } from '../../../registries'; +import { t } from '../../../strings/t'; +import { announce } from '../../../ui'; +import { rulerState, setRulerShown } from '../ruler/store'; +import { shownQueue } from '../sync/shown'; + +commands.register({ + id: 'view.ruler', + title: 'textRuler.toggle', + keywords: 'textRuler.toggleKeywords', + category: 'view', + icon: 'Ruler', + flag: 'text.ruler', + when: () => shownQueue.get() !== null, + checked: () => rulerState.get().shown, + run: () => { + const shown = !rulerState.get().shown; + setRulerShown(shown); + announce(t(shown ? 'textRuler.shown' : 'textRuler.hidden')); + }, +}); + +commandBar.register({ + id: 'view.ruler', + tab: 'view', + group: 'pageTools', + command: 'view.ruler', + priority: 70, + presentation: 'toggle', + flag: 'text.ruler', +}); diff --git a/app/src/features/page/ruler/Ruler.tsx b/app/src/features/page/ruler/Ruler.tsx new file mode 100644 index 000000000..5c93b6a4f --- /dev/null +++ b/app/src/features/page/ruler/Ruler.tsx @@ -0,0 +1,107 @@ +// The ruler above the focused text block (text DESIGN.md 5.4). This part reads the editor: it finds the block's +// content box at the current zoom, reads the paragraph's indent and tab stops, and writes a change back as one +// command, so each drag is one undo step. The bar itself is in RulerBar.tsx. +import type { Editor } from '@tiptap/core'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useFlag } from '../../../app/flags'; +import { paraAt, setPara } from '../../../editor/commands/paragraph'; +import type { Indent, TabStop } from '../../../editor/paragraph/attributes'; +import { useStore } from '../../../state/store'; +import { targetEditor } from '../formattingBar/target'; +import type { MountedPage } from '../mount'; +import { RulerBar } from './RulerBar'; +import { rulerState, rulerTabType, setRulerUnit } from './store'; +import { roundPoints } from './units'; +import styles from './ruler.module.css'; + +interface Frame { + editor: Editor; + /** Where the block's content box starts, in client pixels from the host's left edge. */ + left: number; + /** The content box's width in points, as the page lays it out (before zoom). */ + width: number; + zoom: number; +} + +const NO_INDENT: Indent = { start: 0, end: 0, first: 0 }; + +/** The editor's content box: the page's zoom (its scale), its width, and its left edge. */ +export function measureFrame(editor: Editor, host: HTMLElement): Frame | null { + const dom = editor.view.dom as HTMLElement; + if (!dom.isConnected || dom.offsetWidth === 0) return null; + const rect = dom.getBoundingClientRect(); + const zoom = rect.width / dom.offsetWidth; + const style = getComputedStyle(dom); + const pad = (side: string) => Number.parseFloat(style.getPropertyValue(`padding-${side}`)) || 0; + const widthPx = dom.clientWidth - pad('left') - pad('right'); + return { + editor, + left: rect.left + pad('left') * zoom - host.getBoundingClientRect().left, + width: roundPoints(widthPx * 0.75), + zoom, + }; +} + +const storedIndent = (indent: Indent): Indent | null => + indent.start === 0 && indent.end === 0 && indent.first === 0 ? null : indent; + +export function Ruler({ mounted }: { mounted: MountedPage }) { + const allowed = useFlag('text.ruler'); + const { shown, unit } = useStore(rulerState, (state) => state); + const tabType = useStore(rulerTabType, (type) => type); + const host = useRef(null); + const [frame, setFrame] = useState(null); + const [, bump] = useState(0); + const on = allowed && shown; + + const refresh = useCallback(() => { + const editor = targetEditor(); + setFrame(editor && host.current ? measureFrame(editor, host.current) : null); + bump((n) => n + 1); + }, []); + + const editor = frame?.editor; + useEffect(() => { + if (!on) return; + refresh(); + const stops = [mounted.pool.onActiveChange(refresh), mounted.viewport.onCamera(refresh)]; + if (editor) { + editor.on('transaction', refresh); + stops.push(() => editor.off('transaction', refresh)); + } + const observer = new ResizeObserver(refresh); + if (host.current) observer.observe(host.current); + window.addEventListener('resize', refresh); + return () => { + stops.forEach((stop) => stop()); + observer.disconnect(); + window.removeEventListener('resize', refresh); + }; + }, [on, mounted, refresh, editor]); + + if (!on) return null; + const para = editor && !editor.isDestroyed ? paraAt(editor.state) : null; + const run = (key: 'indent' | 'tabs', value: unknown) => { + if (editor) setPara(key, value)(editor.state, editor.view.dispatch); + }; + return ( + + ); +} diff --git a/app/src/features/page/ruler/RulerBar.test.tsx b/app/src/features/page/ruler/RulerBar.test.tsx new file mode 100644 index 000000000..72bacb723 --- /dev/null +++ b/app/src/features/page/ruler/RulerBar.test.tsx @@ -0,0 +1,150 @@ +// The ruler in a real browser: drags at two zooms, the tap threshold, the keyboard, tab stops, and the 44 px targets. +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { Indent, TabStop } from '../../../editor/paragraph/attributes'; +import { announcements } from '../../../ui/announce'; +import type { TabType } from './geometry'; +import { RulerBar } from './RulerBar'; +import type { RulerUnit } from './units'; + +const WIDTH = 468; + +function setup( + options: { zoom?: number; indent?: Indent; tabs?: TabStop[]; tabType?: TabType; unit?: RulerUnit } = {}, +) { + const onIndent = vi.fn(); + const onTabs = vi.fn(); + const view = render( + undefined} + onUnit={() => undefined} + onIndent={onIndent} + onTabs={onTabs} + />, + ); + const track = view.container.querySelector('[data-track]')!; + const marker = (id: string) => view.container.querySelector(`[data-marker="${id}"]`)!; + const pointer = (type: string, x: number, y: number, init: PointerEventInit = {}) => + track.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId: 7, + pointerType: 'pen', + button: 0, + clientX: track.getBoundingClientRect().left + x, + clientY: track.getBoundingClientRect().top + y, + ...init, + }), + ); + /** Presses at `x` pixels and `y` from the track's top, moves by `dx`, and lets go. */ + const drag = (x: number, y: number, dx: number, dy = 0, init: PointerEventInit = {}) => { + pointer('pointerdown', x, y, init); + pointer('pointermove', x + dx / 2, y + dy / 2, init); + pointer('pointermove', x + dx, y + dy, init); + pointer('pointerup', x + dx, y + dy, init); + }; + return { onIndent, onTabs, track, marker, drag, pointer, view }; +} + +describe('dragging a marker', () => { + it('a 48 px drag of the first-line marker at 100 percent zoom sets first to 36 pt', () => { + const { onIndent, drag } = setup({ zoom: 1 }); + drag(0, 9, 48); + expect(onIndent).toHaveBeenCalledTimes(1); + expect(onIndent).toHaveBeenCalledWith({ start: 0, end: 0, first: 36 }); + }); + + it('the same drag at 200 percent zoom sets first to 18 pt', () => { + const { onIndent, drag } = setup({ zoom: 2 }); + drag(0, 9, 48); + expect(onIndent).toHaveBeenCalledWith({ start: 0, end: 0, first: 18 }); + }); + + it('a drag of 3 px does not move a marker', () => { + const { onIndent, onTabs, drag } = setup(); + drag(0, 9, 3); + expect(onIndent).not.toHaveBeenCalled(); + expect(onTabs).not.toHaveBeenCalled(); + }); + + it('snaps to 1/16 inch, and Alt drags freely', () => { + const snapped = setup(); + snapped.drag(0, 9, 10); + // 10 px is 7.5 pt; the nearest 1/16 inch (4.5 pt) is 9 pt. + expect(snapped.onIndent).toHaveBeenCalledWith({ start: 0, end: 0, first: 9 }); + const free = setup(); + free.drag(0, 9, 10, 0, { altKey: true }); + expect(free.onIndent).toHaveBeenCalledWith({ start: 0, end: 0, first: 7.5 }); + }); + + it('the left marker moves both indents, and the hanging marker keeps the first line where it is', () => { + const left = setup({ indent: { start: 36, end: 0, first: 0 } }); + left.drag(48, 38, 48); + expect(left.onIndent).toHaveBeenCalledWith({ start: 72, end: 0, first: 0 }); + const hanging = setup({ indent: { start: 36, end: 0, first: 0 } }); + hanging.drag(48, 24, 48); + expect(hanging.onIndent).toHaveBeenCalledWith({ start: 72, end: 0, first: -36 }); + }); + + it('the right marker sets the right indent from the right edge', () => { + const { onIndent, drag } = setup(); + const edge = (WIDTH * 4) / 3; + drag(edge, 24, -60); + expect(onIndent).toHaveBeenCalledWith({ start: 0, end: 45, first: 0 }); + }); +}); + +describe('tab stops', () => { + it('clicking the bare ruler adds a stop of the selected type', () => { + const { onTabs, pointer } = setup({ tabType: 'center' }); + pointer('pointerdown', 192, 20); + pointer('pointerup', 192, 20); + expect(onTabs).toHaveBeenCalledWith([{ pos: 144, align: 'center', leader: 'none' }]); + }); + + it('dragging a stop moves it, and dragging it off the ruler removes it', () => { + const tabs: TabStop[] = [{ pos: 144, align: 'start', leader: 'none' }]; + const moved = setup({ tabs }); + moved.drag(192, 38, 60); + expect(moved.onTabs).toHaveBeenCalledWith([{ pos: 189, align: 'start', leader: 'none' }]); + const removed = setup({ tabs }); + removed.drag(192, 38, 0, 80); + expect(removed.onTabs).toHaveBeenCalledWith([]); + }); +}); + +describe('the markers as sliders', () => { + it('are sliders with a value in the unit, and a hit area of 44 px', () => { + const { marker } = setup({ indent: { start: 36, end: 0, first: 0 } }); + const first = marker('first'); + expect(first.getAttribute('role')).toBe('slider'); + expect(first.getAttribute('aria-label')).toBe('First line indent'); + expect(first.getAttribute('aria-valuenow')).toBe('0.5'); + expect(first.getAttribute('aria-valuetext')).toBe('0.5 inches'); + expect(first.getBoundingClientRect().width).toBeGreaterThanOrEqual(44); + expect(first.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + }); + + it('arrow keys on a focused marker move it 1/16 inch and announce the value', () => { + const { onIndent, marker } = setup({ indent: { start: 36, end: 0, first: 0 } }); + const hanging = marker('hanging'); + hanging.focus(); + hanging.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true })); + expect(onIndent).toHaveBeenCalledWith({ start: 40.5, end: 0, first: -4.5 }); + // 40.5 pt is 0.5625 inch, and the value is shown to two decimals. + expect(announcements()).toContain('Hanging indent, 0.56 inches.'); + }); + + it('Delete on a tab stop removes it', () => { + const { onTabs, marker } = setup({ tabs: [{ pos: 144, align: 'end', leader: 'none' }] }); + marker('tab:144').dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true, cancelable: true })); + expect(onTabs).toHaveBeenCalledWith([]); + }); +}); diff --git a/app/src/features/page/ruler/RulerBar.tsx b/app/src/features/page/ruler/RulerBar.tsx new file mode 100644 index 000000000..9ae0c9e62 --- /dev/null +++ b/app/src/features/page/ruler/RulerBar.tsx @@ -0,0 +1,368 @@ +// The ruler itself (text DESIGN.md 5.4): the markers, the tab stops, and the pointer and keyboard moves that change +// them. It knows nothing of the editor: it takes the paragraph's indent and tab stops, and hands back the new ones. +// One drag is one change on release, so one Ctrl+Z undoes it. Every marker is a slider with a value in the unit, and +// the arrow keys move it one step of the snap grid. +import { useRef, useState } from 'react'; +import type { KeyboardEvent, PointerEvent } from 'react'; +import type { Indent, TabStop } from '../../../editor/paragraph/attributes'; +import { t } from '../../../strings/t'; +import type { MessageKey } from '../../../strings/t'; +import { announce } from '../../../ui'; +import { + DRAG_THRESHOLD_PX, + HIT_PX, + TAB_TYPES, + clamp, + dragValue, + firstLineAt, + isTabAlign, + movedIndent, + movedTab, + offRuler, + pointsToPixels, + withTab, + withoutTab, +} from './geometry'; +import type { MarkerKind, TabType } from './geometry'; +import { RULER_UNITS, TICKS, fromPoints, roundPoints, shownNumber, snapPoints } from './units'; +import type { RulerUnit } from './units'; +import styles from './ruler.module.css'; + +export interface RulerBarProps { + unit: RulerUnit; + /** The page's zoom, 1 for 100 percent. */ + zoom: number; + /** The width of the block's content box, in points. */ + width: number; + indent: Indent; + tabs: readonly TabStop[]; + tabType: TabType; + onTabType(type: TabType): void; + onUnit(unit: RulerUnit): void; + onIndent(next: Indent): void; + onTabs(next: TabStop[]): void; +} + +export interface Marker { + id: string; + kind: MarkerKind; + /** Points from the left edge of the content box, as drawn. */ + at: number; + /** The tab stop, for a tab marker. */ + tab?: TabStop; +} + +interface Press { + marker: Marker | null; + pointer: number; + x: number; + y: number; + started: boolean; +} + +/** Where each marker's glyph sits in the band, as a fraction of its height: the three indent parts stack. */ +const GLYPH_Y: Readonly> = { first: 0.2, hanging: 0.55, left: 0.85, right: 0.55, tab: 0.85 }; + +export function markersOf(indent: Indent, tabs: readonly TabStop[], width: number): Marker[] { + const out: Marker[] = [ + { id: 'first', kind: 'first', at: firstLineAt(indent) }, + { id: 'hanging', kind: 'hanging', at: indent.start }, + { id: 'left', kind: 'left', at: indent.start }, + { id: 'right', kind: 'right', at: width - indent.end }, + ]; + for (const tab of tabs) if (tab.align !== 'clear') out.push({ id: `tab:${tab.pos}`, kind: 'tab', at: tab.pos, tab }); + return out; +} + +/** The marker under a pointer: within half a hit area sideways, then the one whose glyph is nearest vertically. */ +export function pickMarker( + markers: readonly Marker[], + x: number, + y: number, + height: number, + zoom: number, +): Marker | null { + let best: Marker | null = null; + let bestDistance = Infinity; + for (const marker of markers) { + const dx = Math.abs(pointsToPixels(marker.at, zoom) - x); + if (dx > HIT_PX / 2) continue; + const distance = Math.abs(GLYPH_Y[marker.kind] * height - y) + dx / 100; + if (distance < bestDistance) { + best = marker; + bestDistance = distance; + } + } + return best; +} + +function markerName(marker: Marker): string { + if (marker.kind === 'tab') { + return t('textRuler.marker.tab', { type: t(`textRuler.tabName.${marker.tab!.align}` as MessageKey) }); + } + return t(`textRuler.marker.${marker.kind}` as MessageKey); +} + +/** The value a marker shows, in points: indents from their own edge, tab stops from the left. */ +function shownPoints(marker: Marker, at: number, width: number): number { + return marker.kind === 'right' ? width - at : at; +} + +export function valueText(points: number, unit: RulerUnit): string { + return t(`textRuler.value.${unit}` as 'textRuler.value.in', { n: Number(shownNumber(points, unit)) }); +} + +/** The tick marks and the numbers, from the left edge of the content box. */ +function Ticks({ unit, zoom, width }: Pick) { + const { major, parts } = TICKS[unit]; + const perMajor = major / fromPoints(1, unit); + const count = Math.floor((width / perMajor) * parts); + const marks = []; + for (let i = 0; i <= count; i++) { + const points = (i * perMajor) / parts; + const whole = i % parts === 0; + const half = parts % 2 === 0 && i % (parts / 2) === 0; + marks.push({ + x: pointsToPixels(points, zoom), + size: whole ? 'major' : half ? 'half' : 'minor', + label: whole && i > 0 ? String(Math.round(fromPoints(points, unit))) : undefined, + }); + } + const top = { major: '55%', half: '70%', minor: '80%' } as const; + return ( + + ); +} + +interface Edits { + commit(marker: Marker, at: number): void; + addAt(x: number): void; + remove(marker: Marker): void; +} + +/** What a marker or a click does to the paragraph, each as one change with the new value announced. */ +function useEdits(props: RulerBarProps, grid: number): Edits { + const { unit, zoom, width, indent, tabs, tabType } = props; + const remove = (marker: Marker) => { + props.onTabs(withoutTab(tabs, marker.tab!.pos)); + announce(t('textRuler.removed', { name: markerName(marker) })); + }; + + /** A marker ends at `at` points from the left edge. */ + const commit = (marker: Marker, at: number) => { + if (marker.kind === 'tab') { + const target = roundPoints(clamp(at, 0, width)); + if (target === marker.tab!.pos) return; + props.onTabs(movedTab(tabs, marker.tab!.pos, target)); + announce(t('textRuler.announced', { name: markerName(marker), value: valueText(target, unit) })); + return; + } + const next = movedIndent(marker.kind, indent, marker.kind === 'right' ? width - at : at, width); + if (JSON.stringify(next) === JSON.stringify(indent)) return; + props.onIndent(next); + const value = marker.kind === 'right' ? next.end : marker.kind === 'first' ? firstLineAt(next) : next.start; + announce(t('textRuler.announced', { name: markerName(marker), value: valueText(value, unit) })); + }; + + /** A click on the bare ruler adds a stop of the selected type, or sets the indent for the two indent types. */ + const addAt = (x: number) => { + const at = roundPoints(clamp(dragValue(0, x, zoom, grid, false), 0, width)); + if (tabType === 'first' || tabType === 'hanging') return props.onIndent(movedIndent(tabType, indent, at, width)); + if (!isTabAlign(tabType)) return; + const next = withTab(tabs, { pos: at, align: tabType, leader: 'none' }); + if (!next) return; + props.onTabs(next); + announce( + t('textRuler.added', { name: t(`textRuler.tabName.${tabType}` as MessageKey), value: valueText(at, unit) }), + ); + }; + return { commit, addAt, remove }; +} + +/** The arrow keys move a marker one step of the grid (Alt: half a point; Shift: four steps); Home and End go to the ends. */ +function markerKey(marker: Marker, edits: Edits, grid: number, width: number) { + return (event: KeyboardEvent) => { + const step = (event.altKey ? 0.5 : grid) * (event.shiftKey ? 4 : 1); + const forward = event.key === 'ArrowRight' || event.key === 'ArrowUp'; + const back = event.key === 'ArrowLeft' || event.key === 'ArrowDown'; + if (forward || back) edits.commit(marker, clamp(roundPoints(marker.at + (forward ? step : -step)), 0, width)); + else if (event.key === 'Home') edits.commit(marker, 0); + else if (event.key === 'End') edits.commit(marker, width); + else if ((event.key === 'Delete' || event.key === 'Backspace') && marker.kind === 'tab') edits.remove(marker); + else return; + event.preventDefault(); + }; +} + +/** The track's pointer handling: pick a marker, start a drag past the threshold, and commit on release. */ +function useTrackPointer(props: RulerBarProps, markers: readonly Marker[], edits: Edits, grid: number) { + const { zoom, width } = props; + const track = useRef(null); + const press = useRef(null); + const [drag, setDrag] = useState<{ id: string; at: number } | null>(null); + const local = (event: PointerEvent) => { + const box = track.current!.getBoundingClientRect(); + return { x: event.clientX - box.left, y: event.clientY - box.top, height: box.height }; + }; + const end = () => { + press.current = null; + setDrag(null); + }; + + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0 || !track.current) return; + const { x, y, height } = local(event); + const marker = pickMarker(markers, x, y, height, zoom); + press.current = { marker, pointer: event.pointerId, x: event.clientX, y: event.clientY, started: false }; + capture(track.current, event.pointerId, true); + if (marker) track.current.querySelector(`[data-marker="${marker.id}"]`)?.focus(); + event.preventDefault(); + }; + + const onPointerMove = (event: PointerEvent) => { + const current = press.current; + if (current?.pointer !== event.pointerId) return; + const dx = event.clientX - current.x; + // A press that stays within the threshold is a tap: nothing moves. + if (!current.started && Math.hypot(dx, event.clientY - current.y) < DRAG_THRESHOLD_PX) return; + current.started = true; + if (!current.marker) return; + const at = clamp(dragValue(current.marker.at, dx, zoom, grid, event.altKey), 0, width); + setDrag({ id: current.marker.id, at }); + }; + + const onPointerUp = (event: PointerEvent) => { + const current = press.current; + if (current?.pointer !== event.pointerId) return; + capture(track.current, event.pointerId, false); + end(); + const { marker } = current; + if (!current.started) return marker ? undefined : edits.addAt(local(event).x); + if (!marker) return; + const box = local(event); + // A tab stop dragged off the ruler, above, or below it, is removed. + const outside = box.y < 0 ? box.y : box.y > box.height ? box.y - box.height : 0; + if (marker.kind === 'tab' && offRuler(outside)) return edits.remove(marker); + edits.commit(marker, clamp(dragValue(marker.at, event.clientX - current.x, zoom, grid, event.altKey), 0, width)); + }; + + return { track, drag, handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: end } }; +} + +interface MarkerViewProps { + marker: Marker; + at: number; + dragging: boolean; + unit: RulerUnit; + zoom: number; + width: number; + onKeyDown(event: KeyboardEvent): void; +} + +/** A marker: a slider with its value in the unit, and a 44 px box that is a target for pen and touch. */ +function MarkerView({ marker, at, dragging, unit, zoom, width, onKeyDown }: MarkerViewProps) { + const shown = shownPoints(marker, at, width); + return ( + +