@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.
Files changed (57) hide show
  1. package/README.md +127 -0
  2. package/dist/document/normalize.d.ts +61 -0
  3. package/dist/document/normalize.d.ts.map +1 -0
  4. package/dist/document/normalize.js +373 -0
  5. package/dist/document/normalize.js.map +1 -0
  6. package/dist/document/types.d.ts +47 -0
  7. package/dist/document/types.d.ts.map +1 -0
  8. package/dist/document/types.js +9 -0
  9. package/dist/document/types.js.map +1 -0
  10. package/dist/editor/handle.d.ts +28 -0
  11. package/dist/editor/handle.d.ts.map +1 -0
  12. package/dist/editor/handle.js +34 -0
  13. package/dist/editor/handle.js.map +1 -0
  14. package/dist/editor/slashCommands.d.ts +21 -0
  15. package/dist/editor/slashCommands.d.ts.map +1 -0
  16. package/dist/editor/slashCommands.js +98 -0
  17. package/dist/editor/slashCommands.js.map +1 -0
  18. package/dist/editor/vocabulary.d.ts +110 -0
  19. package/dist/editor/vocabulary.d.ts.map +1 -0
  20. package/dist/editor/vocabulary.js +11 -0
  21. package/dist/editor/vocabulary.js.map +1 -0
  22. package/dist/index.d.ts +15 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +11 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/schema/contract.d.ts +14 -0
  27. package/dist/schema/contract.d.ts.map +1 -0
  28. package/dist/schema/contract.js +51 -0
  29. package/dist/schema/contract.js.map +1 -0
  30. package/dist/schema/extensions.d.ts +23 -0
  31. package/dist/schema/extensions.d.ts.map +1 -0
  32. package/dist/schema/extensions.js +81 -0
  33. package/dist/schema/extensions.js.map +1 -0
  34. package/dist/schema/image.d.ts +14 -0
  35. package/dist/schema/image.d.ts.map +1 -0
  36. package/dist/schema/image.js +28 -0
  37. package/dist/schema/image.js.map +1 -0
  38. package/dist/schema/index.d.ts +17 -0
  39. package/dist/schema/index.d.ts.map +1 -0
  40. package/dist/schema/index.js +18 -0
  41. package/dist/schema/index.js.map +1 -0
  42. package/dist/schema/slashCommand.d.ts +19 -0
  43. package/dist/schema/slashCommand.d.ts.map +1 -0
  44. package/dist/schema/slashCommand.js +146 -0
  45. package/dist/schema/slashCommand.js.map +1 -0
  46. package/package.json +134 -0
  47. package/src/document/normalize.ts +456 -0
  48. package/src/document/types.ts +74 -0
  49. package/src/editor/handle.ts +65 -0
  50. package/src/editor/slashCommands.ts +120 -0
  51. package/src/editor/vocabulary.ts +126 -0
  52. package/src/index.ts +52 -0
  53. package/src/schema/contract.ts +76 -0
  54. package/src/schema/extensions.ts +120 -0
  55. package/src/schema/image.ts +44 -0
  56. package/src/schema/index.ts +29 -0
  57. 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
+ };