@tarhnama/core 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/dist/code-tokens.d.ts +39 -0
- package/dist/code-tokens.d.ts.map +1 -0
- package/dist/code-tokens.js +189 -0
- package/dist/code-tokens.js.map +1 -0
- package/dist/context-menu.d.ts +59 -0
- package/dist/context-menu.d.ts.map +1 -0
- package/dist/context-menu.js +69 -0
- package/dist/context-menu.js.map +1 -0
- package/dist/features.d.ts +114 -0
- package/dist/features.d.ts.map +1 -0
- package/dist/features.js +128 -0
- package/dist/features.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/nested.d.ts +66 -0
- package/dist/nested.d.ts.map +1 -0
- package/dist/nested.js +194 -0
- package/dist/nested.js.map +1 -0
- package/dist/ops.d.ts +120 -0
- package/dist/ops.d.ts.map +1 -0
- package/dist/ops.js +333 -0
- package/dist/ops.js.map +1 -0
- package/dist/shortcuts.d.ts +90 -0
- package/dist/shortcuts.d.ts.map +1 -0
- package/dist/shortcuts.js +179 -0
- package/dist/shortcuts.js.map +1 -0
- package/dist/spine.d.ts +92 -0
- package/dist/spine.d.ts.map +1 -0
- package/dist/spine.js +213 -0
- package/dist/spine.js.map +1 -0
- package/dist/styles.d.ts +63 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +169 -0
- package/dist/styles.js.map +1 -0
- package/dist/tree.d.ts +70 -0
- package/dist/tree.d.ts.map +1 -0
- package/dist/tree.js +190 -0
- package/dist/tree.js.map +1 -0
- package/dist/types.d.ts +239 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +47 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -0
- package/src/code-tokens.ts +236 -0
- package/src/context-menu.ts +139 -0
- package/src/features.ts +173 -0
- package/src/index.ts +58 -0
- package/src/nested.ts +271 -0
- package/src/ops.ts +341 -0
- package/src/shortcuts.ts +280 -0
- package/src/spine.ts +214 -0
- package/src/styles.ts +201 -0
- package/src/tree.ts +189 -0
- package/src/types.ts +275 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The colouring 1a draws in a code block — keywords in the accent, strings in green —
|
|
3
|
+
* as a pure function over text, so the editor's decorations and both readers colour the
|
|
4
|
+
* same characters.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately small. It is not a parser and does not try to be: a full grammar per
|
|
7
|
+
* language is a dependency this package must not force on a host, and a host that wants
|
|
8
|
+
* one passes its own tokenizer with the same signature. What it does, it does exactly —
|
|
9
|
+
* whole words only, escapes honoured, comment markers inside strings left alone — because
|
|
10
|
+
* a highlighter that is right most of the time teaches the reader to distrust the colours.
|
|
11
|
+
*
|
|
12
|
+
* Numbers and identifiers are not tokens. The prototype colours neither consistently
|
|
13
|
+
* (`editor` is accent once in 1a and plain on the next line; `const` is plain in 2a-06),
|
|
14
|
+
* so there is no rule to measure — see docs/DESIGN-REQUESTS.md.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type CodeTokenKind = 'keyword' | 'string' | 'comment';
|
|
18
|
+
|
|
19
|
+
export interface CodeToken {
|
|
20
|
+
/** Offset of the first character, in UTF-16 code units of the block's text. */
|
|
21
|
+
readonly from: number;
|
|
22
|
+
/** Offset after the last character. */
|
|
23
|
+
readonly to: number;
|
|
24
|
+
readonly kind: CodeTokenKind;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A host-supplied replacement for `tokenizeCode`, e.g. one backed by a real grammar. */
|
|
28
|
+
export type CodeTokenizer = (code: string, lang: string | undefined) => readonly CodeToken[];
|
|
29
|
+
|
|
30
|
+
interface Grammar {
|
|
31
|
+
readonly keywords: ReadonlySet<string>;
|
|
32
|
+
readonly lineComment: '//' | '#' | null;
|
|
33
|
+
readonly blockComment: boolean;
|
|
34
|
+
/** Quote characters that may run past the end of their line. */
|
|
35
|
+
readonly multilineQuotes: string;
|
|
36
|
+
readonly quotes: string;
|
|
37
|
+
/** Shell: inside `'…'` a backslash is an ordinary character. */
|
|
38
|
+
readonly rawQuotes: string;
|
|
39
|
+
readonly tripleQuotes: boolean;
|
|
40
|
+
/** Shell: `#` opens a comment only at the start of a word (`$#` is a variable). */
|
|
41
|
+
readonly hashNeedsBoundary: boolean;
|
|
42
|
+
/** JavaScript: `x.new` is a property, not the keyword. */
|
|
43
|
+
readonly memberAccessIsNotKeyword: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const words = (list: string) => new Set(list.split(/\s+/).filter(Boolean));
|
|
47
|
+
|
|
48
|
+
const C_LIKE: Omit<Grammar, 'keywords'> = {
|
|
49
|
+
lineComment: '//',
|
|
50
|
+
blockComment: true,
|
|
51
|
+
quotes: `'"\``,
|
|
52
|
+
rawQuotes: '',
|
|
53
|
+
multilineQuotes: '`',
|
|
54
|
+
tripleQuotes: false,
|
|
55
|
+
hashNeedsBoundary: false,
|
|
56
|
+
memberAccessIsNotKeyword: true,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/*
|
|
60
|
+
* Contextual words that are routinely identifiers (`type`, `from`, `of`, `as`, `get`, `set`)
|
|
61
|
+
* are left out: colouring `const type = …` as a keyword is wrong far more often than
|
|
62
|
+
* leaving `import x from` plain is.
|
|
63
|
+
*/
|
|
64
|
+
const JS = {
|
|
65
|
+
...C_LIKE,
|
|
66
|
+
keywords: words(`
|
|
67
|
+
abstract async await break case catch class const continue debugger declare default delete
|
|
68
|
+
do else enum export extends false finally for function if implements import in instanceof
|
|
69
|
+
interface keyof let namespace new null private protected public readonly return satisfies
|
|
70
|
+
static super switch this throw true try typeof undefined var void while with yield
|
|
71
|
+
`),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const JSON_GRAMMAR: Grammar = {
|
|
75
|
+
keywords: words('true false null'),
|
|
76
|
+
lineComment: null,
|
|
77
|
+
blockComment: false,
|
|
78
|
+
quotes: '"',
|
|
79
|
+
rawQuotes: '',
|
|
80
|
+
multilineQuotes: '',
|
|
81
|
+
tripleQuotes: false,
|
|
82
|
+
hashNeedsBoundary: false,
|
|
83
|
+
memberAccessIsNotKeyword: false,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const PYTHON: Grammar = {
|
|
87
|
+
keywords: words(`
|
|
88
|
+
False None True and as assert async await break class continue def del elif else except
|
|
89
|
+
finally for from global if import in is lambda nonlocal not or pass raise return try while
|
|
90
|
+
with yield
|
|
91
|
+
`),
|
|
92
|
+
lineComment: '#',
|
|
93
|
+
blockComment: false,
|
|
94
|
+
quotes: `'"`,
|
|
95
|
+
rawQuotes: '',
|
|
96
|
+
multilineQuotes: '',
|
|
97
|
+
tripleQuotes: true,
|
|
98
|
+
hashNeedsBoundary: false,
|
|
99
|
+
memberAccessIsNotKeyword: true,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const SHELL: Grammar = {
|
|
103
|
+
keywords: words('if then else elif fi case esac for while until do done in function return exit local export select'),
|
|
104
|
+
lineComment: '#',
|
|
105
|
+
blockComment: false,
|
|
106
|
+
quotes: `'"`,
|
|
107
|
+
rawQuotes: "'",
|
|
108
|
+
multilineQuotes: `'"`,
|
|
109
|
+
tripleQuotes: false,
|
|
110
|
+
hashNeedsBoundary: true,
|
|
111
|
+
memberAccessIsNotKeyword: false,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const GRAMMARS: Readonly<Record<string, Grammar>> = {
|
|
115
|
+
js: JS, jsx: JS, mjs: JS, cjs: JS, javascript: JS,
|
|
116
|
+
ts: JS, tsx: JS, mts: JS, cts: JS, typescript: JS,
|
|
117
|
+
json: JSON_GRAMMAR, jsonc: { ...JSON_GRAMMAR, lineComment: '//', blockComment: true },
|
|
118
|
+
py: PYTHON, python: PYTHON, python3: PYTHON,
|
|
119
|
+
sh: SHELL, bash: SHELL, zsh: SHELL, shell: SHELL, console: SHELL,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const WORD_START = /[\p{L}_$]/u;
|
|
123
|
+
const WORD_PART = /[\p{L}\p{N}_$]/u;
|
|
124
|
+
|
|
125
|
+
export function tokenizeCode(code: string, lang: string | undefined): CodeToken[] {
|
|
126
|
+
const grammar = lang ? GRAMMARS[lang.trim().toLowerCase()] : undefined;
|
|
127
|
+
if (!grammar) return [];
|
|
128
|
+
|
|
129
|
+
const tokens: CodeToken[] = [];
|
|
130
|
+
const length = code.length;
|
|
131
|
+
let i = 0;
|
|
132
|
+
|
|
133
|
+
while (i < length) {
|
|
134
|
+
const ch = code[i]!;
|
|
135
|
+
|
|
136
|
+
if (grammar.lineComment && code.startsWith(grammar.lineComment, i)) {
|
|
137
|
+
const atBoundary = i === 0 || /\s/.test(code[i - 1]!);
|
|
138
|
+
if (!grammar.hashNeedsBoundary || atBoundary) {
|
|
139
|
+
const end = lineEnd(code, i);
|
|
140
|
+
tokens.push({ from: i, to: end, kind: 'comment' });
|
|
141
|
+
i = end;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (grammar.blockComment && code.startsWith('/*', i)) {
|
|
147
|
+
const close = code.indexOf('*/', i + 2);
|
|
148
|
+
const end = close === -1 ? length : close + 2;
|
|
149
|
+
tokens.push({ from: i, to: end, kind: 'comment' });
|
|
150
|
+
i = end;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (grammar.quotes.includes(ch)) {
|
|
155
|
+
const end = grammar.tripleQuotes && code.startsWith(ch.repeat(3), i)
|
|
156
|
+
? tripleQuoteEnd(code, i, ch)
|
|
157
|
+
: quoteEnd(code, i, ch, grammar.multilineQuotes.includes(ch), !grammar.rawQuotes.includes(ch));
|
|
158
|
+
tokens.push({ from: i, to: end, kind: 'string' });
|
|
159
|
+
i = end;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (WORD_START.test(ch)) {
|
|
164
|
+
let end = i + 1;
|
|
165
|
+
while (end < length && WORD_PART.test(code[end]!)) end += 1;
|
|
166
|
+
const word = code.slice(i, end);
|
|
167
|
+
const member = grammar.memberAccessIsNotKeyword && i > 0 && code[i - 1] === '.';
|
|
168
|
+
if (!member && grammar.keywords.has(word)) tokens.push({ from: i, to: end, kind: 'keyword' });
|
|
169
|
+
i = end;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// A digit run is skipped whole, so `1e5` or `0x1f` never starts a word in its middle.
|
|
174
|
+
if (/\p{N}/u.test(ch)) {
|
|
175
|
+
let end = i + 1;
|
|
176
|
+
while (end < length && WORD_PART.test(code[end]!)) end += 1;
|
|
177
|
+
i = end;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
i += 1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return tokens;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface CodeSegment {
|
|
188
|
+
readonly text: string;
|
|
189
|
+
/** Absent for the text between tokens. */
|
|
190
|
+
readonly kind?: CodeTokenKind;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The text cut into runs, each token its own. Every renderer goes through this, so the
|
|
195
|
+
* editor and both readers colour the same characters — including when a HOST tokenizer
|
|
196
|
+
* returns something malformed: a token that is empty, out of range or overlaps the one
|
|
197
|
+
* before is skipped, and the text itself always comes back whole.
|
|
198
|
+
*/
|
|
199
|
+
export function codeSegments(code: string, tokens: readonly CodeToken[]): CodeSegment[] {
|
|
200
|
+
const segments: CodeSegment[] = [];
|
|
201
|
+
let at = 0;
|
|
202
|
+
for (const token of tokens) {
|
|
203
|
+
if (token.from < at || token.to <= token.from || token.to > code.length) continue;
|
|
204
|
+
if (token.from > at) segments.push({ text: code.slice(at, token.from) });
|
|
205
|
+
segments.push({ text: code.slice(token.from, token.to), kind: token.kind });
|
|
206
|
+
at = token.to;
|
|
207
|
+
}
|
|
208
|
+
if (at < code.length) segments.push({ text: code.slice(at) });
|
|
209
|
+
return segments;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function lineEnd(code: string, from: number): number {
|
|
213
|
+
const newline = code.indexOf('\n', from);
|
|
214
|
+
return newline === -1 ? code.length : newline;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** After the closing quote; an unterminated single-line quote stops at the end of its line. */
|
|
218
|
+
function quoteEnd(code: string, from: number, quote: string, multiline: boolean, escapes: boolean): number {
|
|
219
|
+
let i = from + 1;
|
|
220
|
+
while (i < code.length) {
|
|
221
|
+
const ch = code[i]!;
|
|
222
|
+
if (escapes && ch === '\\') {
|
|
223
|
+
i += 2;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (ch === quote) return i + 1;
|
|
227
|
+
if (ch === '\n' && !multiline) return i;
|
|
228
|
+
i += 1;
|
|
229
|
+
}
|
|
230
|
+
return code.length;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function tripleQuoteEnd(code: string, from: number, quote: string): number {
|
|
234
|
+
const close = code.indexOf(quote.repeat(3), from + 3);
|
|
235
|
+
return close === -1 ? code.length : close + 3;
|
|
236
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { OutlineFeatureName, OutlineFeatures } from './features.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a right-click on the document offers.
|
|
5
|
+
*
|
|
6
|
+
* The spine's handle already has a node menu. The TEXT does not, so right-clicking a
|
|
7
|
+
* paragraph gets the browser's own menu — spell-check and "view source", nothing about the
|
|
8
|
+
* document you are writing.
|
|
9
|
+
*
|
|
10
|
+
* Computed as DATA, like the shortcut registry and for the same reasons: a host can render
|
|
11
|
+
* it in their own menu component, translate it through their own label pack, filter it, or
|
|
12
|
+
* ignore it entirely. Nothing here holds a closure or touches the DOM.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type ContextCommand =
|
|
16
|
+
| 'cut'
|
|
17
|
+
| 'copy'
|
|
18
|
+
| 'paste'
|
|
19
|
+
| 'undo'
|
|
20
|
+
| 'redo'
|
|
21
|
+
| 'indent'
|
|
22
|
+
| 'outdent'
|
|
23
|
+
| 'moveUp'
|
|
24
|
+
| 'moveDown'
|
|
25
|
+
| 'toggleCollapse'
|
|
26
|
+
| 'isolate'
|
|
27
|
+
| 'focus'
|
|
28
|
+
| 'toggleLock';
|
|
29
|
+
|
|
30
|
+
/** What the host knows about the place that was right-clicked. */
|
|
31
|
+
export interface ContextTarget {
|
|
32
|
+
readonly blockId: string;
|
|
33
|
+
readonly level: number;
|
|
34
|
+
readonly hasSelection: boolean;
|
|
35
|
+
readonly locked: boolean;
|
|
36
|
+
readonly collapsed: boolean;
|
|
37
|
+
/** Whether this heading governs anything — a fold with nothing under it does nothing. */
|
|
38
|
+
readonly governsSubtree: boolean;
|
|
39
|
+
readonly canUndo: boolean;
|
|
40
|
+
readonly canRedo: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Whether a sibling exists to move past.
|
|
43
|
+
*
|
|
44
|
+
* Part of the target rather than derived here, for the same reason as the undo stack:
|
|
45
|
+
* this function is handed one block, and "is there a previous sibling" is a question only
|
|
46
|
+
* the whole document can answer. Getting it wrong is not cosmetic — "move down" was live
|
|
47
|
+
* on a heading with nowhere to go, and choosing it did nothing.
|
|
48
|
+
*/
|
|
49
|
+
readonly canMoveUp: boolean;
|
|
50
|
+
readonly canMoveDown: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ContextMenuItem {
|
|
54
|
+
readonly command: ContextCommand;
|
|
55
|
+
/** English; a host translates through their own label pack. */
|
|
56
|
+
readonly label: string;
|
|
57
|
+
readonly enabled: boolean;
|
|
58
|
+
/** A rule drawn before this item. Named here so a renderer does not invent grouping. */
|
|
59
|
+
readonly separator?: boolean;
|
|
60
|
+
readonly feature?: OutlineFeatureName;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the menu for one right-click.
|
|
65
|
+
*
|
|
66
|
+
* Two rules, and the difference between them is the whole design:
|
|
67
|
+
*
|
|
68
|
+
* **A command that cannot act right now is DIMMED.** The menu has the same shape every
|
|
69
|
+
* time it opens, so its geometry can be learned and the third item is always the third
|
|
70
|
+
* item. This is the rule the toolbars follow.
|
|
71
|
+
*
|
|
72
|
+
* **A command whose FEATURE is off is REMOVED.** Dimming says "not now", which is a
|
|
73
|
+
* promise. A feature the host switched off is never coming back, and leaving it there
|
|
74
|
+
* spends the reader's attention on it at every opening, forever.
|
|
75
|
+
*/
|
|
76
|
+
export function contextMenuFor(
|
|
77
|
+
target: ContextTarget,
|
|
78
|
+
features: OutlineFeatures,
|
|
79
|
+
): readonly ContextMenuItem[] {
|
|
80
|
+
// A lock stops EDITING, never reading — so copy survives it and the mutating pair does not.
|
|
81
|
+
const editable = !target.locked;
|
|
82
|
+
|
|
83
|
+
const all: ContextMenuItem[] = [
|
|
84
|
+
{
|
|
85
|
+
command: 'cut',
|
|
86
|
+
label: 'Cut',
|
|
87
|
+
enabled: target.hasSelection && editable,
|
|
88
|
+
feature: 'clipboard',
|
|
89
|
+
},
|
|
90
|
+
{ command: 'copy', label: 'Copy', enabled: target.hasSelection, feature: 'clipboard' },
|
|
91
|
+
{ command: 'paste', label: 'Paste', enabled: editable, feature: 'clipboard' },
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
command: 'undo',
|
|
95
|
+
label: 'Undo',
|
|
96
|
+
enabled: target.canUndo,
|
|
97
|
+
separator: true,
|
|
98
|
+
feature: 'history',
|
|
99
|
+
},
|
|
100
|
+
{ command: 'redo', label: 'Redo', enabled: target.canRedo, feature: 'history' },
|
|
101
|
+
|
|
102
|
+
/*
|
|
103
|
+
* Structural commands carry no feature: indent and outdent ARE the outline, not a
|
|
104
|
+
* feature of it, and an editor without them is a different product rather than this
|
|
105
|
+
* one configured down.
|
|
106
|
+
*/
|
|
107
|
+
{ command: 'indent', label: 'Indent', enabled: editable && target.level > 0, separator: true },
|
|
108
|
+
{ command: 'outdent', label: 'Outdent', enabled: editable && target.level > 1 },
|
|
109
|
+
{ command: 'moveUp', label: 'Move up', enabled: editable && target.canMoveUp },
|
|
110
|
+
{ command: 'moveDown', label: 'Move down', enabled: editable && target.canMoveDown },
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
command: 'toggleCollapse',
|
|
114
|
+
label: target.collapsed ? 'Expand' : 'Collapse',
|
|
115
|
+
// A heading that governs nothing has nothing to fold — the spine's eighth state,
|
|
116
|
+
// in menu form.
|
|
117
|
+
enabled: target.governsSubtree,
|
|
118
|
+
separator: true,
|
|
119
|
+
feature: 'collapse',
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
command: 'isolate',
|
|
123
|
+
label: 'Keep only this subtree open',
|
|
124
|
+
enabled: target.governsSubtree,
|
|
125
|
+
feature: 'collapse',
|
|
126
|
+
},
|
|
127
|
+
{ command: 'focus', label: 'Focus this subtree', enabled: target.governsSubtree },
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
command: 'toggleLock',
|
|
131
|
+
label: target.locked ? 'Unlock' : 'Lock',
|
|
132
|
+
enabled: true,
|
|
133
|
+
separator: true,
|
|
134
|
+
feature: 'lock',
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
return all.filter((item) => item.feature === undefined || features[item.feature]);
|
|
139
|
+
}
|
package/src/features.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a host turns on.
|
|
3
|
+
*
|
|
4
|
+
* This editor is embedded in projects that want very different subsets of it: a note pane
|
|
5
|
+
* that needs no cross-document links, a review surface that needs no formatting, a viewer
|
|
6
|
+
* that needs no editing at all. Every one of those is a supported configuration rather
|
|
7
|
+
* than a fork.
|
|
8
|
+
*
|
|
9
|
+
* ── The rule that shapes this whole file ───────────────────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* **Features gate BEHAVIOUR, never the schema.**
|
|
12
|
+
*
|
|
13
|
+
* It is tempting to make `links: false` remove the link mark from the document schema —
|
|
14
|
+
* it would be smaller, and it would tree-shake. It is also data loss: ProseMirror strips
|
|
15
|
+
* any mark its schema does not know, so a host with links disabled that opens a document
|
|
16
|
+
* containing links **destroys them on load**, silently, and writes the damaged version
|
|
17
|
+
* back on the next save. A document must round-trip through every configuration
|
|
18
|
+
* unchanged.
|
|
19
|
+
*
|
|
20
|
+
* So the schema is always complete, and a disabled feature removes its input rules, its
|
|
21
|
+
* keymap entries, its commands, its decorations and its UI. Bundle size is addressed the
|
|
22
|
+
* honest way instead — subpath exports, so a host that never imports the link picker
|
|
23
|
+
* never ships it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Every switchable behaviour. Deliberately flat and small; this is a public contract. */
|
|
27
|
+
export interface OutlineFeatures {
|
|
28
|
+
/**
|
|
29
|
+
* `[[` autocomplete, anchor commands, link input rules, reference previews.
|
|
30
|
+
* Existing link marks still render and still round-trip when this is off.
|
|
31
|
+
*/
|
|
32
|
+
readonly links: boolean;
|
|
33
|
+
/** Bold/italic/colour/highlight commands and the formatting keymap (⌘B and friends). */
|
|
34
|
+
readonly formatting: boolean;
|
|
35
|
+
/** Collapse state, the spine, the hidden-count decorations, ⌘. and the ladder. */
|
|
36
|
+
readonly collapse: boolean;
|
|
37
|
+
/** Read-only subtrees, the lock guard, and the padlock affordance. */
|
|
38
|
+
readonly lock: boolean;
|
|
39
|
+
/** Undo/redo. Turn OFF when adopting Yjs: two undo managers fight (see CLAUDE.md §4). */
|
|
40
|
+
readonly history: boolean;
|
|
41
|
+
/** Table editing commands. Existing tables still render either way. */
|
|
42
|
+
readonly tables: boolean;
|
|
43
|
+
/** Image insertion. Existing images still render either way. */
|
|
44
|
+
readonly images: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Outline-aware copy: the host's serializer decides the `text/plain` flavour.
|
|
47
|
+
* Off leaves ProseMirror's default, which loses every level.
|
|
48
|
+
*/
|
|
49
|
+
readonly clipboard: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type OutlineFeatureName = keyof OutlineFeatures;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The default: everything.
|
|
56
|
+
*
|
|
57
|
+
* A host that has not thought about features yet should get the whole editor, not an empty
|
|
58
|
+
* shell to debug. "Off unless named" reads as safer and is worse — the failure it produces
|
|
59
|
+
* is a missing feature with no error, which is the hardest kind to diagnose.
|
|
60
|
+
*/
|
|
61
|
+
export const ALL_FEATURES: OutlineFeatures = {
|
|
62
|
+
links: true,
|
|
63
|
+
formatting: true,
|
|
64
|
+
collapse: true,
|
|
65
|
+
lock: true,
|
|
66
|
+
history: true,
|
|
67
|
+
tables: true,
|
|
68
|
+
images: true,
|
|
69
|
+
clipboard: true,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Nothing but text. Useful as a base to switch individual features back on. */
|
|
73
|
+
export const NO_FEATURES: OutlineFeatures = {
|
|
74
|
+
links: false,
|
|
75
|
+
formatting: false,
|
|
76
|
+
collapse: false,
|
|
77
|
+
lock: false,
|
|
78
|
+
history: false,
|
|
79
|
+
tables: false,
|
|
80
|
+
images: false,
|
|
81
|
+
clipboard: false,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const NAMES = Object.keys(ALL_FEATURES) as readonly OutlineFeatureName[];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Normalize whatever the host passed into a complete record.
|
|
88
|
+
*
|
|
89
|
+
* Three input shapes, because hosts reach for different ones and all three are reasonable:
|
|
90
|
+
*
|
|
91
|
+
* undefined → everything (the default)
|
|
92
|
+
* { links: false } → everything except links (a partial override)
|
|
93
|
+
* ['collapse', 'formatting'] → ONLY those two (an allowlist)
|
|
94
|
+
*
|
|
95
|
+
* The array form flips the default deliberately: writing a list reads as "these are the
|
|
96
|
+
* features I want", and having it mean "these, plus the six I did not mention" would be a
|
|
97
|
+
* trap. The object form keeps the opposite reading, which is why both exist.
|
|
98
|
+
*
|
|
99
|
+
* An unknown name in the array throws rather than being ignored — a typo'd feature that
|
|
100
|
+
* silently does nothing is the exact failure this API exists to prevent.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveFeatures(
|
|
103
|
+
input?: Partial<OutlineFeatures> | readonly OutlineFeatureName[] | undefined,
|
|
104
|
+
): OutlineFeatures {
|
|
105
|
+
if (input === undefined) return ALL_FEATURES;
|
|
106
|
+
|
|
107
|
+
if (Array.isArray(input)) {
|
|
108
|
+
const wanted = new Set(input as readonly OutlineFeatureName[]);
|
|
109
|
+
for (const name of wanted) {
|
|
110
|
+
if (!NAMES.includes(name)) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Unknown outline feature ${JSON.stringify(name)}. Known: ${NAMES.join(', ')}.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const out = { ...NO_FEATURES } as Record<OutlineFeatureName, boolean>;
|
|
117
|
+
for (const name of wanted) out[name] = true;
|
|
118
|
+
return out as OutlineFeatures;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { ...ALL_FEATURES, ...(input as Partial<OutlineFeatures>) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* How the document is being used.
|
|
126
|
+
*
|
|
127
|
+
* A separate axis from features, not another flag among them: `read` is not "formatting
|
|
128
|
+
* off plus links off", it is a different contract — nothing may mutate the document, and
|
|
129
|
+
* the machinery that exists to mutate it does not need to be constructed at all.
|
|
130
|
+
*/
|
|
131
|
+
export type OutlineMode = 'edit' | 'read';
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The features a mode can actually support.
|
|
135
|
+
*
|
|
136
|
+
* Read mode keeps `collapse` and `links`, and that distinction is the point: **a lock stops
|
|
137
|
+
* editing, never reading.** A reader who cannot fold a section or follow a reference has
|
|
138
|
+
* been given a screenshot, not a document. Everything that mutates content is off, and no
|
|
139
|
+
* host flag can turn it back on — an editing command reachable in read mode is a bug, not
|
|
140
|
+
* a configuration.
|
|
141
|
+
*/
|
|
142
|
+
export function featuresForMode(features: OutlineFeatures, mode: OutlineMode): OutlineFeatures {
|
|
143
|
+
if (mode === 'edit') return features;
|
|
144
|
+
return {
|
|
145
|
+
...features,
|
|
146
|
+
formatting: false,
|
|
147
|
+
history: false,
|
|
148
|
+
tables: false,
|
|
149
|
+
images: false,
|
|
150
|
+
lock: false,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A stable string for a resolved feature set.
|
|
156
|
+
*
|
|
157
|
+
* Exists because of how React memoization actually gets used. The natural way for a host to
|
|
158
|
+
* configure this editor is an inline literal:
|
|
159
|
+
*
|
|
160
|
+
* <OutlineEditor features={{ links: hasLinks }} />
|
|
161
|
+
*
|
|
162
|
+
* That object has a new identity on every render. Memoizing the extension list on it
|
|
163
|
+
* rebuilds the Tiptap editor on every render — the document is torn down and recreated
|
|
164
|
+
* between keystrokes, and the symptom is not an error but an editor that never settles:
|
|
165
|
+
* the spine's handles move continuously and a click can never land.
|
|
166
|
+
*
|
|
167
|
+
* An API that is only correct when the caller remembers to `useMemo` is a bad API, so the
|
|
168
|
+
* comparison is by CONTENT here rather than by reference. The key is ordered by
|
|
169
|
+
* `ALL_FEATURES` so two equal sets always produce the same string.
|
|
170
|
+
*/
|
|
171
|
+
export function featureKey(features: OutlineFeatures): string {
|
|
172
|
+
return NAMES.filter((name) => features[name]).join(',');
|
|
173
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './tree.js';
|
|
3
|
+
export * from './ops.js';
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
clearDirectFormat,
|
|
7
|
+
effectiveFormat,
|
|
8
|
+
effectiveRunStyle,
|
|
9
|
+
redefineStyle,
|
|
10
|
+
styleChain,
|
|
11
|
+
styleOutlineLevel,
|
|
12
|
+
styleUsage,
|
|
13
|
+
} from './styles.js';
|
|
14
|
+
export type { StyleUsage } from './styles.js';
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
collapseSiblings,
|
|
18
|
+
currentOpenLevel,
|
|
19
|
+
deepestParentLevel,
|
|
20
|
+
isHidden,
|
|
21
|
+
isolateSubtree,
|
|
22
|
+
openToFit,
|
|
23
|
+
openToLevel,
|
|
24
|
+
subtreeSize,
|
|
25
|
+
} from './spine.js';
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
ALL_FEATURES,
|
|
29
|
+
NO_FEATURES,
|
|
30
|
+
featureKey,
|
|
31
|
+
featuresForMode,
|
|
32
|
+
resolveFeatures,
|
|
33
|
+
} from './features.js';
|
|
34
|
+
export type { OutlineFeatureName, OutlineFeatures, OutlineMode } from './features.js';
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
DEFAULT_SHORTCUTS,
|
|
38
|
+
formatShortcut,
|
|
39
|
+
resolveShortcuts,
|
|
40
|
+
shortcutsFor,
|
|
41
|
+
} from './shortcuts.js';
|
|
42
|
+
export type {
|
|
43
|
+
ShortcutBinding,
|
|
44
|
+
ShortcutCommand,
|
|
45
|
+
ShortcutGroup,
|
|
46
|
+
ShortcutOptions,
|
|
47
|
+
ShortcutPlatform,
|
|
48
|
+
} from './shortcuts.js';
|
|
49
|
+
|
|
50
|
+
export { skippedLevelAt } from './ops.js';
|
|
51
|
+
export { contextMenuFor } from './context-menu.js';
|
|
52
|
+
export type { ContextCommand, ContextMenuItem, ContextTarget } from './context-menu.js';
|
|
53
|
+
|
|
54
|
+
export { blocksToNested, nestedToBlocks } from './nested.js';
|
|
55
|
+
export type { NestedNode, NestedOptions, NestedToBlocksResult } from './nested.js';
|
|
56
|
+
|
|
57
|
+
export { codeSegments, tokenizeCode } from './code-tokens.js';
|
|
58
|
+
export type { CodeSegment, CodeToken, CodeTokenKind, CodeTokenizer } from './code-tokens.js';
|