@texuli/core 0.1.0-next.0 → 0.1.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +17 -4
  2. package/dist/editor/document.d.ts +65 -0
  3. package/dist/editor/document.js +394 -0
  4. package/dist/editor/edit.d.ts +7 -0
  5. package/dist/editor/edit.js +7 -0
  6. package/dist/editor/graphemes.d.ts +17 -0
  7. package/dist/editor/graphemes.js +71 -0
  8. package/dist/editor/history.d.ts +57 -0
  9. package/dist/editor/history.js +83 -0
  10. package/dist/editor/inline.d.ts +56 -0
  11. package/dist/editor/inline.js +166 -0
  12. package/dist/editor/input-rules.d.ts +17 -0
  13. package/dist/editor/input-rules.js +151 -0
  14. package/dist/editor/keymap.d.ts +30 -0
  15. package/dist/editor/keymap.js +69 -0
  16. package/dist/editor/load.d.ts +44 -0
  17. package/dist/editor/load.js +248 -0
  18. package/dist/editor/markdown.d.ts +33 -0
  19. package/dist/editor/markdown.js +705 -0
  20. package/dist/editor/model.d.ts +13 -0
  21. package/dist/editor/model.js +13 -0
  22. package/dist/editor/selection.d.ts +27 -0
  23. package/dist/editor/selection.js +85 -0
  24. package/dist/editor/transforms.d.ts +93 -0
  25. package/dist/editor/transforms.js +847 -0
  26. package/dist/editor/tree.d.ts +35 -0
  27. package/dist/editor/tree.js +103 -0
  28. package/dist/editor/types.d.ts +147 -0
  29. package/dist/editor/types.js +39 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +1 -0
  32. package/dist/url/safe-href.d.ts +1 -20
  33. package/dist/url/safe-href.js +29 -2
  34. package/dist/url-policy/index.d.ts +1 -0
  35. package/dist/url-policy/index.js +1 -0
  36. package/dist/url-policy/safe-image-src.d.ts +1 -1
  37. package/dist/url-policy/safe-image-src.js +4 -4
  38. package/dist/url-policy/safe-image-srcset.d.ts +1 -1
  39. package/dist/url-policy/safe-image-srcset.js +3 -1
  40. package/dist/url-policy/safe-media-src.d.ts +1 -1
  41. package/dist/url-policy/safe-media-src.js +4 -4
  42. package/dist/url-policy/shared.d.ts +8 -2
  43. package/dist/url-policy/shared.js +11 -6
  44. package/dist/url-policy/trust.d.ts +8 -0
  45. package/dist/url-policy/trust.js +9 -0
  46. package/package.json +22 -6
