@medway-ui/notes-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/README.md +127 -0
- package/dist/document/normalize.d.ts +61 -0
- package/dist/document/normalize.d.ts.map +1 -0
- package/dist/document/normalize.js +373 -0
- package/dist/document/normalize.js.map +1 -0
- package/dist/document/types.d.ts +47 -0
- package/dist/document/types.d.ts.map +1 -0
- package/dist/document/types.js +9 -0
- package/dist/document/types.js.map +1 -0
- package/dist/editor/handle.d.ts +28 -0
- package/dist/editor/handle.d.ts.map +1 -0
- package/dist/editor/handle.js +34 -0
- package/dist/editor/handle.js.map +1 -0
- package/dist/editor/slashCommands.d.ts +21 -0
- package/dist/editor/slashCommands.d.ts.map +1 -0
- package/dist/editor/slashCommands.js +98 -0
- package/dist/editor/slashCommands.js.map +1 -0
- package/dist/editor/vocabulary.d.ts +110 -0
- package/dist/editor/vocabulary.d.ts.map +1 -0
- package/dist/editor/vocabulary.js +11 -0
- package/dist/editor/vocabulary.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/schema/contract.d.ts +14 -0
- package/dist/schema/contract.d.ts.map +1 -0
- package/dist/schema/contract.js +51 -0
- package/dist/schema/contract.js.map +1 -0
- package/dist/schema/extensions.d.ts +23 -0
- package/dist/schema/extensions.d.ts.map +1 -0
- package/dist/schema/extensions.js +81 -0
- package/dist/schema/extensions.js.map +1 -0
- package/dist/schema/image.d.ts +14 -0
- package/dist/schema/image.d.ts.map +1 -0
- package/dist/schema/image.js +28 -0
- package/dist/schema/image.js.map +1 -0
- package/dist/schema/index.d.ts +17 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +18 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/slashCommand.d.ts +19 -0
- package/dist/schema/slashCommand.d.ts.map +1 -0
- package/dist/schema/slashCommand.js +146 -0
- package/dist/schema/slashCommand.js.map +1 -0
- package/package.json +134 -0
- package/src/document/normalize.ts +456 -0
- package/src/document/types.ts +74 -0
- package/src/editor/handle.ts +65 -0
- package/src/editor/slashCommands.ts +120 -0
- package/src/editor/vocabulary.ts +126 -0
- package/src/index.ts +52 -0
- package/src/schema/contract.ts +76 -0
- package/src/schema/extensions.ts +120 -0
- package/src/schema/image.ts +44 -0
- package/src/schema/index.ts +29 -0
- package/src/schema/slashCommand.ts +181 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation and normalization of the note document. The walk is a whitelist:
|
|
3
|
+
* unknown nodes, marks and attributes are dropped, not passed through, which is
|
|
4
|
+
* also what keeps `javascript:` hrefs out.
|
|
5
|
+
*
|
|
6
|
+
* Dependency-free on purpose: runs in React Native, the WebView bundle and the
|
|
7
|
+
* web, so it may not import from any of them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
EditorAttributes,
|
|
12
|
+
EditorDocument,
|
|
13
|
+
EditorMark,
|
|
14
|
+
EditorMarkType,
|
|
15
|
+
EditorNode,
|
|
16
|
+
EditorNodeType,
|
|
17
|
+
} from './types.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Minimum valid document, and the value sent as `content` when creating a note.
|
|
21
|
+
* Note that this is *not* what the editor is fed: ProseMirror's `doc` requires
|
|
22
|
+
* at least one block, so `toEditorContent` expands it to an empty paragraph.
|
|
23
|
+
*/
|
|
24
|
+
export const EMPTY_DOCUMENT: EditorDocument = { type: 'doc', content: [] };
|
|
25
|
+
|
|
26
|
+
const MAX_HEADING_LEVEL = 3;
|
|
27
|
+
/**
|
|
28
|
+
* Far beyond any real document: caps `normalizeNode`'s recursion so a corrupt
|
|
29
|
+
* payload collapses into a dropped node instead of overflowing the call stack,
|
|
30
|
+
* which would violate `normalizeDocument`'s "never throws" contract.
|
|
31
|
+
*/
|
|
32
|
+
const MAX_NODE_DEPTH = 64;
|
|
33
|
+
const SAFE_LINK_PROTOCOLS = ['http:', 'https:', 'mailto:'];
|
|
34
|
+
const SAFE_IMAGE_PROTOCOLS = ['http:', 'https:', 'data:'];
|
|
35
|
+
|
|
36
|
+
/*
|
|
37
|
+
* `Record<Union, true>` rather than an array, so the list is exhaustive by
|
|
38
|
+
* compilation: adding a member to `EditorMarkType` without listing it here
|
|
39
|
+
* stops `tsc`, instead of being silently dropped by `normalizeDocument` at
|
|
40
|
+
* runtime. Same pattern as `MARK_TYPE_MAP` in `../schema/contract.ts` and
|
|
41
|
+
* `EDITOR_MESSAGE_VALIDATORS` in `@medway-ui/native`.
|
|
42
|
+
*/
|
|
43
|
+
const MARK_TYPES: Record<EditorMarkType, true> = {
|
|
44
|
+
bold: true,
|
|
45
|
+
italic: true,
|
|
46
|
+
underline: true,
|
|
47
|
+
strike: true,
|
|
48
|
+
code: true,
|
|
49
|
+
link: true,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** What each node accepts as children, mirroring the Tiptap schema. */
|
|
53
|
+
type ContentKind =
|
|
54
|
+
| 'block'
|
|
55
|
+
| 'inline'
|
|
56
|
+
| 'listItem'
|
|
57
|
+
| 'taskItem'
|
|
58
|
+
| 'text'
|
|
59
|
+
| 'none';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Node types that may sit directly under `doc` or under a block container.
|
|
63
|
+
* Declared as an `Exclude` so a new member of `EditorNodeType` has to be
|
|
64
|
+
* classified here, in one direction or the other, before this file compiles.
|
|
65
|
+
*/
|
|
66
|
+
type EditorBlockNodeType = Exclude<
|
|
67
|
+
EditorNodeType,
|
|
68
|
+
'doc' | 'text' | 'hardBreak' | 'listItem' | 'taskItem'
|
|
69
|
+
>;
|
|
70
|
+
|
|
71
|
+
const BLOCK_TYPES: Record<EditorBlockNodeType, true> = {
|
|
72
|
+
paragraph: true,
|
|
73
|
+
heading: true,
|
|
74
|
+
bulletList: true,
|
|
75
|
+
orderedList: true,
|
|
76
|
+
taskList: true,
|
|
77
|
+
blockquote: true,
|
|
78
|
+
codeBlock: true,
|
|
79
|
+
horizontalRule: true,
|
|
80
|
+
image: true,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const isBlockType = (type: EditorNodeType): boolean =>
|
|
84
|
+
Object.prototype.hasOwnProperty.call(BLOCK_TYPES, type);
|
|
85
|
+
|
|
86
|
+
const CONTENT_KIND: Record<EditorNodeType, ContentKind> = {
|
|
87
|
+
doc: 'block',
|
|
88
|
+
paragraph: 'inline',
|
|
89
|
+
text: 'none',
|
|
90
|
+
hardBreak: 'none',
|
|
91
|
+
heading: 'inline',
|
|
92
|
+
bulletList: 'listItem',
|
|
93
|
+
orderedList: 'listItem',
|
|
94
|
+
listItem: 'block',
|
|
95
|
+
taskList: 'taskItem',
|
|
96
|
+
taskItem: 'block',
|
|
97
|
+
blockquote: 'block',
|
|
98
|
+
codeBlock: 'text',
|
|
99
|
+
horizontalRule: 'none',
|
|
100
|
+
image: 'none',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
104
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
105
|
+
|
|
106
|
+
const toArray = (value: unknown): unknown[] =>
|
|
107
|
+
Array.isArray(value) ? value : [];
|
|
108
|
+
|
|
109
|
+
const clampHeadingLevel = (value: unknown): 1 | 2 | 3 => {
|
|
110
|
+
const level = Math.trunc(Number(value));
|
|
111
|
+
|
|
112
|
+
if (!Number.isFinite(level) || level < 1) return 1;
|
|
113
|
+
|
|
114
|
+
return (level > MAX_HEADING_LEVEL ? MAX_HEADING_LEVEL : level) as 1 | 2 | 3;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Accepts only absolute `http(s)` and `mailto` links. Relative URLs are
|
|
119
|
+
* rejected too: a note is not served from a page, so there is no base to
|
|
120
|
+
* resolve them against.
|
|
121
|
+
*/
|
|
122
|
+
export const sanitizeLinkHref = (value: unknown): string | null => {
|
|
123
|
+
if (typeof value !== 'string') return null;
|
|
124
|
+
|
|
125
|
+
const href = value.trim();
|
|
126
|
+
if (!href) return null;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const { protocol } = new URL(href);
|
|
130
|
+
return SAFE_LINK_PROTOCOLS.includes(protocol) ? href : null;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** Same idea as `sanitizeLinkHref`, with `data:` additionally allowed. */
|
|
137
|
+
export const sanitizeImageSrc = (value: unknown): string | null => {
|
|
138
|
+
if (typeof value !== 'string') return null;
|
|
139
|
+
|
|
140
|
+
const src = value.trim();
|
|
141
|
+
if (!src) return null;
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const { protocol } = new URL(src);
|
|
145
|
+
return SAFE_IMAGE_PROTOCOLS.includes(protocol) ? src : null;
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const normalizeMark = (value: unknown): EditorMark | null => {
|
|
152
|
+
if (!isPlainObject(value)) return null;
|
|
153
|
+
|
|
154
|
+
const { type } = value;
|
|
155
|
+
if (typeof type !== 'string') return null;
|
|
156
|
+
// `hasOwnProperty`, not `in`: `type` is raw JSON, and `in` walks the
|
|
157
|
+
// prototype chain, so `"toString"` would pass as a mark type.
|
|
158
|
+
if (!Object.prototype.hasOwnProperty.call(MARK_TYPES, type)) return null;
|
|
159
|
+
|
|
160
|
+
if (type !== 'link') {
|
|
161
|
+
return { type: type as EditorMarkType };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const attrs = isPlainObject(value.attrs) ? value.attrs : {};
|
|
165
|
+
const href = sanitizeLinkHref(attrs.href);
|
|
166
|
+
if (!href) return null;
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
type: 'link',
|
|
170
|
+
attrs: { href, target: '_blank', rel: 'noopener noreferrer nofollow' },
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const normalizeMarks = (value: unknown): EditorMark[] | undefined => {
|
|
175
|
+
const marks = toArray(value)
|
|
176
|
+
.map(normalizeMark)
|
|
177
|
+
.filter((mark): mark is EditorMark => mark !== null);
|
|
178
|
+
|
|
179
|
+
return marks.length ? marks : undefined;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const normalizeAttrs = (
|
|
183
|
+
type: EditorNodeType,
|
|
184
|
+
value: unknown
|
|
185
|
+
): EditorAttributes | undefined => {
|
|
186
|
+
const attrs = isPlainObject(value) ? value : {};
|
|
187
|
+
|
|
188
|
+
switch (type) {
|
|
189
|
+
case 'heading':
|
|
190
|
+
return { level: clampHeadingLevel(attrs.level) };
|
|
191
|
+
case 'orderedList': {
|
|
192
|
+
const start = Math.trunc(Number(attrs.start));
|
|
193
|
+
return { start: Number.isFinite(start) && start > 0 ? start : 1 };
|
|
194
|
+
}
|
|
195
|
+
case 'taskItem':
|
|
196
|
+
return { checked: attrs.checked === true };
|
|
197
|
+
case 'codeBlock':
|
|
198
|
+
return {
|
|
199
|
+
language: typeof attrs.language === 'string' ? attrs.language : null,
|
|
200
|
+
};
|
|
201
|
+
default:
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const acceptsChild = (
|
|
207
|
+
kind: ContentKind,
|
|
208
|
+
childType: EditorNodeType
|
|
209
|
+
): boolean => {
|
|
210
|
+
switch (kind) {
|
|
211
|
+
case 'block':
|
|
212
|
+
return isBlockType(childType);
|
|
213
|
+
case 'inline':
|
|
214
|
+
return childType === 'text' || childType === 'hardBreak';
|
|
215
|
+
case 'listItem':
|
|
216
|
+
return childType === 'listItem';
|
|
217
|
+
case 'taskItem':
|
|
218
|
+
return childType === 'taskItem';
|
|
219
|
+
case 'text':
|
|
220
|
+
return childType === 'text';
|
|
221
|
+
case 'none':
|
|
222
|
+
default:
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const stripMarks = (node: EditorNode): EditorNode => {
|
|
228
|
+
if (!node.marks) return node;
|
|
229
|
+
|
|
230
|
+
const stripped = { ...node };
|
|
231
|
+
delete stripped.marks;
|
|
232
|
+
|
|
233
|
+
return stripped;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const normalizeNode = (value: unknown, depth = 0): EditorNode | null => {
|
|
237
|
+
// Deliberately dropped rather than throwing or truncating in place, same as
|
|
238
|
+
// every other "unusable input" case in this function.
|
|
239
|
+
if (depth > MAX_NODE_DEPTH) return null;
|
|
240
|
+
if (!isPlainObject(value)) return null;
|
|
241
|
+
|
|
242
|
+
const { type } = value;
|
|
243
|
+
if (typeof type !== 'string') return null;
|
|
244
|
+
// Same reason as in `normalizeMark`: `in` would let a prototype key such as
|
|
245
|
+
// `"toString"` through as a node type.
|
|
246
|
+
if (!Object.prototype.hasOwnProperty.call(CONTENT_KIND, type)) return null;
|
|
247
|
+
if (type === 'doc') return null;
|
|
248
|
+
|
|
249
|
+
const nodeType = type as EditorNodeType;
|
|
250
|
+
|
|
251
|
+
if (nodeType === 'text') {
|
|
252
|
+
// A text node with no text is not representable in ProseMirror.
|
|
253
|
+
if (typeof value.text !== 'string' || value.text === '') return null;
|
|
254
|
+
|
|
255
|
+
const marks = normalizeMarks(value.marks);
|
|
256
|
+
return marks
|
|
257
|
+
? { type: 'text', text: value.text, marks }
|
|
258
|
+
: {
|
|
259
|
+
type: 'text',
|
|
260
|
+
text: value.text,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (nodeType === 'image') {
|
|
265
|
+
// Dropped outright: unlike a block, there is no sane placeholder for "a
|
|
266
|
+
// photo that isn't there".
|
|
267
|
+
const attrs = isPlainObject(value.attrs) ? value.attrs : {};
|
|
268
|
+
const src = sanitizeImageSrc(attrs.src);
|
|
269
|
+
if (!src) return null;
|
|
270
|
+
|
|
271
|
+
const alt = typeof attrs.alt === 'string' ? attrs.alt : undefined;
|
|
272
|
+
return alt
|
|
273
|
+
? { type: 'image', attrs: { src, alt } }
|
|
274
|
+
: { type: 'image', attrs: { src } };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const kind = CONTENT_KIND[nodeType];
|
|
278
|
+
const attrs = normalizeAttrs(nodeType, value.attrs);
|
|
279
|
+
const node: EditorNode = attrs
|
|
280
|
+
? { type: nodeType, attrs }
|
|
281
|
+
: { type: nodeType };
|
|
282
|
+
|
|
283
|
+
if (kind === 'none') return node;
|
|
284
|
+
|
|
285
|
+
// Arrow, not `.map(normalizeNode)`: `map` passes `index` as the second
|
|
286
|
+
// argument, which would land in `depth` — the `.map(parseInt)` footgun.
|
|
287
|
+
const children = toArray(value.content)
|
|
288
|
+
.map((child) => normalizeNode(child, depth + 1))
|
|
289
|
+
.filter((child): child is EditorNode => child !== null)
|
|
290
|
+
.filter((child) => acceptsChild(kind, child.type));
|
|
291
|
+
|
|
292
|
+
// Code blocks hold plain text; marks inside them are meaningless.
|
|
293
|
+
const content =
|
|
294
|
+
nodeType === 'codeBlock' ? children.map(stripMarks) : children;
|
|
295
|
+
|
|
296
|
+
// Containers that cannot legally be empty get an empty paragraph instead of
|
|
297
|
+
// being dropped, so a malformed list keeps its structure and stays editable.
|
|
298
|
+
if (
|
|
299
|
+
!content.length &&
|
|
300
|
+
(kind === 'block' || kind === 'listItem' || kind === 'taskItem')
|
|
301
|
+
) {
|
|
302
|
+
if (
|
|
303
|
+
nodeType === 'listItem' ||
|
|
304
|
+
nodeType === 'taskItem' ||
|
|
305
|
+
nodeType === 'blockquote'
|
|
306
|
+
) {
|
|
307
|
+
return { ...node, content: [{ type: 'paragraph' }] };
|
|
308
|
+
}
|
|
309
|
+
if (nodeType === 'bulletList' || nodeType === 'orderedList') {
|
|
310
|
+
return {
|
|
311
|
+
...node,
|
|
312
|
+
content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }],
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
if (nodeType === 'taskList') {
|
|
316
|
+
return {
|
|
317
|
+
...node,
|
|
318
|
+
content: [
|
|
319
|
+
{
|
|
320
|
+
type: 'taskItem',
|
|
321
|
+
attrs: { checked: false },
|
|
322
|
+
content: [{ type: 'paragraph' }],
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return content.length ? { ...node, content } : node;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Turns anything into a document that fits the schema. Never throws and never
|
|
334
|
+
* returns `null` — unusable input collapses to `EMPTY_DOCUMENT`, which is what
|
|
335
|
+
* keeps a corrupt payload from blanking the screen or reaching the API.
|
|
336
|
+
*/
|
|
337
|
+
export const normalizeDocument = (value: unknown): EditorDocument => {
|
|
338
|
+
if (!isPlainObject(value) || value.type !== 'doc') return EMPTY_DOCUMENT;
|
|
339
|
+
|
|
340
|
+
const content = toArray(value.content)
|
|
341
|
+
.map((node) => normalizeNode(node))
|
|
342
|
+
.filter((node): node is EditorNode => node !== null)
|
|
343
|
+
.filter((node) => isBlockType(node.type));
|
|
344
|
+
|
|
345
|
+
return { type: 'doc', content };
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
/** Type guard for callers that need to know whether normalization changed anything. */
|
|
349
|
+
export const isEditorDocument = (value: unknown): value is EditorDocument =>
|
|
350
|
+
isPlainObject(value) && value.type === 'doc' && Array.isArray(value.content);
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* ProseMirror's `doc` node requires `block+`, so the empty document has to grow
|
|
354
|
+
* a paragraph before it can be loaded into the editor. The API keeps storing
|
|
355
|
+
* the empty form.
|
|
356
|
+
*/
|
|
357
|
+
export const toEditorContent = (doc: EditorDocument): EditorDocument =>
|
|
358
|
+
doc.content.length ? doc : { type: 'doc', content: [{ type: 'paragraph' }] };
|
|
359
|
+
|
|
360
|
+
const nodeHasText = (node: EditorNode): boolean => {
|
|
361
|
+
if (node.type === 'text') return Boolean(node.text?.trim());
|
|
362
|
+
if (node.type === 'horizontalRule' || node.type === 'image') return true;
|
|
363
|
+
|
|
364
|
+
return (node.content ?? []).some(nodeHasText);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* True for a document a student would consider blank. An image or a horizontal
|
|
369
|
+
* rule counts as content, so a note holding only a photo is not empty — which
|
|
370
|
+
* is why `documentToPlainText` can return `""` for a document this call
|
|
371
|
+
* reports as non-empty. See the note there.
|
|
372
|
+
*/
|
|
373
|
+
export const isEmptyDocument = (doc: EditorDocument): boolean =>
|
|
374
|
+
!doc.content.some(nodeHasText);
|
|
375
|
+
|
|
376
|
+
const jsonEquals = (a: unknown, b: unknown): boolean => {
|
|
377
|
+
if (a === b) return true;
|
|
378
|
+
if (typeof a !== typeof b) return false;
|
|
379
|
+
if (a === null || b === null) return false;
|
|
380
|
+
|
|
381
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
382
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return a.every((item, index) => jsonEquals(item, b[index]));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (typeof a !== 'object') return false;
|
|
390
|
+
|
|
391
|
+
const left = a as Record<string, unknown>;
|
|
392
|
+
const right = b as Record<string, unknown>;
|
|
393
|
+
const keys = Object.keys(left);
|
|
394
|
+
|
|
395
|
+
if (keys.length !== Object.keys(right).length) return false;
|
|
396
|
+
|
|
397
|
+
return keys.every((key) => key in right && jsonEquals(left[key], right[key]));
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Deep equality for documents, used by the autosave to skip no-op writes.
|
|
402
|
+
* Key order is irrelevant, so a document that only travelled through
|
|
403
|
+
* serialization does not read as changed.
|
|
404
|
+
*/
|
|
405
|
+
export const documentsAreEqual = (
|
|
406
|
+
a: EditorDocument,
|
|
407
|
+
b: EditorDocument
|
|
408
|
+
): boolean => jsonEquals(a, b);
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Plain-text projection, used for previews and as a fallback note title.
|
|
412
|
+
*
|
|
413
|
+
* Returns `""` for documents that are *not* empty: an uncaptioned image, or a
|
|
414
|
+
* horizontal rule, is content for `isEmptyDocument` but has no text to project.
|
|
415
|
+
* Hosts deriving a title from this must keep their own fallback — do not read
|
|
416
|
+
* `documentToPlainText(doc) === ""` as "the note is blank", use
|
|
417
|
+
* `isEmptyDocument` for that.
|
|
418
|
+
*/
|
|
419
|
+
export const documentToPlainText = (doc: EditorDocument): string => {
|
|
420
|
+
const lines: string[] = [];
|
|
421
|
+
|
|
422
|
+
const walk = (nodes: EditorNode[], into: string[]) => {
|
|
423
|
+
nodes.forEach((node) => {
|
|
424
|
+
if (node.type === 'text') {
|
|
425
|
+
into.push(node.text ?? '');
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (node.type === 'hardBreak') {
|
|
429
|
+
into.push(' ');
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (node.type === 'image') {
|
|
433
|
+
// The only text an image carries. Emitted so a note whose first block is
|
|
434
|
+
// a captioned image still projects something — see the contract note on
|
|
435
|
+
// `documentToPlainText` for the uncaptioned case.
|
|
436
|
+
const alt =
|
|
437
|
+
typeof node.attrs?.alt === 'string' ? node.attrs.alt.trim() : '';
|
|
438
|
+
if (alt) lines.push(alt);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const isBlock = isBlockType(node.type);
|
|
443
|
+
const target = isBlock ? [] : into;
|
|
444
|
+
walk(node.content ?? [], target);
|
|
445
|
+
|
|
446
|
+
if (isBlock && target !== into) {
|
|
447
|
+
const line = target.join('').trim();
|
|
448
|
+
if (line) lines.push(line);
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
walk(doc.content, []);
|
|
454
|
+
|
|
455
|
+
return lines.join('\n');
|
|
456
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document model of the notes editor. Mirrors Tiptap's `JSONContent` but
|
|
3
|
+
* declared by hand, so importing the document model never drags Tiptap in.
|
|
4
|
+
*
|
|
5
|
+
* This is the *whole* supported schema: adding a block means adding it here, in
|
|
6
|
+
* `./normalize.ts` and in `../schema/extensions.ts` — in that order.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type EditorMarkType =
|
|
10
|
+
| 'bold'
|
|
11
|
+
| 'italic'
|
|
12
|
+
| 'underline'
|
|
13
|
+
| 'strike'
|
|
14
|
+
| 'code'
|
|
15
|
+
| 'link';
|
|
16
|
+
|
|
17
|
+
export type EditorNodeType =
|
|
18
|
+
| 'doc'
|
|
19
|
+
| 'paragraph'
|
|
20
|
+
| 'text'
|
|
21
|
+
| 'hardBreak'
|
|
22
|
+
| 'heading'
|
|
23
|
+
| 'bulletList'
|
|
24
|
+
| 'orderedList'
|
|
25
|
+
| 'listItem'
|
|
26
|
+
| 'taskList'
|
|
27
|
+
| 'taskItem'
|
|
28
|
+
| 'blockquote'
|
|
29
|
+
| 'codeBlock'
|
|
30
|
+
| 'horizontalRule'
|
|
31
|
+
| 'image';
|
|
32
|
+
|
|
33
|
+
export type EditorAttributes = Record<string, unknown>;
|
|
34
|
+
|
|
35
|
+
export interface EditorMark {
|
|
36
|
+
type: EditorMarkType;
|
|
37
|
+
attrs?: EditorAttributes;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EditorLinkMark extends EditorMark {
|
|
41
|
+
type: 'link';
|
|
42
|
+
attrs: {
|
|
43
|
+
href: string;
|
|
44
|
+
target: string | null;
|
|
45
|
+
rel: string | null;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `src` is whatever the host's upload callback returned (bucket URL in prod,
|
|
51
|
+
* `data:` only in a backend-less demo). Uploading and deleting the underlying
|
|
52
|
+
* file is the host's job; this package only carries the reference.
|
|
53
|
+
*/
|
|
54
|
+
export interface EditorImageNode extends EditorNode {
|
|
55
|
+
type: 'image';
|
|
56
|
+
attrs: {
|
|
57
|
+
src: string;
|
|
58
|
+
alt?: string | null;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface EditorNode {
|
|
63
|
+
type: EditorNodeType;
|
|
64
|
+
attrs?: EditorAttributes;
|
|
65
|
+
content?: EditorNode[];
|
|
66
|
+
marks?: EditorMark[];
|
|
67
|
+
text?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Root of a note. This is the shape persisted in `Note.content`. */
|
|
71
|
+
export interface EditorDocument {
|
|
72
|
+
type: 'doc';
|
|
73
|
+
content: EditorNode[];
|
|
74
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { EditorDocument } from '../document/types.js';
|
|
2
|
+
import type {
|
|
3
|
+
EditorCommand,
|
|
4
|
+
EditorSelectionState,
|
|
5
|
+
SlashCommandId,
|
|
6
|
+
SlashMenuState,
|
|
7
|
+
} from './vocabulary.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What every host exposes once it has an editor running — a WebView plus
|
|
11
|
+
* message bridge on React Native, an in-process Tiptap `Editor` on web.
|
|
12
|
+
* Neither the WebView nor the `Editor` may leak past this interface.
|
|
13
|
+
*/
|
|
14
|
+
export type NoteEditorHandle = {
|
|
15
|
+
/** `false` until the editor is mounted and ready to accept commands. */
|
|
16
|
+
isReady: boolean;
|
|
17
|
+
state: EditorSelectionState;
|
|
18
|
+
slash: SlashMenuState;
|
|
19
|
+
/**
|
|
20
|
+
* Pulls the current document. Asynchronous because on React Native it is a
|
|
21
|
+
* round trip across the bridge — the document is serialized on demand, never
|
|
22
|
+
* pushed on every keystroke.
|
|
23
|
+
*/
|
|
24
|
+
requestDocument: () => Promise<EditorDocument>;
|
|
25
|
+
runCommand: (command: EditorCommand) => void;
|
|
26
|
+
runSlashCommand: (commandId: SlashCommandId) => void;
|
|
27
|
+
dismissSlash: () => void;
|
|
28
|
+
focus: (position?: 'start' | 'end') => void;
|
|
29
|
+
blur: () => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The state a host starts from, before the editor has reported anything. */
|
|
33
|
+
export const INITIAL_EDITOR_STATE: EditorSelectionState = {
|
|
34
|
+
isFocused: false,
|
|
35
|
+
isEmpty: true,
|
|
36
|
+
canUndo: false,
|
|
37
|
+
canRedo: false,
|
|
38
|
+
canIndent: false,
|
|
39
|
+
canOutdent: false,
|
|
40
|
+
active: {
|
|
41
|
+
bold: false,
|
|
42
|
+
italic: false,
|
|
43
|
+
underline: false,
|
|
44
|
+
strike: false,
|
|
45
|
+
code: false,
|
|
46
|
+
link: false,
|
|
47
|
+
paragraph: true,
|
|
48
|
+
heading1: false,
|
|
49
|
+
heading2: false,
|
|
50
|
+
heading3: false,
|
|
51
|
+
bulletList: false,
|
|
52
|
+
orderedList: false,
|
|
53
|
+
taskList: false,
|
|
54
|
+
blockquote: false,
|
|
55
|
+
codeBlock: false,
|
|
56
|
+
},
|
|
57
|
+
linkHref: null,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const EMPTY_SLASH_STATE: SlashMenuState = {
|
|
61
|
+
active: false,
|
|
62
|
+
query: '',
|
|
63
|
+
commandIds: [],
|
|
64
|
+
caret: null,
|
|
65
|
+
};
|