Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
baeb147
Check spelling per run and paragraph language
XrxcGH Nov 7, 2026
8b7fb62
Draw paragraph direction, language, and hyphenation
XrxcGH Nov 7, 2026
7364855
Add Language, Do not check spelling, and direction commands
XrxcGH Nov 7, 2026
cba05dc
Raise the start-up JavaScript budget from 539 to 541 KB
XrxcGH Nov 7, 2026
becdb31
Close the language and direction verification gaps
XrxcGH Nov 7, 2026
76c1097
Add the ruler for indents and tab stops
XrxcGH Nov 7, 2026
74d53f1
Ruler: align to the text box, ARIA right slider, forced colors, palm …
XrxcGH Nov 7, 2026
b4df891
Ruler: focus ring, marker limits, negative indents, rtl mirror, per-f…
XrxcGH Nov 7, 2026
b22e152
Add list level read and write commands for the Format pane
XrxcGH Nov 11, 2026
d3a56b5
Add Format pane fields, units, tab stops, borders, and reveal
XrxcGH Nov 11, 2026
9d64b41
Add the Format pane, the full color picker, and Modify style mode
XrxcGH Nov 11, 2026
9f4238a
Merge origin/beta into b11-text-format-pane
XrxcGH Nov 11, 2026
14fd8a9
Fix Format pane verification gaps: default tab in the page view, reve…
XrxcGH Nov 11, 2026
02077d2
Format pane gaps: per-paragraph indent, tabs and borders, per-run eff…
XrxcGH Nov 11, 2026
54ba133
Merge b11-text-format-pane into train-rtext
XrxcGH Nov 13, 2026
5f64770
Merge b11-text-language-direction into train-rtext
XrxcGH Nov 13, 2026
167daa6
Merge b11-text-ruler into train-rtext
XrxcGH Nov 13, 2026
e1ca035
Show the page's default tab stops on the ruler
XrxcGH Nov 13, 2026
d3826d7
Move the text lanes' command sentences into their own module
XrxcGH Nov 13, 2026
b9a162b
Format the Format pane fields file with Prettier
XrxcGH Nov 13, 2026
3448b52
Fit the static direction test to the paragraph style render on beta
XrxcGH Nov 13, 2026
162c3a4
Give the direction and ruler commands their own icons
XrxcGH Nov 13, 2026
5a3615a
Give the static render the text block's options in the parity test
XrxcGH Nov 13, 2026
96e2324
List the Format pane, ruler, and passage language in the docs
XrxcGH Nov 13, 2026
66563a8
Widen the Draw bar check now that the Review tab shows
XrxcGH Nov 13, 2026
ca3b5a9
Drop text.language from the waiting flags now that its lane reads it
XrxcGH Nov 13, 2026
d4a6059
Train rtext: keep the Format pane's reveal command off the start-up p…
XrxcGH Nov 13, 2026
ea74ca4
Merge remote-tracking branch 'origin/beta' into train-rtext
XrxcGH Nov 15, 2026
4464dba
Settings: show the web-image hint as help text under a labeled switch…
XrxcGH Nov 15, 2026
39a9fcb
CI: retry a component test shard once when the runner closes the browser
XrxcGH Nov 15, 2026
9f3835b
CI: keep the shard retry line under the length limit
XrxcGH Nov 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/bundle-budget.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"startup": {
"jsKb": 612,
"jsKb": 620,
"cssKb": 30
},
"after": [
Expand Down
3 changes: 0 additions & 3 deletions app/src/app/flagUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ const WAITING: Readonly<Record<string, string>> = {
'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.',
Expand Down
42 changes: 41 additions & 1 deletion app/src/editor/commands/lists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
});
}
29 changes: 24 additions & 5 deletions app/src/editor/commands/paragraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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));
}

Expand Down
57 changes: 57 additions & 0 deletions app/src/editor/commands/runFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions app/src/editor/extensions/direction.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading