diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57d0b0147..ca5f2b247 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,7 +88,10 @@ jobs: shell: bash run: | status=0 - for shard in 1 2 3 4 5; do npm run test:components -- --shard=$shard/5 || status=1; done + # A runner that closes the browser fails one shard at a random file. A real failure fails the second try too. + for shard in 1 2 3 4 5; do + npm run test:components -- --shard=$shard/5 || npm run test:components -- --shard=$shard/5 || status=1 + done exit $status - name: Keep the failure screenshots # Vitest saves a screenshot of the page for each failed test. diff --git a/README.md b/README.md index 84ab166cb..c76bafa9e 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ You keep notebooks, sections, and pages in a notes folder you choose, as plain f ### Write and draw -The Word-level text, Excel-level table, and code studio foundations are on the beta branch behind flags that are off. The table work so far adds a cell format bar, spreadsheet keys, conditional formatting, styled tables in Word and HTML exports, and the logic, lookup, array, and statistics functions. +The Word-level text, Excel-level table, and code studio foundations are on the beta branch behind flags that are off. The text work so far adds a Format pane with every font and paragraph field, a ruler for indents and tab stops, and a language and a direction for each passage. The table work so far adds a cell format bar, spreadsheet keys, conditional formatting, styled tables in Word and HTML exports, and the logic, lookup, array, and statistics functions. - [Typed notes](docs/help/typed-notes.md): text, headings, text styles kept with the notebook, lists, checklists, tables, code, callouts, Markdown, templates, and find and replace. Find by format, special character, wildcard, or regular expression. The Home tab has font and size boxes, split buttons for underline, colors, and lists, and a styles gallery. Typed web and e-mail addresses turn into links. Pasted tables and spreadsheet cells become tables. Copies give other apps plain text. - [Moving between blocks](docs/help/moving-between-blocks.md): keys that move between text, pictures, tables, and code without the mouse. @@ -64,7 +64,7 @@ The Word-level text, Excel-level table, and code studio foundations are on the b - [My handwriting](docs/help/my-handwriting.md): typed text and math written in your own hand, learned from a calibration page. - [Paper and page views](docs/help/paper-and-page-views.md): flow, paginated, and canvas pages, with lined, grid, and dot paper; slides, print, and PDF. - [Tables and charts](docs/help/tables-and-charts.md): smart tables with views, Excel-style formulas (logic, lookup, arrays, and statistics), cell formats (colors, borders, number formats), conditional formatting, spreadsheet keys with Grid and Edit modes (a setting chooses them for new tables), and charts that update with them. -- [Language and spelling](docs/help/language-and-spelling.md): American English by default, and one choice for every part that reads or writes words. +- [Language and spelling](docs/help/language-and-spelling.md): American English by default, and one choice for every part that reads or writes words. A passage can have its own language, or skip spell check. ### Organize and find diff --git a/app/bundle-budget.json b/app/bundle-budget.json index 30f23fcf9..95c825874 100644 --- a/app/bundle-budget.json +++ b/app/bundle-budget.json @@ -1,6 +1,6 @@ { "startup": { - "jsKb": 612, + "jsKb": 620, "cssKb": 30 }, "after": [ diff --git a/app/src/app/flagUsage.test.ts b/app/src/app/flagUsage.test.ts index 2e74442bb..4739f86aa 100644 --- a/app/src/app/flagUsage.test.ts +++ b/app/src/app/flagUsage.test.ts @@ -17,9 +17,6 @@ const WAITING: Readonly> = { 'text.paraFormats': 'Beta 10 to 12: the lane that builds it reads it.', '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.', 'code.tier2': 'Beta 14: code:L16 and code:L17 run installed toolchains behind it.', 'code.models': 'Beta 13: code:L13a and code:L13b read it for the code models.', diff --git a/app/src/editor/commands/lists.ts b/app/src/editor/commands/lists.ts index d725caf89..9d04789ab 100644 --- a/app/src/editor/commands/lists.ts +++ b/app/src/editor/commands/lists.ts @@ -7,8 +7,9 @@ import type { Node as PMNode } from '@tiptap/pm/model'; import type { EditorState, Transaction } from '@tiptap/pm/state'; import { LISTS_META, listDefinitionsOf } from '../extensions/listItem'; import { META_ELEMENT_DATA } from '../meta'; +import { isFontName } from '../schema/constants'; import { LIST_ID } from '../paragraph/attributes'; -import { MAX_LEVELS, MAX_START, NUMBER_FORMATS, numberItems } from '../paragraph/numbering'; +import { MAX_LEVELS, MAX_START, NUMBER_FORMATS, levelOf, numberItems } from '../paragraph/numbering'; import type { ListDefinitions, ListLevel, NumberFormat } from '../paragraph/numbering'; import { ancestorDepth, fromChange } from './command'; import type { Command } from './command'; @@ -187,6 +188,7 @@ export function applyBullet(symbol: string, font: string | null = null): Command const chars = [...symbol]; // An invisible bullet is refused: controls, format characters, spaces, and a lone combining mark. if (chars.length !== 1 || /^[\p{C}\p{Z}\p{M}]$/u.test(symbol)) return () => false; + if (font && !isFontName(font)) return () => false; return redefine('bulletList', (levels, depth) => { levels[depth] = { ...levels[depth], format: 'bullet', symbol, ...(font ? { font } : {}) }; return levels; @@ -312,3 +314,41 @@ export function labelAt(state: EditorState): string | null { if (!at) return null; return numberItems(state.doc, listDefinitionsOf(state)).find((item) => item.pos === at.pos)?.label ?? null; } + +// ---- Format pane: any field of the caret's level ---- + +/** A level's fields as the Format pane reads them: the caret's depth, whether the list counts, and the level. */ +export interface ListLevelAt { + depth: number; + ordered: boolean; + level: ListLevel; +} + +/** The level of the caret's list item, with defaults filled in, or null outside a list. */ +export function listLevelAt(state: EditorState): ListLevelAt | null { + const at = itemAt(state); + if (!at) return null; + const ordered = at.list.type.name === 'orderedList'; + const ref = refOf(at.item); + const levels = ref ? listDefinitionsOf(state).get(ref.id) : undefined; + return { depth: at.depth, ordered, level: levelOf(levels, at.depth, ordered) }; +} + +/** Sets some fields of the caret's level (a new list definition, as Word writes one). A bad field is refused. */ +export function setListLevel(patch: Level): Command { + const chars = patch.symbol == null ? 1 : [...patch.symbol].length; + const valid = + (patch.format === undefined || (NUMBER_FORMATS as readonly string[]).includes(patch.format)) && + (patch.text === undefined || [...patch.text].length <= 64) && + (patch.start === undefined || (Number.isInteger(patch.start) && patch.start >= 0 && patch.start <= MAX_START)) && + (patch.indent === undefined || (patch.indent >= 0 && patch.indent <= 1584)) && + (patch.hanging === undefined || (patch.hanging >= 0 && patch.hanging <= 1584)) && + (patch.font == null || isFontName(patch.font)) && + chars === 1 && + (patch.symbol == null || !/^[\p{C}\p{Z}\p{M}]$/u.test(patch.symbol)); + if (!valid) return () => false; + return redefine(null, (levels, depth) => { + levels[depth] = { ...levels[depth], ...patch }; + return levels; + }); +} diff --git a/app/src/editor/commands/paragraph.ts b/app/src/editor/commands/paragraph.ts index 4ff9916d2..93dd281b6 100644 --- a/app/src/editor/commands/paragraph.ts +++ b/app/src/editor/commands/paragraph.ts @@ -7,14 +7,13 @@ import type { EditorState, Selection } from '@tiptap/pm/state'; import { META_ELEMENT_DATA } from '../meta'; import { PARA_KEYS, PARA_NODES, paraFormat } from '../paragraph/attributes'; import type { Align, Indent, LineSpacing, ParaFormat, ParaKey } from '../paragraph/attributes'; +import { getDefaultTab } from '../paragraph/defaultTab'; import { fromChange, textblocksBetween } from './command'; import type { Change, Command } from './command'; import { viaFormatTarget } from './formatTarget'; import { clearFormattingChange } from './marks'; import { turnIntoChange } from './turnInto'; -/** The default tab interval in points: Ctrl+M moves the indent to the next multiple (Word). */ -export const DEFAULT_TAB = 36; /** Ctrl+0 toggles this space before (Word). */ export const OPEN_SPACE = 12; @@ -84,6 +83,26 @@ export function setPara(key: ParaKey, value: unknown): Command { return viaFormatTarget({ format: 'para', key, value }, fromChange(setParaChange(key, value))); } +/** Sets one key on every selected element from that element's own format, so an edit to one part of a value (one + * side of an indent, one tab stop) leaves the rest of each element as it was. */ +export function setParaEach(key: ParaKey, edit: (format: ParaFormat) => unknown): Command { + const change = fromChange((tr) => { + let changed = false; + for (const { node, pos } of selectionParaTargets(tr.doc, tr.selection)) { + const value = edit(paraFormat(node.attrs)); + if (!validParaValue(key, value)) return false; + const clean = cleanParaValue(key, value); + if (JSON.stringify(node.attrs[key] ?? null) === JSON.stringify(clean)) continue; + tr.setNodeAttribute(tr.mapping.map(pos), key, clean); + changed = true; + } + if (changed) tr.setMeta(META_ELEMENT_DATA, true); + return changed; + }); + return (state, dispatch) => + viaFormatTarget({ format: 'para', key, value: edit(paraAt(state)) }, change)(state, dispatch); +} + /** The paragraph formatting of the first element the selection touches, read through the spec's ranges. */ export function paraAt(state: EditorState): ParaFormat { const first = selectionParaTargets(state.doc, state.selection)[0]; @@ -138,11 +157,11 @@ export function toggleSpaceBefore(): Command { // ---- Indents ---- /** The next default tab stop past `points` (1), or the one before it (-1), from 0 to 1584. */ -export function nextTabStop(points: number, direction: 1 | -1): number { +export function nextTabStop(points: number, direction: 1 | -1, interval = getDefaultTab()): number { const next = direction > 0 - ? Math.floor(points / DEFAULT_TAB + 1e-9) * DEFAULT_TAB + DEFAULT_TAB - : Math.ceil(points / DEFAULT_TAB - 1e-9) * DEFAULT_TAB - DEFAULT_TAB; + ? Math.floor(points / interval + 1e-9) * interval + interval + : Math.ceil(points / interval - 1e-9) * interval - interval; return Math.min(1584, Math.max(0, next)); } diff --git a/app/src/editor/commands/runFormat.ts b/app/src/editor/commands/runFormat.ts index a07f00c8d..62c56476c 100644 --- a/app/src/editor/commands/runFormat.ts +++ b/app/src/editor/commands/runFormat.ts @@ -135,6 +135,63 @@ export function setRunValue(name: string, value: string | null): Command { return setRunMark(name, value === null ? null : { [key]: value }); } +/** The words of a token list mark (effects, OpenType features), written in order and without repeats. */ +const joinTokens = (tokens: readonly string[]) => [...new Set(tokens)].sort().join(' '); + +/** + * Edits the token list of a single-attribute mark on each text run by its own list, so a range whose runs differ + * keeps what each run has apart from the edit. An empty result takes the mark off that run. + */ +export function editRunTokens(name: string, edit: (tokens: readonly string[]) => readonly string[]): Command { + const key = spanKey(name); + const tokensOf = (marks: readonly Mark[]) => + String(marks.find((mark) => mark.type.name === name)?.attrs[key ?? ''] ?? '') + .split(' ') + .filter(Boolean); + const change = fromChange((tr) => { + const type = tr.doc.type.schema.marks[name] as MarkType | undefined; + if (!type || key === null || CONTENT_MARKS.has(name)) return false; + const write = (next: readonly string[]) => next.length === 0 || validRunAttrs(name, { [key]: joinTokens(next) }); + if (tr.selection.empty) { + const next = edit(tokensOf(caretMarks(tr))); + if (!write(next)) return false; + return next.length === 0 ? removeMark(tr, type) : addMark(tr, type, { [key]: joinTokens(next) }); + } + if (!canMark(tr, type)) return false; + const runs: { from: number; to: number; next: readonly string[]; same: boolean }[] = []; + for (const range of tr.selection.ranges) { + tr.doc.nodesBetween(range.$from.pos, range.$to.pos, (node, pos) => { + if (!node.isText || !node.type.schema.marks[name]) return true; + const have = tokensOf(node.marks); + const next = edit(have); + runs.push({ + from: Math.max(pos, range.$from.pos), + to: Math.min(pos + node.nodeSize, range.$to.pos), + next, + same: joinTokens(next) === joinTokens(have), + }); + return true; + }); + } + if (!runs.every((run) => write(run.next))) return false; + for (const run of runs) { + if (run.same) continue; + tr.removeMark(run.from, run.to, type); + if (run.next.length > 0) tr.addMark(run.from, run.to, type.create({ [key]: joinTokens(run.next) })); + } + return runs.some((run) => !run.same); + }); + return (state, dispatch) => { + // A table cell or drawing target takes one value: the first run's list, edited. + const first = state.selection.empty + ? tokensOf(state.storedMarks ?? state.selection.$from.marks()) + : tokensOf(state.doc.resolve(state.selection.from).marks()); + const next = edit(first); + const attrs = next.length === 0 || key === null ? null : { [key]: joinTokens(next) }; + return viaFormatTarget({ format: 'run', mark: name, attrs, toggle: false }, change)(state, dispatch); + }; +} + /** On unless the whole selection has the mark with these attributes; then off. The first press of a mixed * selection applies to all of it (DESIGN.md 4.1). */ export function toggleRunMark(name: string, attrs: Attrs | null = null): Command { diff --git a/app/src/editor/extensions/direction.test.ts b/app/src/editor/extensions/direction.test.ts new file mode 100644 index 000000000..c25431ad0 --- /dev/null +++ b/app/src/editor/extensions/direction.test.ts @@ -0,0 +1,44 @@ +// 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(); + // 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 new file mode 100644 index 000000000..89cea5216 --- /dev/null +++ b/app/src/editor/extensions/direction.test.tsx @@ -0,0 +1,176 @@ +// 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'; +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'; + +let editor: Editor | null = null; +afterEach(() => { + editor?.destroy(); + editor = null; + document.body.replaceChildren(); +}); + +const ARABIC = 'مرحبا'; + +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( + 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; +} + +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('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, undefined, { + 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'); + 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'); + }); + + 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 new file mode 100644 index 000000000..c72221f18 --- /dev/null +++ b/app/src/editor/extensions/direction.ts @@ -0,0 +1,199 @@ +// 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 } from '../paragraph/attributes'; +import { directionDom } from '../paragraph/directionDom'; +import type { Direction } from '../paragraph/directionDom'; +import { editorExtensions } from './kit'; + +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. */ + 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); + // 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) => { + 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), + 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), + hyphenate: () => host.pageSettings?.().text.hyphenate ?? false, + }), + }); +} 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..f3be63b52 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,63 @@ 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 }); + }; + // `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; 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, inCode || (mask && child.localName === 'code'), inner); } } }; - walk(element, false); - return { text: mask ? text.replace(ADDRESS, (match) => ' '.repeat(match.length)) : text, nodes }; + const base = paragraphLang(element); + walk(element, false, 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 +130,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 +176,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 +234,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 +256,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..8efc94e46 100644 --- a/app/src/editor/host.ts +++ b/app/src/editor/host.ts @@ -17,15 +17,40 @@ export interface EditingSettingsView { spelling: { enabled: boolean; languages: readonly string[] }; } +/** 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; + readonly length: number; + 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. */ - 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 { 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/attributes.ts b/app/src/editor/paragraph/attributes.ts index a1c4681e7..92f8e35a4 100644 --- a/app/src/editor/paragraph/attributes.ts +++ b/app/src/editor/paragraph/attributes.ts @@ -200,7 +200,7 @@ export const BORDER_STYLES = [ 'inset', 'outset', ] as const; -const SHADE_PATTERNS = [ +export const SHADE_PATTERNS = [ 'clear', 'solid', 'horzStripe', diff --git a/app/src/editor/paragraph/defaultTab.test.ts b/app/src/editor/paragraph/defaultTab.test.ts new file mode 100644 index 000000000..abf83140e --- /dev/null +++ b/app/src/editor/paragraph/defaultTab.test.ts @@ -0,0 +1,39 @@ +// The page's default tab interval (format section 5.4.2, view.text.defaultTab): 36 points when the page saves none, +// and what the page saves otherwise. A tab with no stops and Ctrl+M move to the next multiple of it. +import { afterEach, describe, expect, it } from 'vitest'; +import { nextTabStop } from '../commands/paragraph'; +import { getDefaultTab, onDefaultTab, readDefaultTab, setDefaultTab } from './defaultTab'; +import { nextStop } from './tabLayout'; + +afterEach(() => void setDefaultTab(36)); + +describe('the default tab interval', () => { + it('reads view.text.defaultTab, else 36', () => { + expect(readDefaultTab({ text: { defaultTab: 72 } })).toBe(72); + expect(readDefaultTab({ text: {} })).toBe(36); + expect(readDefaultTab(null)).toBe(36); + expect(readDefaultTab({ text: { defaultTab: 0 } })).toBe(36); + expect(readDefaultTab({ text: { defaultTab: 2000 } })).toBe(36); + expect(readDefaultTab({ text: { defaultTab: '72' } })).toBe(36); + }); + + it('moves a tab with no stops, and the indent, to 72 when the interval is 72', () => { + expect(nextStop(10, [], getDefaultTab()).pos).toBe(36); + expect(setDefaultTab(72)).toBe(true); + expect(nextStop(0, [], getDefaultTab()).pos).toBe(72); + expect(nextTabStop(0, 1)).toBe(72); + expect(nextTabStop(100, -1)).toBe(72); + }); + + it('refuses a value out of range and tells listeners about a change', () => { + let calls = 0; + const stop = onDefaultTab(() => (calls += 1)); + expect(setDefaultTab(0)).toBe(false); + expect(setDefaultTab(1585)).toBe(false); + expect(getDefaultTab()).toBe(36); + setDefaultTab(54); + setDefaultTab(54); + expect(calls).toBe(1); + stop(); + }); +}); diff --git a/app/src/editor/paragraph/defaultTab.ts b/app/src/editor/paragraph/defaultTab.ts new file mode 100644 index 000000000..a441c24b0 --- /dev/null +++ b/app/src/editor/paragraph/defaultTab.ts @@ -0,0 +1,43 @@ +// The page's default tab interval in points (`view.text.defaultTab`, format section 5.4.2). The page view sets it when +// a page opens or its view changes; the tab layout, the indent commands, and the Format pane read it here. +export const DEFAULT_TAB_POINTS = 36; +export const MIN_DEFAULT_TAB = 1; +export const MAX_DEFAULT_TAB = 1584; + +let current = DEFAULT_TAB_POINTS; +const listeners = new Set<() => void>(); + +/** The interval now in force. */ +export const getDefaultTab = (): number => current; + +/** The interval a page's `view` saves, or the default when it saves none or a value out of range. */ +export function readDefaultTab(view: unknown): number { + const value = (view as { text?: { defaultTab?: unknown } } | null | undefined)?.text?.defaultTab; + return typeof value === 'number' && value >= MIN_DEFAULT_TAB && value <= MAX_DEFAULT_TAB ? value : DEFAULT_TAB_POINTS; +} + +/** Sets the interval in force. Returns false for a value out of range. */ +export function setDefaultTab(points: number): boolean { + if (!(points >= MIN_DEFAULT_TAB && points <= MAX_DEFAULT_TAB)) return false; + if (points !== current) { + current = points; + for (const listener of [...listeners]) listener(); + } + return true; +} + +/** Calls the listener when the interval changes. */ +export function onDefaultTab(listener: () => void): () => void { + listeners.add(listener); + return () => void listeners.delete(listener); +} + +let pageHyphenate = false; + +/** Whether the page hyphenates paragraphs that say nothing (`view.text.hyphenate`). */ +export const getPageHyphenate = (): boolean => pageHyphenate; + +/** Sets the page's hyphenation default from its `view`. */ +export function setPageHyphenate(view: unknown): void { + pageHyphenate = (view as { text?: { hyphenate?: unknown } } | null | undefined)?.text?.hyphenate === true; +} 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/paragraph/tabLayout.ts b/app/src/editor/paragraph/tabLayout.ts index 06acb5483..c166eea37 100644 --- a/app/src/editor/paragraph/tabLayout.ts +++ b/app/src/editor/paragraph/tabLayout.ts @@ -11,6 +11,7 @@ import type { EditorView } from '@tiptap/pm/view'; import { editorExtensions } from '../extensions/kit'; import { paraFormat } from './attributes'; import type { TabLeader, TabStop } from './attributes'; +import { getDefaultTab, onDefaultTab } from './defaultTab'; import { applyMeasurement, blocksOf, initial, mapState } from './tabIndex'; import type { Measurement, TabAt, TabState, Width } from './tabIndex'; @@ -208,9 +209,15 @@ class TabLayoutView implements PluginView { this.resize = new ResizeObserver(() => this.resized()); this.resize.observe(view.dom); } + this.stopTab = onDefaultTab(() => { + if (this.view.isDestroyed) return; + this.view.dispatch(this.view.state.tr.setMeta(REMEASURE, true).setMeta('addToHistory', false)); + }); this.schedule(); } + private readonly stopTab: () => void; + private resized(): void { const width = this.view.dom.clientWidth; if (width === this.width || this.view.isDestroyed) return; @@ -260,6 +267,7 @@ class TabLayoutView implements PluginView { } destroy(): void { + this.stopTab(); if (this.frame) cancelAnimationFrame(this.frame); this.resize?.disconnect(); } @@ -267,7 +275,7 @@ class TabLayoutView implements PluginView { /** The tab layout plugin. */ export function tabLayoutPlugin(options: TabLayoutOptions = {}): Plugin { - const defaultTab = options.defaultTab ?? (() => DEFAULT_TAB); + const defaultTab = options.defaultTab ?? getDefaultTab; return new Plugin({ key, state: { diff --git a/app/src/editor/schema/dom.test.ts b/app/src/editor/schema/dom.test.ts index 0ee0dce48..f2f5ab592 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,41 @@ 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, undefined, { 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('adds none of them to the paragraph style render when text.language is off', () => { + // paraDom (render/paraStyle.ts) always draws a paragraph's stored direction and language, as the editor does. + // The option adds the dir attribute, the physical side, and the hyphens; without it none of those appear. + const into = document.createElement('div'); + renderStatic(doc(), into); + const [a, b, c] = [...into.querySelectorAll('p')]; + expect(into.querySelector('[dir]')).toBeNull(); + expect(a.style.textAlign).toBe('start'); + expect(a.getAttribute('style')).not.toContain('hyphens'); + expect(b.getAttribute('style')).not.toContain('hyphens: auto'); + expect(c.hasAttribute('style')).toBe(false); + expect(c.hasAttribute('lang')).toBe(false); + }); +}); diff --git a/app/src/editor/schema/dom.ts b/app/src/editor/schema/dom.ts index dcc0f8e33..04419fe71 100644 --- a/app/src/editor/schema/dom.ts +++ b/app/src/editor/schema/dom.ts @@ -2,13 +2,78 @@ // 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 type { ListDefinitions } from '../paragraph/numbering'; import { applyBlockMarks } from '../render/blocks'; +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, lists?: ListDefinitions): void { - const serializer = DOMSerializer.fromSchema(doc.type.schema); +export function renderStatic( + doc: PMNode, + into: HTMLElement, + lists?: ListDefinitions, + options: StaticOptions = {}, +): void { + const serializer = serializerFor(doc.type.schema, options); const fragment = serializer.serializeFragment(doc.content, { document: into.ownerDocument }); into.replaceChildren(fragment); // Border groups, list labels, and scaled words need the whole document, so they come after the serializer. diff --git a/app/src/features/page/blocks/rendering.test.tsx b/app/src/features/page/blocks/rendering.test.tsx index 5192f526d..43cb077fc 100644 --- a/app/src/features/page/blocks/rendering.test.tsx +++ b/app/src/features/page/blocks/rendering.test.tsx @@ -12,7 +12,7 @@ import { LISTS_META } from '../../../editor/extensions/listItem'; import { applyElementData } from '../../../editor/markdown/elements'; import { parseTextBlock } from '../../../editor/markdown/parse'; import { readLists } from '../../../editor/paragraph/numbering'; -import { renderStatic } from '../../../editor/schema/dom'; +import { renderStatic, staticOptions } from '../../../editor/schema/dom'; // Importing registers the block marks in every text editor. import '../../../editor/render/plugin'; @@ -340,10 +340,13 @@ describe('static render equals mounted render', () => { for (const fixture of all) { it(fixture.name, () => { + // One host for both, so the static render gets the options a text block gives it (direction with text.language). + const editorHost = testHost(); const staticRoot = host(); - renderStatic(fixture.doc, staticRoot, fixture.lists === undefined ? undefined : readLists(fixture.lists)); + const lists = fixture.lists === undefined ? undefined : readLists(fixture.lists); + renderStatic(fixture.doc, staticRoot, lists, staticOptions(editorHost)); const mountedRoot = host(); - editor = createBlockEditor(mountedRoot, fixture.doc, { kind: 'text', block: 'b1', host: testHost() }); + editor = createBlockEditor(mountedRoot, fixture.doc, { kind: 'text', block: 'b1', host: editorHost }); if (fixture.lists !== undefined) editor.view.dispatch(editor.state.tr.setMeta(LISTS_META, fixture.lists)); expect(normalized(mountedRoot)).toBe(normalized(staticRoot)); editor.destroy(); diff --git a/app/src/features/page/blocks/tableBlock.ts b/app/src/features/page/blocks/tableBlock.ts index 676129ffd..776c378fa 100644 --- a/app/src/features/page/blocks/tableBlock.ts +++ b/app/src/features/page/blocks/tableBlock.ts @@ -25,7 +25,7 @@ import { import type { CellAt, TableBook, 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'; @@ -182,7 +182,7 @@ class TableView implements InnerView { } private showStatic(doc: PMNode): void { - renderStatic(doc, this.editRoot); + renderStatic(doc, this.editRoot, undefined, 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 1de6c4ade..c8f7c8c76 100644 --- a/app/src/features/page/blocks/textBlock.ts +++ b/app/src/features/page/blocks/textBlock.ts @@ -10,7 +10,7 @@ import { highlightStatic } from '../../../editor/highlight'; import { parseTextBlock, serializeTextBlock } from '../../../editor/markdown'; import { applyElementData } from '../../../editor/markdown/elements'; import { readLists } from '../../../editor/paragraph/numbering'; -import { renderStatic } from '../../../editor/schema/dom'; +import { renderStatic, staticOptions } from '../../../editor/schema/dom'; // eslint-disable-next-line opennote/feature-boundaries -- The search index pulls the whole feature into start-up; this model file is small. import { hasLineData, lineDataOf } from '../../search/elements/model'; import type { BlockId, BlockJson } from '../../../services/pages/types'; @@ -234,6 +234,7 @@ class TextBlockView implements LazyBlockView, LiveText { hasLineData(lines) ? applyElementData(doc, lines) : doc, this.editRoot, readLists(this.block.data.lists), + 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/formatPane/BordersPanel.tsx b/app/src/features/page/formatPane/BordersPanel.tsx new file mode 100644 index 000000000..205e20801 --- /dev/null +++ b/app/src/features/page/formatPane/BordersPanel.tsx @@ -0,0 +1,206 @@ +// The Borders and shading tab of the Format pane (text DESIGN.md 5.3): the paragraph's border sides, presets, and shading. +import { useState } from 'react'; +import type { Border, BorderSide, BorderSideName } from '../../../editor/paragraph/attributes'; +import { t } from '../../../strings/t'; +import { Button } from '../../../ui'; +import { + BORDER_SIDES, + BORDER_STYLES, + DEFAULT_SIDE, + applyBorderPreset, + borderAt, + setBorderSide, + setShade, + shadeAt, +} from './blocks'; +import type { BorderPreset } from './blocks'; +import { Box, Choice, Group } from './controls'; +import { ColorField } from './ColorPicker'; +import { lengthParse } from './fields'; +import type { Parsed } from './fields'; +import { optionLabel } from './labels'; +import { run } from './TabsPanel'; +import type { PanelProps } from './TabsPanel'; +import { formatPoints } from './units'; +import styles from './FormatPane.module.css'; + +const SHADE_PATTERNS = [ + 'clear', + 'solid', + 'horzStripe', + 'vertStripe', + 'diagStripe', + 'horzCross', + 'diagCross', + 'pct10', + 'pct25', + 'pct50', + 'pct75', +]; + +/** The number a box parsed to, or the message that says why it is not one. */ +function numberOf(result: Parsed, example: string): number | string { + if (!result.ok) return result.message; + return typeof result.value === 'number' ? result.value : t('textPane.invalid', { example }); +} + +/** The width and distance typed, as a side of the border, or the message for each box that is wrong. */ +function sideFrom( + style: string, + width: string, + space: string, + color: string, +): { side: BorderSide } | { errors: Record } { + const w = numberOf(lengthParse(0.25, 6, 0.125)(width), '0.5'); + const s = numberOf(lengthParse(0, 31, 1)(space), '1'); + const errors: Record = {}; + if (typeof w === 'string') errors['width'] = w; + if (typeof s === 'string') errors['space'] = s; + if (typeof w === 'string' || typeof s === 'string') return { errors }; + return { side: { style, width: w, space: s, color: color || null } }; +} + +function SideSummary({ border }: { border: Border }) { + return ( +
    + {BORDER_SIDES.map((name) => { + const have = border[name]; + const side = optionLabel('borderSide', name); + const style = have ? optionLabel('borderStyle', have.style) : ''; + return ( +
  • + {have + ? t('textPane.borders.sideState', { side, style, width: formatPoints(have.width) }) + : t('textPane.borders.sideNone', { side })} +
  • + ); + })} +