@@ -0,0 +1,57 @@
1
+ import { type EditorIssue, type EditorSelection, type EditorState, type EditorTransformResult } from './types.js';
2
+ /**
3
+ * What an edit was, for undo grouping. Consecutive `typing` (or `deletion`)
4
+ * in one block within `EDITOR_HISTORY_MERGE_MS` is one undo step; an
5
+ * `input-rule` is always its own step, so undo right after it restores the
6
+ * characters the user typed.
7
+ */
8
+ export type EditorEditKind = 'typing' | 'deletion' | 'input-rule' | 'other';
9
+ /** Pause after which typing starts a new undo step. */
10
+ export declare const EDITOR_HISTORY_MERGE_MS = 500;
11
+ export interface EditorHistory {
12
+ past: readonly EditorState[];
13
+ present: EditorState;
14
+ future: readonly EditorState[];
15
+ /** Why the last refused edit was refused (cleared by the next accepted action). */
16
+ errors: readonly EditorIssue[];
17
+ /** Snapshots kept in `past` (default 200). */
18
+ limit: number;
19
+ /** The last accepted edit, while it can still merge. */
20
+ last: {
21
+ kind: EditorEditKind;
22
+ block: string | null;
23
+ at: number;
24
+ } | null;
25
+ }
26
+ export type EditorHistoryAction = {
27
+ type: 'apply';
28
+ result: EditorTransformResult;
29
+ kind?: EditorEditKind;
30
+ at?: number;
31
+ }
32
+ /** A selection change: no undo step, and the next edit starts a new one. */
33
+ | {
34
+ type: 'select';
35
+ selection: EditorSelection;
36
+ } | {
37
+ type: 'undo';
38
+ } | {
39
+ type: 'redo';
40
+ }
41
+ /** A new document from outside: history restarts. */
42
+ | {
43
+ type: 'replace';
44
+ state: EditorState;
45
+ };
46
+ /** A history whose present is `state`. */
47
+ export declare function createEditorHistory(state: EditorState, options?: {
48
+ limit?: number;
49
+ }): EditorHistory;
50
+ /**
51
+ * Snapshot-stack undo and redo (ADR-003). States are immutable and share
52
+ * unchanged blocks, so a snapshot costs only what changed. A refused edit
53
+ * keeps `present` and records `errors`.
54
+ */
55
+ export declare function editorHistoryReducer(history: EditorHistory, action: EditorHistoryAction): EditorHistory;
56
+ export declare const canUndo: (history: EditorHistory) => boolean;
57
+ export declare const canRedo: (history: EditorHistory) => boolean;
@@ -0,0 +1,83 @@
1
+ import { EDITOR_LIMITS, } from "./types.js";
2
+ /** Pause after which typing starts a new undo step. */
3
+ export const EDITOR_HISTORY_MERGE_MS = 500;
4
+ /** A history whose present is `state`. */
5
+ export function createEditorHistory(state, options = {}) {
6
+ const limit = Math.max(1, Math.trunc(options.limit ?? EDITOR_LIMITS.history));
7
+ return { past: [], present: state, future: [], errors: [], limit, last: null };
8
+ }
9
+ const focusBlock = (state) => state.selection.kind === 'text' ? state.selection.focus.block : null;
10
+ /**
11
+ * Snapshot-stack undo and redo (ADR-003). States are immutable and share
12
+ * unchanged blocks, so a snapshot costs only what changed. A refused edit
13
+ * keeps `present` and records `errors`.
14
+ */
15
+ export function editorHistoryReducer(history, action) {
16
+ switch (action.type) {
17
+ case 'apply': {
18
+ if (!action.result.ok)
19
+ return { ...history, errors: action.result.errors };
20
+ const state = action.result.state;
21
+ // A change that leaves the document as it was (a caret mark toggle) is
22
+ // no undo step, but like a selection change it ends the running typing
23
+ // step: undo after "a **b**" stops at the format change, not at "".
24
+ if (state.doc === history.present.doc) {
25
+ return { ...history, present: state, errors: [], last: null };
26
+ }
27
+ const kind = action.kind ?? 'other';
28
+ const at = action.at ?? 0;
29
+ const block = focusBlock(state);
30
+ const { last } = history;
31
+ const merge = last !== null &&
32
+ (kind === 'typing' || kind === 'deletion') &&
33
+ last.kind === kind &&
34
+ last.block === block &&
35
+ at - last.at <= EDITOR_HISTORY_MERGE_MS;
36
+ const past = merge ? history.past : [...history.past, history.present].slice(-history.limit);
37
+ return {
38
+ ...history,
39
+ past,
40
+ present: state,
41
+ future: [],
42
+ errors: [],
43
+ last: { kind, block, at },
44
+ };
45
+ }
46
+ case 'select':
47
+ return {
48
+ ...history,
49
+ present: { ...history.present, selection: action.selection, storedMarks: null },
50
+ last: null,
51
+ };
52
+ case 'undo': {
53
+ const previous = history.past.at(-1);
54
+ if (!previous)
55
+ return history;
56
+ return {
57
+ ...history,
58
+ past: history.past.slice(0, -1),
59
+ present: previous,
60
+ future: [history.present, ...history.future],
61
+ errors: [],
62
+ last: null,
63
+ };
64
+ }
65
+ case 'redo': {
66
+ const [next, ...future] = history.future;
67
+ if (!next)
68
+ return history;
69
+ return {
70
+ ...history,
71
+ past: [...history.past, history.present].slice(-history.limit),
72
+ present: next,
73
+ future,
74
+ errors: [],
75
+ last: null,
76
+ };
77
+ }
78
+ case 'replace':
79
+ return createEditorHistory(action.state, { limit: history.limit });
80
+ }
81
+ }
82
+ export const canUndo = (history) => history.past.length > 0;
83
+ export const canRedo = (history) => history.future.length > 0;
@@ -0,0 +1,56 @@
1
+ import { type EditorInline, type EditorMark, type EditorMarkType } from './types.js';
2
+ type Text = Extract<EditorInline, {
3
+ type: 'text';
4
+ }>;
5
+ /**
6
+ * Remove the characters the model never stores. `keep` lists control
7
+ * characters that stay (`'\n\t'` for code). Line and paragraph separators and
8
+ * `\r\n` become `\n` first.
9
+ */
10
+ export declare function cleanText(text: string, keep?: string): {
11
+ text: string;
12
+ stripped: boolean;
13
+ };
14
+ /** The identity of a mark: its type, plus the href for links. */
15
+ export declare const markKey: (mark: EditorMark) => string;
16
+ export declare const hasMark: (marks: readonly EditorMark[], type: EditorMarkType) => boolean;
17
+ /**
18
+ * Canonical mark set: one mark per type (the first wins), sorted in
19
+ * `EDITOR_MARK_ORDER`, and `code` excludes every mark but `link`.
20
+ */
21
+ export declare function sortMarks(marks: readonly EditorMark[]): readonly EditorMark[];
22
+ export declare const sameMarks: (a: readonly EditorMark[], b: readonly EditorMark[]) => boolean;
23
+ export declare const withMark: (marks: readonly EditorMark[], mark: EditorMark) => readonly EditorMark[];
24
+ export declare const withoutMark: (marks: readonly EditorMark[], type: EditorMarkType) => EditorMark[];
25
+ /** Length in UTF-16 code units; a break counts 1. */
26
+ export declare const inlineLength: (content: readonly EditorInline[]) => number;
27
+ /** The text of inline content; a break is `\n`. */
28
+ export declare const inlineText: (content: readonly EditorInline[]) => string;
29
+ /**
30
+ * Canonical inline content: marks sorted, empty runs dropped, neighbors with
31
+ * equal marks merged. Returns `content` itself when it is already canonical.
32
+ */
33
+ export declare function normalizeInline(content: readonly EditorInline[]): readonly EditorInline[];
34
+ /** Inline content between two offsets. */
35
+ export declare function sliceInline(content: readonly EditorInline[], from: number, to?: number): EditorInline[];
36
+ export declare const concatInline: (...parts: (readonly EditorInline[])[]) => readonly EditorInline[];
37
+ /** Replace `[from, to)` with `insert`. */
38
+ export declare const spliceInline: (content: readonly EditorInline[], from: number, to: number, insert?: readonly EditorInline[]) => readonly EditorInline[];
39
+ /**
40
+ * The marks text typed at `offset` gets: those of the character before (the
41
+ * first character at offset 0). A link continues only strictly inside it.
42
+ */
43
+ export declare function inheritedMarks(content: readonly EditorInline[], offset: number): readonly EditorMark[];
44
+ /** Apply `update` to the marks of every text character in `[from, to)`. */
45
+ export declare function mapMarks(content: readonly EditorInline[], from: number, to: number, update: (marks: readonly EditorMark[]) => readonly EditorMark[]): readonly EditorInline[];
46
+ /** Every text run in `[from, to)` (sliced). */
47
+ export declare const textRuns: (content: readonly EditorInline[], from: number, to: number) => Text[];
48
+ /**
49
+ * Inline content for plain text: `\n` becomes a break, unsafe characters are
50
+ * stripped (`stripped` reports it), and every run gets `marks`.
51
+ */
52
+ export declare function textToInline(text: string, marks?: readonly EditorMark[]): {
53
+ content: readonly EditorInline[];
54
+ stripped: boolean;
55
+ };
56
+ export {};
@@ -0,0 +1,166 @@
1
+ import { EDITOR_MARK_ORDER, } from "./types.js";
2
+ // C0/C1 controls, bidi marks, embeddings, overrides and isolates, BOM, zero-width
3
+ // space, word joiner and line/paragraph separators: the characters the block
4
+ // schemas refuse. ZWJ/ZWNJ stay (emoji, some scripts).
5
+ const UNSAFE = [
6
+ [0x00, 0x1f],
7
+ [0x7f, 0x9f],
8
+ [0x061c, 0x061c],
9
+ [0x200b, 0x200b],
10
+ [0x200e, 0x200f],
11
+ [0x2028, 0x2029],
12
+ [0x202a, 0x202e],
13
+ [0x2060, 0x2060],
14
+ [0x2066, 0x2069],
15
+ [0xfeff, 0xfeff],
16
+ ];
17
+ function unsafe(code, keep) {
18
+ if (keep.includes(String.fromCharCode(code)))
19
+ return false;
20
+ return UNSAFE.some(([low, high]) => code >= low && code <= high);
21
+ }
22
+ /**
23
+ * Remove the characters the model never stores. `keep` lists control
24
+ * characters that stay (`'\n\t'` for code). Line and paragraph separators and
25
+ * `\r\n` become `\n` first.
26
+ */
27
+ export function cleanText(text, keep = '') {
28
+ const source = text.replace(/\r\n?|[\u2028\u2029]/g, '\n');
29
+ let out = '';
30
+ let stripped = false;
31
+ for (const char of source) {
32
+ if (unsafe(char.codePointAt(0) ?? 0, keep))
33
+ stripped = true;
34
+ else
35
+ out += char;
36
+ }
37
+ return { text: out, stripped };
38
+ }
39
+ /** The identity of a mark: its type, plus the href for links. */
40
+ export const markKey = (mark) => mark.type === 'link' ? `link:${mark.href}` : mark.type;
41
+ export const hasMark = (marks, type) => marks.some((mark) => mark.type === type);
42
+ /**
43
+ * Canonical mark set: one mark per type (the first wins), sorted in
44
+ * `EDITOR_MARK_ORDER`, and `code` excludes every mark but `link`.
45
+ */
46
+ export function sortMarks(marks) {
47
+ const byType = new Map();
48
+ for (const mark of marks)
49
+ if (!byType.has(mark.type))
50
+ byType.set(mark.type, mark);
51
+ const code = byType.has('code');
52
+ const out = EDITOR_MARK_ORDER.flatMap((type) => {
53
+ const mark = byType.get(type);
54
+ return mark && (!code || type === 'link' || type === 'code') ? [mark] : [];
55
+ });
56
+ return out.length === marks.length && out.every((mark, i) => mark === marks[i]) ? marks : out;
57
+ }
58
+ export const sameMarks = (a, b) => a.length === b.length && a.every((mark, i) => markKey(mark) === markKey(b[i]));
59
+ export const withMark = (marks, mark) => sortMarks([mark, ...marks.filter((m) => m.type !== mark.type)]);
60
+ export const withoutMark = (marks, type) => marks.filter((mark) => mark.type !== type);
61
+ const length = (node) => (node.type === 'text' ? node.text.length : 1);
62
+ /** Length in UTF-16 code units; a break counts 1. */
63
+ export const inlineLength = (content) => content.reduce((sum, node) => sum + length(node), 0);
64
+ /** The text of inline content; a break is `\n`. */
65
+ export const inlineText = (content) => content.map((node) => (node.type === 'text' ? node.text : '\n')).join('');
66
+ /**
67
+ * Canonical inline content: marks sorted, empty runs dropped, neighbors with
68
+ * equal marks merged. Returns `content` itself when it is already canonical.
69
+ */
70
+ export function normalizeInline(content) {
71
+ const out = [];
72
+ let changed = false;
73
+ for (const node of content) {
74
+ if (node.type === 'break') {
75
+ out.push(node);
76
+ continue;
77
+ }
78
+ if (node.text === '') {
79
+ changed = true;
80
+ continue;
81
+ }
82
+ const marks = sortMarks(node.marks);
83
+ const previous = out.at(-1);
84
+ if (previous?.type === 'text' && sameMarks(previous.marks, marks)) {
85
+ out[out.length - 1] = {
86
+ type: 'text',
87
+ text: previous.text + node.text,
88
+ marks: previous.marks,
89
+ };
90
+ changed = true;
91
+ continue;
92
+ }
93
+ if (marks !== node.marks)
94
+ changed = true;
95
+ out.push(marks === node.marks ? node : { type: 'text', text: node.text, marks });
96
+ }
97
+ return changed ? out : content;
98
+ }
99
+ /** Inline content between two offsets. */
100
+ export function sliceInline(content, from, to = Number.POSITIVE_INFINITY) {
101
+ const out = [];
102
+ let at = 0;
103
+ for (const node of content) {
104
+ const end = at + length(node);
105
+ if (end > from && at < to) {
106
+ out.push(node.type === 'text'
107
+ ? {
108
+ ...node,
109
+ text: node.text.slice(Math.max(0, from - at), Math.min(node.text.length, to - at)),
110
+ }
111
+ : node);
112
+ }
113
+ at = end;
114
+ }
115
+ return out;
116
+ }
117
+ export const concatInline = (...parts) => normalizeInline(parts.flat());
118
+ /** Replace `[from, to)` with `insert`. */
119
+ export const spliceInline = (content, from, to, insert = []) => concatInline(sliceInline(content, 0, from), insert, sliceInline(content, to));
120
+ /** The run covering the character at `offset`, if it is text. */
121
+ function textAt(content, offset) {
122
+ let at = 0;
123
+ for (const node of content) {
124
+ const end = at + length(node);
125
+ if (offset >= at && offset < end)
126
+ return node.type === 'text' ? node : undefined;
127
+ at = end;
128
+ }
129
+ return undefined;
130
+ }
131
+ /**
132
+ * The marks text typed at `offset` gets: those of the character before (the
133
+ * first character at offset 0). A link continues only strictly inside it.
134
+ */
135
+ export function inheritedMarks(content, offset) {
136
+ const before = offset > 0 ? textAt(content, offset - 1) : undefined;
137
+ const after = textAt(content, offset);
138
+ const marks = (offset > 0 ? before : after)?.marks ?? [];
139
+ const link = marks.find((mark) => mark.type === 'link');
140
+ const inside = link !== undefined &&
141
+ before !== undefined &&
142
+ after?.marks.some((mark) => markKey(mark) === markKey(link)) === true;
143
+ return link && !inside ? withoutMark(marks, 'link') : marks;
144
+ }
145
+ /** Apply `update` to the marks of every text character in `[from, to)`. */
146
+ export function mapMarks(content, from, to, update) {
147
+ const middle = sliceInline(content, from, to).map((node) => node.type === 'text' ? { ...node, marks: sortMarks(update(node.marks)) } : node);
148
+ return concatInline(sliceInline(content, 0, from), middle, sliceInline(content, to));
149
+ }
150
+ /** Every text run in `[from, to)` (sliced). */
151
+ export const textRuns = (content, from, to) => sliceInline(content, from, to).filter((node) => node.type === 'text');
152
+ /**
153
+ * Inline content for plain text: `\n` becomes a break, unsafe characters are
154
+ * stripped (`stripped` reports it), and every run gets `marks`.
155
+ */
156
+ export function textToInline(text, marks = []) {
157
+ const clean = cleanText(text, '\n');
158
+ const content = [];
159
+ clean.text.split('\n').forEach((part, i) => {
160
+ if (i > 0)
161
+ content.push({ type: 'break' });
162
+ if (part)
163
+ content.push({ type: 'text', text: part, marks: sortMarks(marks) });
164
+ });
165
+ return { content: normalizeInline(content), stripped: clean.stripped };
166
+ }
@@ -0,0 +1,17 @@
1
+ import { type EditorTransformOptions } from './transforms.js';
2
+ import type { EditorState, EditorTransformResult } from './types.js';
3
+ /**
4
+ * Markdown input rules, run by the binding after typed text (the result is
5
+ * its own undo step, so undo restores the literal characters):
6
+ *
7
+ * - at a paragraph's start: `#`–`####` and a space make a heading; `-`, `*`
8
+ * or `+` and a space a bullet list; `1.` and a space an ordered list;
9
+ * `[ ]` or `[x]` and a space a todo; `>` and a space a quote; three
10
+ * backticks (and a language) and a space a code block; `---` a divider;
11
+ * - inline: `**x**`, `*x*`, `~~x~~` and `` `x` `` mark `x`, and a typed URL
12
+ * followed by a space becomes a link when `safeHref` accepts it (always at
13
+ * the `untrusted` tier).
14
+ *
15
+ * Returns `null` when no rule applies.
16
+ */
17
+ export declare function applyInputRules(state: EditorState, options?: EditorTransformOptions): EditorTransformResult | null;
@@ -0,0 +1,151 @@
1
+ import { safeHref } from "../url/safe-href.js";
2
+ import { blockText, locateBlock, paragraph, replaceBlock, replaceItem, } from "./document.js";
3
+ import { hasMark, inlineText, mapMarks, sliceInline, spliceInline, textRuns, withMark, } from "./inline.js";
4
+ import { caret, isCollapsed } from "./selection.js";
5
+ import { insertBlock, setBlockType, toggleList, toggleQuote, } from "./transforms.js";
6
+ const INLINE_RULES = [
7
+ { pattern: /(?:^|[^`\\])`([^`\n]+)`$/, size: 1, mark: { type: 'code' } },
8
+ {
9
+ pattern: /(?:^|[^*\\])\*\*([^*\s\n](?:[^*\n]*[^*\s\n])?)\*\*$/,
10
+ size: 2,
11
+ mark: { type: 'strong' },
12
+ },
13
+ { pattern: /(?:^|[^~\\])~~([^~\s\n](?:[^~\n]*[^~\s\n])?)~~$/, size: 2, mark: { type: 'strike' } },
14
+ { pattern: /(?:^|[^*\\])\*([^*\s\n](?:[^*\n]*[^*\s\n])?)\*$/, size: 1, mark: { type: 'em' } },
15
+ ];
16
+ const AUTOLINK = /(?:^|\s)((?:https?:\/\/|mailto:)[^\s]+) $/i;
17
+ /**
18
+ * Markdown input rules, run by the binding after typed text (the result is
19
+ * its own undo step, so undo restores the literal characters):
20
+ *
21
+ * - at a paragraph's start: `#`–`####` and a space make a heading; `-`, `*`
22
+ * or `+` and a space a bullet list; `1.` and a space an ordered list;
23
+ * `[ ]` or `[x]` and a space a todo; `>` and a space a quote; three
24
+ * backticks (and a language) and a space a code block; `---` a divider;
25
+ * - inline: `**x**`, `*x*`, `~~x~~` and `` `x` `` mark `x`, and a typed URL
26
+ * followed by a space becomes a link when `safeHref` accepts it (always at
27
+ * the `untrusted` tier).
28
+ *
29
+ * Returns `null` when no rule applies.
30
+ */
31
+ export function applyInputRules(state, options) {
32
+ if (state.selection.kind !== 'text' || !isCollapsed(state.selection))
33
+ return null;
34
+ const { block: id, offset } = state.selection.focus;
35
+ const location = locateBlock(state.doc, id);
36
+ const block = location?.block;
37
+ if (!location || !block || (block.type !== 'paragraph' && block.type !== 'heading'))
38
+ return null;
39
+ const before = blockText(block).slice(0, offset);
40
+ if (block.type === 'paragraph' && !before.includes('\n')) {
41
+ const result = blockRule(state, block, location, before, options);
42
+ if (result)
43
+ return result;
44
+ }
45
+ return inlineRule(state, block, before, offset);
46
+ }
47
+ function blockRule(state, block, location, before, options) {
48
+ const firstInItem = location.ancestors.at(-1)?.kind === 'item' && location.index === 0;
49
+ const strip = (size) => ({
50
+ ...state,
51
+ doc: replaceBlock(state.doc, block.id, () => [
52
+ { ...block, content: sliceInline(block.content, size) },
53
+ ]),
54
+ selection: caret(block.id, 0),
55
+ });
56
+ const accepted = (result) => (result.ok ? result : null);
57
+ const heading = /^(#{1,4}) $/.exec(before);
58
+ if (heading && !firstInItem) {
59
+ const level = heading[1].length;
60
+ return accepted(setBlockType(strip(before.length), 'heading', level, options));
61
+ }
62
+ if (firstInItem)
63
+ return null;
64
+ if (/^[-*+] $/.test(before))
65
+ return accepted(toggleList(strip(before.length), 'bullet', options));
66
+ const ordered = /^(\d{1,9})[.)] $/.exec(before);
67
+ if (ordered) {
68
+ const result = toggleList(strip(before.length), 'ordered', options);
69
+ const start = Number(ordered[1]);
70
+ if (!result.ok || start === 1)
71
+ return accepted(result);
72
+ const list = locateBlock(result.state.doc, block.id)?.ancestors.at(-1);
73
+ if (list?.kind !== 'item')
74
+ return result;
75
+ const doc = replaceBlock(result.state.doc, list.list.id, (l) => [
76
+ { ...l, start },
77
+ ]);
78
+ return { ok: true, state: { ...result.state, doc } };
79
+ }
80
+ const todo = /^\[([ xX])\] $/.exec(before);
81
+ if (todo) {
82
+ const result = toggleList(strip(before.length), 'todo', options);
83
+ if (!result.ok || todo[1] === ' ')
84
+ return accepted(result);
85
+ const item = locateBlock(result.state.doc, block.id)?.ancestors.at(-1);
86
+ if (item?.kind !== 'item')
87
+ return result;
88
+ const doc = replaceItem(result.state.doc, item.item.id, (i) => [{ ...i, checked: true }]);
89
+ return { ok: true, state: { ...result.state, doc } };
90
+ }
91
+ if (before === '> ')
92
+ return accepted(toggleQuote(strip(2), options));
93
+ const fence = /^```([a-z][a-z0-9+-]{0,31})? $/i.exec(before);
94
+ if (fence) {
95
+ const stripped = strip(before.length);
96
+ const result = setBlockType(stripped, 'code', 1, options);
97
+ if (!result.ok || !fence[1])
98
+ return accepted(result);
99
+ const lang = fence[1].toLowerCase();
100
+ const doc = replaceBlock(result.state.doc, block.id, (b) => [{ ...b, lang }]);
101
+ return { ok: true, state: { ...result.state, doc } };
102
+ }
103
+ if (before === '---' && inlineText(block.content) === '---') {
104
+ const emptied = {
105
+ ...state,
106
+ doc: replaceBlock(state.doc, block.id, () => [paragraph([], block.id)]),
107
+ selection: caret(block.id, 0),
108
+ };
109
+ return accepted(insertBlock(emptied, { type: 'divider' }, options));
110
+ }
111
+ return null;
112
+ }
113
+ function inlineRule(state, block, before, offset) {
114
+ const done = (content, at) => ({
115
+ ok: true,
116
+ state: {
117
+ ...state,
118
+ doc: replaceBlock(state.doc, block.id, () => [{ ...block, content }]),
119
+ selection: caret(block.id, at),
120
+ storedMarks: null,
121
+ },
122
+ });
123
+ for (const { pattern, size, mark } of INLINE_RULES) {
124
+ const match = pattern.exec(before);
125
+ if (!match)
126
+ continue;
127
+ const inner = match[1];
128
+ const close = offset - size;
129
+ const start = close - inner.length;
130
+ const open = start - size;
131
+ const runs = textRuns(block.content, open, offset);
132
+ if (runs.some((run) => hasMark(run.marks, 'code')))
133
+ continue;
134
+ let content = spliceInline(block.content, close, offset);
135
+ content = mapMarks(content, start, close, (marks) => withMark(marks, mark));
136
+ content = spliceInline(content, open, start);
137
+ return done(content, offset - 2 * size);
138
+ }
139
+ const link = AUTOLINK.exec(before);
140
+ if (link) {
141
+ const url = link[1];
142
+ const href = safeHref(url, 'untrusted');
143
+ const end = offset - 1;
144
+ const start = end - url.length;
145
+ const runs = textRuns(block.content, start, end);
146
+ if (href && !runs.some((run) => hasMark(run.marks, 'link') || hasMark(run.marks, 'code'))) {
147
+ return done(mapMarks(block.content, start, end, (marks) => withMark(marks, { type: 'link', href })), offset);
148
+ }
149
+ }
150
+ return null;
151
+ }
@@ -0,0 +1,30 @@
1
+ import { type KeyboardEventLike } from '../keyboard/keys.js';
2
+ import { type ShortcutBinding, type ShortcutPlatform, type ShortcutStyle } from '../keyboard/shortcuts.js';
3
+ /** What a key chord asks the editor to do. The binding maps intents to transforms. */
4
+ export type EditorKeyIntent = 'toggle-strong' | 'toggle-em' | 'toggle-code' | 'toggle-strike' | 'link' | 'undo' | 'redo' | 'paragraph' | 'heading-1' | 'heading-2' | 'heading-3' | 'heading-4' | 'ordered-list' | 'bullet-list' | 'todo-list' | 'quote' | 'toggle-todo'
5
+ /** Tab: indent a list item. Outside lists the binding lets Tab move focus. */
6
+ | 'indent' | 'outdent'
7
+ /** Leave the toolbar or dialog; in the text, let the next Tab leave the editor. */
8
+ | 'escape';
9
+ /** The editor's key chords (ADR-003). `Mod` is ⌘ on Apple platforms, Ctrl elsewhere. */
10
+ export declare const EDITOR_SHORTCUTS: readonly ShortcutBinding<EditorKeyIntent>[];
11
+ export interface EditorKeymap {
12
+ readonly platform: ShortcutPlatform;
13
+ /** The intent of a keydown, or `null`. Composing events never match. */
14
+ match(event: KeyboardEventLike): EditorKeyIntent | null;
15
+ /** Value for `aria-keyshortcuts` on the control that runs `intent`. */
16
+ ariaKeyShortcuts(intent: EditorKeyIntent): string;
17
+ /** The chords of `intent`, formatted for tooltips and help text. */
18
+ describe(intent: EditorKeyIntent, style?: ShortcutStyle): string[];
19
+ }
20
+ /** The editor keymap for one platform (see `detectPlatform`). */
21
+ export declare function createEditorKeymap(platform: ShortcutPlatform): EditorKeymap;
22
+ /** What a `beforeinput` event asks for. */
23
+ export type EditorInputIntent = 'insert-text' | 'split-block' | 'insert-break' | 'delete-backward' | 'delete-forward' | 'delete-word-backward' | 'delete-word-forward' | 'delete-line-backward' | 'delete-line-forward' | 'delete-selection' | 'paste' | 'drop' | 'replace-text' | 'undo' | 'redo' | 'toggle-strong' | 'toggle-em' | 'toggle-strike'
24
+ /** Leave the DOM alone; apply the change at `compositionend`. */
25
+ | 'composition';
26
+ /**
27
+ * The intent of a `beforeinput` `inputType`, or `null` for types the editor
28
+ * refuses (formatting it does not support, such as `formatUnderline`).
29
+ */
30
+ export declare const intentFromInputType: (inputType: string) => EditorInputIntent | null;
@@ -0,0 +1,69 @@
1
+ import { isComposing } from "../keyboard/keys.js";
2
+ import { createShortcutMap, } from "../keyboard/shortcuts.js";
3
+ /** The editor's key chords (ADR-003). `Mod` is ⌘ on Apple platforms, Ctrl elsewhere. */
4
+ export const EDITOR_SHORTCUTS = [
5
+ { id: 'toggle-strong', keys: 'Mod+B' },
6
+ { id: 'toggle-em', keys: 'Mod+I' },
7
+ { id: 'toggle-code', keys: 'Mod+E' },
8
+ { id: 'toggle-strike', keys: 'Mod+Shift+X' },
9
+ { id: 'link', keys: 'Mod+K' },
10
+ { id: 'undo', keys: 'Mod+Z' },
11
+ { id: 'redo', keys: ['Mod+Shift+Z', 'Mod+Y'] },
12
+ { id: 'paragraph', keys: 'Mod+Alt+0' },
13
+ { id: 'heading-1', keys: 'Mod+Alt+1' },
14
+ { id: 'heading-2', keys: 'Mod+Alt+2' },
15
+ { id: 'heading-3', keys: 'Mod+Alt+3' },
16
+ { id: 'heading-4', keys: 'Mod+Alt+4' },
17
+ { id: 'ordered-list', keys: 'Mod+Shift+7' },
18
+ { id: 'bullet-list', keys: 'Mod+Shift+8' },
19
+ { id: 'todo-list', keys: 'Mod+Shift+9' },
20
+ { id: 'quote', keys: 'Mod+Shift+B' },
21
+ { id: 'toggle-todo', keys: 'Mod+Enter' },
22
+ { id: 'indent', keys: 'Tab' },
23
+ { id: 'outdent', keys: 'Shift+Tab' },
24
+ { id: 'escape', keys: 'Escape' },
25
+ ];
26
+ /** The editor keymap for one platform (see `detectPlatform`). */
27
+ export function createEditorKeymap(platform) {
28
+ const map = createShortcutMap(EDITOR_SHORTCUTS, platform);
29
+ return {
30
+ platform,
31
+ match: (event) => (isComposing(event) ? null : map.match(event)),
32
+ ariaKeyShortcuts: (intent) => map.ariaKeyShortcuts(intent),
33
+ describe: (intent, style) => map.describe(intent, style),
34
+ };
35
+ }
36
+ const INPUT_TYPES = {
37
+ insertText: 'insert-text',
38
+ insertParagraph: 'split-block',
39
+ insertLineBreak: 'insert-break',
40
+ deleteContentBackward: 'delete-backward',
41
+ deleteContentForward: 'delete-forward',
42
+ deleteWordBackward: 'delete-word-backward',
43
+ deleteWordForward: 'delete-word-forward',
44
+ deleteSoftLineBackward: 'delete-line-backward',
45
+ deleteHardLineBackward: 'delete-line-backward',
46
+ deleteSoftLineForward: 'delete-line-forward',
47
+ deleteHardLineForward: 'delete-line-forward',
48
+ deleteContent: 'delete-selection',
49
+ deleteByCut: 'delete-selection',
50
+ deleteByDrag: 'delete-selection',
51
+ insertFromPaste: 'paste',
52
+ insertFromPasteAsQuotation: 'paste',
53
+ insertFromDrop: 'drop',
54
+ insertReplacementText: 'replace-text',
55
+ insertFromYank: 'insert-text',
56
+ historyUndo: 'undo',
57
+ historyRedo: 'redo',
58
+ formatBold: 'toggle-strong',
59
+ formatItalic: 'toggle-em',
60
+ formatStrikeThrough: 'toggle-strike',
61
+ insertCompositionText: 'composition',
62
+ deleteCompositionText: 'composition',
63
+ insertFromComposition: 'composition',
64
+ };
65
+ /**
66
+ * The intent of a `beforeinput` `inputType`, or `null` for types the editor
67
+ * refuses (formatting it does not support, such as `formatUnderline`).
68
+ */
69
+ export const intentFromInputType = (inputType) => Object.hasOwn(INPUT_TYPES, inputType) ? INPUT_TYPES[inputType] : null;