@rathkit/rte-core 0.1.0-alpha.3 → 0.1.0-alpha.5

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 (71) hide show
  1. package/README.md +1 -1
  2. package/dist/autoformat/autoformat.js +18 -3
  3. package/dist/base64.js +5 -1
  4. package/dist/dom/index.d.ts +1 -1
  5. package/dist/dom/render.js +26 -15
  6. package/dist/dom/view.d.ts +59 -6
  7. package/dist/dom/view.js +195 -38
  8. package/dist/edit.d.ts +6 -0
  9. package/dist/edit.js +101 -11
  10. package/dist/engine.d.ts +26 -4
  11. package/dist/engine.js +113 -23
  12. package/dist/export.d.ts +2 -2
  13. package/dist/export.js +22 -6
  14. package/dist/footnotes/index.d.ts +2 -0
  15. package/dist/footnotes/index.js +2 -0
  16. package/dist/footnotes/plugin.d.ts +6 -0
  17. package/dist/footnotes/plugin.js +130 -0
  18. package/dist/footnotes/reconcile.d.ts +4 -0
  19. package/dist/footnotes/reconcile.js +155 -0
  20. package/dist/html/parse.d.ts +8 -2
  21. package/dist/html/parse.js +6 -2
  22. package/dist/images/images.js +4 -3
  23. package/dist/images/index.d.ts +5 -1
  24. package/dist/images/index.js +3 -1
  25. package/dist/images/plugin.js +2 -1
  26. package/dist/{dom/imageview.d.ts → images/view.d.ts} +9 -6
  27. package/dist/{dom/imageview.js → images/view.js} +20 -14
  28. package/dist/import.d.ts +18 -2
  29. package/dist/import.js +87 -42
  30. package/dist/index.d.ts +4 -1
  31. package/dist/index.js +3 -1
  32. package/dist/lists/lists.js +55 -30
  33. package/dist/markdown/detect.d.ts +1 -0
  34. package/dist/markdown/detect.js +81 -0
  35. package/dist/markdown/export.d.ts +10 -0
  36. package/dist/markdown/export.js +252 -0
  37. package/dist/markdown/index.d.ts +5 -0
  38. package/dist/markdown/index.js +4 -0
  39. package/dist/markdown/inline.d.ts +37 -0
  40. package/dist/markdown/inline.js +604 -0
  41. package/dist/markdown/parse.d.ts +4 -0
  42. package/dist/markdown/parse.js +586 -0
  43. package/dist/markdown/plugin.d.ts +20 -0
  44. package/dist/markdown/plugin.js +56 -0
  45. package/dist/model.d.ts +30 -2
  46. package/dist/model.js +2 -0
  47. package/dist/notes.d.ts +6 -0
  48. package/dist/notes.js +49 -0
  49. package/dist/ops.d.ts +6 -1
  50. package/dist/ops.js +5 -4
  51. package/dist/paste.js +9 -2
  52. package/dist/path.d.ts +10 -1
  53. package/dist/path.js +110 -11
  54. package/dist/plugin.d.ts +53 -2
  55. package/dist/policy.d.ts +41 -0
  56. package/dist/policy.js +40 -0
  57. package/dist/standard/index.d.ts +5 -3
  58. package/dist/standard/index.js +6 -5
  59. package/dist/stats.js +1 -1
  60. package/dist/svg/svg.js +9 -1
  61. package/dist/{dom/tableview.d.ts → tables/cells.d.ts} +7 -6
  62. package/dist/{dom/tableview.js → tables/cells.js} +16 -12
  63. package/dist/{dom/tablegrips.d.ts → tables/grips.d.ts} +5 -4
  64. package/dist/{dom/tablegrips.js → tables/grips.js} +10 -9
  65. package/dist/tables/index.d.ts +5 -1
  66. package/dist/tables/index.js +3 -1
  67. package/dist/tables/view.d.ts +2 -0
  68. package/dist/tables/view.js +15 -0
  69. package/dist/warning.d.ts +16 -0
  70. package/dist/warning.js +5 -0
  71. package/package.json +23 -1
package/dist/model.d.ts CHANGED
@@ -31,7 +31,17 @@ export interface Image {
31
31
  readonly caption?: string;
32
32
  }
33
33
  export type ImageAlign = "left" | "right" | "center";
34
- export type Inline = TextRun | LineBreak | Image;
34
+ /**
35
+ * A footnote reference: one character in the text, shown as the note's number. The note itself is an item of the document's notes
36
+ * list (`List.notes`) with the same `id`. Numbers are never stored: they come from the order of the references.
37
+ */
38
+ export interface Note {
39
+ readonly type: "note";
40
+ readonly id: string;
41
+ }
42
+ export type Inline = TextRun | LineBreak | Image | Note;
43
+ /** What an id of a note may be. Anything else is replaced when read, so an id is always safe in an attribute. */
44
+ export declare const NOTE_ID: RegExp;
35
45
  export type Align = "left" | "center" | "right" | "justify";
36
46
  /** The most a block can be indented, in steps. */
37
47
  export declare const MAX_INDENT = 8;
@@ -56,17 +66,35 @@ export interface Heading {
56
66
  }