+ ); +} + +function BorderGroup({ editor, refresh, notebook }: PanelProps) { + const [side, setSide] = useState('top'); + const [style, setStyle] = useState(DEFAULT_SIDE.style); + const [width, setWidth] = useState(formatPoints(DEFAULT_SIDE.width)); + const [space, setSpace] = useState(formatPoints(DEFAULT_SIDE.space)); + const [color, setColor] = useState(''); + const [errors, setErrors] = useState>({}); + /** Applies a command built from the typed side; the boxes show their messages when a value is wrong. */ + const withSide = (build: (made: BorderSide) => ReturnType) => { + const made = sideFrom(style, width, space, color); + setErrors('errors' in made ? made.errors : {}); + if ('errors' in made) return; + run(editor, build(made.side)); + refresh(); + }; + const without = () => { + run(editor, setBorderSide(side, null)); + refresh(); + }; + const preset = (kind: BorderPreset) => withSide((made) => applyBorderPreset(kind, made)); + return ( + + setSide(value as BorderSideName)} + /> + + + + +
+ + +
+
+ + + +
+ +
+ ); +} + +function ShadeGroup({ editor, refresh, notebook }: PanelProps) { + const shade = shadeAt(editor.state); + const [fill, setFill] = useState(shade?.fill ?? ''); + const [pattern, setPattern] = useState(shade?.pattern ?? 'clear'); + const [patternColor, setPatternColor] = useState(shade?.color ?? ''); + const apply = () => { + run(editor, setShade({ fill: fill || null, pattern, color: patternColor || null })); + refresh(); + }; + const remove = () => { + run(editor, setShade(null)); + setFill(''); + setPattern('clear'); + setPatternColor(''); + refresh(); + }; + return ( + + + + +
+ + +
+
+ ); +} + +export function BordersPanel(props: PanelProps) { + return ( +
+ + +
+ ); +} diff --git a/app/src/features/page/formatPane/ColorPicker.tsx b/app/src/features/page/formatPane/ColorPicker.tsx new file mode 100644 index 000000000..2b26ab50e --- /dev/null +++ b/app/src/features/page/formatPane/ColorPicker.tsx @@ -0,0 +1,106 @@ +// The color field of the Format pane (text DESIGN.md 5.3). A box takes a value typed as a pen name, #rrggbb, or +// theme:NAME. A button opens the shared palette: theme colors with tints, standard colors, recent colors, and hex, +// RGB, and HSL fields. A text color that is hard to read on the light or the dark page gets a warning. +import { useId, useState } from 'react'; +import { ColorPalette, resolveColor } from '../../../ui/color'; +import type { ColorPick, Scheme } from '../../../ui/color'; +import { t } from '../../../strings/t'; +import { contrastWarning } from '../styles/styleVars'; +import styles from './FormatPane.module.css'; + +/** The page's color scheme, from the resolved theme the root element carries. */ +export function currentScheme(): Scheme { + return document.documentElement.dataset['theme'] === 'dark' ? 'dark' : 'light'; +} + +/** The warning for a color that is hard to read, or null. A color that is not a color gives none. */ +export function contrastNote(value: string): string | null { + const hex = resolveColor(value, 'light'); + if (!hex) return null; + const warning = contrastWarning(hex); + if (warning.ok || (warning.light === 0 && warning.dark === 0)) return null; + if (warning.light < 4.5 && warning.dark < 4.5) return t('textPane.color.contrastBoth'); + return t(warning.light < 4.5 ? 'textPane.color.contrastLight' : 'textPane.color.contrastDark'); +} + +export interface ColorFieldProps { + label: string; + /** The stored value: a pen name, `#rrggbb`, `theme:NAME`, or '' for none. */ + value: string; + onCommit(value: string): void; + /** The notebook whose recent colors the palette keeps. */ + notebookId?: string; + /** A text color is checked for contrast; a fill or an underline color is not. */ + checkContrast?: boolean; + /** An error from the last commit. */ + error?: string; +} + +export function ColorField({ label, value, onCommit, notebookId, checkContrast = false, error }: ColorFieldProps) { + const id = useId(); + const noteId = useId(); + const [draft, setDraft] = useState(null); + const [open, setOpen] = useState(false); + const shown = draft ?? value; + const note = checkContrast ? contrastNote(shown) : null; + const hex = resolveColor(value, currentScheme()); + return ( +
+ +
+
+ {open ? ( + { + setOpen(false); + setDraft(null); + onCommit(pick.kind === 'color' ? pick.value : ''); + }} + /> + ) : null} + {note ? ( +

+ {note} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/app/src/features/page/formatPane/FormatPane.module.css b/app/src/features/page/formatPane/FormatPane.module.css new file mode 100644 index 000000000..3941eb4a0 --- /dev/null +++ b/app/src/features/page/formatPane/FormatPane.module.css @@ -0,0 +1,204 @@ +/* The Format pane: a sheet at the dock with tabs and labeled fields. Tokens only (BRAND.md section 14). */ +@layer components { + .sheet { + position: fixed; + inset-block: var(--panel-top) var(--panel-bottom); + inset-inline-end: var(--space-4); + z-index: var(--layer-overlay); + inline-size: min(22rem, 90vw); + overflow: auto; + box-sizing: border-box; + padding: var(--space-4); + border: 1px solid var(--color-border-control); + border-radius: var(--radius-md); + background: var(--color-surface-raised); + box-shadow: var(--elevation-2); + } + + .stack { + display: grid; + gap: var(--space-3); + } + + .head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); + } + + .heading { + margin: 0; + font-size: var(--type-caption-size); + font-weight: var(--font-weight-semibold); + line-height: var(--type-caption-line); + } + + .tabs { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + } + + .tab { + min-block-size: var(--target-size); + padding: var(--space-1) var(--space-3); + border: 1px solid var(--color-border-control); + border-radius: var(--radius-md); + background: transparent; + color: var(--color-text-primary); + font: inherit; + cursor: pointer; + } + + .tab[aria-selected='true'] { + background: var(--color-surface-selected); + font-weight: var(--font-weight-semibold); + } + + .panel { + display: grid; + gap: var(--space-3); + } + + .group { + display: grid; + gap: var(--space-2); + margin: 0; + padding: 0; + border: 0; + } + + .legend { + padding: 0; + color: var(--color-text-secondary); + font-size: var(--type-small-size); + font-weight: var(--font-weight-semibold); + } + + .field { + display: grid; + gap: var(--space-1); + } + + .check { + display: flex; + align-items: center; + gap: var(--space-2); + min-block-size: var(--target-size); + } + + .label { + font-size: var(--type-small-size); + line-height: var(--type-small-line); + } + + .row { + display: flex; + align-items: center; + gap: var(--space-2); + } + + .input, + .select { + min-block-size: var(--target-size); + box-sizing: border-box; + min-inline-size: 0; + flex: 1; + padding: var(--space-1) var(--space-2); + border: 1px solid var(--color-border-control); + border-radius: var(--radius-sm); + background: var(--color-surface-page); + color: var(--color-text-primary); + font: inherit; + } + + .input[aria-invalid='true'] { + border-color: var(--color-status-danger); + } + + .unit { + color: var(--color-text-secondary); + font-size: var(--type-small-size); + } + + .swatch { + inline-size: 1.25rem; + block-size: 1.25rem; + flex: none; + border: 1px solid var(--color-border-control); + border-radius: var(--radius-sm); + } + + .small { + min-block-size: var(--target-size); + padding: var(--space-1) var(--space-3); + border: 1px solid var(--color-border-control); + border-radius: var(--radius-md); + background: transparent; + color: var(--color-text-primary); + font: inherit; + cursor: pointer; + } + + .help, + .detail { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--type-small-size); + line-height: var(--type-small-line); + } + + .warning { + margin: 0; + color: var(--color-status-danger); + font-size: var(--type-small-size); + line-height: var(--type-small-line); + } + + .preview { + overflow: hidden; + margin: 0; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + background: var(--color-surface-page); + color: var(--color-text-primary); + text-overflow: ellipsis; + white-space: nowrap; + } + + .list { + display: grid; + gap: var(--space-1); + margin: 0; + padding: 0; + list-style: none; + } + + .reveal { + display: grid; + gap: var(--space-1); + margin: 0; + padding-inline-start: var(--space-4); + } +} + +@layer forced { + @media (forced-colors: active) { + .sheet, + .tab, + .input, + .select, + .small, + .swatch, + .preview { + border-color: ButtonBorder; + } + + .tab[aria-selected='true'] { + border-color: Highlight; + } + } +} diff --git a/app/src/features/page/formatPane/FormatPane.test.tsx b/app/src/features/page/formatPane/FormatPane.test.tsx new file mode 100644 index 000000000..fbf5a43d2 --- /dev/null +++ b/app/src/features/page/formatPane/FormatPane.test.tsx @@ -0,0 +1,178 @@ +// The Format pane (text DESIGN.md 5.3), in a real browser. Typed values with units reach the document. A bad value +// shows its range message and changes nothing. Reveal formatting lists the sources, and style mode edits a style. +// Every tab works from the keyboard with no axe violations in either theme. +import { act, screen, within } from '@testing-library/react'; +import { userEvent } from 'vitest/browser'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { setRunValue, toggleRunMark } from '../../../editor/commands/runFormat'; +import { mountEditor } from '../../../editor/commands/testing'; +import type { TestEditor } from '../../../editor/commands/testing'; +import { paraFormat } from '../../../editor/paragraph/attributes'; +import { expectNoAxeViolations } from '../../../test'; +import { notebookStyles, pageSheet, configureStyleStorage, localStylesClient } from '../styles/store'; +import { TABS } from './fields'; +import { closeFormatPane, openFormatPane } from './mount'; + +let mounted: TestEditor | null = null; + +beforeEach(() => { + configureStyleStorage({ client: localStylesClient(null), persistent: false, storage: null }); + notebookStyles.set({ nb: { h2: { font: 'Georgia' } } }); +}); + +afterEach(() => { + act(() => closeFormatPane()); + mounted?.destroy(); + mounted = null; + notebookStyles.set({}); + pageSheet.set(null); + configureStyleStorage(null); + document.documentElement.removeAttribute('data-theme'); + document.body.replaceChildren(); +}); + +function open(source: string, options: Partial[0]> = {}) { + mounted = mountEditor(source); + const editor = mounted.editor; + act(() => openFormatPane({ notebook: 'nb', editorOf: () => editor, ...options })); + return editor; +} + +const pane = () => document.querySelector('[data-region="format-pane"]')!; + +async function typeIn(name: string | RegExp, text: string) { + const box = within(pane()).getByRole('spinbutton', { name }); + await userEvent.clear(box); + await userEvent.type(box, `${text}{Enter}`); +} + +describe('the Format pane', () => { + it('applies 10.5 typed in Size as data-size 10.5', async () => { + open('one [two] three'); + await typeIn('Size', '10.5'); + expect(mounted!.markdown()).toBe('one two three'); + }); + + it('applies 0.5in typed in Left indent as start 36 on the Paragraph tab', async () => { + const editor = open('one [two] three', { tab: 'paragraph' }); + await typeIn('Left indent', '0.5in'); + expect(paraFormat(editor.state.doc.firstChild!.attrs).indent?.start).toBe(36); + }); + + it('shows the range message for an invalid value and changes nothing', async () => { + open('one [two] three'); + const before = mounted!.markdown(); + await typeIn('Size', '0'); + const alert = await within(pane()).findByRole('alert'); + expect(alert.textContent).toBe('The number must be between 1 and 1638.'); + expect(within(pane()).getByRole('spinbutton', { name: 'Size' }).getAttribute('aria-invalid')).toBe('true'); + expect(mounted!.markdown()).toBe(before); + }); + + it('shows a range message on the Paragraph tab too', async () => { + const editor = open('one [two] three', { tab: 'paragraph' }); + await typeIn('Left indent', '5000'); + expect((await within(pane()).findByRole('alert')).textContent).toBe('The number must be between -1584 and 1584.'); + expect(paraFormat(editor.state.doc.firstChild!.attrs).indent).toBeUndefined(); + }); +}); + +describe('the Format pane, reveal and style mode', () => { + it('reveals Bold (direct) and Georgia (style Heading 2) for a heading with bold set directly', async () => { + const editor = open('## [Title]'); + act(() => { + toggleRunMark('bold')(editor.state, editor.view.dispatch); + }); + await userEvent.click(within(pane()).getByRole('button', { name: 'Reveal formatting' })); + const list = await within(pane()).findByRole('list', { name: 'Formatting at the caret' }); + const rows = within(list) + .getAllByRole('listitem') + .map((row) => row.textContent); + expect(rows).toContain('Bold (direct)'); + expect(rows).toContain('Georgia (style Heading 2)'); + }); + + it('follows the selection: the Font box shows what the text has', async () => { + const editor = open('one [two] three'); + act(() => { + setRunValue('font', 'Palatino')(editor.state, editor.view.dispatch); + }); + expect(within(pane()).getByRole('textbox', { name: 'Font' })).toBeTruthy(); + expect((within(pane()).getByRole('textbox', { name: 'Font' }) as HTMLInputElement).value).toBe('Palatino'); + }); + + it('edits a style in style mode and saves it to the notebook', async () => { + open('## [Title]', { style: 'h2', tab: 'text' }); + expect(within(pane()).getByRole('heading', { name: 'Format style: Heading 2' })).toBeTruthy(); + expect(within(pane()).queryByRole('tab', { name: 'Tabs' })).toBeNull(); + await typeIn('Size', '20'); + await act(async () => Promise.resolve()); + expect(notebookStyles.get()['nb']?.['h2']).toMatchObject({ font: 'Georgia', sizePt: 20 }); + }); +}); + +describe('the Format pane, keyboard and accessibility', () => { + it('walks every tab from the keyboard and each panel has labeled controls', async () => { + open('one [two] three'); + const tabs = within(pane()).getAllByRole('tab'); + expect(tabs.map((tab) => tab.textContent)).toEqual([ + 'Text', + 'Advanced', + 'Paragraph', + 'Tabs', + 'Borders and shading', + 'Lists', + ]); + // Opening the pane puts the focus on its selected tab, with no Tab through the page first. + expect(document.activeElement).toBe(tabs[0]); + for (let index = 0; index < TABS.length; index += 1) { + const current = within(pane()).getAllByRole('tab')[index]; + expect(document.activeElement).toBe(current); + expect(current.getAttribute('aria-selected')).toBe('true'); + const panel = within(pane()).getByRole('tabpanel'); + expect(panel.getAttribute('aria-labelledby')).toBe(current.id); + await userEvent.tab(); + expect(panel.contains(document.activeElement) || index === TABS.length - 1).toBe(true); + for (const control of panel.querySelectorAll('input, select, button')) { + const labelled = + (control as HTMLElement).getAttribute('aria-label') || + (control as HTMLInputElement).labels?.length || + control.textContent?.trim(); + expect(labelled, control.outerHTML).toBeTruthy(); + } + (within(pane()).getAllByRole('tab')[index] as HTMLElement).focus(); + await userEvent.keyboard('{ArrowRight}'); + } + // Arrows wrap from the last tab to the first. + expect(document.activeElement).toBe(within(pane()).getAllByRole('tab')[0]); + }); + + it('closes with Escape and gives the focus back', async () => { + const opener = document.body.appendChild(document.createElement('button')); + opener.focus(); + open('one [two] three'); + await userEvent.keyboard('{Escape}'); + expect(document.querySelector('[data-region="format-pane"]')).toBeNull(); + expect(document.activeElement).toBe(opener); + }); + + it.each(['light', 'dark'])('has no axe violations on any tab in the %s theme', async (theme) => { + document.documentElement.dataset['theme'] = theme; + open('one [two] three'); + for (const name of ['Text', 'Advanced', 'Paragraph', 'Tabs', 'Borders and shading', 'Lists']) { + await userEvent.click(within(pane()).getByRole('tab', { name })); + await expectNoAxeViolations(pane()); + } + }); + + it('focuses the tab it opens on, such as Paragraph', () => { + open('one [two] three', { tab: 'paragraph' }); + expect(document.activeElement).toBe(within(pane()).getByRole('tab', { name: 'Paragraph' })); + }); + + it('shows the Lists tab for a list item', async () => { + open('- [one]', { tab: 'lists' }); + expect(within(pane()).getByRole('combobox', { name: 'Number format' })).toBeTruthy(); + expect(screen.queryByText('Put the caret in a list item to change its list.')).toBeNull(); + }); +}); diff --git a/app/src/features/page/formatPane/FormatPane.tsx b/app/src/features/page/formatPane/FormatPane.tsx new file mode 100644 index 000000000..01b142950 --- /dev/null +++ b/app/src/features/page/formatPane/FormatPane.tsx @@ -0,0 +1,444 @@ +// The Format pane (text DESIGN.md 5.3): a side pane with six tabs for text, advanced text, paragraphs, tab stops, +// borders, and lists. It also has a preview line and Reveal formatting. Every field is labeled, takes units, and shows +// its range message when a value is refused. In style mode (Modify style) the same fields edit one style. +import { useEffect, useId, useReducer, useRef, useState } from 'react'; +import type { CSSProperties, KeyboardEvent, ReactNode } from 'react'; +import type { Editor } from '@tiptap/core'; +import type { EditorState } from '@tiptap/pm/state'; +import type { FontsClient } from '../../../platform/types'; +import { useStore } from '../../../state/store'; +import { t } from '../../../strings/t'; +import type { MessageKey } from '../../../strings/t'; +import { Button, showToast } from '../../../ui'; +import { resolveColor } from '../../../ui/color'; +import { FontPicker, useFontCatalog } from '../../fonts'; +import { targetEditor } from '../formattingBar/target'; +import { builtInLabel } from '../styles/gallery'; +import { patchStyle, styleLabel, withSheetStyle } from '../styles/model'; +import { resolveStyle } from '../styles/resolve'; +import { notebookStyles, pageSheet, saveStyles, shownContext } from '../styles/store'; +import { BordersPanel } from './BordersPanel'; +import { ColorField, currentScheme } from './ColorPicker'; +import { Box, Check, Choice, Group } from './controls'; +import { TABS, fieldById } from './fields'; +import type { Field, StyleValues, TabId } from './fields'; +import { fieldLabel } from './labels'; +import { ListsPanel } from './ListsPanel'; +import { paneKey } from './paneKey'; +import { revealFormatting } from './reveal'; +import { TabsPanel } from './TabsPanel'; +import styles from './FormatPane.module.css'; + +type Groups = readonly { title: MessageKey; ids: readonly string[] }[]; + +/** The groups of the three field tabs, by field id. */ +const GROUPS: Readonly>> = { + text: [ + { title: 'textPane.group.font', ids: ['font', 'weight', 'size', 'italic', 'color', 'highlight'] }, + { + title: 'textPane.group.effects', + ids: [ + 'underline', + 'underlineColor', + 'strike', + 'superscript', + 'subscript', + 'caps', + 'emboss', + 'engrave', + 'outline', + 'shadow', + ], + }, + ], + advanced: [ + { title: 'textPane.group.spacing', ids: ['scale', 'tracking', 'shift', 'kern'] }, + { title: 'textPane.group.opentype', ids: ['ligatures', 'numberForms', 'numberSpacing', 'stylisticSet', 'zero'] }, + ], + paragraph: [ + { title: 'textPane.group.general', ids: ['align', 'outlineLevel', 'dir'] }, + { title: 'textPane.group.indentation', ids: ['indentStart', 'indentEnd', 'special', 'specialBy'] }, + { title: 'textPane.group.spacingPara', ids: ['before', 'after', 'lineRule', 'lineValue', 'contextual'] }, + { title: 'textPane.group.pagination', ids: ['keepNext', 'keepLines', 'breakBefore', 'widows', 'hyphenate'] }, + ], +}; + +/** The tabs a style can be edited on: the others have no style keys here. */ +const STYLE_TABS: readonly TabId[] = ['text', 'advanced', 'paragraph']; + +const unitText = (unit: Field['unit']) => + unit === '%' ? t('textPane.unitPercent') : unit === 'pt' ? t('textPane.unitPoints') : undefined; + +export interface FormatPaneProps { + notebook: string; + /** The tab shown first. */ + tab?: TabId; + /** The name of a style to edit instead of the selection. */ + style?: string; + /** The device's fonts, for the font box. Without them the font is a plain text box. */ + fonts?: FontsClient; + onClose(): void; + /** The editor the fields act on: the shown page's focused or last-used text block. */ + editorOf?: () => Editor | null; +} + +/** The editor, and a function that draws the pane again. It also follows the selection and the focus. */ +function useEditor(editorOf: () => Editor | null): { editor: Editor | null; refresh(): void } { + const [, redraw] = useReducer((n: number) => n + 1, 0); + const editor = editorOf(); + const drawn = useRef(null); + const bump = () => { + drawn.current = editor ? paneKey(editor.state) : null; + redraw(); + }; + useEffect(() => { + if (!editor) return undefined; + // Typing at a caret shows the same format, so it does not draw the pane (a key per transaction was too much). + const change = () => { + const key = paneKey(editor.state); + if (key !== null && key === drawn.current) return; + drawn.current = key; + redraw(); + }; + editor.on('transaction', change); + editor.on('selectionUpdate', change); + return () => { + editor.off('transaction', change); + editor.off('selectionUpdate', change); + }; + }, [editor]); + useEffect(() => { + const again = () => redraw(); + document.addEventListener('focusin', again); + return () => document.removeEventListener('focusin', again); + }, []); + return { editor, refresh: bump }; +} + +/** The look of the preview line from the fields of the selection or the style. */ +function previewLook(read: (id: string) => string | boolean): CSSProperties { + const text = (id: string) => { + const value = read(id); + return typeof value === 'string' && value !== 'mixed' ? value : ''; + }; + const css: CSSProperties = {}; + const font = text('font'); + if (font && !font.startsWith('+')) css.fontFamily = `"${font.replace(/["\\]/g, '')}"`; + const size = Number(text('size')); + if (size > 0) css.fontSize = `${Math.min(size, 24)}pt`; + const weight = Number(text('weight')); + if (weight > 0) css.fontWeight = weight; + if (read('italic') === true) css.fontStyle = 'italic'; + const color = resolveColor(text('color'), currentScheme()); + if (color) css.color = color; + const lines = [text('underline') ? 'underline' : '', text('strike') ? 'line-through' : ''].filter(Boolean); + if (lines.length > 0) { + css.textDecorationLine = lines.join(' '); + const style = text('underline'); + if (style === 'double' || style === 'dotted' || style === 'dashed' || style === 'wavy') { + css.textDecorationStyle = style; + } + } + const caps = text('caps'); + if (caps === 'small') css.fontVariantCaps = 'small-caps'; + if (caps === 'all') css.textTransform = 'uppercase'; + const tracking = Number(text('tracking')); + if (tracking !== 0 && Number.isFinite(tracking)) css.letterSpacing = `${tracking}pt`; + if (read('superscript') === true) css.verticalAlign = 'super'; + if (read('subscript') === true) css.verticalAlign = 'sub'; + if (read('shadow') === true) css.textShadow = '1px 1px 2px currentColor'; + if (read('outline') === true) css.WebkitTextStroke = '0.5px currentColor'; + const features: string[] = []; + const ligatures = text('ligatures'); + if (ligatures === 'liga-none') features.push('"liga" 0', '"clig" 0'); + if (ligatures === 'dlig' || ligatures === 'hlig') features.push(`"${ligatures}" 1`); + const forms = text('numberForms'); + if (forms) features.push(`"${forms}" 1`); + const spacing = text('numberSpacing'); + if (spacing) features.push(`"${spacing}" 1`); + if (text('zero')) features.push('"zero" 1'); + if (features.length > 0) css.fontFeatureSettings = features.join(', '); + return css; +} + +type Commit = (id: string, input: string | boolean) => void; + +interface Source { + editor: Editor | null; + /** The style being edited, or null for the selection. */ + style: string | null; + values: StyleValues; + notebook: string; + refresh(): void; +} + +/** Parses a typed value and applies it to the selection or the style. A bad value gives its message and changes nothing. */ +function useCommit({ editor, style, values, notebook, refresh }: Source) { + const [errors, setErrors] = useState>({}); + const fail = (id: string, message: string) => setErrors((now) => ({ ...now, [id]: message })); + const commit: Commit = (id, input) => { + const field = fieldById(id); + if (!field) return; + const parsed = field.parse(input, editor?.state); + if (!parsed.ok) return fail(id, parsed.message); + fail(id, ''); + if (style && field.style) { + const sheet = shownContext(notebook).notebook; + const next = withSheetStyle(sheet, style, patchStyle(sheet[style], field.style.patch(parsed.value, values))); + void saveStyles(notebook, next, { message: t('textPane.styleSaved') }); + return; + } + if (!editor) return void showToast({ message: t('textPane.noEditor') }); + const ran = field.apply(parsed.value)(editor.state, editor.view.dispatch); + if (!ran && String(field.read(editor.state)) !== String(input)) fail(id, t('textPane.refused')); + refresh(); + }; + return { errors, commit }; +} + +interface FontFieldProps { + value: string; + fonts: FontsClient; + commit: Commit; +} + +function FontField({ value, fonts, commit }: FontFieldProps) { + const catalog = useFontCatalog(fonts); + return ( +
+ {fieldLabel('font')} + { + commit('font', name); + if (weight !== undefined) commit('weight', String(weight)); + }} + /> +
+ ); +} + +interface FieldViewProps { + field: Field; + shown: string | boolean; + error?: string; + notebook: string; + fonts?: FontsClient; + commit: Commit; +} + +function FieldView({ field, shown, error, notebook, fonts, commit }: FieldViewProps) { + const { id } = field; + const label = fieldLabel(id); + if (field.kind === 'toggle') { + return ( + commit(id, on)} /> + ); + } + if (field.kind === 'choice') { + const onCommit = (value: string) => commit(id, value); + return ; + } + if (field.kind === 'color') { + const value = shown === 'mixed' ? '' : String(shown); + const onCommit = (next: string) => commit(id, next); + return ( + + ); + } + if (id === 'font' && fonts) return ; + return ( + commit(id, text)} + /> + ); +} + +interface BodyProps { + tab: TabId; + source: Source; + state: EditorState | undefined; + fonts?: FontsClient; +} + +/** The fields of the Text, Advanced, and Paragraph tabs, in groups, and the preview line on Text. */ +function FieldTab({ tab, source, state, fonts }: BodyProps) { + const { errors, commit } = useCommit(source); + const read = (field: Field): string | boolean => { + if (source.style) return field.style ? field.style.read(source.values) : ''; + return state ? field.read(state) : ''; + }; + return ( + <> + {GROUPS[tab]?.map((group) => ( + + {group.ids.map((id) => { + const field = fieldById(id); + if (!field || (source.style && !field.style)) return null; + const props = { + field, + shown: read(field), + error: errors[id] || undefined, + notebook: source.notebook, + fonts, + commit, + }; + return ; + })} + + ))} + {tab === 'text' ? ( +

read(fieldById(id)!))} + aria-label={t('textPane.preview')} + > + {t('textPane.previewText')} +

+ ) : null} + + ); +} + +function Panel(props: BodyProps): ReactNode { + const { tab, source } = props; + const { editor, notebook, refresh } = source; + if (tab === 'tabs' || tab === 'borders' || tab === 'lists') { + if (!editor) return

{t('textPane.noEditor')}

; + const shared = { editor, refresh, notebook }; + if (tab === 'tabs') return ; + return tab === 'borders' ? : ; + } + if (!source.style && !editor) return

{t('textPane.noEditor')}

; + return ; +} + +interface TabListProps { + tabs: readonly TabId[]; + current: TabId; + idBase: string; + onChange(tab: TabId): void; +} + +/** The tab buttons. Left and Right, Home and End move between them, and the last wraps to the first. */ +function TabList({ tabs, current, idBase, onChange }: TabListProps) { + const list = useRef(null); + const keys = (event: KeyboardEvent) => { + const at = tabs.indexOf(current); + const to = { ArrowRight: at + 1, ArrowLeft: at - 1, Home: 0, End: tabs.length - 1 }[event.key]; + if (to === undefined) return; + event.preventDefault(); + const next = tabs[(to + tabs.length) % tabs.length]; + onChange(next); + list.current?.querySelector(`[data-tab="${next}"]`)?.focus(); + }; + return ( +
+ {tabs.map((id) => ( + + ))} +
+ ); +} + +function Reveal({ state, notebook }: { state: EditorState | undefined; notebook: string }) { + const [shown, setShown] = useState(false); + const rows = state && shown ? revealFormatting(state, shownContext(notebook)) : []; + return ( + + + {shown ? ( +
    + {rows.length > 0 ? ( + rows.map((row) =>
  • {row.text}
  • ) + ) : ( +
  • {t('textPane.reveal.none')}
  • + )} +
+ ) : null} +
+ ); +} + +export function FormatPane({ + notebook, + tab = 'text', + style, + fonts, + onClose, + editorOf = targetEditor, +}: FormatPaneProps) { + useStore(notebookStyles, (all) => all[notebook]); + useStore(pageSheet, (sheet) => sheet); + const { editor, refresh } = useEditor(editorOf); + const headingId = useId(); + const section = useRef(null); + const idBase = useId(); + const tabs = style ? STYLE_TABS : TABS; + const [current, setCurrent] = useState(tabs.includes(tab) ? tab : 'text'); + // Opening the pane puts focus on its selected tab, so the keys reach it at once (the Styles pane does the same). + useEffect(() => section.current?.querySelector('[role="tab"][aria-selected="true"]')?.focus(), []); + const ctx = shownContext(notebook); + const values = style ? resolveStyle(ctx, style).values : {}; + const source: Source = { editor, style: style ?? null, values, notebook, refresh }; + const named = style + ? styleLabel(style, ctx.page?.[style] ?? ctx.notebook[style] ?? ctx.app[style], builtInLabel) + : ''; + return ( +
{ + if (event.key !== 'Escape' || event.defaultPrevented) return; + event.preventDefault(); + onClose(); + }} + > +
+

