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 (
+
+ );
+}
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 (
+
- );
-}
-
function CustomColorDialog({ initial, finish }: { initial: string; finish(answer: string | null): void }) {
- const [current, setCurrent] = useState(initial);
- const [tried, setTried] = useState(false);
- const invalid = tried && normalizeHex(current) === null;
return (
);
}
-/** Asks for a custom color. Resolves to #rrggbb, or null when canceled. */
+/** Asks for a custom color. Resolves to a color value (#rrggbb, a pen name, or theme:NAME), or null when canceled. */
export function chooseCustomColor(initial = '#'): Promise {
const host = document.body.appendChild(document.createElement('div'));
const root = createRoot(host);
diff --git a/app/src/shell/commandbar/Toolbar.icons.test.tsx b/app/src/shell/commandbar/Toolbar.icons.test.tsx
index 3bb30e9a9..d42b96c0a 100644
--- a/app/src/shell/commandbar/Toolbar.icons.test.tsx
+++ b/app/src/shell/commandbar/Toolbar.icons.test.tsx
@@ -134,6 +134,8 @@ async function viewBar() {
}
async function drawBar() {
+ // With every flag on, the Review tab shows too, so the Draw bar needs a little more room than 2200 px gives it.
+ await setViewport(2400, 900);
await showTab('Draw');
const curve = await waitFor(() => tool('Draw', 'Make curve'));
await waitFor(() => expect(svgs(curve)).toHaveLength(1));
diff --git a/app/src/strings/en/textLanguage.ts b/app/src/strings/en/textLanguage.ts
index 98f1bd96c..fd6ad0236 100644
--- a/app/src/strings/en/textLanguage.ts
+++ b/app/src/strings/en/textLanguage.ts
@@ -1,4 +1,28 @@
// Per-run language and text direction.
// Owner: text:language-direction (B11 and B12). text:scaffold registered it in the string index.
+// Every interface string is a full sentence or a full label, never joined from pieces.
-export const textLanguage = {} as const;
+export const textLanguage = {
+ language: 'Language',
+ languageKeywords: 'language proofing spelling dictionary set language for selection paragraph',
+ menuLabel: 'Language of the selected text',
+ paragraphMenuLabel: 'Language of this paragraph',
+ pageLanguage: 'Use the page language',
+ noProof: 'Do not check spelling',
+ noProofKeywords: 'do not check spelling proofing ignore skip no proof',
+ rtl: 'Right-to-left text direction',
+ rtlKeywords: 'right to left direction rtl bidi arabic hebrew persian urdu',
+ ltr: 'Left-to-right text direction',
+ ltrKeywords: 'left to right direction ltr bidi',
+ hyphenate: 'Automatic hyphenation',
+ hyphenateKeywords: 'hyphenation hyphenate hyphens break words',
+ needsText: 'Select some text first.',
+ setTo: 'Language set to {name}.',
+ setToPage: 'The text now uses the page language.',
+ noProofOn: 'Spelling will not be checked here.',
+ noProofOff: 'Spelling will be checked here again.',
+ rtlOn: 'The paragraph now reads right to left.',
+ ltrOn: 'The paragraph now reads left to right.',
+ hyphenateOn: 'Automatic hyphenation is on for this paragraph.',
+ hyphenateOff: 'Automatic hyphenation is off for this paragraph.',
+} as const;
diff --git a/app/src/strings/en/textPane.ts b/app/src/strings/en/textPane.ts
index b8f0548ff..208ae5bae 100644
--- a/app/src/strings/en/textPane.ts
+++ b/app/src/strings/en/textPane.ts
@@ -1,4 +1,358 @@
// The Format pane for text.
// Owner: text:format-pane (B11 and B12). text:scaffold registered it in the string index, and the owner fills it in.
-export const textPane = {} as const;
+export const textPane = {
+ title: 'Format',
+ titleStyle: 'Format style: {name}',
+ region: 'Format pane',
+ close: 'Close',
+ tabsLabel: 'Format sections',
+ noEditor: 'Select some text to format it.',
+ noList: 'Put the caret in a list item to change its list.',
+ range: 'The number must be between {min} and {max}.',
+ invalid: 'Enter a value such as {example}.',
+ badColor: 'Enter a color as #rrggbb, a pen name, or theme:accent1.',
+ refused: 'This format cannot be set here.',
+ stylePageOverrides: 'This page has its own version of this style, so a change here does not show on this page.',
+ kernRange: 'Enter 0 to turn kerning off, or 1 to 1638 points.',
+ underlineFirst: 'Choose an underline style first.',
+ fontTooLong: 'A font name has at most 64 characters.',
+ unitPoints: 'pt',
+ unitPercent: '%',
+ mixed: 'Mixed',
+ setN: 'Set {n}',
+ none: 'None',
+ defaultValue: 'Default',
+ preview: 'Preview',
+ previewText: 'The quick brown fox jumps over the lazy dog.',
+ styleModeHint: 'Changes go to the style, for every paragraph that uses it.',
+ styleSaved: 'Style changed.',
+ tab: {
+ text: 'Text',
+ advanced: 'Advanced',
+ paragraph: 'Paragraph',
+ tabs: 'Tabs',
+ borders: 'Borders and shading',
+ lists: 'Lists',
+ },
+ group: {
+ font: 'Font',
+ effects: 'Effects',
+ spacing: 'Character spacing',
+ opentype: 'OpenType features',
+ general: 'General',
+ indentation: 'Indentation',
+ spacingPara: 'Spacing',
+ pagination: 'Pagination',
+ tabStops: 'Tab stops',
+ border: 'Border',
+ shading: 'Shading',
+ level: 'Level {level}',
+ reveal: 'Reveal formatting',
+ },
+ field: {
+ font: 'Font',
+ weight: 'Weight',
+ size: 'Size',
+ italic: 'Italic',
+ color: 'Text color',
+ underline: 'Underline style',
+ underlineColor: 'Underline color',
+ strike: 'Strikethrough',
+ superscript: 'Superscript',
+ subscript: 'Subscript',
+ caps: 'Capitals',
+ emboss: 'Emboss',
+ engrave: 'Engrave',
+ outline: 'Outline',
+ outlineLevel: 'Outline level',
+ shadow: 'Shadow',
+ highlight: 'Highlight color',
+ scale: 'Scale',
+ tracking: 'Spacing',
+ shift: 'Position',
+ kern: 'Kerning for fonts at or above',
+ ligatures: 'Ligatures',
+ numberForms: 'Number forms',
+ numberSpacing: 'Number spacing',
+ stylisticSet: 'Stylistic set',
+ zero: 'Slashed zero',
+ align: 'Alignment',
+ indentStart: 'Left indent',
+ indentEnd: 'Right indent',
+ special: 'Special',
+ specialBy: 'By',
+ before: 'Space before',
+ after: 'Space after',
+ lineRule: 'Line spacing',
+ lineValue: 'At',
+ contextual: 'No space between paragraphs of the same style',
+ keepNext: 'Keep with next',
+ keepLines: 'Keep lines together',
+ breakBefore: 'Page break before',
+ widows: 'Widow and orphan control',
+ hyphenate: 'Hyphenate',
+ dir: 'Direction',
+ },
+ opt: {
+ underline: {
+ none: 'None',
+ single: 'Single',
+ double: 'Double',
+ words: 'Words only',
+ thick: 'Thick',
+ dotted: 'Dotted',
+ dottedHeavy: 'Thick dotted',
+ dash: 'Dashed',
+ dashedHeavy: 'Thick dashed',
+ dashLong: 'Long dashed',
+ dashLongHeavy: 'Thick long dashed',
+ dotDash: 'Dots and dashes',
+ dashDotHeavy: 'Thick dots and dashes',
+ dotDotDash: 'Two dots and a dash',
+ dashDotDotHeavy: 'Thick two dots and a dash',
+ wave: 'Wavy',
+ wavyHeavy: 'Thick wavy',
+ wavyDouble: 'Double wavy',
+ },
+ strike: { none: 'None', single: 'Single', double: 'Double' },
+ caps: { none: 'None', small: 'Small capitals', all: 'All capitals' },
+ weight: {
+ '100': 'Thin',
+ '200': 'Extra Light',
+ '300': 'Light',
+ '400': 'Regular',
+ '500': 'Medium',
+ '600': 'Semibold',
+ '700': 'Bold',
+ '800': 'Extra Bold',
+ '900': 'Black',
+ },
+ ligatures: {
+ none: 'Default',
+ 'liga-none': 'No standard ligatures',
+ 'clig-none': 'No contextual ligatures',
+ dlig: 'Discretionary',
+ hlig: 'Historical',
+ },
+ numberForms: { none: 'Default', lnum: 'Lining', onum: 'Old style' },
+ numberSpacing: { none: 'Default', pnum: 'Proportional', tnum: 'Tabular' },
+ stylisticSet: { none: 'None' },
+ zero: { none: 'Off', zero: 'On' },
+ align: { start: 'Start', center: 'Center', end: 'End', justify: 'Justified', distribute: 'Distributed' },
+ outlineLevel: { none: 'Body text' },
+ special: { none: 'None', first: 'First line', hanging: 'Hanging' },
+ lineRule: { multiple: 'Multiple', atLeast: 'At least', exact: 'Exactly' },
+ dir: { none: 'Default', ltr: 'Left to right', rtl: 'Right to left' },
+ tabAlign: { start: 'Start', center: 'Center', end: 'End', decimal: 'Decimal', bar: 'Bar' },
+ tabLeader: {
+ none: 'None',
+ dot: 'Dots',
+ hyphen: 'Hyphens',
+ underscore: 'Low line',
+ heavy: 'Heavy line',
+ middleDot: 'Middle dots',
+ },
+ borderSide: { top: 'Top', bottom: 'Bottom', start: 'Start', end: 'End', between: 'Between' },
+ borderStyle: {
+ single: 'Single',
+ double: 'Double',
+ dotted: 'Dotted',
+ dashed: 'Dashed',
+ thick: 'Thick',
+ triple: 'Triple',
+ thinThickSmallGap: 'Thin and thick, small gap',
+ thickThinSmallGap: 'Thick and thin, small gap',
+ wave: 'Wavy',
+ doubleWave: 'Double wavy',
+ dashDotStroked: 'Dashes and dots',
+ threeDEmboss: 'Raised, three-dimensional',
+ threeDEngrave: 'Sunken, three-dimensional',
+ inset: 'Inset',
+ outset: 'Outset',
+ },
+ pattern: {
+ clear: 'Clear',
+ solid: 'Solid',
+ horzStripe: 'Horizontal stripe',
+ vertStripe: 'Vertical stripe',
+ diagStripe: 'Diagonal stripe',
+ reverseDiagStripe: 'Reverse diagonal stripe',
+ thinHorzStripe: 'Thin horizontal stripe',
+ thinVertStripe: 'Thin vertical stripe',
+ thinReverseDiagStripe: 'Thin reverse diagonal stripe',
+ thinDiagStripe: 'Thin diagonal stripe',
+ thinHorzCross: 'Thin horizontal cross',
+ thinDiagCross: 'Thin diagonal cross',
+ horzCross: 'Horizontal cross',
+ diagCross: 'Diagonal cross',
+ pct5: '5 percent',
+ pct12: '12 percent',
+ pct15: '15 percent',
+ pct20: '20 percent',
+ pct30: '30 percent',
+ pct35: '35 percent',
+ pct37: '37 percent',
+ pct40: '40 percent',
+ pct45: '45 percent',
+ pct55: '55 percent',
+ pct60: '60 percent',
+ pct62: '62 percent',
+ pct65: '65 percent',
+ pct70: '70 percent',
+ pct80: '80 percent',
+ pct85: '85 percent',
+ pct87: '87 percent',
+ pct90: '90 percent',
+ pct95: '95 percent',
+ pct10: '10 percent',
+ pct25: '25 percent',
+ pct50: '50 percent',
+ pct75: '75 percent',
+ },
+ format: {
+ decimal: 'Numbers',
+ upperRoman: 'Roman, capitals',
+ lowerRoman: 'Roman, small',
+ upperLetter: 'Letters, capitals',
+ lowerLetter: 'Letters, small',
+ bullet: 'Bullet',
+ decimalZero: 'Numbers with a leading zero',
+ ordinal: 'Ordinal numbers',
+ cardinalText: 'Number words',
+ ordinalText: 'Ordinal words',
+ none: 'None',
+ hebrew1: 'Hebrew letters',
+ hebrew2: 'Hebrew letters, alternate',
+ arabicAlpha: 'Arabic letters',
+ arabicAbjad: 'Arabic abjad',
+ hindiNumbers: 'Hindi numerals',
+ thaiNumbers: 'Thai numerals',
+ chineseCounting: 'Chinese counting',
+ japaneseCounting: 'Japanese counting',
+ koreanCounting: 'Korean counting',
+ },
+ effect: { emboss: 'Emboss', engrave: 'Engrave', outline: 'Outline', shadow: 'Shadow' },
+ otfToken: {
+ 'liga-none': 'No standard ligatures',
+ 'clig-none': 'No contextual ligatures',
+ dlig: 'Discretionary ligatures',
+ hlig: 'Historical ligatures',
+ lnum: 'Lining numbers',
+ onum: 'Old style numbers',
+ pnum: 'Proportional numbers',
+ tnum: 'Tabular numbers',
+ zero: 'Slashed zero',
+ },
+ },
+ tabs: {
+ position: 'Tab stop position',
+ align: 'Alignment',
+ leader: 'Leader',
+ set: 'Set',
+ clear: 'Clear',
+ clearAll: 'Clear all',
+ interval: 'Default tab stops every',
+ listLabel: 'Tab stops of this paragraph',
+ empty: 'No tab stops.',
+ stop: '{pos} pt, {align}, leader {leader}',
+ tooMany: 'A paragraph can have 64 tab stops at most.',
+ stopSet: 'Tab stop set at {pos} points.',
+ cleared: 'Tab stop cleared.',
+ clearedAll: 'All tab stops cleared.',
+ },
+ borders: {
+ side: 'Side',
+ style: 'Style',
+ width: 'Width',
+ space: 'Distance from text',
+ color: 'Border color',
+ apply: 'Apply to side',
+ remove: 'Remove side',
+ presets: 'Presets',
+ presetNone: 'No border',
+ presetBox: 'Box',
+ presetLines: 'Lines above and below',
+ fill: 'Fill color',
+ pattern: 'Pattern',
+ patternColor: 'Pattern color',
+ applyShade: 'Apply shading',
+ removeShade: 'Remove shading',
+ sideState: '{side}: {style}, {width} pt',
+ sideNone: '{side}: none',
+ },
+ lists: {
+ format: 'Number format',
+ pattern: 'Pattern',
+ symbol: 'Bullet symbol',
+ symbolFont: 'Symbol font',
+ start: 'Start at',
+ indent: 'Text indent',
+ hanging: 'Number hangs',
+ apply: 'Apply',
+ patternHint: 'Use %1 to %9 for the numbers of the levels.',
+ },
+ color: {
+ open: 'Choose a color',
+ automatic: 'Automatic',
+ current: 'Color: {value}',
+ custom: 'Custom color',
+ hex: 'Color value',
+ apply: 'Apply',
+ cancel: 'Cancel',
+ contrastLight: 'This color is hard to read on a light page.',
+ contrastDark: 'This color is hard to read on a dark page.',
+ contrastBoth: 'This color is hard to read on both light and dark pages.',
+ },
+ reveal: {
+ show: 'Reveal formatting',
+ direct: 'direct',
+ paragraph: 'paragraph',
+ fromStyle: 'style {name}',
+ none: 'No formatting beyond the Normal style.',
+ listLabel: 'Formatting at the caret',
+ what: {
+ font: '{value}',
+ sizePt: '{value} points',
+ size: '{value} points',
+ weight: 'Weight {value}',
+ bold: 'Bold',
+ italic: 'Italic',
+ underline: 'Underline {value}',
+ strike: 'Strikethrough',
+ strikeDouble: 'Double strikethrough',
+ textColor: 'Color {value}',
+ highlight: 'Highlight {value}',
+ caps: 'Capitals {value}',
+ tracking: 'Spacing {value} points',
+ scale: 'Scale {value} percent',
+ shift: 'Position {value} points',
+ kern: 'Kerning {value}',
+ effect: 'Effect {value}',
+ otf: 'Features {value}',
+ subscript: 'Subscript',
+ superscript: 'Superscript',
+ lang: 'Language {value}',
+ dir: 'Direction {value}',
+ align: 'Aligned {value}',
+ indent: 'Indent {value}',
+ before: 'Space before {value} points',
+ after: 'Space after {value} points',
+ line: 'Line spacing {value}',
+ lineMultiple: 'multiple {value}',
+ lineAtLeast: 'at least {value} pt',
+ lineExact: 'exactly {value} pt',
+ outline: 'Outline level {value}',
+ keepNext: 'Keep with next',
+ keepLines: 'Keep lines together',
+ tabs: '{value} tab stops',
+ border: 'Border',
+ shade: 'Shading',
+ },
+ },
+ commands: {
+ fontDialog: 'Open the Format pane on Text',
+ paragraphDialog: 'Open the Format pane on Paragraph',
+ reveal: 'Reveal formatting',
+ },
+} as const;
diff --git a/app/src/strings/en/textRuler.ts b/app/src/strings/en/textRuler.ts
index e49d81f6d..b7d6b93ea 100644
--- a/app/src/strings/en/textRuler.ts
+++ b/app/src/strings/en/textRuler.ts
@@ -1,4 +1,54 @@
// The ruler above the page.
// Owner: text:ruler (B11 and B12). text:scaffold registered it in the string index, and the owner fills it in.
-export const textRuler = {} as const;
+export const textRuler = {
+ toggle: 'Ruler',
+ toggleKeywords: 'ruler indent tab stops margin hanging first line',
+ label: 'Ruler',
+ unitLabel: 'Ruler units',
+ tabTypeLabel: 'Tab type',
+ tabTypeHint: 'Click the ruler to add a stop of this type.',
+ needsText: 'Click in a text block to see its ruler.',
+ shown: 'Ruler shown.',
+ hidden: 'Ruler hidden.',
+ unitName: {
+ in: 'Inches',
+ cm: 'Centimeters',
+ mm: 'Millimeters',
+ pt: 'Points',
+ pc: 'Picas',
+ },
+ value: {
+ in: '{n, plural, one {# inch} other {# inches}}',
+ cm: '{n, plural, one {# centimeter} other {# centimeters}}',
+ mm: '{n, plural, one {# millimeter} other {# millimeters}}',
+ pt: '{n, plural, one {# point} other {# points}}',
+ pc: '{n, plural, one {# pica} other {# picas}}',
+ },
+ marker: {
+ first: 'First line indent',
+ hanging: 'Hanging indent',
+ left: 'Left indent',
+ right: 'Right indent',
+ tab: '{type} tab stop',
+ },
+ tabType: {
+ start: 'Left tab',
+ center: 'Center tab',
+ end: 'Right tab',
+ decimal: 'Decimal tab',
+ bar: 'Bar tab',
+ first: 'First line indent',
+ hanging: 'Hanging indent',
+ },
+ tabName: {
+ start: 'Left',
+ center: 'Center',
+ end: 'Right',
+ decimal: 'Decimal',
+ bar: 'Bar',
+ },
+ announced: '{name}, {value}.',
+ removed: '{name} removed.',
+ added: '{name} added at {value}.',
+} as const;
diff --git a/app/src/ui/commandIcons.lazy.ts b/app/src/ui/commandIcons.lazy.ts
index 3cce71a9e..10d10209a 100644
--- a/app/src/ui/commandIcons.lazy.ts
+++ b/app/src/ui/commandIcons.lazy.ts
@@ -24,6 +24,7 @@ import { NotebookIcon } from '@phosphor-icons/react/dist/csr/Notebook';
import { NotepadIcon } from '@phosphor-icons/react/dist/csr/Notepad';
import { PaperclipIcon } from '@phosphor-icons/react/dist/csr/Paperclip';
import { QuotesIcon } from '@phosphor-icons/react/dist/csr/Quotes';
+import { RulerIcon } from '@phosphor-icons/react/dist/csr/Ruler';
import { ShapesIcon } from '@phosphor-icons/react/dist/csr/Shapes';
import { TableIcon } from '@phosphor-icons/react/dist/csr/Table';
import { VideoCameraIcon } from '@phosphor-icons/react/dist/csr/VideoCamera';
@@ -49,6 +50,7 @@ export const LAZY_ICONS: Readonly> = {
'page.toc': ListDashesIcon,
'page.markdownSource': MarkdownLogoIcon,
'page.lockForReading': BookOpenTextIcon,
+ 'view.ruler': RulerIcon,
'ink.inkToShape': ShapesIcon,
'ink.curve.make': BezierCurveIcon,
'ink.zoomBox': MagnifyingGlassPlusIcon,
diff --git a/app/src/ui/commandIcons.ts b/app/src/ui/commandIcons.ts
index 21e4790d7..3430602d3 100644
--- a/app/src/ui/commandIcons.ts
+++ b/app/src/ui/commandIcons.ts
@@ -14,6 +14,8 @@ import { ArrowUUpLeftIcon } from '@phosphor-icons/react/dist/csr/ArrowUUpLeft';
import { ArrowsVerticalIcon } from '@phosphor-icons/react/dist/csr/ArrowsVertical';
import { ArrowLineLeftIcon } from '@phosphor-icons/react/dist/csr/ArrowLineLeft';
import { ArrowLineRightIcon } from '@phosphor-icons/react/dist/csr/ArrowLineRight';
+import { ArrowLeftIcon } from '@phosphor-icons/react/dist/csr/ArrowLeft';
+import { ArrowRightIcon } from '@phosphor-icons/react/dist/csr/ArrowRight';
import { ArrowsClockwiseIcon } from '@phosphor-icons/react/dist/csr/ArrowsClockwise';
import { BinocularsIcon } from '@phosphor-icons/react/dist/csr/Binoculars';
import { BoundingBoxIcon } from '@phosphor-icons/react/dist/csr/BoundingBox';
@@ -89,6 +91,8 @@ export const HOME_ICONS: Readonly> = {
'para.alignCenter': TextAlignCenterIcon,
'para.alignEnd': TextAlignRightIcon,
'para.justify': TextAlignJustifyIcon,
+ 'text.paragraphRtl': ArrowLeftIcon,
+ 'text.paragraphLtr': ArrowRightIcon,
'para.lineSpacing': ArrowsVerticalIcon,
'para.shade': PaintBucketIcon,
'para.border': BoundingBoxIcon,
diff --git a/docs/FEATURE-LIST.md b/docs/FEATURE-LIST.md
index a3d34c65e..da0d97b11 100644
--- a/docs/FEATURE-LIST.md
+++ b/docs/FEATURE-LIST.md
@@ -2,7 +2,7 @@
Every tool, action, and feature in OpenNote, grouped by category, with one sentence each. `npm run docs:features` makes this page. The commands, tool windows, title bar items, settings sections, and setup steps come from what the app registers, so a new one shows up here or fails the tests. The feature sentences come from [FEATURES.md](FEATURES.md), which also says what has been tested by hand and which flag turns each one on. The [help pages](help/README.md) explain how to use them. [docs/screens](screens/README.md) has every picture in light and dark.
-The app registers 1140 commands and 48 tool windows. 24 of the windows are subject and engineering kits. The count includes the ink commands, which register when a page first shows its ink, and the commands that register once start-up is done or with the first page: on-device intelligence, the second pass of page commands, and the code block and table commands. The command palette (Ctrl+K) runs every command not marked as left out of it. Some commands need a feature flag, which FEATURES.md names. The keys shown are the defaults of the standard shortcut set.
+The app registers 1149 commands and 48 tool windows. 24 of the windows are subject and engineering kits. The count includes the ink commands, which register when a page first shows its ink, and the commands that register once start-up is done or with the first page: on-device intelligence, the second pass of page commands, and the code block and table commands. The command palette (Ctrl+K) runs every command not marked as left out of it. Some commands need a feature flag, which FEATURES.md names. The keys shown are the defaults of the standard shortcut set.
## Contents
@@ -60,6 +60,9 @@ The app registers 1140 commands and 48 tool windows. 24 of the windows are subje
- **Footnotes and endnotes.** Insert, number in reading order, restart per document or at each Heading 1, edit in a notes area under the page, and jump between the reference and the note.
- **Symbols and formatting marks.** The Symbol dialog searches by name or code.
- **Copy and paste with formatting.** Copy writes inline CSS that Word and Google Docs keep.
+- **Format pane.** Font (Ctrl+D) and Paragraph open one pane with every text and paragraph field, tab stops, borders, shading, and list levels.
+- **Language and text direction.** Set the language of a selection or paragraph, or mark it Do not check spelling.
+- **Text ruler.** View, Ruler shows a ruler above the text box with the cursor, in the unit you choose, at any zoom.
- **Bundled fonts and device fonts.** Carlito and Caladea stand in for Calibri and Cambria, and load only when a page uses them.
- **Word import and export.** Import reads styles, themes, lists, notes, comments, and tracked changes into version 2 data.
- **Shared color palette.** One palette for text and tables: theme colors with tints and shades, standard colors, recent colors per notebook, Automatic, No color, and More colors.
@@ -206,10 +209,18 @@ Open it with the command "Open the code studio".
| Edit link (not in the palette) | Edits the text and address of the link at the caret. | |
| Remove link (not in the palette) | Removes the link but keeps its text. | |
| Text styles | Edits the look of the text styles, such as headings and quotes. | |
+| Open the Format pane on Text | Opens the Format pane on the Text tab: font, size, color, underline, effects, and a preview of the selected text. | |
+| Open the Format pane on Paragraph | Opens the Format pane on the Paragraph tab: alignment, indents, spacing, line spacing, and pagination. | |
+| Reveal formatting | Says where each format at the caret comes from: the run, the paragraph, or its style. | |
| Image… | Inserts a picture from a file. | |
| Crop image | Crops the edges of the selected image. | |
| Alt text… | Writes the alt text that screen readers read for the image, or marks it decorative. | |
| Copy as Markdown | Copies the selection as Markdown text. | |
+| Language | Sets the language of the selected text or paragraph, for spell check and hyphenation. | |
+| Do not check spelling | Turns spell check off or on for the selected text or paragraph. | |
+| Right-to-left text direction | Makes the paragraph read right to left, with its start on the right. | |
+| Left-to-right text direction | Makes the paragraph read left to right. | |
+| Automatic hyphenation | Turns automatic hyphenation on or off for the paragraph, by its language. | |
| Page break | Starts a new page after the paragraph at the caret, from the Insert tab or the slash menu. | |
| Column break | Moves the text after the caret to the next column of the text box. | Ctrl+Shift+Enter |
| Insert footnote | Inserts a footnote at the caret. Its note shows under the page, numbered in reading order. | |
@@ -981,6 +992,7 @@ Open it with the command "Open the code studio".
| Extract selection to a new page | Moves the selection to a new page and leaves a link to it. | |
| Merge pages | Merges the selected pages into one page, each under its own heading. | |
| Split page at headings | Splits the page into separate pages at its headings. | |
+| Ruler | Shows or hides the ruler above the text, with the indents and the tab stops. | |
| Open command palette (not in the palette) | Opens the command palette to find and run any command. | Ctrl+K |
| Go to a page | Finds a page by name and opens it. | Ctrl+O |
| Pin page | Pins the page to the top of the pages pane. | |
diff --git a/docs/FEATURES.md b/docs/FEATURES.md
index 15e295753..96e9e71ef 100644
--- a/docs/FEATURES.md
+++ b/docs/FEATURES.md
@@ -844,6 +844,9 @@ On in Beta and off in Stable. The [help page](help/subject-tool-kits.md) explain
| Footnotes and endnotes | Insert, number in reading order, restart per document or at each Heading 1, edit in a notes area under the page, and jump between the reference and the note. Restart per printed sheet is not built. | Built, untested by hand |
| Symbols and formatting marks | The Symbol dialog searches by name or code. Alt+X turns hex digits into a character and back. Nonbreaking space, dashes, and soft hyphens have commands. Show formatting marks draws paragraph, space, and tab marks without changing the Markdown. Behind the text.symbols flag. | Built, untested by hand |
| Copy and paste with formatting | Copy writes inline CSS that Word and Google Docs keep. Paste keeps the source formatting from Word, Google Docs, and OneNote, and a Paste Options notice switches between keep source, merge, and plain text. Settings sets a default per source. | Built, untested by hand |
+| Format pane | Font (Ctrl+D) and Paragraph open one pane with every text and paragraph field, tab stops, borders, shading, and list levels. Values take pt, in, cm, mm, px, or pc, and a bad value shows its range. Reveal formatting lists each format and its source. Modify on a style edits the style. Behind the text.formatPane flag. | Built, untested by hand |
+| Language and text direction | Set the language of a selection or paragraph, or mark it Do not check spelling. Spell check sends each part with its own language. Right-to-left and left-to-right paragraph buttons, start and end alignment that follows the direction, and automatic hyphenation by language. Behind the text.language flag. | Built, untested by hand |
+| Text ruler | View, Ruler shows a ruler above the text box with the cursor, in the unit you choose, at any zoom. Drag the first line, hanging, left, and right indent markers, and add, move, or remove tab stops. Markers work with the arrow keys and screen readers, and the bar mirrors for right-to-left paragraphs. Behind the text.ruler flag. | Built, untested by hand |
| Bundled fonts and device fonts | Carlito and Caladea stand in for Calibri and Cambria, and load only when a page uses them. The app lists the device's fonts with their metrics, and nothing about them leaves the device. | Built, untested by hand |
| Word import and export | Import reads styles, themes, lists, notes, comments, and tracked changes into version 2 data. Export writes every version 2 format, can embed the bundled fonts, and has a Remove personal information switch. The device font list is not yet sent to the writer. | Built, untested by hand |
| Shared color palette | One palette for text and tables: theme colors with tints and shades, standard colors, recent colors per notebook, Automatic, No color, and More colors. | Built, untested by hand |
diff --git a/docs/adr/0082-named-styles-v2.md b/docs/adr/0082-named-styles-v2.md
index c44a9c594..f5ef29005 100644
--- a/docs/adr/0082-named-styles-v2.md
+++ b/docs/adr/0082-named-styles-v2.md
@@ -45,6 +45,7 @@ Two parts differ from the decision above:
- The DOCX captures of owner decision N5 were not delivered before B10.2. The Word and Google Docs fixtures are hand-written WordprocessingML with the apps' documented defaults. A sanitized capture can replace each file, and the set test then checks the set against it.
- Space before, space after, and line spacing have no base value in `resolve.ts`. A style gets them only when a layer sets them (`R-PARA-1`: the default is the app's value). So a Title, a Quote, or a user style keeps the page's own line height and paragraph spacing.
- The Styles pane (`StylesPane.tsx`, command `styles.pane`) is a minimal sheet at the dock. It lists the gallery with a preview, the source, and a short look summary, and applies a style on activate. The Format pane's style mode (`text:format-pane`) replaces it later.
+- Beta 11 (`text:format-pane`): with `text.formatPane` on, Modify style opens the Format pane in style mode (`app/src/features/page/formatPane/`) for every style, not only the ten the dialog knows. Style mode edits the keys of R-STY-1 that have a field (font, size, weight, italic, color, highlight, strike, capitals, tracking, alignment, outline level, indents, spacing, line spacing, direction, and the keep switches) through `patchStyle` and `saveStyles`, so each change is one notebook change with the Undo toast. Tab stops, borders, shading, and list levels are not edited on a style yet; the Format pane's Tabs, Borders and shading, and Lists tabs act on the selection only.
- A user style based on a heading style keeps the element a heading of that level, with the style's name in `data.styles`.
- The style commands write only valid names (`R-STY-3`). A paragraph that carries another name gets a toast and no change.
diff --git a/docs/help/language-and-spelling.md b/docs/help/language-and-spelling.md
index c8f9ec236..95cce65ff 100644
--- a/docs/help/language-and-spelling.md
+++ b/docs/help/language-and-spelling.md
@@ -22,6 +22,15 @@ Setup has a step called Language and spelling. It shows English (United States)
You can change it later in Settings, under Language.
+## Language and direction of a passage
+
+These are on the beta branch behind a flag that is off.
+
+- Select text, or put the cursor in a paragraph, and choose Language on the Review tab. Pick a language, or Use the page language. Spell check then checks that text in its own language. A language with no spelling dictionary on this PC is left unchecked.
+- Choose Do not check spelling for text such as code names or citations. Choose it again to check that text once more.
+- On the Home tab, choose Right-to-left text direction to make the paragraph read from the right. Left-to-right text direction sets it back. Start alignment follows the direction, so a right-to-left paragraph starts on the right.
+- On the Layout tab, Automatic hyphenation breaks the paragraph's words by its language.
+
## What stays in American English
OpenNote's own text stays in American English for now, because translations are not built yet. There is one dictionary pack, the English one. A language with no dictionary yet gets a calm note saying so. The English dictionary stays available. Spelling, recognition, and read aloud still use your language.
diff --git a/docs/help/typed-notes.md b/docs/help/typed-notes.md
index 5458404af..5fb4d4758 100644
--- a/docs/help/typed-notes.md
+++ b/docs/help/typed-notes.md
@@ -50,6 +50,16 @@ Press Ctrl+Enter to check or uncheck an item. Finished items move down, and the
- A pattern may have at most 250 characters. A pattern that takes more than a moment stops with a message. Make it simpler. A class such as \s or [^x] never takes the paragraph mark. Only ^p (or \n in a regular expression) does.
- In the replacement, ^p splits the paragraph. Replacing ^p with nothing removes an empty paragraph.
+## Format pane and ruler
+
+These are on the beta branch behind flags that are off.
+
+- Press Ctrl+D to open the Format pane on Text. Its tabs are Text, Advanced, Paragraph, Tabs, Borders and shading, and Lists. A preview line shows the selected text. The arrow keys move between tabs, and Escape returns to the page.
+- Type a value with its unit: pt, in, cm, mm, px, or pc. A value out of range shows the range and changes nothing.
+- Choose Reveal formatting to see each format at the caret and where it comes from: the text itself, the paragraph, or a style.
+- Modify on a style opens the same pane for the style. A change there applies to every paragraph that uses it.
+- On the View tab, choose Ruler to show the ruler above the text box with the cursor. Drag the first line, hanging, left, and right indent markers. Click the ruler to add a tab stop of the type shown at its left, drag it to move it, and drag it off the ruler to remove it. Hold Alt to drag without snapping. A marker moves with the arrow keys and tells a screen reader its place.
+
## Templates, series, and the source
- Start a page from a template when you make it.
diff --git a/docs/tools/featureCommands.ts b/docs/tools/featureCommands.ts
index 63bf99f91..dee3ce655 100644
--- a/docs/tools/featureCommands.ts
+++ b/docs/tools/featureCommands.ts
@@ -4,6 +4,7 @@
import { CELL_COMMAND_TEXT } from './featureCommandsCells.ts';
import { HOME_TAB_TEXT } from './featureCommandsHome.ts';
+import { TEXT_PANE_TEXT } from './featureCommandsText.ts';
import { SHEET_COMMAND_TEXT } from './featureCommandsSheet.ts';
/** Each settings section, by its registry id. */
@@ -44,6 +45,7 @@ export const SETUP_TEXT: Readonly> = {
export const COMMAND_TEXT: Readonly> = {
...CELL_COMMAND_TEXT,
...HOME_TAB_TEXT,
+ ...TEXT_PANE_TEXT,
'tools.calculator.basic': 'Opens the calculator on its basic tab for everyday arithmetic.',
'tools.calculator.scientific': 'Opens the calculator on its scientific tab, with functions, powers, and angle modes.',
'tools.calculator.graphing': 'Opens the calculator on its graphing tab to plot functions in 2D.',
diff --git a/docs/tools/featureCommandsText.ts b/docs/tools/featureCommandsText.ts
new file mode 100644
index 000000000..89601b0b9
--- /dev/null
+++ b/docs/tools/featureCommandsText.ts
@@ -0,0 +1,16 @@
+// The sentences of the Format pane, language and direction, and ruler commands (Beta 11 text lanes), kept here so
+// featureCommands.ts stays under its size limit.
+
+export const TEXT_PANE_TEXT: Readonly> = {
+ 'text.setLanguage': 'Sets the language of the selected text or paragraph, for spell check and hyphenation.',
+ 'text.noProof': 'Turns spell check off or on for the selected text or paragraph.',
+ 'text.paragraphRtl': 'Makes the paragraph read right to left, with its start on the right.',
+ 'text.paragraphLtr': 'Makes the paragraph read left to right.',
+ 'text.hyphenate': 'Turns automatic hyphenation on or off for the paragraph, by its language.',
+ 'format.fontDialog':
+ 'Opens the Format pane on the Text tab: font, size, color, underline, effects, and a preview of the selected text.',
+ 'format.paragraphDialog':
+ 'Opens the Format pane on the Paragraph tab: alignment, indents, spacing, line spacing, and pagination.',
+ 'format.reveal': 'Says where each format at the caret comes from: the run, the paragraph, or its style.',
+ 'view.ruler': 'Shows or hides the ruler above the text, with the indents and the tab stops.',
+};
diff --git a/docs/tools/registry.json b/docs/tools/registry.json
index 45ddd99de..a2a96d9db 100644
--- a/docs/tools/registry.json
+++ b/docs/tools/registry.json
@@ -1972,6 +1972,27 @@
"keys": [],
"flag": "study.exams"
},
+ {
+ "id": "format.fontDialog",
+ "title": "Open the Format pane on Text",
+ "category": "format",
+ "keys": [],
+ "flag": "text.formatPane"
+ },
+ {
+ "id": "format.paragraphDialog",
+ "title": "Open the Format pane on Paragraph",
+ "category": "format",
+ "keys": [],
+ "flag": "text.formatPane"
+ },
+ {
+ "id": "format.reveal",
+ "title": "Reveal formatting",
+ "category": "format",
+ "keys": [],
+ "flag": "text.formatPane"
+ },
{
"id": "math.simplify",
"title": "Simplify",
@@ -4316,6 +4337,41 @@
"keys": [],
"flag": "kits.thermo"
},
+ {
+ "id": "text.setLanguage",
+ "title": "Language",
+ "category": "format",
+ "keys": [],
+ "flag": "text.language"
+ },
+ {
+ "id": "text.noProof",
+ "title": "Do not check spelling",
+ "category": "format",
+ "keys": [],
+ "flag": "text.language"
+ },
+ {
+ "id": "text.paragraphRtl",
+ "title": "Right-to-left text direction",
+ "category": "format",
+ "keys": [],
+ "flag": "text.language"
+ },
+ {
+ "id": "text.paragraphLtr",
+ "title": "Left-to-right text direction",
+ "category": "format",
+ "keys": [],
+ "flag": "text.language"
+ },
+ {
+ "id": "text.hyphenate",
+ "title": "Automatic hyphenation",
+ "category": "format",
+ "keys": [],
+ "flag": "text.language"
+ },
{
"id": "insert.pageBreakMenu",
"title": "Page break",
@@ -4916,6 +4972,13 @@
"keys": [],
"flag": "editor.readAloud"
},
+ {
+ "id": "view.ruler",
+ "title": "Ruler",
+ "category": "view",
+ "keys": [],
+ "flag": "text.ruler"
+ },
{
"id": "tools.latex",
"title": "Insert LaTeX",