57
67
  export interface ListItem {
58
68
  readonly content: readonly Inline[];
59
- readonly sublists: readonly List[];
69
+ /**
70
+ * What follows the item's own text, in order: nested lists, and also paragraphs, headings, code, quotes and rules. A paragraph
71
+ * that follows the text with nothing between is not a child but a paragraph break inside `content` (`br` with `paragraph`);
72
+ * a paragraph is a child only after another child (text, code, then more text).
73
+ */
74
+ readonly children: readonly ItemChild[];
60
75
  /** Present when the item is a task: whether it is done. Absent for an ordinary item. */
61
76
  readonly checked?: boolean;
77
+ /** In a notes list: the id of the reference this note belongs to. */
78
+ readonly id?: string;
79
+ /**
80
+ * Set on an item that has no text and holds only a nested list, and shows no marker of its own: the nested list sits under it
81
+ * without a bullet or number above it. Without this an item with no text still shows its marker. Ignored once the item has text.
82
+ */
83
+ readonly bare?: true;
62
84
  }
85
+ /** What a list item may hold besides its own text. Tables and definition lists are not allowed in an item. */
86
+ export type ItemChild = List | Paragraph | Heading | CodeBlock | Quote | Rule;
63
87
  export interface List {
64
88
  readonly type: "ul" | "ol";
65
89
  readonly start?: number;
66
90
  /** A CSS list-style-type keyword such as `circle` or `lower-alpha`. Absent means the default for the depth. */
67
91
  readonly style?: string;
92
+ /** The footnotes of the document: a numbered list whose items are the notes, kept in the order of their references. */
93
+ readonly notes?: true;
68
94
  readonly items: readonly ListItem[];
69
95
  }