+ {style ? t('textPane.titleStyle', { name: named }) : t('textPane.title')} +

+ +
+ {style ?

{t('textPane.styleModeHint')}

: null} + {style && ctx.page?.[style] ?

{t('textPane.stylePageOverrides')}

: null} + +
+ +
+ {style ? null : } +
+ ); +} diff --git a/app/src/features/page/formatPane/ListsPanel.tsx b/app/src/features/page/formatPane/ListsPanel.tsx new file mode 100644 index 000000000..93b3a817c --- /dev/null +++ b/app/src/features/page/formatPane/ListsPanel.tsx @@ -0,0 +1,113 @@ +// The Lists tab of the Format pane (text DESIGN.md 5.3): the caret's list level, as a new list definition. +import { useState } from 'react'; +import { listLevelAt, setListLevel } from '../../../editor/commands/lists'; +import type { ListLevelAt } from '../../../editor/commands/lists'; +import { NUMBER_FORMATS } from '../../../editor/paragraph/numberFormats'; +import { t } from '../../../strings/t'; +import { Box, Choice, Group } from './controls'; +import { lengthParse } from './fields'; +import { run } from './TabsPanel'; +import type { PanelProps } from './TabsPanel'; +import { formatPoints } from './units'; +import styles from './FormatPane.module.css'; + +type Patch = Parameters[0]; + +/** Sets some fields of the level, and gives back the message for the box that was wrong, or ''. */ +function useLevelEdit({ editor, refresh }: PanelProps) { + const [errors, setErrors] = useState>({}); + const set = (id: string, patch: Patch, message?: string) => { + const ran = run(editor, setListLevel(patch)); + setErrors((now) => ({ ...now, [id]: ran ? '' : (message ?? t('textPane.refused')) })); + if (ran) refresh(); + }; + const length = (id: 'indent' | 'hanging', text: string) => { + const result = lengthParse(0, 1584)(text); + const message = result.ok ? t('textPane.invalid', { example: '36, 0.5in' }) : result.message; + if (!result.ok || typeof result.value !== 'number') return setErrors((now) => ({ ...now, [id]: message })); + set(id, { [id]: result.value }); + }; + return { errors, set, length }; +} + +function Kind({ at, edit }: { at: ListLevelAt; edit: ReturnType }) { + const { level } = at; + const { errors, set } = edit; + if (level.format === 'bullet') { + return ( + <> + set('symbol', { symbol: text }, t('textPane.invalid', { example: '•' }))} + /> + set('font', { font: text === '' ? undefined : text }, t('textPane.fontTooLong'))} + /> + + ); + } + return ( + <> + set('pattern', { text }, t('textPane.invalid', { example: '%1.' }))} + /> +

{t('textPane.lists.patternHint')}

+ { + // A blank box changes nothing (Number('') is 0, which would set the start to 0). + if (text.trim() === '') return; + set('start', { start: Number(text) }, t('textPane.range', { min: '0', max: '32767' })); + }} + /> + + ); +} + +export function ListsPanel(props: PanelProps) { + const edit = useLevelEdit(props); + const at = listLevelAt(props.editor.state); + if (!at) return

{t('textPane.noList')}

; + const { level, depth } = at; + return ( +
+ + edit.set('format', { format: value as typeof level.format })} + /> + + edit.length('indent', text)} + /> + edit.length('hanging', text)} + /> + +
+ ); +} diff --git a/app/src/features/page/formatPane/TabsPanel.tsx b/app/src/features/page/formatPane/TabsPanel.tsx new file mode 100644 index 000000000..542ed26b8 --- /dev/null +++ b/app/src/features/page/formatPane/TabsPanel.tsx @@ -0,0 +1,142 @@ +// The Tabs tab of the Format pane (text DESIGN.md 5.3): the paragraph's tab stops and the default interval. +import { useState } from 'react'; +import type { Editor } from '@tiptap/core'; +import type { Command } from '../../../editor/commands/command'; +import type { TabAlign, TabLeader, TabStop } from '../../../editor/paragraph/attributes'; +import { useStore } from '../../../state/store'; +import { t } from '../../../strings/t'; +import { Button } from '../../../ui'; +import { + TAB_ALIGNS, + TAB_LEADERS, + clearAllTabStops, + clearTabStop, + defaultTabInterval, + setDefaultTabInterval, + setTabStop, + tabsAt, +} from './blocks'; +import { Box, Choice, Group } from './controls'; +import { lengthParse } from './fields'; +import { optionLabel } from './labels'; +import { formatPoints } from './units'; +import styles from './FormatPane.module.css'; + +export interface PanelProps { + editor: Editor; + /** Draws the pane again after a command ran. */ + refresh(): void; + notebook: string; +} + +/** Runs a command on the editor. */ +export function run(editor: Editor, command: Command): boolean { + return command(editor.state, editor.view.dispatch); +} + +/** The position typed in the box: points from 0 to 1584, or the message that says why not. */ +function positionOf(text: string): { at: number } | { message: string } { + const result = lengthParse(0, 1584)(text); + if (!result.ok) return { message: result.message }; + if (typeof result.value !== 'number') return { message: t('textPane.invalid', { example: '36, 1in' }) }; + return { at: result.value }; +} + +function StopList({ stops }: { stops: readonly TabStop[] }) { + if (stops.length === 0) return

{t('textPane.tabs.empty')}