96
+ /** The marker a list has when it names none: numbers for an ordered list, and a bullet by depth for the rest. */
97
+ export declare const DEFAULT_MARKER: (type: "ul" | "ol", depth: number) => string;
70
98
  export interface DefinitionEntry {
71
99
  /** A term (`dt`) or the detail that describes it (`dd`). */
72
100
  readonly kind: "term" | "detail";
package/dist/model.js CHANGED
@@ -1,4 +1,6 @@
1
+ export const NOTE_ID = /^[A-Za-z0-9_-]{1,40}$/;
1
2
  export const MAX_INDENT = 8;
3
+ export const DEFAULT_MARKER = (type, depth) => (type === "ol" ? "decimal" : ["disc", "circle", "square"][Math.min(depth, 2)]);
2
4
  export const ROW_HEIGHT = /^\d{1,4}(\.\d{1,2})?px$/;
3
5
  export const COLOR_VALUE = /^(#[0-9a-f]{3,8}|[a-z]{3,20}|(?:rgb|hsl)a?\([\d\s.,%/-]+\))$/i;
4
6
  export const COLUMN_WIDTH = /^\d{1,5}(\.\d{1,2})?(px|%)$/;
@@ -0,0 +1,6 @@
1
+ import type { Block, Doc, List } from "./model.ts";
2
+ export declare const isNotes: (block: Block | undefined) => block is List & {
3
+ notes: true;
4
+ };
5
+ /** The ids of every reference in the document, in reading order. */
6
+ export declare const noteIds: (doc: Doc) => string[];
package/dist/notes.js ADDED
@@ -0,0 +1,49 @@
1
+ export const isNotes = (block) => block?.type === "ol" && block.notes === true;
2
+ function collect(block, out) {
3
+ const inline = (content) => { for (const n of content)
4
+ if (n.type === "note")
5
+ out.push(n.id); };
6
+ switch (block.type) {
7
+ case "p":
8
+ case "h":
9
+ inline(block.content);
10
+ break;
11
+ case "ul":
12
+ case "ol":
13
+ for (const item of block.items) {
14
+ inline(item.content);
15
+ for (const child of item.children)
16
+ collect(child, out);
17
+ }
18
+ break;
19
+ case "dl":
20
+ for (const entry of block.entries)
21
+ inline(entry.content);
22
+ break;
23
+ case "table":
24
+ for (const row of block.rows)
25
+ for (const cell of row)
26
+ for (const inner of cell.blocks)
27
+ collect(inner, out);
28
+ break;
29
+ case "quote":
30
+ for (const inner of block.blocks)
31
+ collect(inner, out);
32
+ break;
33
+ default: break;
34
+ }
35
+ }
36
+ const found = new WeakMap();
37
+ const NONE = [];
38
+ function idsOf(block) {
39
+ if (isNotes(block))
40
+ return NONE;
41
+ let ids = found.get(block);
42
+ if (!ids) {
43
+ const out = [];
44
+ collect(block, out);
45
+ found.set(block, (ids = out.length === 0 ? NONE : out));
46
+ }
47
+ return ids;
48
+ }
49
+ export const noteIds = (doc) => doc.blocks.flatMap(idsOf);
package/dist/ops.d.ts CHANGED
@@ -3,6 +3,7 @@ import { deleteBackward, deleteForward, deleteSelection, extractSelection, inser
3
3
  import { exportBlock, exportHtml, exportInline } from "./export.ts";
4
4
  import { importBlocks, importHtml, importText } from "./import.ts";
5
5
  import { setLink } from "./links.ts";
6
+ import { isNotes, noteIds } from "./notes.ts";
6
7
  import { decodeBase64, encodeBase64 } from "./base64.ts";
7
8
  import { escapeAttribute, escapeText } from "./html/entities.ts";
8
9
  import { checkImageSrc, parseStyle } from "./import.ts";
@@ -10,8 +11,11 @@ import { imageSelection, selectedImage } from "./imageselect.ts";
10
11
  import { sameLine, sidesOf, withSides } from "./cellborder.ts";
11
12
  import { extent, fromGrid, inRow, locateCell, meets, selectedCells, selectedRect, toGrid } from "./tablegrid.ts";
12
13
  import { inlineLength, inlineText, normalizeInline } from "./model.ts";
13
- import { caret, clampPos, containerPaths, fixup, getBlock, getContent, isCollapsed, kindAt, mapBlockList, pathKey, selectedContainers, setContent } from "./path.ts";
14
+ import { asChildren, caret, clampPos, containerPaths, fixup, getBlock, getContent, isCollapsed, kindAt, mapBlockList, pathKey, selectedContainers, setContent } from "./path.ts";
14
15
  export declare const ops: {
16
+ readonly isNotes: typeof isNotes;
17
+ readonly noteIds: typeof noteIds;
18
+ readonly asChildren: typeof asChildren;
15
19
  readonly caret: typeof caret;
16
20
  readonly clampPos: typeof clampPos;
17
21
  readonly containerPaths: typeof containerPaths;
@@ -28,6 +32,7 @@ export declare const ops: {
28
32
  readonly inlineText: typeof inlineText;
29
33
  readonly normalizeInline: typeof normalizeInline;
30
34
  readonly sliceInline: typeof sliceInline;
35
+ readonly NOTE_ID: RegExp;
31
36
  readonly COLUMN_WIDTH: RegExp;
32
37
  readonly COLOR_VALUE: RegExp;
33
38
  readonly ROW_HEIGHT: RegExp;
package/dist/ops.js CHANGED
@@ -3,17 +3,18 @@ import { deleteBackward, deleteForward, deleteSelection, extractSelection, inser
3
3
  import { exportBlock, exportHtml, exportInline } from "./export.js";
4
4
  import { importBlocks, importHtml, importText } from "./import.js";
5
5
  import { setLink } from "./links.js";
6
+ import { isNotes, noteIds } from "./notes.js";
6
7
  import { decodeBase64, encodeBase64 } from "./base64.js";
7
8
  import { escapeAttribute, escapeText } from "./html/entities.js";
8
9
  import { checkImageSrc, parseStyle } from "./import.js";
9
10
  import { imageSelection, selectedImage } from "./imageselect.js";
10
11
  import { sameLine, SIDES, sidesOf, withSides } from "./cellborder.js";
11
12
  import { extent, fromGrid, inRow, locateCell, meets, selectedCells, selectedRect, toGrid } from "./tablegrid.js";
12
- import { COLOR_VALUE, COLUMN_WIDTH, ROW_HEIGHT, inlineLength, inlineText, normalizeInline } from "./model.js";
13
- import { caret, clampPos, containerPaths, fixup, getBlock, getContent, isCollapsed, kindAt, mapBlockList, pathKey, selectedContainers, setContent, } from "./path.js";
13
+ import { NOTE_ID, COLOR_VALUE, COLUMN_WIDTH, ROW_HEIGHT, inlineLength, inlineText, normalizeInline } from "./model.js";
14
+ import { asChildren, caret, clampPos, containerPaths, fixup, getBlock, getContent, isCollapsed, kindAt, mapBlockList, pathKey, selectedContainers, setContent, } from "./path.js";
14
15
  export const ops = {
15
- caret, clampPos, containerPaths, selectedContainers, getContent, setContent, getBlock, kindAt, mapBlockList, fixup, pathKey, isCollapsed,
16
- inlineLength, inlineText, normalizeInline, sliceInline, COLUMN_WIDTH, COLOR_VALUE, ROW_HEIGHT, imageSelection, selectedImage, sameLine, SIDES, sidesOf, withSides,
16
+ isNotes, noteIds, asChildren, caret, clampPos, containerPaths, selectedContainers, getContent, setContent, getBlock, kindAt, mapBlockList, fixup, pathKey, isCollapsed,
17
+ inlineLength, inlineText, normalizeInline, sliceInline, NOTE_ID, COLUMN_WIDTH, COLOR_VALUE, ROW_HEIGHT, imageSelection, selectedImage, sameLine, SIDES, sidesOf, withSides,
17
18
  toGrid, fromGrid, inRow, extent, meets, selectedRect, selectedCells, locateCell,
18
19
  parseStyle, checkImageSrc, escapeText, escapeAttribute, encodeBase64, decodeBase64,
19
20
  insertText, insertBlocks, insertLineBreak, splitBlock, deleteSelection, deleteBackward, deleteForward, moveSelection,
package/dist/paste.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { importBlocks, importText } from "./import.js";
2
+ import { CONTENT_PROFILES } from "./policy.js";
2
3
  const START = "<!--StartFragment-->";
3
4
  const END = "<!--EndFragment-->";
4
5
  export function extractFragment(html) {
@@ -17,11 +18,17 @@ function unwrapSingleCell(blocks) {
17
18
  }
18
19
  export function defaultPaste(data, importOptions = {}) {
19
20
  if (data.html && data.html.trim() !== "") {
20
- const blocks = importBlocks(data.html, { ...importOptions, styles: "semantic" });
21
+ const blocks = importBlocks(data.html, { ...importOptions, styles: "semantic", relativeImages: importOptions.pasteRelativeImages ?? false });
21
22
  if (blocks.length > 0)
22
23
  return unwrapSingleCell(blocks);
23
24
  }
24
- return importText(data.text ?? "");
25
+ const text = data.text ?? "";
26
+ const max = importOptions.maxInputLength ?? CONTENT_PROFILES.standard.maxInputLength;
27
+ if (text.length > max) {
28
+ importOptions.onLimit?.({ limit: "maxInputLength", max, length: text.length });
29
+ return [];
30
+ }
31
+ return importText(text);
25
32
  }
26
33
  export function pasteBlocks(data, handlers = [], importOptions = {}) {
27
34
  for (const handler of handlers) {
package/dist/path.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Block, type Doc, type Inline, type List } from "./model.ts";
1
+ import { type Block, type Doc, type Inline, type ItemChild, type List } from "./model.ts";
2
2
  export type Path = readonly number[];
3
3
  export interface Pos {
4
4
  readonly path: Path;
@@ -21,11 +21,18 @@ export declare const pathKey: (path: Path) => string;
21
21
  * nested under it, rows run left to right then top to bottom, and so on. Negative when `a` comes first.
22
22
  */
23
23
  export declare function comparePath(a: Path, b: Path): number;
24
+ /** A horizontal rule: where it is, and how many containers come before it in reading order. */
25
+ export interface RuleAt {
26
+ readonly path: Path;
27
+ readonly before: number;
28
+ }
24
29
  /**
25
30
  * Every container in the document, in reading order. Documents are never changed once made, so the answer is remembered
26
31
  * per document. The array is shared: do not change it.
27
32
  */
28
33
  export declare function containerPaths(doc: Doc): Path[];
34
+ /** Every horizontal rule, in reading order, with the number of containers before it. A rule between container n-1 and n has `before` n. The array is shared. */
35
+ export declare function rulePaths(doc: Doc): RuleAt[];
29
36
  export declare function blockContainers(block: Block, prefix: readonly number[]): Path[];
30
37
  /** True when the path leads to a container that exists. Cheap: it follows the path instead of listing every container. */
31
38
  export declare function hasContainer(doc: Doc, path: Path): boolean;
@@ -35,6 +42,8 @@ export declare function kindAt(doc: Doc, path: Path): "p" | "h" | "pre" | "li" |
35
42
  export declare const setContent: (doc: Doc, path: Path, content: readonly Inline[]) => Doc;
36
43
  /** Replaces the block that holds a container (a paragraph, heading or code block) in its parent list of blocks. */
37
44
  export declare function mapBlockList(doc: Doc, path: Path, fn: (blocks: readonly Block[], index: number) => readonly Block[]): Doc;
45
+ /** Blocks as list item children: a table or definition list cannot sit in an item, so its text stays as paragraphs. */
46
+ export declare function asChildren(blocks: readonly Block[]): ItemChild[];
38
47
  /** The block at a path that ends at a block: `[b]`, `[b, n]` inside a quote, `[b, r, c, n]` inside a table cell. */
39
48
  export declare function getBlock(doc: Doc, path: Path): Block | undefined;
40
49
  /** True when the container is a direct child of a list of blocks (so blocks can be inserted beside it). */
package/dist/path.js CHANGED
@@ -11,13 +11,13 @@ export function comparePath(a, b) {
11
11
  }
12
12
  return a.length - b.length;
13
13
  }
14
- function listPaths(list, prefix, out) {
14
+ function listPaths(list, prefix, out, rules) {
15
15
  list.items.forEach((item, i) => {
16
16
  out.push([...prefix, i]);
17
- item.sublists.forEach((sub, k) => listPaths(sub, [...prefix, i, k], out));
17
+ item.children.forEach((child, k) => blockPaths(child, [...prefix, i, k], out, rules));
18
18
  });
19
19
  }
20
- function blockPaths(block, prefix, out) {
20
+ function blockPaths(block, prefix, out, rules) {
21
21
  switch (block.type) {
22
22
  case "p":
23
23
  case "h":
@@ -26,18 +26,20 @@ function blockPaths(block, prefix, out) {
26
26
  break;
27
27
  case "ul":
28
28
  case "ol":
29
- listPaths(block, prefix, out);
29
+ listPaths(block, prefix, out, rules);
30
30
  break;
31
31
  case "dl":
32
32
  block.entries.forEach((_, i) => out.push([...prefix, i]));
33
33
  break;
34
34
  case "table":
35
- block.rows.forEach((row, r) => row.forEach((cell, c) => cell.blocks.forEach((inner, n) => blockPaths(inner, [...prefix, r, c, n], out))));
35
+ block.rows.forEach((row, r) => row.forEach((cell, c) => cell.blocks.forEach((inner, n) => blockPaths(inner, [...prefix, r, c, n], out, rules))));
36
36
  break;
37
37
  case "quote":
38
- block.blocks.forEach((inner, n) => blockPaths(inner, [...prefix, n], out));
38
+ block.blocks.forEach((inner, n) => blockPaths(inner, [...prefix, n], out, rules));
39
+ break;
40
+ case "hr":
41
+ rules?.push({ path: prefix, before: out.length });
39
42
  break;
40
- case "hr": break;
41
43
  }
42
44
  }
43
45
  const containerCache = new WeakMap();
@@ -50,6 +52,17 @@ export function containerPaths(doc) {
50
52
  containerCache.set(doc, out);
51
53
  return out;
52
54
  }
55
+ const ruleCache = new WeakMap();
56
+ export function rulePaths(doc) {
57
+ const hit = ruleCache.get(doc);
58
+ if (hit)
59
+ return hit;
60
+ const out = [];
61
+ const containers = [];
62
+ doc.blocks.forEach((block, b) => blockPaths(block, [b], containers, out));
63
+ ruleCache.set(doc, out);
64
+ return out;
65
+ }
53
66
  export function blockContainers(block, prefix) {
54
67
  const out = [];
55
68
  blockPaths(block, prefix, out);
@@ -83,8 +96,8 @@ function slotInList(list, rest) {
83
96
  return undefined;
84
97
  if (k === undefined)
85
98
  return { content: item.content, item };
86
- const sub = item.sublists[k];
87
- return sub ? slotInList(sub, more) : undefined;
99
+ const child = item.children[k];
100
+ return child ? slotInBlock(child, more) : undefined;
88
101
  }
89
102
  function slotInBlocks(blocks, rest) {
90
103
  const [n, ...more] = rest;
@@ -114,7 +127,7 @@ function updateList(list, rest, content) {
114
127
  return item;
115
128
  if (k === undefined)
116
129
  return { ...item, content };
117
- return { ...item, sublists: item.sublists.map((sub, s) => (s === k ? updateList(sub, more, content) : sub)) };
130
+ return { ...item, children: item.children.map((child, s) => (s === k ? updateBlock(child, more, content) : child)) };
118
131
  });
119
132
  return { ...list, items };
120
133
  }
@@ -143,6 +156,28 @@ function updateBlocks(blocks, rest, content) {
143
156
  }
144
157
  export const setContent = (doc, path, content) => ({ blocks: updateBlocks(doc.blocks, path, content) });
145
158
  export function mapBlockList(doc, path, fn) {
159
+ const applyList = (list, rest) => {
160
+ const [i, k, ...more] = rest;
161
+ if (i === undefined || k === undefined)
162
+ return list;
163
+ return { ...list, items: list.items.map((item, idx) => (idx !== i ? item : { ...item, children: applyChildren(item.children, [k, ...more]) })) };
164
+ };
165
+ const applyChildren = (children, rest) => {
166
+ const [k, ...more] = rest;
167
+ if (k === undefined)
168
+ return [...children];
169
+ if (more.length === 0)
170
+ return asChildren(fn(children, k));
171
+ return children.map((child, idx) => {
172
+ if (idx !== k)
173
+ return child;
174
+ if (child.type === "ul" || child.type === "ol")
175
+ return applyList(child, more);
176
+ if (child.type === "quote")
177
+ return { ...child, blocks: apply(child.blocks, more) };
178
+ return child;
179
+ });
180
+ };
146
181
  const apply = (blocks, rest) => {
147
182
  const [n, ...more] = rest;
148
183
  if (n === undefined)
@@ -154,6 +189,8 @@ export function mapBlockList(doc, path, fn) {
154
189
  return block;
155
190
  if (block.type === "quote")
156
191
  return { ...block, blocks: apply(block.blocks, more) };
192
+ if (block.type === "ul" || block.type === "ol")
193
+ return applyList(block, more);
157
194
  if (block.type === "table") {
158
195
  const [r, c, ...inner] = more;
159
196
  return { ...block, rows: block.rows.map((row, ri) => (ri !== r ? row : row.map((cell, ci) => (ci !== c ? cell : { ...cell, blocks: apply(cell.blocks, inner) })))) };
@@ -163,6 +200,15 @@ export function mapBlockList(doc, path, fn) {
163
200
  };
164
201
  return { blocks: apply(doc.blocks, path) };
165
202
  }
203
+ export function asChildren(blocks) {
204
+ return blocks.flatMap((block) => {
205
+ switch (block.type) {
206
+ case "table": return block.rows.flatMap((row) => row.flatMap((cell) => asChildren(cell.blocks)));
207
+ case "dl": return block.entries.map((entry) => ({ type: "p", content: entry.content }));
208
+ default: return [block];
209
+ }
210
+ });
211
+ }
166
212
  export function getBlock(doc, path) {
167
213
  const find = (blocks, rest) => {
168
214
  const [n, ...more] = rest;
@@ -171,6 +217,8 @@ export function getBlock(doc, path) {
171
217
  return block;
172
218
  if (block.type === "quote")
173
219
  return find(block.blocks, more);
220
+ if (block.type === "ul" || block.type === "ol")
221
+ return findInList(block, more);
174
222
  if (block.type === "table") {
175
223
  const [r, c, ...inner] = more;
176
224
  const cell = r === undefined || c === undefined ? undefined : block.rows[r]?.[c];
@@ -178,6 +226,17 @@ export function getBlock(doc, path) {
178
226
  }
179
227
  return undefined;
180
228
  };
229
+ const findInList = (list, rest) => {
230
+ const [i, k, ...inner] = rest;
231
+ const child = i === undefined || k === undefined ? undefined : list.items[i]?.children[k];
232
+ if (!child)
233
+ return undefined;
234
+ if (inner.length === 0)
235
+ return child;
236
+ if (child.type === "ul" || child.type === "ol")
237
+ return findInList(child, inner);
238
+ return child.type === "quote" ? find(child.blocks, inner) : undefined;
239
+ };
181
240
  return find(doc.blocks, path);
182
241
  }
183
242
  export function inBlockList(doc, path) {
@@ -193,7 +252,18 @@ export function mapListOf(doc, path, fn) {
193
252
  return fn(list, i);
194
253
  return {
195
254
  ...list,
196
- items: list.items.map((item, idx) => idx !== i ? item : { ...item, sublists: item.sublists.flatMap((sub, s) => (s === k ? [apply(sub, more)].filter((x) => x !== undefined) : [sub])) }),
255
+ items: list.items.map((item, idx) => idx !== i ? item : {
256
+ ...item,
257
+ children: item.children.flatMap((sub, s) => {
258
+ if (s !== k)
259
+ return [sub];
260
+ if (sub.type === "ul" || sub.type === "ol") {
261
+ const next = apply(sub, more);
262
+ return next ? [next] : [];
263
+ }
264
+ return sub.type === "quote" ? [{ ...sub, blocks: walk(sub.blocks, more) }] : [sub];
265
+ }),
266
+ }),
197
267
  };
198
268
  };
199
269
  const walk = (blocks, rest) => {
@@ -226,10 +296,27 @@ export function fixup(doc) {
226
296
  if (block.type === "table") {
227
297
  return [{ ...block, rows: block.rows.map((row) => row.map((cell) => ({ ...cell, blocks: fixBlocks(cell.blocks, true) }))) }];
228
298
  }
299
+ if (block.type === "ul" || block.type === "ol")
300
+ return [fixList(block)];
229
301
  return [block];
230
302
  });
231
303
  return out.length === 0 && needOne ? [{ type: "p", content: [] }] : out;
232
304
  };
305
+ const fixList = (list) => ({
306
+ ...list,
307
+ items: list.items.map((item) => ({
308
+ ...item,
309
+ children: item.children.flatMap((child) => {
310
+ if (child.type === "ul" || child.type === "ol")
311
+ return [fixList(child)];
312
+ if (child.type === "quote") {
313
+ const inner = fixBlocks(child.blocks, false);
314
+ return inner.length > 0 ? [{ ...child, blocks: inner }] : [];
315
+ }
316
+ return [child];
317
+ }),
318
+ })),
319
+ });
233
320
  return { blocks: fixBlocks(doc.blocks, true) };
234
321
  }
235
322
  export function clampPos(doc, pos) {
@@ -258,6 +345,8 @@ export function cellChain(doc, path) {
258
345
  return;
259
346
  if (block.type === "quote")
260
347
  walk(block.blocks, more, [...prefix, n]);
348
+ else if (block.type === "ul" || block.type === "ol")
349
+ walkList(block, more, [...prefix, n]);
261
350
  else if (block.type === "table") {
262
351
  const [r, c, ...inner] = more;
263
352
  if (r === undefined || c === undefined)
@@ -268,6 +357,16 @@ export function cellChain(doc, path) {
268
357
  walk(cell.blocks, inner, [...prefix, n, r, c]);
269
358
  }
270
359
  };
360
+ const walkList = (list, rest, prefix) => {
361
+ const [i, k, ...inner] = rest;
362
+ const child = i === undefined || k === undefined ? undefined : list.items[i]?.children[k];
363
+ if (i === undefined || k === undefined || !child)
364
+ return;
365
+ if (child.type === "ul" || child.type === "ol")
366
+ walkList(child, inner, [...prefix, i, k]);
367
+ else if (child.type === "quote")
368
+ walk(child.blocks, inner, [...prefix, i, k]);
369
+ };
271
370
  walk(doc.blocks, path, []);
272
371
  return chain;
273
372
  }
package/dist/plugin.d.ts CHANGED
@@ -3,13 +3,58 @@ import type { CommandDef, Unsubscribe } from "./commands.ts";
3
3
  import type { Engine } from "./engine.ts";
4
4
  import type { ImportOptions } from "./import.ts";
5
5
  import type { KeyBinding } from "./keymap.ts";
6
- import type { Block } from "./model.ts";
6
+ import type { Block, Doc } from "./model.ts";
7
+ import type { EditResult, Pos, Selection } from "./path.ts";
7
8
  import type { UiItem, UiSlot } from "./ui.ts";
8
9
  import type { Ops } from "./ops.ts";
9
10
  import type { PasteHandler } from "./paste.ts";
10
11
  export declare const PLUGIN_API_VERSION = 1;
11
12
  /** Looks at blocks about to be pasted and returns the blocks to insert. May take time (an upload); then the paste waits. */
12
13
  export type PasteTransform = (blocks: readonly Block[]) => readonly Block[] | Promise<readonly Block[]>;
14
+ /**
15
+ * Gives the formats a copy, cut or drag writes besides the editor's own HTML and plain text: `{ "text/markdown": "..." }`.
16
+ * Return a format the editor already writes (`text/plain`, `text/html`) to replace it. Return undefined to add nothing.
17
+ * Called once per copy with the selected blocks; it must be quick and must not change the document.
18
+ */
19
+ export type CopyFormatter = (blocks: readonly Block[]) => Readonly<Record<string, string>> | undefined;
20
+ /**
21
+ * Puts right what an edit left, such as a footnote whose reference was deleted. Runs after every edit that changes the document, on
22
+ * the result, and returns the document and selection to use; the repair is part of the same undo step. It must be quick, and
23
+ * must return the same document when there is nothing to put right.
24
+ */
25
+ export type Normalizer = (doc: Doc, selection: Selection) => EditResult;
26
+ /** What changed in the editor, as the page-facing part of a plugin hears of it. */
27
+ export interface ViewChange {
28
+ readonly docChanged: boolean;
29
+ readonly selectionChanged: boolean;
30
+ /** The editor gained or lost the focus. */
31
+ readonly focusMoved: boolean;
32
+ }
33
+ /**
34
+ * What the browser view gives the page-facing part of a plugin. Types that name the page (`HTMLElement`, `Node`) are written
35
+ * as `object` here so this file stays free of the DOM; the plugin's view file knows what they are.
36
+ */
37
+ export interface ViewHost {
38
+ /** The element the editor is drawn in (an `HTMLElement`). */
39
+ readonly element: object;
40
+ readonly engine: Engine;
41
+ readonly ops: Ops;
42
+ /** Adds an event listener that is taken away again when the editor or the plugin is. */
43
+ listen(target: object, type: string, handler: (event: never) => void): void;
44
+ /** Called after the page was changed by the plugin itself, so the editor does not take it for a change from outside. */
45
+ quiet(): void;
46
+ /** Called after the editor has drawn each change. */
47
+ onChange(listener: (change: ViewChange) => void): Unsubscribe;
48
+ /** The position in the document for a place in the page (`Node`, offset), or null. */
49
+ posFromDom(node: object, offset: number): Pos | null;
50
+ /** The place in the page (`Node`, offset) for a position in the document, or null. */
51
+ domFromPos(pos: Pos): {
52
+ readonly node: object;
53
+ readonly offset: number;
54
+ } | null;
55
+ }
56
+ /** The page-facing part of a plugin: set up once the editor has a page, returns what takes it away again. */
57
+ export type ViewExtension = (host: ViewHost) => void | Unsubscribe;
13
58
  /** What a plugin needs of a file that was pasted or dropped. A browser `File` fits. */
14
59
  export interface BlobLike {
15
60
  readonly type: string;
@@ -24,7 +69,7 @@ export interface FileLike extends BlobLike {
24
69
  /** Turns pasted or dropped files into blocks, or returns undefined if it does not handle them. */
25
70
  export type FileHandler = (files: readonly FileLike[]) => readonly Block[] | undefined | Promise<readonly Block[] | undefined>;
26
71
  /** What a plugin sees of the editor: the same object a host gets, nothing more. */
27
- export type EditorHandle = Pick<Engine, "snapshot" | "apply" | "commands" | "subscribe" | "select" | "keymap" | "warn" | "importOptions" | "ui">;
72
+ export type EditorHandle = Pick<Engine, "snapshot" | "apply" | "commands" | "subscribe" | "select" | "keymap" | "warn" | "report" | "importOptions" | "ui">;
28
73
  export interface PluginHost {
29
74
  readonly editor: EditorHandle;
30
75
  /** The editing operations, so a plugin does not import the editor to use them. */
@@ -32,6 +77,10 @@ export interface PluginHost {
32
77
  command<A, V>(def: CommandDef<A, V>): void;
33
78
  keybinding(binding: KeyBinding): void;
34
79
  pasteHandler(handler: PasteHandler): void;
80
+ /** Adds a repair that runs after every edit that changes the document. */
81
+ normalize(normalizer: Normalizer): void;
82
+ /** Adds formats to what copy, cut and drag write to the clipboard. Later plugins win when two give the same type. */
83
+ copyFormat(formatter: CopyFormatter): void;
35
84
  /** Takes over Enter, Backspace at the start, and paste inside a kind of container the plugin owns. One plugin per kind. */
36
85
  behavior(kind: ContainerKind, behavior: ContainerBehavior): void;
37
86
  /** Watches typed text before it is inserted, to turn a shortcut into something else. */
@@ -51,6 +100,8 @@ export interface Plugin {
51
100
  /** The plugin API versions it supports: `1`, `1.x`, or a range such as `>=1 <3`. A mismatch fails when it is added. */
52
101
  readonly api: string;
53
102
  setup(host: PluginHost): void | Unsubscribe;
103
+ /** Only in a browser: the part that draws and handles things on the page (table handles, say). The engine never calls it. */
104
+ readonly view?: ViewExtension;
54
105
  }
55
106
  /** Whether a version satisfies a range written as `N`, `N.x`, `>=N`, `<N` or a pair like `>=1 <3`. */
56
107
  export declare function satisfiesApi(range: string, version: number): boolean;
@@ -0,0 +1,41 @@
1
+ /** Elements that are dropped together with everything inside them. */
2
+ export declare const DROPPED_ELEMENTS: ReadonlySet<string>;
3
+ /** Elements that only group blocks: they end the paragraph before and after, and what is inside is kept. */
4
+ export declare const GROUPING_ELEMENTS: ReadonlySet<string>;
5
+ export declare const DEFAULT_LINK_SCHEMES: readonly string[];
6
+ /** Schemes an image address may use. Not configurable: a `data:` address is checked separately. */
7
+ export declare const IMAGE_SCHEMES: readonly string[];
8
+ /** The `data:` image types kept, with the first bytes (base64) their data must start with: the bytes are trusted, not the declared type. */
9
+ export declare const DATA_IMAGE_SIGNATURES: readonly (readonly [string, string])[];
10
+ /** The limits on what is read. */
11
+ export interface ContentLimits {
12
+ /** Longest markup read, in characters. Longer is refused whole: nothing is read and `onLimit` is told. */
13
+ readonly maxInputLength: number;
14
+ /** Deepest nesting of elements. An element nested deeper is dropped and what is inside it stays at the deepest allowed level. */
15
+ readonly maxDepth: number;
16
+ /** Longest `data:` image address, in characters. 0 refuses every `data:` image. */
17
+ readonly maxDataImageLength: number;
18
+ }
19
+ /** A profile: the limits and the choices a host makes. */
20
+ export interface ContentPolicy extends ContentLimits {
21
+ /** URL schemes a link may use besides relative and fragment addresses. */
22
+ readonly linkSchemes: readonly string[];
23
+ /** `semantic` keeps meaning only; `allowlist` also keeps text color, background and alignment. (Pasting is always `semantic`.) */
24
+ readonly styles: "semantic" | "allowlist";
25
+ /** Keep relative image addresses (`a.png`, `/a.png`) when pasting. Loading always keeps them. Default false in every profile. */
26
+ readonly pasteRelativeImages: boolean;
27
+ }
28
+ /** The name of a profile in `CONTENT_PROFILES`. */
29
+ export type ContentProfile = "strict" | "standard" | "loose";
30
+ /** The three profiles. `standard` is the default; `strict` is for hosts that take content from people they do not trust; `loose` for hosts that trust theirs. */
31
+ export declare const CONTENT_PROFILES: Readonly<Record<ContentProfile, ContentPolicy>>;
32
+ /** What a host may say: a profile to start from, then single choices on top of it. */
33
+ export type ContentOptions = {
34
+ readonly profile?: ContentProfile;
35
+ } & Partial<ContentPolicy>;
36
+ /**
37
+ * The policy for the options: the profile (`standard` when none is named), with each given choice laid over it. A choice that
38
+ * cannot be used (an unknown profile, a limit that is not a whole number of at least 0, a list that is not strings) is left out, the
39
+ * profile's value is used, and `complain` is told.
40
+ */
41
+ export declare function contentPolicy(options?: ContentOptions, complain?: (message: string) => void): ContentPolicy;
package/dist/policy.js ADDED
@@ -0,0 +1,40 @@
1
+ export const DROPPED_ELEMENTS = new Set([
2
+ "script", "style", "link", "meta", "base", "object", "embed", "iframe", "frame", "frameset", "noscript", "template",
3
+ "head", "textarea", "input", "select", "option", "button", "canvas", "audio", "video", "applet", "xml",
4
+ ]);
5
+ export const GROUPING_ELEMENTS = new Set([
6
+ "div", "section", "article", "header", "footer", "aside", "nav", "figure", "figcaption", "address", "main", "form",
7
+ "body", "html", "center", "details", "summary", "dd", "dt", "fieldset", "hgroup",
8
+ ]);
9
+ export const DEFAULT_LINK_SCHEMES = ["http", "https", "mailto", "tel"];
10
+ export const IMAGE_SCHEMES = ["http", "https"];
11
+ export const DATA_IMAGE_SIGNATURES = [
12
+ ["image/png", "iVBORw0KGgo"], ["image/jpeg", "/9j/"], ["image/gif", "R0lGOD"], ["image/webp", "UklGR"], ["image/bmp", "Qk"],
13
+ ];
14
+ export const CONTENT_PROFILES = {
15
+ strict: { pasteRelativeImages: false, linkSchemes: ["http", "https", "mailto"], styles: "semantic", maxDataImageLength: 0, maxDepth: 32, maxInputLength: 1_000_000 },
16
+ standard: { pasteRelativeImages: false, linkSchemes: DEFAULT_LINK_SCHEMES, styles: "allowlist", maxDataImageLength: 12_000_000, maxDepth: 100, maxInputLength: 50_000_000 },
17
+ loose: { pasteRelativeImages: false, linkSchemes: ["http", "https", "mailto", "tel", "sms", "ftp", "ftps"], styles: "allowlist", maxDataImageLength: 25_000_000, maxDepth: 200, maxInputLength: 200_000_000 },
18
+ };
19
+ export function contentPolicy(options = {}, complain = () => undefined) {
20
+ const { profile = "standard", ...rest } = options;
21
+ if (!Object.hasOwn(CONTENT_PROFILES, profile))
22
+ complain(`content.profile "${String(profile)}" is not a profile: using standard (strict, standard or loose).`);
23
+ const base = Object.hasOwn(CONTENT_PROFILES, profile) ? CONTENT_PROFILES[profile] : CONTENT_PROFILES.standard;
24
+ const given = {};
25
+ for (const [key, value] of Object.entries(rest)) {
26
+ if (value === undefined)
27
+ continue;
28
+ const ok = key === "linkSchemes" ? Array.isArray(value) && value.every((v) => typeof v === "string" && /^[a-z][a-z0-9+.-]*$/i.test(v))
29
+ : key === "styles" ? value === "semantic" || value === "allowlist"
30
+ : key === "pasteRelativeImages" ? typeof value === "boolean"
31
+ : key === "maxDepth" ? Number.isInteger(value) && value >= 1
32
+ : (key === "maxInputLength" || key === "maxDataImageLength") ? Number.isInteger(value) && value >= 0
33
+ : false;
34
+ if (ok)
35
+ given[key] = value;
36
+ else
37
+ complain(`content.${key} cannot be ${JSON.stringify(value)}: using the ${profile in CONTENT_PROFILES ? profile : "standard"} profile's value.`);
38
+ }
39
+ return { ...base, ...given };
40
+ }