; + return ( +
    + {stops.map((stop) => ( +
  • + {t('textPane.tabs.stop', { + pos: formatPoints(stop.pos), + align: optionLabel('tabAlign', stop.align), + leader: optionLabel('tabLeader', stop.leader), + })} +
  • + ))} +
+ ); +} + +function Interval() { + const interval = useStore(defaultTabInterval, (now) => now); + const [error, setError] = useState(); + return ( + { + const result = lengthParse(1, 1584)(text); + const ok = result.ok && typeof result.value === 'number'; + if (!ok) return setError(result.ok ? t('textPane.invalid', { example: '36, 0.5in' }) : result.message); + setError(undefined); + setDefaultTabInterval(result.value as number); + }} + /> + ); +} + +export function TabsPanel({ editor, refresh }: PanelProps) { + const [pos, setPos] = useState(''); + const [align, setAlign] = useState('start'); + const [leader, setLeader] = useState('none'); + const [error, setError] = useState(); + const act = (build: (at: number) => Command, failure?: string) => { + const where = positionOf(pos); + if ('message' in where) return setError(where.message); + if (!run(editor, build(where.at)) && failure) return setError(failure); + setError(undefined); + refresh(); + }; + const all = () => { + run(editor, clearAllTabStops()); + refresh(); + }; + return ( +
+ + { + setPos(text); + setError(undefined); + }} + /> + setAlign(value as TabAlign)} + /> + setLeader(value as TabLeader)} + /> +
+ + + +
+ +
+ +
+ ); +} diff --git a/app/src/features/page/formatPane/blocks.ts b/app/src/features/page/formatPane/blocks.ts new file mode 100644 index 000000000..3ab6dac8a --- /dev/null +++ b/app/src/features/page/formatPane/blocks.ts @@ -0,0 +1,125 @@ +// What the Tabs and Borders and shading tabs write (text DESIGN.md 5.3): tab stops, paragraph borders, and shading. +// Each is a command over the paragraph keys `tabs`, `border`, and `shade`, so the same checks as for a command apply. +import type { EditorState } from '@tiptap/pm/state'; +import type { Command } from '../../../editor/commands/command'; +import { paraAt, selectionParaTargets, setPara, setParaEach } from '../../../editor/commands/paragraph'; +import { BORDER_STYLES, MAX_TAB_STOPS, paraFormat } from '../../../editor/paragraph/attributes'; +import type { + Border, + BorderSide, + BorderSideName, + Shade, + TabAlign, + TabLeader, + TabStop, +} from '../../../editor/paragraph/attributes'; +import { getDefaultTab, onDefaultTab, setDefaultTab } from '../../../editor/paragraph/defaultTab'; +import { createStore } from '../../../state/store'; +import { shownMounted } from '../pagesApi'; + +export const TAB_ALIGNS: readonly TabAlign[] = ['start', 'center', 'end', 'decimal', 'bar']; +export const TAB_LEADERS: readonly TabLeader[] = ['none', 'dot', 'hyphen', 'underscore', 'heavy', 'middleDot']; +export const BORDER_SIDES: readonly BorderSideName[] = ['top', 'bottom', 'start', 'end', 'between']; +export { BORDER_STYLES }; + +// ---- Tab stops ---- + +/** The tab stops of the first selected paragraph. */ +export const tabsAt = (state: EditorState): TabStop[] => paraAt(state).tabs ?? []; + +/** Sets a stop at a position, replacing the one there. Refuses the 65th stop. */ +export function setTabStop(stop: TabStop): Command { + // Each paragraph keeps its own other stops. One that is full refuses the whole change. + return (state, dispatch) => { + const tooMany = selectionParaTargets(state.doc, state.selection).some( + ({ node }) => (paraFormat(node.attrs).tabs ?? []).filter((have) => have.pos !== stop.pos).length >= MAX_TAB_STOPS, + ); + if (tooMany) return false; + return setParaEach('tabs', (format) => + [...(format.tabs ?? []).filter((have) => have.pos !== stop.pos), stop].sort((a, b) => a.pos - b.pos), + )(state, dispatch); + }; +} + +/** Clears the stop at a position. */ +export function clearTabStop(pos: number): Command { + return setParaEach('tabs', (format) => { + const next = (format.tabs ?? []).filter((have) => have.pos !== pos); + return next.length > 0 ? next : null; + }); +} + +/** Clears every stop of the selected paragraphs. */ +export function clearAllTabStops(): Command { + return setPara('tabs', null); +} + +/** The default tab interval in points (Word's Default tab stops): the page's `view.text.defaultTab`. */ +export const defaultTabInterval = createStore(getDefaultTab(), 'default tab interval'); +onDefaultTab(() => defaultTabInterval.set(getDefaultTab())); + +/** Sets the default interval, 1 to 1584 points, and saves it with the page. 36 is the default, so it is removed. */ +export function setDefaultTabInterval(points: number): boolean { + if (!setDefaultTab(points)) return false; + shownMounted.get()?.layout.patchView({ text: { defaultTab: points === 36 ? null : points } }); + return true; +} + +// ---- Borders ---- + +export const borderAt = (state: EditorState): Border => paraAt(state).border ?? {}; + +/** What the border controls are set to before a side is chosen. */ +export const DEFAULT_SIDE: BorderSide = { style: 'single', width: 0.5, space: 1, color: null }; + +const borderJson = (border: Border) => { + const out: Record = {}; + for (const side of BORDER_SIDES) { + const have = border[side]; + if (have) + out[side] = { + style: have.style, + width: have.width, + space: have.space, + ...(have.color ? { color: have.color } : {}), + }; + } + return Object.keys(out).length > 0 ? out : null; +}; + +/** Sets, or with null removes, one side of the border. */ +export function setBorderSide(side: BorderSideName, spec: BorderSide | null): Command { + return setParaEach('border', (format) => { + const next: Border = { ...format.border }; + if (spec) next[side] = spec; + else delete next[side]; + return borderJson(next); + }); +} + +export type BorderPreset = 'none' | 'box' | 'lines'; + +/** A preset: none, a box (top, bottom, start, end), or lines above and below. */ +export function applyBorderPreset(preset: BorderPreset, spec: BorderSide = DEFAULT_SIDE): Command { + const sides: readonly BorderSideName[] = + preset === 'box' ? ['top', 'bottom', 'start', 'end'] : preset === 'lines' ? ['top', 'bottom'] : []; + const border: Border = Object.fromEntries(sides.map((side) => [side, spec])); + return setPara('border', borderJson(border)); +} + +// ---- Shading ---- + +export const shadeAt = (state: EditorState): Shade | null => paraAt(state).shade ?? null; + +/** Sets the shading, or removes it when there is no fill and the pattern is clear. */ +export function setShade(shade: Shade | null): Command { + const none = shade === null || (shade.fill === null && shade.pattern === 'clear'); + const json = none + ? null + : { + ...(shade.fill ? { fill: shade.fill } : {}), + pattern: shade.pattern, + ...(shade.color ? { color: shade.color } : {}), + }; + return setPara('shade', json); +} diff --git a/app/src/features/page/formatPane/controls.tsx b/app/src/features/page/formatPane/controls.tsx new file mode 100644 index 000000000..a92d0a003 --- /dev/null +++ b/app/src/features/page/formatPane/controls.tsx @@ -0,0 +1,152 @@ +// The small controls of the Format pane: a text or number box that commits on Enter or blur, a choice, and a check. +import { useId, useState } from 'react'; +import type { ReactNode } from 'react'; +import { t } from '../../../strings/t'; +import { optionLabel } from './labels'; +import styles from './FormatPane.module.css'; + +export interface BoxProps { + label: string; + value: string; + /** 'pt' or '%' beside the box. */ + unit?: string; + placeholder?: string; + error?: string; + /** A number box: Up and Down step it, and it is a spin button. */ + step?: number; + disabled?: boolean; + onCommit(text: string): void; +} + +/** A labeled box. What is typed commits on Enter or when focus leaves; Escape puts the old value back. */ +export function Box({ label, value, unit, placeholder, error, step, disabled, onCommit }: BoxProps) { + const id = useId(); + const errorId = useId(); + const [draft, setDraft] = useState(null); + const shown = draft ?? value; + const finish = (text: string) => { + setDraft(null); + if (text !== value) onCommit(text); + }; + const number = Number(shown.replace(',', '.')); + const spin = step !== undefined; + return ( +
+ +
+ setDraft(event.target.value)} + onBlur={() => draft !== null && finish(draft)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + finish(shown); + } else if (event.key === 'Escape' && draft !== null) { + event.stopPropagation(); + setDraft(null); + } else if (spin && (event.key === 'ArrowUp' || event.key === 'ArrowDown') && Number.isFinite(number)) { + event.preventDefault(); + const by = (event.key === 'ArrowUp' ? 1 : -1) * step * (event.shiftKey ? 10 : 1); + finish(String(Math.round((number + by) * 100) / 100)); + } + }} + /> + {unit ? {unit} : null} +
+ {error ? ( + + ) : null} +
+ ); +} + +export interface ChoiceProps { + label: string; + field: string; + options: readonly string[]; + value: string; + disabled?: boolean; + onCommit(value: string): void; +} + +/** A labeled list of choices. A selection that mixes values shows "Mixed" until one is chosen. */ +export function Choice({ label, field, options, value, disabled, onCommit }: ChoiceProps) { + const id = useId(); + const mixed = value === 'mixed'; + return ( +
+ + +
+ ); +} + +export interface CheckProps { + label: string; + value: boolean | 'mixed'; + disabled?: boolean; + onCommit(on: boolean): void; +} + +export function Check({ label, value, disabled, onCommit }: CheckProps) { + const id = useId(); + return ( +
+ { + if (node) node.indeterminate = value === 'mixed'; + }} + onChange={(event) => onCommit(event.target.checked)} + /> + +
+ ); +} + +export function Group({ title, children }: { title: string; children: ReactNode }) { + return ( +
+ {title} + {children} +
+ ); +} diff --git a/app/src/features/page/formatPane/fields.test.ts b/app/src/features/page/formatPane/fields.test.ts new file mode 100644 index 000000000..47bca88c3 --- /dev/null +++ b/app/src/features/page/formatPane/fields.test.ts @@ -0,0 +1,495 @@ +// @vitest-environment jsdom +// The Format pane's fields (text DESIGN.md 5.3): a typed value with a unit is converted and applied through the +// commands, a bad value gives its message and changes nothing, and each field reads back what it set. Expected values +// come from the design and from the grammar of the spec (ranges, units), not from the code's own output. + +// checks-disable-file brand-consistency: the colors typed here are inputs to the fields under test +import { afterEach, describe, expect, it } from 'vitest'; +import type { Command } from '../../../editor/commands/command'; +import { listLevelAt, setListLevel } from '../../../editor/commands/lists'; +import { mountEditor } from '../../../editor/commands/testing'; +import type { TestEditor } from '../../../editor/commands/testing'; +import { paraFormat } from '../../../editor/paragraph/attributes'; +import { setPageHyphenate } from '../../../editor/paragraph/defaultTab'; +import { t } from '../../../strings/t'; +import type { MessageKey } from '../../../strings/t'; +import { + applyBorderPreset, + borderAt, + clearAllTabStops, + clearTabStop, + setBorderSide, + setShade, + setTabStop, + shadeAt, + tabsAt, +} from './blocks'; +import { FIELDS, fieldById } from './fields'; + +let mounted: TestEditor | null = null; +afterEach(() => { + mounted?.destroy(); + mounted = null; +}); + +/** Types a value into a field as the pane does: parse, then apply. Returns the refusal message or null. */ +function type(id: string, input: string | boolean): string | null { + const field = fieldById(id)!; + const { editor } = mounted!; + const parsed = field.parse(input, editor.state); + if (!parsed.ok) return parsed.message; + field.apply(parsed.value)(editor.state, editor.view.dispatch); + return null; +} + +const shown = (id: string) => fieldById(id)!.read(mounted!.editor.state); +const para = () => paraFormat(mounted!.editor.state.doc.firstChild!.attrs); +const open = (source: string) => (mounted = mountEditor(source)); +const run = (command: Command) => command(mounted!.editor.state, mounted!.editor.view.dispatch); +const range = (min: string, max: string) => t('textPane.range', { min, max }); + +describe('the Text tab', () => { + it('applies 10.5 in Size as data-size 10.5', () => { + open('one [two] three'); + expect(type('size', '10.5')).toBeNull(); + expect(mounted!.markdown()).toBe('one two three'); + expect(shown('size')).toBe('10.5'); + }); + + it('converts a size typed in another unit to half points', () => { + open('one [two] three'); + type('size', '1in'); + expect(shown('size')).toBe('72'); + type('size', '16px'); + expect(shown('size')).toBe('12'); + }); + + it('refuses a size outside 1 to 1638 with the range message and changes nothing', () => { + open('one [two] three'); + const before = mounted!.markdown(); + expect(type('size', '0')).toBe(t('textFonts.sizeRange')); + expect(type('size', '1639')).toBe(t('textFonts.sizeRange')); + expect(type('size', 'big')).toBe(t('textFonts.sizeInvalid')); + expect(mounted!.markdown()).toBe(before); + }); + + it('sets a font, refuses a bad name, and a blank box takes the font off', () => { + open('one [two] three'); + expect(type('font', 'Georgia')).toBeNull(); + expect(shown('font')).toBe('Georgia'); + expect(type('font', 'bad;name')).toBe(t('textFonts.invalidName')); + expect(shown('font')).toBe('Georgia'); + type('font', ''); + expect(shown('font')).toBe(''); + }); + + it('sets the weight, and 700 is bold', () => { + open('one [two] three'); + type('weight', '600'); + expect(shown('weight')).toBe('600'); + type('weight', '700'); + expect(mounted!.markdown()).toBe('one **two** three'); + type('weight', '400'); + expect(mounted!.markdown()).toBe('one two three'); + }); +}); + +describe('the Text tab, underline and color', () => { + it('keeps the underline color when the style changes, and the style when the color changes', () => { + open('one [two] three'); + type('underline', 'wave'); + type('underlineColor', 'brick'); + expect(shown('underline')).toBe('wave'); + expect(shown('underlineColor')).toBe('brick'); + type('underline', 'dotted'); + expect(shown('underlineColor')).toBe('brick'); + type('underlineColor', '#336699'); + expect(shown('underline')).toBe('dotted'); + type('underline', ''); + expect(shown('underline')).toBe(''); + }); + + it('refuses a color that is not one', () => { + open('one [two] three'); + expect(type('color', 'not a color')).toBe(t('textPane.badColor')); + expect(type('color', '#aa3355')).toBeNull(); + expect(shown('color')).toBe('#aa3355'); + }); +}); + +describe('the Text tab, effects', () => { + it('toggles an effect without losing the others, and emboss and engrave exclude each other', () => { + open('one [two] three'); + type('shadow', true); + type('outline', true); + expect(shown('shadow')).toBe(true); + expect(shown('outline')).toBe(true); + type('emboss', true); + type('engrave', true); + expect(shown('emboss')).toBe(false); + expect(shown('engrave')).toBe(true); + type('shadow', false); + expect(shown('shadow')).toBe(false); + expect(shown('outline')).toBe(true); + }); + + it('sets capitals, strikethrough, sub and superscript', () => { + open('one [two] three'); + type('caps', 'small'); + expect(shown('caps')).toBe('small'); + type('strike', 'double'); + expect(shown('strike')).toBe('double'); + type('subscript', true); + type('superscript', true); + expect(shown('superscript')).toBe(true); + expect(shown('subscript')).toBe(false); + }); + + it('reads Mixed when only part of the selection has a mark', () => { + open('[one **two** three]'); + expect(shown('weight')).toBe('400'); + expect(fieldById('italic')!.read(mounted!.editor.state)).toBe(false); + open('[one] two'); + type('italic', true); + expect(shown('italic')).toBe(true); + }); +}); + +describe('the Advanced tab', () => { + it('applies scale in percent and 100 is no mark', () => { + open('one [two] three'); + expect(type('scale', '150%')).toBeNull(); + expect(shown('scale')).toBe('150'); + type('scale', '100'); + expect(shown('scale')).toBe(''); + expect(type('scale', '601')).toBe(range('1', '600')); + expect(type('scale', '0')).toBe(range('1', '600')); + }); + + it('converts spacing and position to points and refuses values past the range', () => { + open('one [two] three'); + type('tracking', '0.5pt'); + expect(shown('tracking')).toBe('0.5'); + type('shift', '2'); + expect(shown('shift')).toBe('2'); + expect(type('shift', '2000')).toBe(range('-1584', '1584')); + type('tracking', '0'); + expect(shown('tracking')).toBe(''); + }); + + it('keeps one OpenType token per group, in code point order', () => { + open('one [two] three'); + type('numberForms', 'onum'); + type('ligatures', 'dlig'); + type('numberForms', 'lnum'); + expect(mounted!.markdown()).toContain('data-otf="dlig lnum"'); + type('stylisticSet', 'ss03'); + expect(mounted!.markdown()).toContain('data-otf="dlig lnum ss03"'); + type('ligatures', ''); + expect(shown('ligatures')).toBe(''); + expect(shown('numberForms')).toBe('lnum'); + }); + + it('sets kerning at a size, and 0 turns it off', () => { + open('one [two] three'); + type('kern', '12'); + expect(shown('kern')).toBe('12'); + type('kern', '0'); + expect(shown('kern')).toBe('0'); + }); +}); + +describe('the Paragraph tab', () => { + it('applies 0.5in in Left indent as start 36', () => { + open('one [two] three'); + expect(type('indentStart', '0.5in')).toBeNull(); + expect(para().indent?.start).toBe(36); + expect(shown('indentStart')).toBe('36'); + }); + + it('refuses an indent past 1584 points and changes nothing', () => { + open('one [two] three'); + expect(type('indentStart', '5000')).toBe(range('-1584', '1584')); + expect(para().indent).toBeUndefined(); + expect(type('indentStart', 'wide')).toContain('Enter a value'); + }); + + it('sets a hanging indent as a negative first line, and takes the key off at zero', () => { + open('one [two] three'); + type('indentStart', '1in'); + type('special', 'hanging'); + expect(para().indent).toEqual({ start: 72, end: 0, first: -36 }); + type('specialBy', '0.25in'); + expect(para().indent?.first).toBe(-18); + type('special', ''); + expect(para().indent).toEqual({ start: 72, end: 0, first: 0 }); + type('indentStart', '0'); + expect(para().indent).toBeUndefined(); + }); + + it('sets spacing in points with units, and auto', () => { + open('one [two] three'); + type('before', '6pt'); + type('after', '0.25in'); + expect(para().before).toBe(6); + expect(para().after).toBe(18); + type('after', 'auto'); + expect(para().after).toBe('auto'); + expect(type('before', '2000')).toBe(range('0', '1584')); + }); + + it('sets the line spacing rule and value, with the right range for each rule', () => { + open('one [two] three'); + type('lineRule', 'exact'); + expect(para().line).toEqual({ rule: 'exact', value: 12 }); + type('lineValue', '14pt'); + expect(para().line).toEqual({ rule: 'exact', value: 14 }); + expect(type('lineValue', '0.1')).toBe(range('0.7', '1584')); + type('lineRule', 'multiple'); + expect(para().line).toBeUndefined(); + type('lineValue', '1.5'); + expect(para().line).toEqual({ rule: 'multiple', value: 1.5 }); + expect(type('lineValue', '200')).toBe(range('0.06', '132')); + }); +}); + +describe('the Paragraph tab, switches', () => { + it('sets alignment, the outline level, direction, and the pagination switches', () => { + open('one [two] three'); + type('align', 'justify'); + type('outlineLevel', '3'); + type('dir', 'rtl'); + type('keepNext', true); + type('widows', false); + expect(para()).toMatchObject({ align: 'justify', outline: 3, dir: 'rtl', keepNext: true, widows: false }); + type('keepNext', false); + expect(para().keepNext).toBeUndefined(); + }); +}); + +describe('every field', () => { + it('has a label string and a distinct id', () => { + const ids = FIELDS.map((field) => field.id); + expect(new Set(ids).size).toBe(ids.length); + for (const id of ids) expect(t(`textPane.field.${id}` as MessageKey)).not.toContain('textPane.'); + }); +}); + +describe('tab stops', () => { + it('sets, replaces, and clears stops in position order', () => { + open('one [two] three'); + run(setTabStop({ pos: 144, align: 'end', leader: 'dot' })); + run(setTabStop({ pos: 72, align: 'center', leader: 'none' })); + expect(tabsAt(mounted!.editor.state)).toEqual([ + { pos: 72, align: 'center', leader: 'none' }, + { pos: 144, align: 'end', leader: 'dot' }, + ]); + run(setTabStop({ pos: 72, align: 'decimal', leader: 'none' })); + expect(tabsAt(mounted!.editor.state)[0].align).toBe('decimal'); + run(clearTabStop(72)); + expect(tabsAt(mounted!.editor.state)).toHaveLength(1); + run(clearAllTabStops()); + expect(tabsAt(mounted!.editor.state)).toEqual([]); + }); + + it('refuses the 65th stop', () => { + open('one [two] three'); + for (let i = 0; i < 64; i += 1) run(setTabStop({ pos: (i + 1) * 10, align: 'start', leader: 'none' })); + expect(tabsAt(mounted!.editor.state)).toHaveLength(64); + expect(run(setTabStop({ pos: 1000, align: 'start', leader: 'none' }))).toBe(false); + }); +}); + +describe('borders and shading', () => { + it('sets one side, a box preset, and clears them', () => { + open('one [two] three'); + run(setBorderSide('top', { style: 'double', width: 1, space: 2, color: null })); + expect(borderAt(mounted!.editor.state).top).toMatchObject({ style: 'double', width: 1, space: 2 }); + run(applyBorderPreset('box', { style: 'single', width: 0.5, space: 1, color: 'brick' })); + expect(Object.keys(borderAt(mounted!.editor.state)).sort()).toEqual(['bottom', 'end', 'start', 'top']); + run(setBorderSide('top', null)); + expect(borderAt(mounted!.editor.state).top).toBeUndefined(); + run(applyBorderPreset('none')); + expect(borderAt(mounted!.editor.state)).toEqual({}); + }); + + it('sets and removes shading', () => { + open('one [two] three'); + run(setShade({ fill: '#ffeecc', pattern: 'solid', color: null })); + expect(shadeAt(mounted!.editor.state)).toMatchObject({ fill: '#ffeecc', pattern: 'solid' }); + run(setShade({ fill: null, pattern: 'clear', color: null })); + expect(shadeAt(mounted!.editor.state)).toBeNull(); + }); +}); + +describe('lists', () => { + it('reads the level and changes its indent, hanging, and symbol as a new definition', () => { + open('- [one]'); + expect(listLevelAt(mounted!.editor.state)?.level.format).toBe('bullet'); + expect(run(setListLevel({ indent: 72, hanging: 24 }))).toBe(true); + expect(listLevelAt(mounted!.editor.state)?.level).toMatchObject({ indent: 72, hanging: 24 }); + expect(run(setListLevel({ symbol: '✓' }))).toBe(true); + expect(listLevelAt(mounted!.editor.state)?.level.symbol).toBe('✓'); + expect(run(setListLevel({ symbol: 'ab' }))).toBe(false); + expect(run(setListLevel({ indent: 5000 }))).toBe(false); + }); + + it('is null outside a list', () => { + open('one [two]'); + expect(listLevelAt(mounted!.editor.state)).toBeNull(); + }); +}); + +describe('values the command refuses are refused by the field with a message', () => { + it('kerning is 0 or 1 to 1638 points', () => { + open('one [two]'); + expect(type('kern', '0.5')).toBe(t('textPane.kernRange')); + expect(type('kern', '0')).toBeNull(); + expect(type('kern', '12')).toBeNull(); + expect(type('kern', '2000')).toBe(range('0', '1638')); + }); + + it('highlight takes honey, the highlighter color with no name', () => { + open('one [two]'); + expect(type('highlight', 'honey')).toBeNull(); + expect(shown('highlight')).not.toBe(''); + }); + + it('an underline color needs an underline, and does not turn one on', () => { + open('one [two]'); + expect(type('underlineColor', 'brick')).toBe(t('textPane.underlineFirst')); + expect(shown('underline')).toBe(''); + expect(mounted!.markdown()).toBe('one two'); + expect(type('underline', 'single')).toBeNull(); + expect(type('underlineColor', 'brick')).toBeNull(); + expect(shown('underlineColor')).toBe('brick'); + }); + + it('a list symbol font is checked like a font name', () => { + open('- [one]'); + expect(run(setListLevel({ font: 'x'.repeat(5000) }))).toBe(false); + expect(run(setListLevel({ font: 'Georgia' }))).toBe(true); + }); +}); + +// ---- Selections of several paragraphs and runs: each keeps what it has apart from the edit ---- + +/** The paragraph attributes of the nth top-level element. */ +const paraOf = (index: number) => paraFormat(mounted!.editor.state.doc.child(index).attrs); + +/** Puts a paragraph key straight on the nth element, as an opened page would have it. */ +function seedPara(index: number, key: string, value: unknown) { + const { state, view } = mounted!.editor; + let pos = 0; + for (let i = 0; i < index; i += 1) pos += state.doc.child(i).nodeSize; + view.dispatch(state.tr.setNodeAttribute(pos, key, value)); +} + +describe('a selection of several paragraphs', () => { + const twoHanging = () => { + open('[one\n\ntwo]'); + seedPara(0, 'indent', { start: 0, end: 0, first: -18 }); + seedPara(1, 'indent', { start: 0, end: 9, first: -36 }); + }; + + it('sets Left indent per paragraph and keeps each hanging indent', () => { + twoHanging(); + expect(type('indentStart', '36')).toBeNull(); + expect(paraOf(0).indent).toEqual({ start: 36, end: 0, first: -18 }); + expect(paraOf(1).indent).toEqual({ start: 36, end: 9, first: -36 }); + }); + + it('sets By per paragraph and keeps the sides', () => { + twoHanging(); + expect(type('specialBy', '10')).toBeNull(); + expect(paraOf(0).indent).toEqual({ start: 0, end: 0, first: -10 }); + expect(paraOf(1).indent).toEqual({ start: 0, end: 9, first: -10 }); + }); + + it('shows a Special that differs as mixed, not as none', () => { + open('[one\n\ntwo]'); + seedPara(0, 'indent', { start: 0, end: 0, first: 18 }); + seedPara(1, 'indent', { start: 0, end: 0, first: -18 }); + expect(shown('special')).toBe('mixed'); + }); + + it('adds a tab stop to each paragraph and keeps the stops it had', () => { + open('[one\n\ntwo]'); + seedPara(1, 'tabs', [{ pos: 100, align: 'start', leader: 'none' }]); + run(setTabStop({ pos: 50, align: 'start', leader: 'none' })); + expect(paraOf(0).tabs?.map((stop) => stop.pos)).toEqual([50]); + expect(paraOf(1).tabs?.map((stop) => stop.pos)).toEqual([50, 100]); + run(clearTabStop(50)); + expect(paraOf(0).tabs).toBeUndefined(); + expect(paraOf(1).tabs?.map((stop) => stop.pos)).toEqual([100]); + }); + + it('sets a border side on each paragraph and keeps the other sides', () => { + open('[one\n\ntwo]'); + seedPara(1, 'border', { bottom: { style: 'single', width: 1, space: 2 } }); + run(setBorderSide('top', { style: 'single', width: 0.5, space: 1, color: null })); + expect(Object.keys(paraOf(0).border ?? {})).toEqual(['top']); + expect(Object.keys(paraOf(1).border ?? {}).sort()).toEqual(['bottom', 'top']); + }); +}); + +describe('Pagination defaults', () => { + it('shows Widow control on for a plain paragraph and turns it off with widows:false', () => { + open('one [two] three'); + expect(shown('widows')).toBe(true); + expect(type('widows', false)).toBeNull(); + expect(para().widows).toBe(false); + expect(shown('widows')).toBe(false); + expect(type('widows', true)).toBeNull(); + expect(para().widows).toBeUndefined(); + }); + + it('turns Hyphenate off with an explicit false when the page hyphenates', () => { + open('one [two] three'); + setPageHyphenate({ text: { hyphenate: true } }); + try { + expect(shown('hyphenate')).toBe(true); + expect(type('hyphenate', false)).toBeNull(); + expect(para().hyphenate).toBe(false); + expect(shown('hyphenate')).toBe(false); + } finally { + setPageHyphenate(null); + } + }); + + it('style mode reads widows as on and writes false for off', () => { + const style = fieldById('widows')!.style!; + expect(style.read({})).toBe(true); + expect(style.patch(false, {})).toEqual({ widows: false }); + expect(style.patch(true, { widows: false })).toEqual({ widows: undefined }); + }); +}); + +describe('a range whose runs differ', () => { + it('turns Emboss on for every run and keeps the shadow of the first', () => { + open('[one two]'); + mounted!.editor.commands.setTextSelection({ from: 1, to: 4 }); + expect(type('shadow', true)).toBeNull(); + mounted!.editor.commands.setTextSelection({ from: 1, to: 8 }); + expect(shown('shadow')).toBe('mixed'); + expect(type('emboss', true)).toBeNull(); + const html = mounted!.markdown(); + expect(html).toContain('data-effect="emboss shadow"'); + expect(html).toContain('data-effect="emboss"'); + expect(shown('emboss')).toBe(true); + expect(shown('shadow')).toBe('mixed'); + }); + + it('sets ligatures on a range and keeps each run its number form', () => { + open('[one two]'); + mounted!.editor.commands.setTextSelection({ from: 1, to: 4 }); + expect(type('numberForms', 'onum')).toBeNull(); + mounted!.editor.commands.setTextSelection({ from: 1, to: 8 }); + expect(shown('numberForms')).toBe('mixed'); + expect(type('ligatures', 'dlig')).toBeNull(); + const html = mounted!.markdown(); + expect(html).toContain('data-otf="dlig onum"'); + expect(html).toContain('data-otf="dlig"'); + expect(shown('ligatures')).toBe('dlig'); + }); +}); diff --git a/app/src/features/page/formatPane/fields.ts b/app/src/features/page/formatPane/fields.ts new file mode 100644 index 000000000..380ba4d9f --- /dev/null +++ b/app/src/features/page/formatPane/fields.ts @@ -0,0 +1,558 @@ +// The fields of the Format pane (text DESIGN.md 5.3): the Text, Advanced, and Paragraph tabs. Each field reads the +// selection, parses what was typed with its unit and range, and applies it through the editor commands, so the pane +// never writes a value the commands would refuse. Parsing is separate from applying: a bad value changes nothing and +// gives a message. A field may also say how it reads and writes a style, for style mode (Modify style). +import type { EditorState } from '@tiptap/pm/state'; +import type { Command } from '../../../editor/commands/command'; +import { setAlign, setPara, setParaEach } from '../../../editor/commands/paragraph'; +import { + editRunTokens, + runMarkPressed, + setRunMark, + setRunValue, + setWeight, + weightAt, +} from '../../../editor/commands/runFormat'; +import { sizeText } from '../../../editor/commands/sizes'; +import type { Align, ParaFormat, ParaKey } from '../../../editor/paragraph/attributes'; +import { + OTF_FEATURES, + SCALE_PERCENT, + SHIFT_POINTS, + TEXT_EFFECTS, + TRACKING_POINTS, + UNDERLINE_STYLES, + isColor, + isNumberText, +} from '../../../editor/schema/constants'; +import { t } from '../../../strings/t'; +import { getPageHyphenate } from '../../../editor/paragraph/defaultTab'; +import { parseSize, validFontName } from '../../fonts'; +import { firstMark, paraValue, runTokens, runValue } from './read'; +import type { Shown } from './read'; +import { formatPoints, parseLength } from './units'; + +export type TabId = 'text' | 'advanced' | 'paragraph' | 'tabs' | 'borders' | 'lists'; +export const TABS: readonly TabId[] = ['text', 'advanced', 'paragraph', 'tabs', 'borders', 'lists']; + +export type Parsed = { ok: true; value: unknown } | { ok: false; message: string }; +export type StyleValues = Readonly>; + +export interface Field { + readonly id: string; + readonly tab: TabId; + readonly kind: 'text' | 'choice' | 'toggle' | 'color'; + /** The values of a choice, in order. '' is "none" or "default". */ + readonly options?: readonly string[]; + /** The unit a bare number is in, shown beside the box. */ + readonly unit?: 'pt' | '%'; + read(state: EditorState): Shown; + /** The state is given to fields whose rules depend on another field, such as the line spacing rule. */ + parse(input: string | boolean, state?: EditorState): Parsed; + apply(value: unknown): Command; + /** How style mode reads and writes this field. A field without it is not shown there. */ + readonly style?: { + read(values: StyleValues): Shown; + patch(value: unknown, values: StyleValues): Readonly>; + }; +} + +const ok = (value: unknown): Parsed => ({ ok: true, value }); +const fail = (message: string): Parsed => ({ ok: false, message }); +const range = (min: number, max: number) => t('textPane.range', { min: String(min), max: String(max) }); +const invalid = (example: string) => t('textPane.invalid', { example }); + +/** A length with a unit: blank clears, else points inside [min, max] on a grid. */ +export function lengthParse(min: number, max: number, step = 0.05) { + return (input: string | boolean): Parsed => { + const text = String(input).trim(); + if (text === '') return ok(null); + const points = parseLength(text); + if (points === null) return fail(invalid('12, 0.5in, 1.25cm')); + const snapped = Math.round(points / step) * step; + if (snapped < min || snapped > max) return fail(range(min, max)); + return ok(Math.round(snapped * 100) / 100); + }; +} + +const choiceParse = (options: readonly string[]) => (input: string | boolean) => + options.includes(String(input)) ? ok(String(input)) : fail(invalid(options.join(', '))); + +const toggleParse = (input: string | boolean) => ok(input === true || input === 'true'); + +const shownNumber = (value: unknown): Shown => (typeof value === 'number' ? formatPoints(value) : ''); + +const colorParse = (input: string | boolean): Parsed => { + const text = String(input).trim(); + if (text === '') return ok(null); + return isColor(text) ? ok(text) : fail(t('textPane.badColor')); +}; + +// ---- Text tab ---- + +/** A field that sets one single-attribute mark. `parse` turns the typed value into the attribute text or null. */ +function span( + id: string, + tab: TabId, + kind: Field['kind'], + mark: string, + key: string, + parse: Field['parse'], + extra: Partial = {}, +): Field { + return { + id, + tab, + kind, + read: (state) => runValue(state, mark, key), + parse, + apply: (value) => setRunValue(mark, value === null ? null : String(value)), + ...extra, + }; +} + +/** A mark that is on or off. */ +function flag(id: string, mark: string, extra: Partial = {}): Field { + return { + id, + tab: 'text', + kind: 'toggle', + read: (state) => runMarkPressed(state, mark), + parse: toggleParse, + apply: (on) => setRunMark(mark, on ? {} : null), + ...extra, + }; +} + +const UNDERLINES = ['', 'single', ...UNDERLINE_STYLES.filter((style) => style !== 'single')]; +const WEIGHTS = ['100', '200', '300', '400', '500', '600', '700', '800', '900']; +const CAPS = ['', 'small', 'all']; + +/** An effect toggled in the `effect` mark's list, with emboss and engrave excluding each other. */ +function effect(token: (typeof TEXT_EFFECTS)[number]): Field { + const opposite = token === 'emboss' ? 'engrave' : token === 'engrave' ? 'emboss' : ''; + return { + id: token, + tab: 'text', + kind: 'toggle', + read: (state) => { + const runs = runTokens(state, 'effect').map((tokens) => tokens.includes(token)); + return runs.every(Boolean) ? true : runs.some(Boolean) ? 'mixed' : false; + }, + parse: toggleParse, + apply: (on) => + editRunTokens('effect', (tokens) => { + const rest = tokens.filter((have) => have !== token && !(on && have === opposite)); + return on ? [...rest, token] : rest; + }), + }; +} + +/** One group of OpenType tokens (ligatures, number forms): the group's token replaces the group's others. */ +function otf(id: string, group: readonly string[]): Field { + const options = ['', ...group]; + return { + id, + tab: 'advanced', + kind: 'choice', + options, + read: (state) => { + const runs = new Set( + runTokens(state, 'otf').map((tokens) => tokens.find((token) => group.includes(token)) ?? ''), + ); + return runs.size > 1 ? 'mixed' : ([...runs][0] ?? ''); + }, + parse: choiceParse(options), + apply: (value) => + editRunTokens('otf', (tokens) => [ + ...tokens.filter((token) => !group.includes(token)), + ...(value ? [String(value)] : []), + ]), + }; +} + +/** A number mark (scale, tracking, shift): `not` holds the value that means "no mark". */ +function numberMark( + id: string, + limits: { min: number; max: number; step: number; not?: readonly number[] }, + parse: Field['parse'], + unit: Field['unit'], + extra: Partial = {}, +): Field { + return span(id, 'advanced', 'text', id, id, parse, { + unit, + apply: (value) => { + const off = value === null || (limits.not ?? []).includes(Number(value)); + return setRunValue(id, off ? null : String(value)); + }, + ...extra, + }); +} + +/** Kerning is off (0) or starts at 1 point, as the page format says (R-MRK-10). */ +const kernParse = (input: string | boolean): Parsed => { + const result = lengthParse(0, 1638, 0.5)(input); + if (result.ok && typeof result.value === 'number' && result.value > 0 && result.value < 1) { + return fail(t('textPane.kernRange')); + } + return result; +}; + +const underlineAttrs = (state: EditorState) => firstMark(state, 'underline')?.attrs ?? {}; + +const sizeParse = (input: string | boolean): Parsed => { + const text = String(input).trim(); + if (text === '') return ok(null); + const size = parseSize(text); + if (size.ok) return ok(sizeText(size.points)); + return fail(t(size.reason === 'range' ? 'textFonts.sizeRange' : 'textFonts.sizeInvalid')); +}; + +const fontParse = (input: string | boolean): Parsed => { + const text = String(input).trim(); + if (text === '') return ok(null); + return validFontName(text) ? ok(text) : fail(t('textFonts.invalidName')); +}; + +const TEXT_FIELDS: readonly Field[] = [ + span('font', 'text', 'text', 'font', 'font', fontParse, { + style: { read: (v) => String(v.font ?? ''), patch: (value) => ({ font: value ?? undefined }) }, + }), + { + id: 'weight', + tab: 'text', + kind: 'choice', + options: WEIGHTS, + read: (state) => String(weightAt(state)), + parse: choiceParse(WEIGHTS), + apply: (value) => setWeight(Number(value)), + style: { read: (v) => String(v.weight ?? 400), patch: (value) => ({ weight: Number(value) }) }, + }, + span('size', 'text', 'text', 'textSize', 'size', sizeParse, { + unit: 'pt', + style: { + read: (v) => shownNumber(v.sizePt), + patch: (value) => ({ sizePt: value === null ? undefined : Number(value) }), + }, + }), + flag('italic', 'italic', { + style: { read: (v) => v.italic === true, patch: (value) => ({ italic: value === true ? true : undefined }) }, + }), + span('color', 'text', 'color', 'textColor', 'color', colorParse, { + style: { read: (v) => String(v.textColor ?? ''), patch: (value) => ({ textColor: value ?? undefined }) }, + }), + { + id: 'underline', + tab: 'text', + kind: 'choice', + options: UNDERLINES, + read: (state) => { + const found = runValue(state, 'underline', 'line'); + return found === '1' ? 'single' : found; + }, + parse: choiceParse(UNDERLINES), + apply: (value) => (state, dispatch) => { + if (value === '') return setRunMark('underline', null)(state, dispatch); + const { color } = underlineAttrs(state); + const attrs = { ...(value === 'single' ? {} : { line: value }), ...(color ? { color } : {}) }; + return setRunMark('underline', attrs)(state, dispatch); + }, + }, + { + id: 'underlineColor', + tab: 'text', + kind: 'color', + read: (state) => runValue(state, 'underline', 'color'), + // A color needs a line to color: setting it on text with no underline would turn the underline on. + parse: (input, state) => { + const parsed = colorParse(input); + if (parsed.ok && parsed.value !== null && state && !firstMark(state, 'underline')) { + return fail(t('textPane.underlineFirst')); + } + return parsed; + }, + apply: (value) => (state, dispatch) => { + if (!firstMark(state, 'underline')) return false; + const { line } = underlineAttrs(state); + const attrs = { ...(line ? { line } : {}), ...(value ? { color: value } : {}) }; + return setRunMark('underline', attrs)(state, dispatch); + }, + }, + { + id: 'strike', + tab: 'text', + kind: 'choice', + options: ['', 'single', 'double'], + read: (state) => { + const found = runValue(state, 'strike', 'line'); + return found === '1' ? 'single' : found; + }, + parse: choiceParse(['', 'single', 'double']), + apply: (value) => setRunMark('strike', value === '' ? null : value === 'double' ? { line: 'double' } : {}), + style: { + read: (v) => (v.strike === 'single' || v.strike === 'double' ? v.strike : ''), + patch: (value) => ({ strike: value === '' ? undefined : value }), + }, + }, + flag('superscript', 'superscript'), + flag('subscript', 'subscript'), + span('caps', 'text', 'choice', 'caps', 'caps', (input) => choiceParse(CAPS)(input), { + options: CAPS, + apply: (value) => setRunValue('caps', value === '' ? null : String(value)), + style: { + read: (v) => (v.caps === 'small' || v.caps === 'all' ? v.caps : ''), + patch: (value) => ({ caps: value === '' ? undefined : value }), + }, + }), + ...TEXT_EFFECTS.map(effect), + span('highlight', 'text', 'color', 'highlight', 'color', colorParse, { + // Honey is the highlighter's own color and has no name in the mark: the mark with no color. + apply: (value) => + setRunMark('highlight', value === null ? null : value === 'honey' ? {} : { color: String(value) }), + style: { read: (v) => String(v.highlight ?? ''), patch: (value) => ({ highlight: value ?? undefined }) }, + }), +]; + +// ---- Advanced tab ---- + +const scaleParse = (input: string | boolean): Parsed => { + const text = String(input).trim().replace(/%$/, ''); + if (text === '') return ok(null); + if (!/^\d+$/.test(text)) return fail(invalid('90, 150')); + const inRange = isNumberText(text, { ...SCALE_PERCENT, not: [] }); + return inRange ? ok(Number(text)) : fail(range(SCALE_PERCENT.min, SCALE_PERCENT.max)); +}; + +const ADVANCED_FIELDS: readonly Field[] = [ + numberMark('scale', SCALE_PERCENT, scaleParse, '%'), + numberMark('tracking', TRACKING_POINTS, lengthParse(TRACKING_POINTS.min, TRACKING_POINTS.max), 'pt', { + style: { + read: (v) => shownNumber(v.tracking === 0 ? undefined : v.tracking), + patch: (value) => ({ tracking: value === null ? undefined : value }), + }, + }), + numberMark('shift', SHIFT_POINTS, lengthParse(SHIFT_POINTS.min, SHIFT_POINTS.max, 0.5), 'pt'), + span('kern', 'advanced', 'text', 'kern', 'kern', kernParse, { unit: 'pt' }), + otf('ligatures', ['liga-none', 'clig-none', 'dlig', 'hlig']), + otf('numberForms', ['lnum', 'onum']), + otf('numberSpacing', ['pnum', 'tnum']), + otf( + 'stylisticSet', + OTF_FEATURES.filter((token) => /^ss\d\d$/.test(token)), + ), + otf('zero', ['zero']), +]; + +// ---- Paragraph tab ---- + +const para = (state: EditorState, pick: (format: ParaFormat) => string | undefined) => paraValue(state, pick); + +interface IndentJson { + start: number; + end: number; + first: number; +} + +/** An indent object, or null when all three sides are 0. */ +const indentJson = (indent: IndentJson) => + indent.start === 0 && indent.end === 0 && indent.first === 0 ? null : indent; + +const styleIndent = (values: StyleValues): IndentJson => ({ + start: 0, + end: 0, + first: 0, + ...(values.indent as Partial | undefined), +}); + +/** One paragraph's indent with one side changed; the other sides stay as that paragraph has them. */ +const withIndent = (format: ParaFormat, change: (own: IndentJson) => Partial) => { + const own: IndentJson = { start: 0, end: 0, first: 0, ...format.indent }; + return indentJson({ ...own, ...change(own) }); +}; + +/** A paragraph key that is a length, or `auto` for the spacing keys. */ +function paraLength(id: 'before' | 'after'): Field { + return { + id, + tab: 'paragraph', + kind: 'text', + unit: 'pt', + read: (state) => para(state, (f) => (f[id] === undefined ? undefined : String(f[id]))), + parse: (input) => (String(input).trim().toLowerCase() === 'auto' ? ok('auto') : lengthParse(0, 1584)(input)), + apply: (value) => setPara(id, value), + style: { + read: (v) => (typeof v[id] === 'number' ? formatPoints(v[id]) : v[id] === 'auto' ? 'auto' : ''), + patch: (value) => ({ [id]: value ?? undefined }), + }, + }; +} + +/** One side of the indent. Typing nothing means 0. */ +function indentSide(id: string, side: 'start' | 'end'): Field { + return { + id, + tab: 'paragraph', + kind: 'text', + unit: 'pt', + read: (state) => para(state, (f) => formatPoints(f.indent?.[side] ?? 0)), + parse: (input) => lengthParse(-1584, 1584)(String(input).trim() === '' ? '0' : input), + apply: (value) => setParaEach('indent', (format) => withIndent(format, () => ({ [side]: Number(value) }))), + style: { + read: (v) => formatPoints(styleIndent(v)[side]), + patch: (value, v) => ({ indent: indentJson({ ...styleIndent(v), [side]: Number(value) }) }), + }, + }; +} + +const SPECIAL = ['', 'first', 'hanging']; +const LINE_RULES = ['multiple', 'atLeast', 'exact']; +const ALIGNS = ['start', 'center', 'end', 'justify', 'distribute']; +const OUTLINES = ['', '1', '2', '3', '4', '5', '6', '7', '8', '9']; +const BOOLEAN_KEYS = ['contextual', 'keepNext', 'keepLines', 'breakBefore', 'widows', 'hyphenate'] as const; +const STYLE_BOOLEANS: ReadonlySet = new Set(['contextual', 'keepNext', 'keepLines', 'widows']); +/** What a boolean key means when a paragraph has none: widow control is on; hyphenation follows the page. */ +const booleanDefault = (key: (typeof BOOLEAN_KEYS)[number]): boolean => + key === 'widows' ? true : key === 'hyphenate' ? getPageHyphenate() : false; + +/** The line spacing value parsed with the rule the selection has: a multiple, or a length in points. */ +function lineValueParse(input: string | boolean, state?: EditorState): Parsed { + const text = String(input).trim(); + if (text === '') return ok(null); + const rule = state ? para(state, (f) => f.line?.rule ?? 'multiple') : 'multiple'; + if (rule !== 'multiple' && rule !== 'mixed') return lengthParse(0.7, 1584)(text); + if (!/^\d+([.,]\d+)?$/.test(text)) return fail(invalid('1, 1.15, 1.5')); + const value = Number(text.replace(',', '.')); + return value >= 0.06 && value <= 132 ? ok(Math.round(value * 100) / 100) : fail(range(0.06, 132)); +} + +const PARAGRAPH_FIELDS: readonly Field[] = [ + { + id: 'align', + tab: 'paragraph', + kind: 'choice', + options: ALIGNS, + read: (state) => para(state, (f) => f.align ?? 'start'), + parse: choiceParse(ALIGNS), + apply: (value) => setAlign(value as Align), + style: { + read: (v) => String(v.align ?? 'start'), + patch: (value) => ({ align: value === 'start' ? undefined : value }), + }, + }, + { + id: 'outlineLevel', + tab: 'paragraph', + kind: 'choice', + options: OUTLINES, + read: (state) => para(state, (f) => (f.outline === undefined ? undefined : String(f.outline))), + parse: choiceParse(OUTLINES), + apply: (value) => setPara('outline', value === '' ? null : Number(value)), + style: { + read: (v) => String(v.outline ?? ''), + patch: (value) => ({ outline: value === '' ? undefined : Number(value) }), + }, + }, + indentSide('indentStart', 'start'), + indentSide('indentEnd', 'end'), + { + id: 'special', + tab: 'paragraph', + kind: 'choice', + options: SPECIAL, + read: (state) => + para(state, (f) => { + const first = f.indent?.first ?? 0; + return first > 0 ? 'first' : first < 0 ? 'hanging' : ''; + }), + parse: choiceParse(SPECIAL), + apply: (kind) => + setParaEach('indent', (format) => + withIndent(format, (own) => { + const size = Math.abs(own.first) || 36; + return { first: kind === 'first' ? size : kind === 'hanging' ? -size : 0 }; + }), + ), + }, + { + id: 'specialBy', + tab: 'paragraph', + kind: 'text', + unit: 'pt', + read: (state) => para(state, (f) => formatPoints(Math.abs(f.indent?.first ?? 0))), + parse: lengthParse(0, 1584), + apply: (value) => + setParaEach('indent', (format) => + withIndent(format, (own) => ({ first: own.first < 0 ? -Number(value ?? 0) : Number(value ?? 0) })), + ), + }, + paraLength('before'), + paraLength('after'), + { + id: 'lineRule', + tab: 'paragraph', + kind: 'choice', + options: LINE_RULES, + read: (state) => para(state, (f) => f.line?.rule ?? 'multiple'), + parse: choiceParse(LINE_RULES), + apply: (rule) => (state, dispatch) => { + const before = para(state, (f) => f.line?.rule ?? 'multiple'); + const had = Number(para(state, (f) => (f.line ? String(f.line.value) : undefined))) || 0; + // A multiple and a length in points do not convert: a change between them starts from 1 or 12 points. + const same = (before === 'multiple') === (rule === 'multiple'); + const value = rule === 'multiple' ? (same && had > 0 ? had : 1) : same ? Math.max(had, 0.7) : 12; + return setPara('line', rule === 'multiple' && value === 1 ? null : { rule, value })(state, dispatch); + }, + }, + { + id: 'lineValue', + tab: 'paragraph', + kind: 'text', + read: (state) => para(state, (f) => String(f.line?.value ?? 1)), + parse: lineValueParse, + apply: (value) => (state, dispatch) => { + const rule = para(state, (f) => f.line?.rule ?? 'multiple'); + const next = value === null ? null : { rule: rule === 'mixed' ? 'multiple' : rule, value }; + return setPara('line', next && next.rule === 'multiple' && next.value === 1 ? null : next)(state, dispatch); + }, + }, + ...BOOLEAN_KEYS.map((key): Field => ({ + id: key, + tab: 'paragraph', + kind: 'toggle', + read: (state) => { + const value = para(state, (f) => String(f[key] ?? booleanDefault(key))); + return value === 'mixed' ? 'mixed' : value === 'true'; + }, + parse: toggleParse, + // The default is no key; the other choice is written out, so Off works where the default is On. + apply: (on) => (state, dispatch) => setPara(key, on === booleanDefault(key) ? null : on)(state, dispatch), + ...(STYLE_BOOLEANS.has(key) + ? { + style: { + read: (v: StyleValues) => (typeof v[key] === 'boolean' ? v[key] === true : booleanDefault(key)), + patch: (value: unknown) => ({ + [key]: (value === true) === booleanDefault(key) ? undefined : value === true, + }), + }, + } + : {}), + })), + { + id: 'dir', + tab: 'paragraph', + kind: 'choice', + options: ['', 'ltr', 'rtl'], + read: (state) => para(state, (f) => f.dir), + parse: choiceParse(['', 'ltr', 'rtl']), + apply: (value) => setPara('dir', value === '' ? null : value), + style: { read: (v) => String(v.dir ?? ''), patch: (value) => ({ dir: value === '' ? undefined : value }) }, + }, +]; + +export const FIELDS: readonly Field[] = [...TEXT_FIELDS, ...ADVANCED_FIELDS, ...PARAGRAPH_FIELDS]; + +export const fieldsOf = (tab: TabId): Field[] => FIELDS.filter((field) => field.tab === tab); +export const fieldById = (id: string): Field | undefined => FIELDS.find((field) => field.id === id); + +/** The paragraph keys the Paragraph tab sets, for tests. */ +export const PARA_FIELD_KEYS: readonly ParaKey[] = ['align', 'indent', 'before', 'after', 'line', 'outline', 'dir']; diff --git a/app/src/features/page/formatPane/labels.test.ts b/app/src/features/page/formatPane/labels.test.ts new file mode 100644 index 000000000..24796aa4b --- /dev/null +++ b/app/src/features/page/formatPane/labels.test.ts @@ -0,0 +1,31 @@ +// Every value the pane offers has its own string, so no option shows a made-up name (American English, no hard-coded +// user-visible text). +import { describe, expect, it } from 'vitest'; +import { BORDER_STYLES, SHADE_PATTERNS } from '../../../editor/paragraph/attributes'; +import { NUMBER_FORMATS } from '../../../editor/paragraph/numberFormats'; +import { textPane } from '../../../strings/en/textPane'; +import { FIELDS } from './fields'; + +const opt = textPane.opt as Readonly>>>; +const has = (field: string, value: string) => opt[field]?.[value === '' ? 'none' : value] !== undefined; + +describe('option strings', () => { + it('has a string for every border style, shading pattern, and number format', () => { + expect(BORDER_STYLES.filter((v) => !has('borderStyle', v))).toEqual([]); + expect(SHADE_PATTERNS.filter((v) => !has('pattern', v))).toEqual([]); + expect(NUMBER_FORMATS.filter((v) => !has('format', v))).toEqual([]); + }); + + it('has a string for every option of every choice field', () => { + const missing: string[] = []; + for (const field of FIELDS) { + if (field.kind !== 'choice') continue; + for (const value of field.options ?? []) { + // Stylistic sets share one message, and outline levels are digits. + if (/^(ss\d\d|\d)$/.test(value)) continue; + if (!has(field.id, String(value))) missing.push(`${field.id}.${String(value)}`); + } + } + expect(missing).toEqual([]); + }); +}); diff --git a/app/src/features/page/formatPane/labels.ts b/app/src/features/page/formatPane/labels.ts new file mode 100644 index 000000000..2d42b9b08 --- /dev/null +++ b/app/src/features/page/formatPane/labels.ts @@ -0,0 +1,19 @@ +// Names for the pane's options. Every value has its own string (a test checks the border styles, shading patterns, +// and number formats), apart from the OpenType stylistic sets, which share one message. +import { textPane } from '../../../strings/en/textPane'; +import { t } from '../../../strings/t'; +import type { MessageKey } from '../../../strings/t'; + +const OPTIONS = textPane.opt as Readonly>>>; + +/** The name of an option of a field. '' is the field's "none" or "default". */ +export function optionLabel(field: string, value: string): string { + const key = value === '' ? 'none' : value; + if (OPTIONS[field]?.[key] !== undefined) return t(`textPane.opt.${field}.${key}` as MessageKey); + const set = /^ss(\d\d)$/.exec(value); + if (set) return t('textPane.setN', { n: String(Number(set[1])) }); + return value; +} + +/** The label of a field. */ +export const fieldLabel = (id: string): string => t(`textPane.field.${id}` as MessageKey); diff --git a/app/src/features/page/formatPane/mount.tsx b/app/src/features/page/formatPane/mount.tsx new file mode 100644 index 000000000..a3643c3dc --- /dev/null +++ b/app/src/features/page/formatPane/mount.tsx @@ -0,0 +1,60 @@ +// Shows the Format pane at the dock (text DESIGN.md 5.3). Like the Styles pane, it has no slot in the workspace grid, +// so it renders in a root of its own. Opening it again moves it to the tab or the style asked for. +import type { Editor } from '@tiptap/core'; +import { createRoot } from 'react-dom/client'; +import type { Root } from 'react-dom/client'; +import type { FontsClient } from '../../../platform/types'; +import { setDockInset } from '../../../shell/layout/dock'; +import { targetEditor } from '../formattingBar/target'; +import { FormatPane } from './FormatPane'; +import type { TabId } from './fields'; +import styles from './FormatPane.module.css'; + +export interface OpenOptions { + notebook: string; + tab?: TabId; + /** A style to edit (Modify style). */ + style?: string; + fonts?: FontsClient; + editorOf?: () => Editor | null; +} + +let host: HTMLElement | null = null; +let root: Root | null = null; +let opener: HTMLElement | null = null; + +export const formatPaneOpen = (): boolean => host !== null; + +export function closeFormatPane(): void { + root?.unmount(); + host?.remove(); + root = null; + host = null; + setDockInset(0, 'format'); + if (opener?.isConnected) opener.focus(); + opener = null; +} + +/** Shows the pane, or moves an open one to the tab or style asked for. */ +export function openFormatPane(options: OpenOptions): void { + if (!host) { + opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; + host = document.body.appendChild(document.createElement('div')); + host.className = styles.sheet ?? ''; + host.dataset['region'] = 'format-pane'; + // The page leaves room for the sheet: its 22 rem, its 16 px gap to the window's edge, and 8 px of air. + setDockInset(352 + 24, 'format'); + root = createRoot(host); + } + root?.render( + , + ); +} diff --git a/app/src/features/page/formatPane/paneKey.test.ts b/app/src/features/page/formatPane/paneKey.test.ts new file mode 100644 index 000000000..2b3af347e --- /dev/null +++ b/app/src/features/page/formatPane/paneKey.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +// The pane skips typing at a caret and draws again for any change of the format it shows. +import { TextSelection } from '@tiptap/pm/state'; +import { afterEach, describe, expect, it } from 'vitest'; +import { setAlign } from '../../../editor/commands/paragraph'; +import { toggleRunMark } from '../../../editor/commands/runFormat'; +import { mountEditor } from '../../../editor/commands/testing'; +import type { TestEditor } from '../../../editor/commands/testing'; +import { paneKey } from './paneKey'; + +let mounted: TestEditor | null = null; +afterEach(() => { + mounted?.destroy(); + mounted = null; +}); + +const run = (command: (s: never, d: never) => boolean) => + (command as unknown as (s: unknown, d: unknown) => boolean)(mounted!.editor.state, mounted!.editor.view.dispatch); + +describe('paneKey', () => { + it('does not change when a character is typed at the caret', () => { + mounted = mountEditor('one two'); + const { editor } = mounted; + editor.commands.setTextSelection(4); + const before = paneKey(editor.state); + editor.view.dispatch(editor.state.tr.insertText('x')); + expect(before).not.toBeNull(); + expect(paneKey(editor.state)).toBe(before); + }); + + it('changes when the caret mark or the paragraph format changes', () => { + mounted = mountEditor('one two'); + const { editor } = mounted; + editor.commands.setTextSelection(4); + const plain = paneKey(editor.state); + run(toggleRunMark('bold') as never); + const bold = paneKey(editor.state); + expect(bold).not.toBe(plain); + run(setAlign('center') as never); + expect(paneKey(editor.state)).not.toBe(bold); + }); + + it('has no key for a range, which always draws again', () => { + mounted = mountEditor('one two'); + const { editor } = mounted; + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 1, 4))); + expect(paneKey(editor.state)).toBeNull(); + }); +}); diff --git a/app/src/features/page/formatPane/paneKey.ts b/app/src/features/page/formatPane/paneKey.ts new file mode 100644 index 000000000..c43e02624 --- /dev/null +++ b/app/src/features/page/formatPane/paneKey.ts @@ -0,0 +1,18 @@ +// Whether the Format pane must draw again after a transaction. Typing at a caret changes the text but not what the +// pane shows: the format at the caret and its paragraph stay the same. Every field's read walks the selection, so the +// pane skips those transactions and keeps typing off the pane's cost (a long page, the pane open). +import type { EditorState } from '@tiptap/pm/state'; + +/** What the pane shows for a caret: the caret's marks and the paragraph's chain of nodes. A range has no key. */ +export function paneKey(state: EditorState): string | null { + const { selection } = state; + if (!selection.empty) return null; + const { $from } = selection; + const marks = (state.storedMarks ?? $from.marks()).map((mark) => [mark.type.name, mark.attrs]); + const chain: unknown[] = []; + for (let depth = $from.depth; depth > 0; depth -= 1) { + const node = $from.node(depth); + chain.push([node.type.name, node.attrs]); + } + return JSON.stringify([marks, chain]); +} diff --git a/app/src/features/page/formatPane/read.ts b/app/src/features/page/formatPane/read.ts new file mode 100644 index 000000000..bde6a21d9 --- /dev/null +++ b/app/src/features/page/formatPane/read.ts @@ -0,0 +1,57 @@ +// Reading what the selection has, for the Format pane's fields: one value when every run or paragraph agrees, +// 'mixed' when they differ, '' when none has it. +import type { Mark, Node as PMNode } from '@tiptap/pm/model'; +import type { EditorState } from '@tiptap/pm/state'; +import { selectionParaTargets } from '../../../editor/commands/paragraph'; +import { paraFormat } from '../../../editor/paragraph/attributes'; +import type { ParaFormat } from '../../../editor/paragraph/attributes'; + +export type Shown = string | boolean | 'mixed'; + +/** The marks of the text the selection covers; the caret's marks for a caret. One list per text node. */ +function markLists(state: EditorState): (readonly Mark[])[] { + const { selection } = state; + if (selection.empty) return [state.storedMarks ?? selection.$from.marks()]; + const lists: (readonly Mark[])[] = []; + for (const range of selection.ranges) { + state.doc.nodesBetween(range.$from.pos, range.$to.pos, (node: PMNode) => { + if (node.isText) lists.push(node.marks); + return true; + }); + } + return lists.length > 0 ? lists : [selection.$from.marks()]; +} + +/** The value of a mark's attribute over the selection. A mark without that attribute gives '1'. */ +export function runValue(state: EditorState, mark: string, key: string | null): string { + const seen = new Set(); + for (const marks of markLists(state)) { + const found = marks.find((candidate) => candidate.type.name === mark); + seen.add(found ? String((key ? found.attrs[key] : null) ?? '1') : ''); + } + if (seen.size > 1) return 'mixed'; + return [...seen][0] ?? ''; +} + +/** The marks of the selection's first text, for fields that combine several attributes. */ +export function firstMark(state: EditorState, mark: string): Mark | undefined { + return markLists(state)[0]?.find((candidate) => candidate.type.name === mark); +} + +/** A paragraph value over the selected elements, written as text. */ +export function paraValue(state: EditorState, pick: (format: ParaFormat) => string | undefined): string { + const seen = new Set( + selectionParaTargets(state.doc, state.selection).map(({ node }) => pick(paraFormat(node.attrs)) ?? ''), + ); + if (seen.size > 1) return 'mixed'; + return [...seen][0] ?? ''; +} + +/** The token list of a mark on each text run of the selection (the caret's marks for a caret). */ +export function runTokens(state: EditorState, mark: string): string[][] { + return markLists(state).map((marks) => + String(marks.find((candidate) => candidate.type.name === mark)?.attrs[mark] ?? '') + .split(' ') + .filter(Boolean), + ); +} diff --git a/app/src/features/page/formatPane/reveal.test.ts b/app/src/features/page/formatPane/reveal.test.ts new file mode 100644 index 000000000..2689a3b1b --- /dev/null +++ b/app/src/features/page/formatPane/reveal.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +// Reveal formatting (text DESIGN.md 4.1 and 5.3): each format at the caret with its source. The fixture is a Heading 2 +// whose notebook style sets Georgia, with bold set directly on the run. +import { afterEach, describe, expect, it } from 'vitest'; +import { setAlign, setPara } from '../../../editor/commands/paragraph'; +import { setRunValue, toggleRunMark } from '../../../editor/commands/runFormat'; +import { mountEditor } from '../../../editor/commands/testing'; +import type { TestEditor } from '../../../editor/commands/testing'; +import { APP_STYLES } from '../styles/sets'; +import { revealFormatting, revealText } from './reveal'; + +let mounted: TestEditor | null = null; +afterEach(() => { + mounted?.destroy(); + mounted = null; +}); + +const ctx = { app: APP_STYLES, notebook: { h2: { font: 'Georgia' } } }; +const texts = () => revealFormatting(mounted!.editor.state, ctx).map((line) => line.text); + +describe('reveal formatting', () => { + it('lists Bold (direct) and Georgia (style Heading 2) for the fixture', () => { + mounted = mountEditor('## [Title]'); + toggleRunMark('bold')(mounted.editor.state, mounted.editor.view.dispatch); + const lines = texts(); + expect(lines).toContain('Bold (direct)'); + expect(lines).toContain('Georgia (style Heading 2)'); + }); + + it('hides the style value of a format the run sets itself', () => { + mounted = mountEditor('## [Title]'); + setRunValue('font', 'Palatino')(mounted.editor.state, mounted.editor.view.dispatch); + const lines = texts(); + expect(lines).toContain('Palatino (direct)'); + expect(lines).not.toContain('Georgia (style Heading 2)'); + }); + + it('says where the paragraph formats come from', () => { + mounted = mountEditor('[one two]'); + setAlign('center')(mounted.editor.state, mounted.editor.view.dispatch); + const lines = revealFormatting(mounted.editor.state, { app: APP_STYLES, notebook: {} }); + expect(lines.find((line) => line.key === 'align')).toMatchObject({ + source: 'paragraph', + text: 'Aligned center (paragraph)', + }); + }); + + it('says there is nothing to reveal for plain Normal text', () => { + mounted = mountEditor('[one two]'); + const lines = revealFormatting(mounted.editor.state, { app: APP_STYLES, notebook: {} }); + expect(lines).toEqual([]); + expect(revealText(lines)).toBe('No formatting beyond the Normal style.'); + }); + + it('joins the lines into one sentence for the live region', () => { + mounted = mountEditor('[one two]'); + toggleRunMark('italic')(mounted.editor.state, mounted.editor.view.dispatch); + setAlign('end')(mounted.editor.state, mounted.editor.view.dispatch); + const text = revealText(revealFormatting(mounted.editor.state, { app: APP_STYLES, notebook: {} })); + expect(text).toBe('Italic (direct). Aligned end (paragraph)'); + }); + + it('says at-least line spacing and small capitals in words, never as identifiers', () => { + mounted = mountEditor('[one two]'); + const { state, dispatch } = { state: mounted.editor.state, dispatch: mounted.editor.view.dispatch }; + expect(setPara('line', { rule: 'atLeast', value: 12 })(state, dispatch)).toBe(true); + setRunValue('caps', 'small')(mounted.editor.state, mounted.editor.view.dispatch); + const lines = revealFormatting(mounted.editor.state, { app: APP_STYLES, notebook: {} }).map((l) => l.text); + expect(lines).toContain('Line spacing at least 12 pt (paragraph)'); + expect(lines).toContain('Capitals small capitals (direct)'); + }); + + it('says a style line spacing in words, not JSON', () => { + mounted = mountEditor('## [Title]'); + const lines = revealFormatting(mounted.editor.state, { + app: APP_STYLES, + notebook: { h2: { line: { rule: 'multiple', value: 1.15 } } }, + }).map((l) => l.text); + expect(lines).toContain('Line spacing multiple 1.15 (style Heading 2)'); + expect(lines.join(' ')).not.toMatch(/[{}"]/); + }); +}); diff --git a/app/src/features/page/formatPane/reveal.ts b/app/src/features/page/formatPane/reveal.ts new file mode 100644 index 000000000..ab57915ff --- /dev/null +++ b/app/src/features/page/formatPane/reveal.ts @@ -0,0 +1,262 @@ +// Reveal formatting (text DESIGN.md 4.1 and 5.3; Word's Shift+F1): the formatting at the caret with where each part +// comes from. A value the run sets itself is "direct", one the paragraph sets is "paragraph", and one that comes from +// the paragraph's named style says the style's name, such as "Georgia (style Heading 2)". A direct value hides the +// style's value for the same key, as it does on the page. +import type { Mark } from '@tiptap/pm/model'; +import type { EditorState } from '@tiptap/pm/state'; +import { paraAt } from '../../../editor/commands/paragraph'; +import { t } from '../../../strings/t'; +import { builtInLabel } from '../styles/gallery'; +import { styleOfNode } from '../styles/commands'; +import { styleLabel } from '../styles/model'; +import { resolveStyle } from '../styles/resolve'; +import type { StyleContext } from '../styles/resolve'; +import { optionLabel as optionName } from './labels'; + +/** An option's name inside a sentence: lowercase, as in "Aligned center". */ +const optionLabel = (field: string, value: string): string => optionName(field, value).toLowerCase(); +import { formatPoints } from './units'; + +export type RevealSource = 'direct' | 'paragraph' | 'style'; + +export interface RevealLine { + /** The format, such as `font` or `bold`. */ + readonly key: string; + readonly source: RevealSource; + /** The style's name when the source is a style. */ + readonly style?: string; + /** The line as shown: "Bold (direct)". */ + readonly text: string; +} + +const WORDS: Readonly string>> = { + font: (value) => t('textPane.reveal.what.font', { value }), + sizePt: (value) => t('textPane.reveal.what.sizePt', { value }), + size: (value) => t('textPane.reveal.what.size', { value }), + weight: (value) => t('textPane.reveal.what.weight', { value }), + bold: () => t('textPane.reveal.what.bold'), + italic: () => t('textPane.reveal.what.italic'), + underline: (value) => t('textPane.reveal.what.underline', { value }), + strike: () => t('textPane.reveal.what.strike'), + strikeDouble: () => t('textPane.reveal.what.strikeDouble'), + textColor: (value) => t('textPane.reveal.what.textColor', { value }), + highlight: (value) => t('textPane.reveal.what.highlight', { value }), + caps: (value) => t('textPane.reveal.what.caps', { value }), + tracking: (value) => t('textPane.reveal.what.tracking', { value }), + scale: (value) => t('textPane.reveal.what.scale', { value }), + shift: (value) => t('textPane.reveal.what.shift', { value }), + kern: (value) => t('textPane.reveal.what.kern', { value }), + effect: (value) => t('textPane.reveal.what.effect', { value }), + otf: (value) => t('textPane.reveal.what.otf', { value }), + subscript: () => t('textPane.reveal.what.subscript'), + superscript: () => t('textPane.reveal.what.superscript'), + lang: (value) => t('textPane.reveal.what.lang', { value }), + dir: (value) => t('textPane.reveal.what.dir', { value }), + align: (value) => t('textPane.reveal.what.align', { value }), + indent: (value) => t('textPane.reveal.what.indent', { value }), + before: (value) => t('textPane.reveal.what.before', { value }), + after: (value) => t('textPane.reveal.what.after', { value }), + line: (value) => t('textPane.reveal.what.line', { value }), + outline: (value) => t('textPane.reveal.what.outline', { value }), + keepNext: () => t('textPane.reveal.what.keepNext'), + keepLines: () => t('textPane.reveal.what.keepLines'), + tabs: (value) => t('textPane.reveal.what.tabs', { value }), + border: () => t('textPane.reveal.what.border'), + shade: () => t('textPane.reveal.what.shade'), +}; + +const what = (key: string, value = ''): string => (Object.hasOwn(WORDS, key) ? WORDS[key](value) : value); + +/** Run marks by their attribute, and the key their line uses (the same keys a style's look has). */ +const MARK_KEYS: Readonly> = { + font: { key: 'font', attr: 'font' }, + textSize: { key: 'sizePt', attr: 'size' }, + weight: { key: 'weight', attr: 'weight' }, + bold: { key: 'weight', attr: null }, + italic: { key: 'italic', attr: null }, + underline: { key: 'underline', attr: 'line' }, + strike: { key: 'strike', attr: 'line' }, + textColor: { key: 'textColor', attr: 'color' }, + highlight: { key: 'highlight', attr: 'color' }, + caps: { key: 'caps', attr: 'caps' }, + tracking: { key: 'tracking', attr: 'tracking' }, + scale: { key: 'scale', attr: 'scale' }, + shift: { key: 'shift', attr: 'shift' }, + kern: { key: 'kern', attr: 'kern' }, + effect: { key: 'effect', attr: 'effect' }, + otf: { key: 'otf', attr: 'otf' }, + subscript: { key: 'subscript', attr: null }, + superscript: { key: 'superscript', attr: null }, + lang: { key: 'lang', attr: 'lang' }, + dir: { key: 'dir', attr: 'dir' }, +}; + +const tokens = (raw: string): string[] => raw.split(' ').filter(Boolean); + +/** An OpenType token in words: a stylistic set, or one of the named features. */ +function otfWord(token: string): string { + return /^ssdd$/.test(token) ? optionLabel('stylisticSet', token) : optionLabel('otfToken', token); +} + +/** A line spacing as words: "at least 12 pt". */ +function lineWords(rule: string, value: number): string { + const args = { value: formatPoints(value) }; + if (rule === 'atLeast') return t('textPane.reveal.what.lineAtLeast', args); + return rule === 'exact' ? t('textPane.reveal.what.lineExact', args) : t('textPane.reveal.what.lineMultiple', args); +} + +/** The words for one run format: `Georgia`, `13 pt`, `Bold`. */ +function runWords(mark: Mark): string | null { + const row = MARK_KEYS[mark.type.name]; + if (!row) return null; + const raw = row.attr === null ? '' : String(mark.attrs[row.attr] ?? ''); + switch (mark.type.name) { + case 'bold': + return what('bold'); + case 'weight': + return what('weight', raw); + case 'textSize': + return what('size', raw); + case 'underline': + return what('underline', optionLabel('underline', raw === '' ? 'single' : raw)); + case 'strike': + return what(raw === 'double' ? 'strikeDouble' : 'strike'); + case 'caps': + return what('caps', optionLabel('caps', raw)); + case 'dir': + return what('dir', optionLabel('dir', raw)); + case 'effect': + return what( + 'effect', + tokens(raw) + .map((token) => optionLabel('effect', token)) + .join(', '), + ); + case 'otf': + return what('otf', tokens(raw).map(otfWord).join(', ')); + default: + return what(row.key, raw); + } +} + +/** The words for a style's look value of a key. */ +function styleWords(key: string, value: unknown): string | null { + if (value === undefined || value === null) return null; + switch (key) { + case 'weight': + return value === 700 ? what('bold') : value === 400 ? null : what('weight', String(value)); + case 'sizePt': + return what('size', String(value)); + case 'italic': + return value === true ? what('italic') : null; + case 'underline': + case 'caps': + return value === 'none' ? null : what(key, optionLabel(key, String(value))); + case 'strike': + return value === 'none' ? null : what(value === 'double' ? 'strikeDouble' : 'strike'); + case 'tracking': + return value === 0 ? null : what(key, String(value)); + case 'textColor': + case 'highlight': + case 'font': + return what(key, String(value)); + case 'align': + return what('align', optionLabel('align', String(value))); + case 'before': + case 'after': + return what(key, String(value)); + case 'line': + return lineStyleWords(value); + default: + return null; + } +} + +/** A style's line spacing, `{rule, value}`, as words. Anything else says nothing. */ +function lineStyleWords(value: unknown): string | null { + const line = value as { rule?: unknown; value?: unknown } | null; + if (typeof line?.rule !== 'string' || typeof line.value !== 'number') return null; + return what('line', lineWords(line.rule, line.value)); +} + +const RUN_STYLE_KEYS = [ + 'font', + 'sizePt', + 'weight', + 'italic', + 'textColor', + 'highlight', + 'underline', + 'strike', + 'caps', + 'tracking', +]; +const PARA_STYLE_ORDER = ['align', 'before', 'after', 'line']; + +const marksAt = (state: EditorState): readonly Mark[] => { + const { selection } = state; + if (selection.empty) return state.storedMarks ?? selection.$from.marks(); + return selection.$from.nodeAfter?.marks ?? selection.$from.marks(); +}; + +/** The paragraph's direct formatting as words, by key. */ +function paragraphWords(state: EditorState): [string, string][] { + const format = paraAt(state); + const out: [string, string][] = []; + if (format.align) out.push(['align', what('align', optionLabel('align', format.align))]); + if (format.indent) { + const { start, end, first } = format.indent; + out.push(['indent', what('indent', `${formatPoints(start)}, ${formatPoints(end)}, ${formatPoints(first)}`)]); + } + if (format.before !== undefined) out.push(['before', what('before', String(format.before))]); + if (format.after !== undefined) out.push(['after', what('after', String(format.after))]); + if (format.line) out.push(['line', what('line', lineWords(format.line.rule, format.line.value))]); + if (format.outline !== undefined) out.push(['outline', what('outline', String(format.outline))]); + if (format.keepNext) out.push(['keepNext', what('keepNext')]); + if (format.keepLines) out.push(['keepLines', what('keepLines')]); + if (format.dir) out.push(['dir', what('dir', optionLabel('dir', format.dir))]); + if (format.tabs?.length) out.push(['tabs', what('tabs', String(format.tabs.length))]); + if (format.border) out.push(['border', what('border')]); + if (format.shade) out.push(['shade', what('shade')]); + return out; +} + +/** The effective formatting at the caret, run formats first, then the paragraph's, each with its source. */ +export function revealFormatting(state: EditorState, ctx: StyleContext): RevealLine[] { + const lines: RevealLine[] = []; + const line = (key: string, words: string, source: RevealSource, style?: string): void => { + const shown = + source === 'style' + ? t('textPane.reveal.fromStyle', { name: style ?? '' }) + : t(source === 'direct' ? 'textPane.reveal.direct' : 'textPane.reveal.paragraph'); + lines.push({ key, source, ...(style ? { style } : {}), text: `${words} (${shown})` }); + }; + const directKeys = new Set(); + for (const mark of marksAt(state)) { + const words = runWords(mark); + if (words === null) continue; + const key = MARK_KEYS[mark.type.name].key; + directKeys.add(key); + line(key, words, 'direct'); + } + const block = state.selection.$from.parent; + const name = styleOfNode(block); + const resolved = resolveStyle(ctx, name); + const label = styleLabel(name, ctx.page?.[name] ?? ctx.notebook[name] ?? ctx.app[name], builtInLabel); + const paragraph = paragraphWords(state); + const paragraphKeys = new Set(paragraph.map(([key]) => key)); + for (const key of [...RUN_STYLE_KEYS, ...PARA_STYLE_ORDER]) { + if (directKeys.has(key) || paragraphKeys.has(key)) continue; + const value = resolved.values[key as keyof typeof resolved.values]; + const words = styleWords(key, value); + const set = resolved.own.has(key as never) || name !== 'normal'; + if (words !== null && set) line(key, words, 'style', label); + } + for (const [key, words] of paragraph) line(key, words, 'paragraph'); + return lines; +} + +/** The lines as one sentence for the live region, or the plain message when there is nothing to reveal. */ +export function revealText(lines: readonly RevealLine[]): string { + return lines.length > 0 ? lines.map((entry) => entry.text).join('. ') : t('textPane.reveal.none'); +} diff --git a/app/src/features/page/formatPane/units.test.ts b/app/src/features/page/formatPane/units.test.ts new file mode 100644 index 000000000..bbec74925 --- /dev/null +++ b/app/src/features/page/formatPane/units.test.ts @@ -0,0 +1,41 @@ +// Lengths with units. Known answers are the definitions: an inch is 72 points, an inch is 2.54 cm and 25.4 mm, a CSS +// pixel is 1/96 inch, and a pica is 12 points. +import { describe, expect, it } from 'vitest'; +import { formatLength, formatPoints, parseLength } from './units'; + +describe('parseLength', () => { + it.each([ + ['0.5in', 36], + ['1in', 72], + ['2.54cm', 72], + ['25.4mm', 72], + ['96px', 72], + ['1pc', 12], + ['10.5pt', 10.5], + ['10.5', 10.5], + ['1,5 in', 108], + ['-0.25in', -18], + [' 12 pt ', 12], + ])('reads %s as %s points', (text, points) => { + expect(parseLength(text)).toBe(points); + }); + + it('reads a bare number in the unit given', () => { + expect(parseLength('2', 'in')).toBe(144); + expect(parseLength('1', 'cm')).toBe(28.35); + }); + + it.each(['', 'abc', '12 furlongs', '1..5', '12pt3'])('refuses %j', (text) => { + expect(parseLength(text)).toBeNull(); + }); +}); + +describe('formatting', () => { + it('writes points without trailing zeros and other units to two places', () => { + expect(formatPoints(36)).toBe('36'); + expect(formatPoints(10.5)).toBe('10.5'); + expect(formatLength(36, 'in')).toBe('0.5 in'); + expect(formatLength(72, 'cm')).toBe('2.54 cm'); + expect(formatLength(12, 'pt')).toBe('12'); + }); +}); diff --git a/app/src/features/page/formatPane/units.ts b/app/src/features/page/formatPane/units.ts new file mode 100644 index 000000000..dadaf9589 --- /dev/null +++ b/app/src/features/page/formatPane/units.ts @@ -0,0 +1,36 @@ +// Lengths typed in the Format pane (text DESIGN.md 5.3): a number with an optional unit, converted to points. A +// bare number is in the field's own unit. The result sits on the 0.05 point grid the paragraph keys use. + +/** Points in one of each unit. A CSS pixel is 3/4 point; an inch is 72 points (the definition of a point). */ +const POINTS: Readonly> = { + pt: 1, + px: 0.75, + in: 72, + '"': 72, + cm: 72 / 2.54, + mm: 72 / 25.4, + pc: 12, +}; + +export type LengthUnit = 'pt' | 'in' | 'cm' | 'mm' | 'px' | 'pc'; + +const PATTERN = /^\s*([+-]?(?:\d+(?:[.,]\d+)?|[.,]\d+))\s*(pt|px|in|cm|mm|pc|")?\s*$/i; + +/** Points for what was typed, such as `0.5in`, `1,5 cm`, or `12`; null when it is not a length. */ +export function parseLength(text: string, bare: LengthUnit = 'pt'): number | null { + const match = PATTERN.exec(text); + if (match === null) return null; + const raw = Number(match[1].replace(',', '.')) * POINTS[(match[2] ?? bare).toLowerCase()]; + return Math.round(raw * 20) / 20; +} + +/** Points as text without trailing zeros: `36`, `10.5`. */ +export function formatPoints(points: number): string { + return String(Math.round(points * 100) / 100); +} + +/** Points as text in a unit, such as 36 as `0.5 in`. Points stay plain numbers. */ +export function formatLength(points: number, unit: LengthUnit = 'pt'): string { + if (unit === 'pt') return formatPoints(points); + return `${String(Math.round((points / POINTS[unit]) * 100) / 100)} ${unit}`; +} diff --git a/app/src/features/page/homeTab/homeControls.test.tsx b/app/src/features/page/homeTab/homeControls.test.tsx index 13f9f1b55..b21ff0191 100644 --- a/app/src/features/page/homeTab/homeControls.test.tsx +++ b/app/src/features/page/homeTab/homeControls.test.tsx @@ -81,14 +81,25 @@ afterEach(async () => { /** Clicks a control by its name. A control the narrow bar moved into More is clicked there, as a person would. */ async function press(toolbar: HTMLElement, name: string): Promise { - const found = within(toolbar).queryByRole('button', { name }); - if (found) { - await userEvent.click(found); - return; - } - await userEvent.click(within(toolbar).getByRole('button', { name: 'More commands' })); - const menu = await screen.findByRole('menu'); - await userEvent.click(within(menu).getByRole('menuitem', { name })); + // The bar re-measures when a font loads, and a tool is then briefly on neither the bar nor the More list. + const visible = () => { + const found = within(toolbar).queryByRole('button', { name }); + if (!found?.checkVisibility({ visibilityProperty: true })) return null; + const box = found.getBoundingClientRect(); + const top = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2); + return top && found.contains(top) ? found : null; + }; + const where = await waitFor( + () => { + const found = visible(); + if (found) return found; + const more = within(toolbar).getByRole('button', { name: 'More commands' }); + if (!screen.queryByRole('menu')) more.click(); + return within(screen.getByRole('menu')).getByRole('menuitem', { name }); + }, + { timeout: 8000 }, + ); + where.click(); } const ran = () => calls.map(([name]) => name); diff --git a/app/src/features/page/keys/text.ts b/app/src/features/page/keys/text.ts index c52ad9055..c8b99c693 100644 --- a/app/src/features/page/keys/text.ts +++ b/app/src/features/page/keys/text.ts @@ -35,6 +35,8 @@ export const TEXT_KEYS = { 'format.pasteFormat': text(['Ctrl+Alt+Shift+V'], ['Ctrl+Alt+Shift+V']), 'format.repeat': text(['F4'], []), 'format.applyStyle': text(['Ctrl+Shift+S'], [], []), + // The Format pane: Ctrl+D opens it on Text (Word's Font dialog). Docs has no such key. + 'format.fontDialog': text(['Ctrl+D'], []), // Paragraph formatting. 'para.alignStart': text(['Ctrl+L'], ['Ctrl+Shift+L']), 'para.alignCenter': text(['Ctrl+E'], ['Ctrl+Shift+E'], []), diff --git a/app/src/features/page/mount.ts b/app/src/features/page/mount.ts index 472bb3cc7..b77a63f10 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'; @@ -45,6 +45,7 @@ import { createSyncQueue, exitHook, shownQueue } from './sync'; import { keepLinksInPlace } from './linkClicks'; import type { FrameContext, SyncQueue } from './sync'; import type { Point } from './viewport/camera'; +import { readDefaultTab, setDefaultTab, setPageHyphenate } from '../../editor/paragraph/defaultTab'; import { createGestureTool } from './viewport/gestures'; import { rememberView, restoreView } from './viewport/remember'; import { dropDuplicateMenus } from './viewport/menuRequests'; @@ -308,25 +309,37 @@ function titleBand(world: HTMLElement, parts: TitleParts, options: MountOptions) }); } +/** One page is shown at a time: only a shown page sets the tab interval and hyphenation default in force. */ +function applyPageDefaults(view: unknown, shown: boolean): void { + if (!shown) return; + setDefaultTab(readDefaultTab(view)); + setPageHyphenate(view); +} + export function mountPage(container: HTMLElement, page: OpenPage, options: MountOptions): MountedPage { registerEscapeToObjects(); const cache = createMarkdownCache(); const viewport = createViewport(container, options.classNames); const flow = createFlow(viewport); flow.setView(page.initial.view); + applyPageDefaults(page.initial.view, options.shown !== false); const reading = opensInReading(page, options); flow.setReading(reading); 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); + applyPageDefaults(fields.view, options.shown !== false); layer.setPreferredOrder(fields.view.readingOrder ?? []); } if (fields.title !== undefined) title?.setTitle(fields.title); 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/formatPane.ts b/app/src/features/page/registrations/formatPane.ts new file mode 100644 index 000000000..cf41bb385 --- /dev/null +++ b/app/src/features/page/registrations/formatPane.ts @@ -0,0 +1,71 @@ +// The Format pane's commands (text program, Beta 11; text DESIGN.md 5.3), behind text.formatPane: Ctrl+D opens the +// pane on Text, the Paragraph launcher opens it on Paragraph, and Reveal formatting says where each format at the +// caret comes from. The pane loads when a command runs, so start-up stays light. +import { defineCommand } from '../../../commands/registry'; +import { commandContext } from '../../../commands/registry'; +import type { CommandId } from '../../../commands/types'; +import { commands } from '../../../registries'; +import type { MessageKey } from '../../../strings/t'; +import { t } from '../../../strings/t'; +import { showToast } from '../../../ui/toast'; +import { targetEditor } from '../formattingBar/target'; +import type { TabId } from '../formatPane/fields'; + +const FLAG = 'text.formatPane'; + +async function openOn(tab: TabId): Promise { + const store = await import('../styles/store'); + const notebook = store.shownNotebook(); + if (!notebook) { + showToast({ message: t('textStyles.noNotebook') }); + return; + } + try { + await store.loadStyles(notebook); + } catch { + showToast({ message: t('textStyles.loadFailed'), tone: 'danger' }); + return; + } + const { openFormatPane } = await import('../formatPane/mount'); + openFormatPane({ notebook, tab, fonts: commandContext('commandBar').platform.fonts }); +} + +function openCommand(id: CommandId, title: MessageKey, tab: TabId): void { + commands.register( + defineCommand({ + id, + title, + category: 'format', + keywords: 'editor.keywords.block', + flag: FLAG, + run: () => openOn(tab), + }), + ); +} + +openCommand('format.fontDialog', 'textPane.commands.fontDialog', 'text'); +openCommand('format.paragraphDialog', 'textPane.commands.paragraphDialog', 'paragraph'); + +commands.register( + defineCommand({ + id: 'format.reveal', + title: 'textPane.commands.reveal', + category: 'format', + keywords: 'editor.keywords.block', + flag: FLAG, + run: async () => { + const editor = targetEditor(); + if (!editor) { + showToast({ message: t('textPane.noEditor') }); + return; + } + const [store, reveal, keys] = await Promise.all([ + import('../styles/store'), + import('../formatPane/reveal'), + import('../../../editor/extensions/keys'), + ]); + const words = reveal.revealText(reveal.revealFormatting(editor.state, store.shownContext(store.shownNotebook()))); + keys.hostOf(editor)?.announce(words); + }, + }), +); diff --git a/app/src/features/page/registrations/language.ts b/app/src/features/page/registrations/language.ts new file mode 100644 index 000000000..cbe92e891 --- /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: 'ArrowLeft', priority: 25 }, + { dir: 'ltr', id: 'text.paragraphLtr', icon: 'ArrowRight', 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/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/registrations/styles.ts b/app/src/features/page/registrations/styles.ts index 956ff5f58..318e68394 100644 --- a/app/src/features/page/registrations/styles.ts +++ b/app/src/features/page/registrations/styles.ts @@ -3,7 +3,7 @@ // heavy parts load when a command runs, so start-up stays light. import type { Editor } from '@tiptap/core'; import { isEnabled } from '../../../app/flags'; -import { defineCommand } from '../../../commands/registry'; +import { commandContext, defineCommand } from '../../../commands/registry'; import type { CommandId } from '../../../commands/types'; import { commands } from '../../../registries'; import type { MessageKey } from '../../../strings/t'; @@ -118,6 +118,12 @@ styleCommand('styles.create', 'textStyles.commands.create', async ({ editor, not styleCommand('styles.modify', 'textStyles.commands.modify', async ({ editor, notebook, loaded }) => { const style = loaded.styleCommands.styleOfNode(editor.state.selection.$from.parent); + // With the Format pane on, Modify style edits any style there (ADR 0082); the dialog knows ten. + if (isEnabled('text.formatPane')) { + const { openFormatPane } = await import('../formatPane/mount'); + openFormatPane({ notebook, style, tab: 'text', fonts: commandContext('commandBar').platform.fonts }); + return; + } const known = loaded.styleCommands.dialogStyle(style); if (!known) { showToast({ message: t('textStyles.modifyUnsupported') }); diff --git a/app/src/features/page/ruler/Ruler.test.tsx b/app/src/features/page/ruler/Ruler.test.tsx new file mode 100644 index 000000000..ce6b388f1 --- /dev/null +++ b/app/src/features/page/ruler/Ruler.test.tsx @@ -0,0 +1,214 @@ +// The Ruler in a real browser, over a real text editor. Its track starts where the text starts at any zoom. +// A drag writes the indent back as one undo step. The units choice sticks. +import { render } from '@testing-library/react'; +import type { Editor } from '@tiptap/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { initFlags } from '../../../app/flags'; +import { paraAt, paraTargets } from '../../../editor/commands/paragraph'; +import { paraFormat } from '../../../editor/paragraph/attributes'; +import { mountEditor } from '../../../editor/commands/testing'; +import type { TestEditor } from '../../../editor/commands/testing'; +import type { MountedPage } from '../mount'; +import type { EditorPool } from '../pool/pool'; +import { shownPool } from '../pool/shown'; +import { Ruler } from './Ruler'; +import { rulerState, setRulerShown, setRulerUnit } from './store'; + +let mounted: TestEditor | null = null; +let camera: (() => void) | null = null; + +beforeEach(() => initFlags('dev', { 'text.ruler': true })); +afterEach(() => { + mounted?.destroy(); + mounted = null; + shownPool.set(null); + camera = null; + setRulerShown(false); +}); + +/** A page whose pool has the one editor active, with a camera the test can nudge. */ +function fakePage(editor: Editor | null): MountedPage { + const pool = { + active: () => (editor ? { block: 'b1', editor } : null), + editor: () => editor, + onActiveChange: () => () => undefined, + } as unknown as EditorPool; + shownPool.set(pool); + return { + pool, + viewport: { + onCamera: (listener: () => void) => { + camera = listener; + return () => undefined; + }, + }, + } as unknown as MountedPage; +} + +/** An editor 600 px wide with 20 px of padding on each side, scaled like a zoomed page. */ +function place(zoom: number, left = 100, source = 'hello'): TestEditor { + const placed = mountEditor(source); + const style = placed.root.style; + style.position = 'absolute'; + style.top = '200px'; + style.left = `${left}px`; + style.width = '600px'; + style.boxSizing = 'border-box'; + style.padding = '0 20px'; + style.transformOrigin = '0 0'; + style.transform = `scale(${zoom})`; + return placed; +} + +const frames = async (count: number): Promise => { + for (let i = 0; i < count; i++) await new Promise((resolve) => requestAnimationFrame(resolve)); +}; +/** The camera moves, and the ruler re-measures within a frame or two. */ +const frame = async (): Promise => { + camera?.(); + await frames(3); +}; + +async function show(zoom: number, left?: number) { + mounted = place(zoom, left); + setRulerShown(true); + const view = render(); + await frame(); + await frames(3); + const track = view.container.querySelector('[data-track]')!; + const contentLeft = (mounted.editor.view.dom as HTMLElement).getBoundingClientRect().left + 20 * zoom; + return { view, track, contentLeft, editor: mounted.editor }; +} + +describe.each([1, 2])('the track at zoom %i', (zoom) => { + it('starts exactly where the editor content box starts, with the controls outside', async () => { + const { view, track, contentLeft } = await show(zoom); + await vi.waitFor(() => expect(track.getBoundingClientRect().left).toBeCloseTo(contentLeft, 0)); + const selector = view.container.querySelector('button')!; + expect(selector.getBoundingClientRect().right).toBeLessThanOrEqual(track.getBoundingClientRect().left + 0.5); + }); +}); + +describe('the ruler when the page is panned', () => { + it('keeps the track on the text when the content box starts left of the ruler', async () => { + const { track, contentLeft } = await show(1, -60); + expect(track.getBoundingClientRect().left).toBeCloseTo(contentLeft, 0); + expect(contentLeft).toBeLessThan(0); + }); +}); + +describe('writing back', () => { + it('a drag of the left indent sets the paragraph indent, and one undo restores it', async () => { + const { track, editor } = await show(1); + const box = track.getBoundingClientRect(); + const send = (type: string, dx: number) => + track.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + pointerId: 9, + pointerType: 'mouse', + button: 0, + clientX: box.left + dx, + clientY: box.top + 0.85 * box.height, + }), + ); + const changes: number[] = []; + editor.on( + 'transaction', + ({ transaction }) => void (transaction.docChanged && changes.push(transaction.steps.length)), + ); + send('pointerdown', 0); + send('pointermove', 24); + send('pointermove', 48); + send('pointerup', 48); + expect(paraAt(editor.state).indent).toEqual({ start: 36, end: 0, first: 0 }); + // One drag is one transaction, so one undo step of the page's history takes the whole drag back. + expect(changes).toHaveLength(1); + }); +}); + +describe('writing back, with several paragraphs selected', () => { + it('a first-line drag changes only the first field of each, so every left indent stays', async () => { + mounted = place(1, 100, '[one\n\ntwo]'); + const run = mounted.editor; + const targets = paraTargets(run.state.doc, 0, run.state.doc.content.size); + expect(targets).toHaveLength(2); + const tr = run.state.tr; + tr.setNodeAttribute(targets[0].pos, 'indent', { start: 36, end: 0, first: 0 }); + tr.setNodeAttribute(targets[1].pos, 'indent', { start: 72, end: 0, first: 0 }); + run.view.dispatch(tr); + setRulerShown(true); + const view = render(); + await frame(); + await frames(3); + const track = view.container.querySelector('[data-track]')!; + const box = track.getBoundingClientRect(); + const send = (type: string, dx: number) => + track.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + pointerId: 9, + pointerType: 'mouse', + button: 0, + clientX: box.left + 48 + dx, + clientY: box.top + 0.2 * box.height, + }), + ); + // The first-line marker of the first paragraph is at 36 pt = 48 px. + send('pointerdown', 0); + send('pointermove', 12); + send('pointermove', 24); + send('pointerup', 24); + const after = paraTargets(run.state.doc, 0, run.state.doc.content.size).map( + ({ node }) => paraFormat(node.attrs).indent, + ); + expect(after).toEqual([ + { start: 36, end: 0, first: 18 }, + { start: 72, end: 0, first: 18 }, + ]); + }); +}); + +describe('the ruler follows the text block', () => { + it('re-measures when the block is resized with no camera move', async () => { + const { track, editor } = await show(1); + expect(track.getBoundingClientRect().width).toBeCloseTo(560, 0); + (editor.view.dom as HTMLElement).style.width = '440px'; + await vi.waitFor(() => expect(track.getBoundingClientRect().width).toBeCloseTo(400, 0)); + }); +}); + +describe('typing with the ruler shown', () => { + it('reads the paragraph only: no style or layout measurement per keystroke', async () => { + const { editor } = await show(1); + const real = window.getComputedStyle.bind(window); + const fromRuler: string[] = []; + const measured = vi.spyOn(window, 'getComputedStyle').mockImplementation((...args) => { + if (new Error().stack?.includes('Ruler.tsx')) fromRuler.push('measured'); + return real(...args); + }); + for (const char of 'abcde') editor.view.dispatch(editor.state.tr.insertText(char, editor.state.selection.to)); + await frames(3); + expect(fromRuler).toEqual([]); + measured.mockRestore(); + }); +}); + +describe('the units choice', () => { + it('changes the unit of the store and the ruler', async () => { + const { view } = await show(1); + setRulerUnit('cm'); + await frame(); + expect(rulerState.get().unit).toBe('cm'); + expect(view.container.querySelector('select')!.value).toBe('cm'); + }); +}); + +describe('no text block', () => { + it('says to click in a text block', async () => { + setRulerShown(true); + const view = render(); + await frame(); + expect(view.container.textContent).toContain('Click in a text block to see its 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..ce445e1ac --- /dev/null +++ b/app/src/features/page/ruler/Ruler.tsx @@ -0,0 +1,165 @@ +// The ruler above the focused text block (text DESIGN.md 5.4). This part reads the editor. It finds the content box +// at the current zoom and reads the indent and tab stops. It writes a change back as one command. The bar is RulerBar. +import type { Editor } from '@tiptap/core'; +import type { RefObject } from 'react'; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { useFlag } from '../../../app/flags'; +import { paraAt, setPara } from '../../../editor/commands/paragraph'; +import type { Indent, ParaFormat, TabStop } from '../../../editor/paragraph/attributes'; +import { getDefaultTab, onDefaultTab } from '../../../editor/paragraph/defaultTab'; +import { useStore } from '../../../state/store'; +import { targetEditor } from '../formattingBar/target'; +import type { MountedPage } from '../mount'; +import { t } from '../../../strings/t'; +import { RulerBar, TabTypeButton, UnitSelect } from './RulerBar'; +import { indentPatch, setIndentFields } from './setIndent'; +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 clip's left edge. It is negative when the page + * is panned left of the ruler. */ + left: number; + /** The content box's width in points, as the page lays it out (before zoom). */ + width: number; + zoom: number; +} + +const sameFrame = (a: Frame | null, b: Frame | null): boolean => + a === b || + (a !== null && + b !== null && + a.editor === b.editor && + Math.abs(a.left - b.left) < 0.05 && + a.width === b.width && + Math.abs(a.zoom - b.zoom) < 0.0005); + +/** A key for the paragraph parts the ruler shows, so a keystroke that changes neither redraws nothing. */ +const paraKey = (para: ParaFormat | null): string => + para ? JSON.stringify([para.indent ?? null, para.tabs ?? null, para.dir ?? null]) : ''; + +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, + }; +} + +/** The frame and paragraph of the target editor, measured only when something that moves them changes. */ +function useRulerFrame(mounted: MountedPage, on: boolean, clip: RefObject) { + const [frame, setFrame] = useState(null); + const [para, setPara_] = useState(null); + // Measuring forces a layout. It runs once per frame at most. It runs only when something that moves the box + // changes: the camera, the window, the active block, or the size of the clip or the text block. + // A keystroke only reads the paragraph, which is cheap. + const frameAt = useRef(0); + const readPara = useCallback((editor: Editor | null) => { + const next = editor && !editor.isDestroyed ? paraAt(editor.state) : null; + setPara_((previous) => (paraKey(previous) === paraKey(next) ? previous : next)); + }, []); + const measure = useCallback(() => { + const editor = targetEditor(); + const next = editor && clip.current ? measureFrame(editor, clip.current) : null; + setFrame((previous) => (sameFrame(previous, next) ? previous : next)); + readPara(next?.editor ?? null); + }, [clip, readPara]); + const refresh = useCallback(() => { + if (frameAt.current) return; + frameAt.current = requestAnimationFrame(() => { + frameAt.current = 0; + measure(); + }); + }, [measure]); + + const editor = frame?.editor; + useEffect(() => { + if (!on) return; + refresh(); + const stops = [mounted.pool.onActiveChange(refresh), mounted.viewport.onCamera(refresh)]; + const onTransaction = () => readPara(editor ?? null); + if (editor) { + editor.on('transaction', onTransaction); + stops.push(() => editor.off('transaction', onTransaction)); + } + const observer = new ResizeObserver(refresh); + if (clip.current) observer.observe(clip.current); + // A block resized, or text reflowed above it, moves the content box without a camera move. + if (editor) observer.observe(editor.view.dom); + window.addEventListener('resize', refresh); + return () => { + stops.forEach((stop) => stop()); + observer.disconnect(); + window.removeEventListener('resize', refresh); + cancelAnimationFrame(frameAt.current); + frameAt.current = 0; + }; + }, [on, mounted, clip, refresh, readPara, editor]); + return { frame, para, editor }; +} + +export function Ruler({ mounted }: { mounted: MountedPage }) { + const allowed = useFlag('text.ruler'); + const { shown, unit } = useStore(rulerState, (state) => state); + const tabType = useStore(rulerTabType, (type) => type); + // The page's default tab interval, which the Format pane and the page view set (editor/paragraph/defaultTab.ts). + const defaultTab = useSyncExternalStore(onDefaultTab, getDefaultTab); + const clip = useRef(null); + const on = allowed && shown; + const { frame, para, editor } = useRulerFrame(mounted, on, clip); + + if (!on) return null; + const ready = frame !== null && para !== null; + const run = (key: 'indent' | 'tabs', value: unknown) => { + if (editor) setPara(key, value)(editor.state, editor.view.dispatch); + }; + const runIndent = (next: Indent) => { + if (!editor) return; + const patch = indentPatch(para?.indent ?? NO_INDENT, next); + setIndentFields(patch)(editor.state, editor.view.dispatch); + }; + return ( +
+ +
+ {ready ? ( +
+ run('tabs', next.length === 0 ? null : next)} + onRelease={() => editor?.commands.focus()} + /> +
+ ) : ( +

{t('textRuler.needsText')}

+ )} +
+ +
+ ); +} 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..0b7ba3725 --- /dev/null +++ b/app/src/features/page/ruler/RulerBar.test.tsx @@ -0,0 +1,172 @@ +// 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; + defaultTab?: number; + } = {}, +) { + const onIndent = vi.fn(); + const onTabs = vi.fn(); + const view = render( + , + ); + 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([]); + }); +}); + +describe('default tab stops', () => { + const at = (container: HTMLElement) => + [...container.querySelectorAll('[data-default-tab]')].map((line) => Number(line.getAttribute('data-default-tab'))); + + it('draws the page interval after the last set stop', () => { + const { view } = setup({ defaultTab: 72, tabs: [{ pos: 100, align: 'start', leader: 'none' }] }); + expect(at(view.container)).toEqual([144, 216, 288, 360, 432]); + }); + + it('places a stop at its distance in pixels at the zoom', () => { + const { view } = setup({ defaultTab: 36, zoom: 2 }); + const first = view.container.querySelector('[data-default-tab="36"]')!; + expect(Number(first.getAttribute('x1'))).toBe(96); + }); +}); diff --git a/app/src/features/page/ruler/RulerBar.tsx b/app/src/features/page/ruler/RulerBar.tsx new file mode 100644 index 000000000..37e728d10 --- /dev/null +++ b/app/src/features/page/ruler/RulerBar.tsx @@ -0,0 +1,377 @@ +// 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. +// The arrow keys move it one step of the snap grid. In a right-to-left paragraph the whole bar is mirrored. +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, + markerLimits, + movedIndent, + movedTab, + offRuler, + pointsToPixels, + withTab, + withoutTab, +} from './geometry'; +import type { Limits, MarkerKind, TabType } from './geometry'; +import { isPalm, useWatchPen } from './palm'; +import { RULER_UNITS, fromPoints, roundPoints, shownNumber, snapPoints } from './units'; +import type { RulerUnit } from './units'; +import { Ticks } from './Ticks'; +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; + /** A right-to-left paragraph: positions are measured from the right edge, and a drag to the left raises them. */ + rtl?: boolean; + tabs: readonly TabStop[]; + /** The page's default tab interval in points (`view.text.defaultTab`); its stops show after the last set stop. */ + defaultTab?: number; + tabType: TabType; + onIndent(next: Indent): void; + onTabs(next: TabStop[]): void; + /** After a pointer drag or click ends: the host gives focus back to the text. */ + onRelease?(): 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); +} + +export function valueText(points: number, unit: RulerUnit): string { + return t(`textRuler.value.${unit}` as 'textRuler.value.in', { n: Number(shownNumber(points, unit)) }); +} + +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); + // The announced value is where the marker really ended up, after the limits. + const landed = markersOf(next, [], width).find((m) => m.kind === marker.kind)!.at; + announce(t('textRuler.announced', { name: markerName(marker), value: valueText(landed, 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') { + props.onIndent(movedIndent(tabType, indent, at, width)); + const name = t(`textRuler.marker.${tabType}` as MessageKey); + announce(t('textRuler.announced', { name, value: valueText(at, unit) })); + return; + } + 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 }; +} + +/** Arrow keys move a marker one grid step (Alt: half a point; Shift: four steps); Home and End go to its limits. */ +function markerKey(marker: Marker, edits: Edits, grid: number, limits: Limits) { + 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)), limits.min, limits.max)); + } else if (event.key === 'Home') edits.commit(marker, limits.min); + else if (event.key === 'End') edits.commit(marker, limits.max); + else if ((event.key === 'Delete' || event.key === 'Backspace') && marker.kind === 'tab') edits.remove(marker); + else return; + event.preventDefault(); + }; +} + +/** The pointer in the track, with x measured from the start edge so a mirrored bar reads like a normal one. */ +function localPoint(track: HTMLElement, event: PointerEvent, rtl: boolean | undefined) { + const box = track.getBoundingClientRect(); + const x = event.clientX - box.left; + return { x: rtl ? box.width - x : x, y: event.clientY - box.top, height: box.height }; +} + +function focusMarker(track: HTMLElement, marker: Marker): void { + track.querySelector(`[data-marker="${marker.id}"]`)?.focus(); +} + +/** What a pointer drag ends with: a tab stop off the ruler is removed, any other marker lands where it was dragged. */ +function finishDrag(marker: Marker, edits: Edits, drop: number, at: { y: number; height: number }): void { + const outside = at.y < 0 ? at.y : at.y > at.height ? at.y - at.height : 0; + if (marker.kind === 'tab' && offRuler(outside)) edits.remove(marker); + else edits.commit(marker, drop); +} + +/** 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, + limitsOf: (marker: Marker) => Limits, +) { + const { zoom, rtl } = props; + useWatchPen(); + const track = useRef(null); + const press = useRef(null); + const [drag, setDrag] = useState<{ id: string; at: number } | null>(null); + const local = (event: PointerEvent) => localPoint(track.current!, event, rtl); + const dropAt = (marker: Marker, event: PointerEvent, from: number) => { + const { min, max } = limitsOf(marker); + return clamp(dragValue(marker.at, (rtl ? -1 : 1) * (event.clientX - from), zoom, grid, event.altKey), min, max); + }; + const end = () => { + press.current = null; + setDrag(null); + }; + + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0 || !track.current || isPalm(event)) 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) focusMarker(track.current, marker); + event.preventDefault(); + }; + + const onPointerMove = (event: PointerEvent) => { + const current = press.current; + if (current?.pointer !== event.pointerId) return; + // A press that stays within the threshold is a tap: nothing moves. + const moved = Math.hypot(event.clientX - current.x, event.clientY - current.y); + if (!current.started && moved < DRAG_THRESHOLD_PX) return; + current.started = true; + if (current.marker) setDrag({ id: current.marker.id, at: dropAt(current.marker, event, current.x) }); + }; + + 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 && marker) finishDrag(marker, edits, dropAt(marker, event, current.x), local(event)); + else if (!current.started && !marker) edits.addAt(local(event).x); + // The pointer is done: the text gets focus back, so typing goes on where it was. + props.onRelease?.(); + }; + + return { track, drag, handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: end } }; +} + +interface MarkerViewProps { + marker: Marker; + at: number; + dragging: boolean; + unit: RulerUnit; + zoom: number; + limits: Limits; + 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, limits, onKeyDown }: MarkerViewProps) { + // Every marker shows its place from the start edge. ArrowRight raises the value of all of them, the right indent + // included. Home and End are the minimum and the maximum. + const shown = at; + return ( + +