@aicayzer/inkkit 0.0.17 → 0.0.18

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 CHANGED
@@ -15,6 +15,8 @@ A Markdown editor for the web and for apps that host a web view. It edits format
15
15
  npm install @aicayzer/inkkit
16
16
  ```
17
17
 
18
+ An app that does not render diagrams can import from `@aicayzer/inkkit/core` instead. That entry leaves Mermaid out of the app's bundle and keeps diagrams as editable source. See [Builds without diagrams](docs/api.md#builds-without-diagrams).
19
+
18
20
  ## Use
19
21
 
20
22
  ```ts
@@ -7,7 +7,6 @@ interface Callout extends Parent {
7
7
  fold: string;
8
8
  title: string;
9
9
  titleSource: string;
10
- listPlaceholder: boolean;
11
10
  children: RootContent[];
12
11
  }
13
12
  declare module 'mdast' {
package/dist/callouts.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { $node, $prose, $remark } from '@milkdown/kit/utils';
2
2
  import { editorLabels } from './labels.js';
3
+ import { singleLine } from './references.js';
3
4
  import { remarkCtx } from '@milkdown/kit/core';
4
5
  import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
5
6
  import { defaultHandlers } from 'mdast-util-to-markdown';
@@ -37,7 +38,7 @@ function remarkCallouts() {
37
38
  });
38
39
  return (tree, file) => {
39
40
  const source = String(file.value).replace(/^\uFEFF/, '');
40
- const visit = (node, parent, index) => {
41
+ const visit = (node) => {
41
42
  if (node.type === 'blockquote') {
42
43
  const start = node.position?.start.offset, end = node.position?.end.offset;
43
44
  const raw = start == null || end == null ? '' : source.slice(start, end);
@@ -117,14 +118,13 @@ function remarkCallouts() {
117
118
  .slice(header.length - (match[4]?.length ?? 0))
118
119
  .trimEnd(),
119
120
  titleSource: match[4]?.trimEnd() ?? '',
120
- listPlaceholder: parent?.type === 'listItem' && index === 0,
121
121
  children: children.map((child) => visit(child)),
122
122
  position: node.position,
123
123
  };
124
124
  }
125
125
  }
126
126
  if ('children' in node)
127
- node.children = node.children.map((child, index) => visit(child, node, index));
127
+ node.children = node.children.map((child) => visit(child));
128
128
  return node;
129
129
  };
130
130
  tree.children = tree.children.map((node) => visit(node));
@@ -141,7 +141,6 @@ export const calloutSchema = $node('inkkit_callout', (ctx) => ({
141
141
  fold: { default: '' },
142
142
  title: { default: '' },
143
143
  titleSource: { default: null },
144
- listPlaceholder: { default: false },
145
144
  },
146
145
  parseDOM: [
147
146
  {
@@ -152,34 +151,25 @@ export const calloutSchema = $node('inkkit_callout', (ctx) => ({
152
151
  const kind = (dom.getAttribute('data-inkkit-callout') ?? '').toUpperCase();
153
152
  const marker = dom.getAttribute('data-inkkit-marker') ?? `[!${kind}]`;
154
153
  const fold = dom.getAttribute('data-inkkit-fold') ?? '';
155
- const titleSource = dom.getAttribute('data-inkkit-title');
156
- const placeholder = dom.getAttribute('data-inkkit-list-placeholder');
157
- const previous = dom.previousElementSibling;
158
- const titleHeader = titleSource == null
159
- ? undefined
160
- : plainHeader(ctx.get(remarkCtx), `${marker} ${titleSource}`);
154
+ // A title read from the visible text is written with its punctuation
155
+ // escaped, so it reads back as the same plain text.
156
+ const visibleTitle = singleLine(dom.querySelector('.inkkit-callout-title')?.textContent ?? '').trim();
157
+ const titleSource = dom.getAttribute('data-inkkit-title') ??
158
+ visibleTitle.replace(/[!-/:-@[-`{-~]/g, '\\$&');
159
+ const titleHeader = plainHeader(ctx.get(remarkCtx), `${marker} ${titleSource}`);
161
160
  if (!kinds.has(kind) ||
162
161
  !/^\[![A-Za-z]+\]$/.test(marker) ||
163
162
  marker.slice(2, -1).toUpperCase() !== kind ||
164
163
  !['', '+', '-'].includes(fold) ||
165
- (titleSource != null &&
166
- (/[\r\n]/.test(titleSource) || titleHeader == null)))
164
+ /[\r\n]/.test(titleSource) ||
165
+ titleHeader == null)
167
166
  return false;
168
167
  return {
169
168
  kind,
170
169
  marker,
171
170
  fold,
172
- title: titleHeader == null
173
- ? (dom.querySelector('.inkkit-callout-title')?.textContent ?? '')
174
- : titleHeader.slice(marker.length + 1).trimEnd(),
171
+ title: titleHeader.slice(marker.length + 1).trimEnd(),
175
172
  titleSource,
176
- listPlaceholder: placeholder == null
177
- ? dom.parentElement?.tagName === 'LI' &&
178
- (!previous ||
179
- (previous.tagName === 'P' &&
180
- !previous.textContent &&
181
- !previous.childElementCount))
182
- : placeholder === 'true',
183
173
  };
184
174
  },
185
175
  },
@@ -192,7 +182,6 @@ export const calloutSchema = $node('inkkit_callout', (ctx) => ({
192
182
  'data-inkkit-marker': node.attrs.marker,
193
183
  'data-inkkit-fold': node.attrs.fold,
194
184
  'data-inkkit-title': node.attrs.titleSource ?? node.attrs.title,
195
- 'data-inkkit-list-placeholder': String(node.attrs.listPlaceholder),
196
185
  },
197
186
  [
198
187
  'strong',
@@ -213,7 +202,6 @@ export const calloutSchema = $node('inkkit_callout', (ctx) => ({
213
202
  fold: node.fold,
214
203
  title: node.title,
215
204
  titleSource: node.titleSource,
216
- listPlaceholder: node.listPlaceholder,
217
205
  });
218
206
  state.next(node.children);
219
207
  state.closeNode();
@@ -222,17 +210,6 @@ export const calloutSchema = $node('inkkit_callout', (ctx) => ({
222
210
  toMarkdown: {
223
211
  match: (node) => node.type.name === 'inkkit_callout',
224
212
  runner(state, node) {
225
- const parent = state.top();
226
- const first = parent?.children?.[0];
227
- // Lists require a leading paragraph even when their authored first block is a callout.
228
- if (node.attrs.listPlaceholder &&
229
- parent?.type === 'listItem' &&
230
- parent.children?.length === 1 &&
231
- first?.type === 'paragraph' &&
232
- first.children?.length === 1 &&
233
- first.children[0]?.type === 'html' &&
234
- first.children[0].value === '<br />')
235
- parent.children = [];
236
213
  state.openNode('inkkitCallout', undefined, { ...node.attrs });
237
214
  state.next(node.content);
238
215
  state.closeNode();
@@ -11,6 +11,8 @@ function readFence(value) {
11
11
  fence.marker.length <= 30000 &&
12
12
  typeof fence.info === 'string' &&
13
13
  !/[\r\n]/.test(fence.info) &&
14
+ // A backtick in the info string of a backtick fence is not a fence.
15
+ !(fence.marker[0] === '`' && fence.info.includes('`')) &&
14
16
  typeof fence.close === 'string' &&
15
17
  (!fence.close ||
16
18
  new RegExp(`^${fence.marker[0]}{${fence.marker.length},}$`).test(fence.close)) &&
@@ -61,6 +63,9 @@ export const preservingCodeBlocks = codeBlockSchema.extendSchema((base) => (ctx)
61
63
  let authoredFence = null;
62
64
  try {
63
65
  authoredFence = readFence(JSON.parse(dom.getAttribute('data-inkkit-fence') ?? 'null'));
66
+ // An unclosed fence would take in the content that follows it.
67
+ if (!authoredFence?.close)
68
+ authoredFence = null;
64
69
  }
65
70
  catch {
66
71
  /* Ordinary code stays usable without provenance. */
@@ -108,7 +113,7 @@ export const fencedCodeHandler = (node, parent, state, info) => {
108
113
  return defaultHandlers.code(node, parent, state, info);
109
114
  const character = fence.marker[0];
110
115
  let length = fence.marker.length;
111
- const body = node.value.replace(/\r\n?/g, '\n');
116
+ const body = (node.value ?? '').replace(/\r\n?/g, '\n');
112
117
  for (const line of body.split('\n')) {
113
118
  const run = new RegExp(`^ {0,3}(${character}+)[ \\t]*$`).exec(line);
114
119
  if (run)
@@ -1,4 +1,5 @@
1
1
  import { Fragment, type Node as ProseNode } from '@milkdown/kit/prose/model';
2
+ import { type Transaction } from '@milkdown/kit/prose/state';
2
3
  import type { EditorView } from '@milkdown/kit/prose/view';
3
4
  import type { Literal } from 'mdast';
4
5
  export type CommentSyntax = 'html' | 'obsidian';
@@ -49,6 +50,26 @@ export declare const commentBlock: import("@milkdown/kit/utils").$Node;
49
50
  export declare const commentVisibility: import("@milkdown/kit/utils").$Prose;
50
51
  export declare function setCommentVisibility(view: EditorView, visible: boolean): void;
51
52
  export declare const comments: (import("@milkdown/kit/utils").$Node | import("@milkdown/kit/utils").$Prose)[];
53
+ /**
54
+ * Whether text inside a comment ends it early for a reader other than InkKit.
55
+ * HTML ends a comment at `-->` or `--!>`, and treats a start of `>` or `->` as
56
+ * an empty comment. A `%%` comment ends at its next `%%`.
57
+ */
58
+ export declare function commentTextEndsEarly(syntax: string, text: string): boolean;
59
+ /**
60
+ * Refuses an edit that gives a comment text which ends it early. A comment
61
+ * that already held that text before the edit is left alone, so a note that
62
+ * has one still loads and can be edited elsewhere.
63
+ */
64
+ export declare function assertCommentsEndWhereWritten(before: ProseNode, after: ProseNode): void;
65
+ /** Marks a transaction that replaces the document with source given as written. */
66
+ export declare const sourceReplacement = "inkkit:sourceReplacement";
67
+ /**
68
+ * Whether an edit is typed or inserted by a person in the formatted editor. A
69
+ * history step restores a document the editor already accepted, and a source
70
+ * replacement is the author's own text, so neither is checked.
71
+ */
72
+ export declare function isWrittenEdit(tr: Transaction): boolean;
52
73
  export declare function isComment(node: ProseNode): boolean;
53
74
  export declare function shareableFragment(content: Fragment): Fragment;
54
75
  export declare function commentSelectionContent(doc: ProseNode, from: number, to: number, content: Fragment): Fragment;
package/dist/comments.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { $node, $prose, $remark } from '@milkdown/kit/utils';
2
2
  import { Fragment } from '@milkdown/kit/prose/model';
3
+ import { isHistoryTransaction } from '@milkdown/kit/prose/history';
3
4
  import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
4
5
  import { isImageBlock } from './image-blocks.js';
5
6
  import { fromMarkdown } from 'mdast-util-from-markdown';
@@ -322,11 +323,16 @@ function commentNode(inline) {
322
323
  {
323
324
  tag: `${inline ? 'span' : 'div'}[data-inkkit-comment]`,
324
325
  preserveWhitespace: 'full',
325
- getAttrs: (dom) => ({
326
- syntax: dom.getAttribute('data-inkkit-comment') === 'obsidian'
326
+ getAttrs(dom) {
327
+ const syntax = dom.getAttribute('data-inkkit-comment') === 'obsidian'
327
328
  ? 'obsidian'
328
- : 'html',
329
- }),
329
+ : 'html';
330
+ // Text that would end the comment early would leave the rest as
331
+ // Markdown, so it stays plain text.
332
+ if (commentTextEndsEarly(syntax, dom.textContent ?? ''))
333
+ return false;
334
+ return { syntax };
335
+ },
330
336
  },
331
337
  ],
332
338
  toDOM: (node) => [
@@ -384,6 +390,51 @@ export function setCommentVisibility(view, visible) {
384
390
  .setMeta('addToHistory', false));
385
391
  }
386
392
  export const comments = [commentInline, commentBlock, commentVisibility].flat();
393
+ /**
394
+ * Whether text inside a comment ends it early for a reader other than InkKit.
395
+ * HTML ends a comment at `-->` or `--!>`, and treats a start of `>` or `->` as
396
+ * an empty comment. A `%%` comment ends at its next `%%`.
397
+ */
398
+ export function commentTextEndsEarly(syntax, text) {
399
+ return syntax === 'obsidian'
400
+ ? text.includes('%%')
401
+ : /^-?>/.test(text) || text.includes('-->') || text.includes('--!>');
402
+ }
403
+ /**
404
+ * Refuses an edit that gives a comment text which ends it early. A comment
405
+ * that already held that text before the edit is left alone, so a note that
406
+ * has one still loads and can be edited elsewhere.
407
+ */
408
+ export function assertCommentsEndWhereWritten(before, after) {
409
+ const unsafe = (doc, visit) => doc.descendants((node) => {
410
+ if (!isComment(node))
411
+ return true;
412
+ if (commentTextEndsEarly(node.attrs.syntax, node.textContent))
413
+ visit(`${node.attrs.syntax}:${node.textContent}`);
414
+ return false;
415
+ });
416
+ let allowed;
417
+ unsafe(after, (key) => {
418
+ if (!allowed) {
419
+ allowed = new Map();
420
+ unsafe(before, (known) => allowed.set(known, (allowed.get(known) ?? 0) + 1));
421
+ }
422
+ const remaining = allowed.get(key) ?? 0;
423
+ if (remaining === 0)
424
+ throw new Error('A comment cannot contain text that would end it early');
425
+ allowed.set(key, remaining - 1);
426
+ });
427
+ }
428
+ /** Marks a transaction that replaces the document with source given as written. */
429
+ export const sourceReplacement = 'inkkit:sourceReplacement';
430
+ /**
431
+ * Whether an edit is typed or inserted by a person in the formatted editor. A
432
+ * history step restores a document the editor already accepted, and a source
433
+ * replacement is the author's own text, so neither is checked.
434
+ */
435
+ export function isWrittenEdit(tr) {
436
+ return !isHistoryTransaction(tr) && !tr.getMeta(sourceReplacement);
437
+ }
387
438
  export function isComment(node) {
388
439
  return (node.type.name === 'comment_inline' || node.type.name === 'comment_block');
389
440
  }
package/dist/core.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { InkKitEditor } from './editor.js';
2
+ export type { EditorEvents, EditorOptions, TextInputPreferences, CommandState, EditingMode, CaretState, Mark, Block, FormatCommand, Keymap, } from './editor.js';
3
+ export type { TableCommand, TableOptions } from './tables.js';
4
+ export type { Heading } from './outline.js';
5
+ export { InkKitError } from './types.js';
6
+ export type { DocumentFormat, DocumentContext, DocumentInput, DocumentSnapshot, EditorErrorCode, ImageAdapter, WikiLinkReference, WikiLinkAdapter, FileReference, FileContext, FilePresentation, FileAdapter, CapturedImage, PortableImage, ClipboardInput, ClipboardOutput, ClipboardImage, ClipboardDiagram, PrintableDocument, PrintableWarning, TextRange, ReadableTextSnapshot, TextRect, ViewportInsets, ViewportOptions, ViewportSnapshot, } from './types.js';
7
+ export type { EditorLabels } from './labels.js';
package/dist/core.js ADDED
@@ -0,0 +1,2 @@
1
+ export { InkKitEditor } from './editor.js';
2
+ export { InkKitError } from './types.js';
@@ -0,0 +1,2 @@
1
+ import { dropIndicatorConfig } from '@milkdown/kit/plugin/cursor';
2
+ export declare const cursor: (import("@milkdown/kit/utils").$Ctx<dropIndicatorConfig, "dropIndicatorConfig"> | import("@milkdown/kit/utils").$Ctx<import("@milkdown/kit/plugin/cursor").DropIndicatorState, "dropIndicatorState"> | import("@milkdown/kit/utils").$Prose)[];
package/dist/cursor.js ADDED
@@ -0,0 +1,40 @@
1
+ import { dropIndicatorConfig, dropIndicatorDOMPlugin, dropIndicatorState, gapCursorPlugin, } from '@milkdown/kit/plugin/cursor';
2
+ import { Plugin } from '@milkdown/kit/prose/state';
3
+ import { $prose } from '@milkdown/kit/utils';
4
+ import { createDropIndicatorPlugin } from 'prosemirror-drop-indicator';
5
+ // Milkdown's drop indicator, except that it does not hide once the editor is
6
+ // gone. The indicator hides 30 ms after a drop or drag end, and setting its
7
+ // state after the editor is destroyed throws.
8
+ const dropIndicator = $prose((ctx) => {
9
+ let live = false;
10
+ const plugin = createDropIndicatorPlugin({
11
+ onShow: (options) => ctx.set(dropIndicatorState.key, options),
12
+ onHide: () => {
13
+ if (live)
14
+ ctx.set(dropIndicatorState.key, null);
15
+ },
16
+ onDrag: () => true,
17
+ });
18
+ const view = plugin.spec.view;
19
+ return new Plugin({
20
+ ...plugin.spec,
21
+ view(editorView) {
22
+ live = true;
23
+ const inner = view(editorView);
24
+ return {
25
+ ...inner,
26
+ destroy() {
27
+ live = false;
28
+ inner.destroy?.();
29
+ },
30
+ };
31
+ },
32
+ });
33
+ });
34
+ export const cursor = [
35
+ gapCursorPlugin,
36
+ dropIndicatorConfig,
37
+ dropIndicatorState,
38
+ dropIndicatorDOMPlugin,
39
+ dropIndicator,
40
+ ].flat();
package/dist/dialect.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import { editorViewCtx, serializerCtx } from '@milkdown/kit/core';
2
2
  import { commonmark, docSchema, hardbreakSchema, imageSchema, paragraphSchema, remarkInlineLinkPlugin, } from '@milkdown/kit/preset/commonmark';
3
3
  import { extendListItemSchemaForTask, strikethroughAttr, strikethroughInputRule, strikethroughKeymap, strikethroughSchema, toggleStrikethroughCommand, wrapInTaskListInputRule, tableSchema, tableHeaderRowSchema, tableHeaderSchema, tableRowSchema, tableCellSchema, tableEditingPlugin, tableKeymap, goToNextTableCellCommand, goToPrevTableCellCommand, insertTableCommand, addRowBeforeCommand, addRowAfterCommand, addColBeforeCommand, addColAfterCommand, selectRowCommand, selectColCommand, deleteSelectedCellsCommand, setAlignCommand, exitTable, } from '@milkdown/kit/preset/gfm';
4
- import { $node, $remark } from '@milkdown/kit/utils';
4
+ import { $node, $prose, $remark } from '@milkdown/kit/utils';
5
+ import { keymap } from '@milkdown/kit/prose/keymap';
5
6
  import { autolinkInputRule } from './autolink.js';
7
+ import { isScriptAddress } from './link-safety.js';
6
8
  import { sourceAttribute } from './source.js';
7
9
  import { linkedSyntax, wikiLinkSchema, namedFileMarkdown, fileReference, } from './linked-syntax.js';
8
10
  import { normalizeTableAlignment, normalizeTableColumnAlignment, } from './table-alignment.js';
9
11
  import { preservingCodeBlocks, remarkCodeFences, fencedCodeHandler, } from './code-fences.js';
10
12
  import { literalBlock, createLiteralPreservation } from './literals.js';
11
- import { imageAttrs, imageBlocks, markdownImageAttrs, readParagraph, } from './image-blocks.js';
13
+ import { imageAttrs, imageBlockName, imageBlocks, markdownImageAttrs, readParagraph, } from './image-blocks.js';
12
14
  import { references, remarkReferencesPlugin } from './references.js';
13
15
  import { callouts, remarkCalloutsPlugin } from './callouts.js';
14
16
  import { inlineHighlight } from './inline-highlight.js';
@@ -47,7 +49,7 @@ function textAt(parent, node, offset) {
47
49
  }
48
50
  /** A link whose text is its own URL is written as the bare URL, the way it was typed. Only where reading
49
51
  * it back gives the same link again: nothing may run into it at either end. */
50
- function writesBare(node, parent) {
52
+ function writesBare(node, parent, inCell) {
51
53
  const [child, ...rest] = node.children;
52
54
  if (node.title ||
53
55
  rest.length > 0 ||
@@ -64,12 +66,16 @@ function writesBare(node, parent) {
64
66
  const after = textAt(parent, node, 1);
65
67
  if (after && (after.type !== 'text' || !afterURL.test(after.value)))
66
68
  return false;
67
- return true;
69
+ // Spaces that end a cell are written as character references, which a bare URL would run into.
70
+ return !(inCell &&
71
+ after?.type === 'text' &&
72
+ /^[ \t]+$/.test(after.value) &&
73
+ parent?.children.at(-1) === after);
68
74
  }
69
- const link = (node, parent, state, info) => writesBare(node, parent)
75
+ const link = (node, parent, state, info) => writesBare(node, parent, state.stack.includes('tableCell'))
70
76
  ? node.url
71
77
  : defaultHandlers.link(node, parent, state, info);
72
- link.peek = (node, parent, state) => writesBare(node, parent)
78
+ link.peek = (node, parent, state) => writesBare(node, parent, state.stack.includes('tableCell'))
73
79
  ? node.url[0]
74
80
  : defaultHandlers.link.peek(node, parent, state);
75
81
  function remarkDialect() {
@@ -94,10 +100,17 @@ function remarkDialect() {
94
100
  handlers: {
95
101
  text(node, parent, state, info) {
96
102
  const value = defaultHandlers.text(node, parent, state, info);
97
- // GFM trims literal whitespace at cell edges on reopening.
98
- return state.stack.includes('tableCell')
99
- ? value.replace(/^[ \t]+|[ \t]+$/g, (spaces) => spaces.replaceAll(' ', '&#x20;').replaceAll('\t', '&#x9;'))
100
- : value;
103
+ // GFM trims spaces and tabs at the edges of a cell on reopening, so
104
+ // only the start of its first node and the end of its last need
105
+ // encoding. Inside a mark or link, delimiters stand before the pipe.
106
+ if (!state.stack.includes('tableCell') ||
107
+ (parent?.type !== 'paragraph' && parent?.type !== 'tableCell'))
108
+ return value;
109
+ const siblings = parent.children;
110
+ const encode = (spaces) => spaces.replaceAll(' ', '&#x20;').replaceAll('\t', '&#x9;');
111
+ return value
112
+ .replace(/^[ \t]+/, (spaces) => siblings[0] === node ? encode(spaces) : spaces)
113
+ .replace(/[ \t]+$/, (spaces) => siblings.at(-1) === node ? encode(spaces) : spaces);
101
114
  },
102
115
  thematicBreak(node, parent, state) {
103
116
  const value = defaultHandlers.thematicBreak(node, parent, state);
@@ -112,6 +125,20 @@ function remarkDialect() {
112
125
  },
113
126
  });
114
127
  add('toMarkdownExtensions', { handlers: { link, code: fencedCodeHandler } });
128
+ return padShortRows;
129
+ }
130
+ // GFM reads a row with fewer cells than its header as ending in empty cells.
131
+ // The editor holds them from the start; otherwise table editing adds them
132
+ // after the first edit, and again after every Undo.
133
+ function padShortRows(node) {
134
+ if (node.type === 'table') {
135
+ const width = node.children[0]?.children.length ?? 0;
136
+ for (const row of node.children)
137
+ while (row.children.length < width)
138
+ row.children.push({ type: 'tableCell', children: [] });
139
+ }
140
+ if ('children' in node)
141
+ node.children.forEach(padShortRows);
115
142
  }
116
143
  export const remarkDialectPlugin = $remark('remarkDialect', () => remarkDialect);
117
144
  // Reference links retain their authored form instead of expanding to inline links.
@@ -200,8 +227,11 @@ const normalizedImages = imageSchema.extendSchema((base) => (ctx) => {
200
227
  {
201
228
  tag: 'img[src]',
202
229
  getAttrs(dom) {
230
+ const src = dom.getAttribute('src') ?? '';
231
+ if (isScriptAddress(src))
232
+ return false;
203
233
  return {
204
- src: dom.getAttribute('src') ?? '',
234
+ src,
205
235
  alt: dom.getAttribute('alt') ?? '',
206
236
  title: dom.getAttribute('title') ?? '',
207
237
  };
@@ -234,6 +264,61 @@ const normalizedImages = imageSchema.extendSchema((base) => (ctx) => {
234
264
  },
235
265
  };
236
266
  });
267
+ // The preset drops a cell's final break, and a paragraph writes an empty one as
268
+ // the spacer `<br />`; either reads back differently, so a cell writes its
269
+ // paragraph's content directly.
270
+ function cellToMarkdown(base) {
271
+ return {
272
+ ...base,
273
+ runner(state, node) {
274
+ if (node.childCount === 1 && node.firstChild.type.name === 'paragraph')
275
+ state.openNode('tableCell').next(node.firstChild.content).closeNode();
276
+ else
277
+ base.runner(state, node);
278
+ },
279
+ };
280
+ }
281
+ // A cell cannot hold a line ending, so Shift-Enter there inserts the `<br />`
282
+ // a cell reads back. The preset's command would instead split the paragraph
283
+ // when it already ends in a break, which reshapes the table.
284
+ const cellBreakKeymap = $prose(() => keymap({
285
+ 'Shift-Enter': (state, dispatch) => {
286
+ const { $from } = state.selection;
287
+ if (!/^table_(cell|header)$/.test($from.node(-1)?.type.name ?? ''))
288
+ return false;
289
+ dispatch?.(state.tr
290
+ // A break inside a mark or link would be inline HTML there, which
291
+ // a cell does not read back as a break.
292
+ .replaceSelectionWith(state.schema.nodes.hardbreak.create({ isHTML: true }), false)
293
+ .scrollIntoView());
294
+ return true;
295
+ },
296
+ }));
297
+ // A list item that opens with a block, such as a code fence, is held with the
298
+ // empty first paragraph the schema requires. Written out, that paragraph would
299
+ // be a spacer `<br />` line; without it the item reads back the same.
300
+ const listItems = extendListItemSchemaForTask.extendSchema((previous) => (ctx) => {
301
+ const schema = previous(ctx);
302
+ return {
303
+ ...schema,
304
+ toMarkdown: {
305
+ ...schema.toMarkdown,
306
+ runner(state, node) {
307
+ const [first, second] = [node.firstChild, node.maybeChild(1)];
308
+ // A task item's checkbox is written on its first line.
309
+ if (node.attrs.checked == null &&
310
+ first?.type.name === 'paragraph' &&
311
+ first.content.size === 0 &&
312
+ second &&
313
+ second.type.name !== 'paragraph' &&
314
+ // An image block reads back as a paragraph in that position.
315
+ second.type.name !== imageBlockName)
316
+ node = node.copy(node.content.cut(first.nodeSize));
317
+ schema.toMarkdown.runner(state, node);
318
+ },
319
+ },
320
+ };
321
+ });
237
322
  export const tables = [
238
323
  tableSchema.extendSchema((previous) => (ctx) => ({
239
324
  ...previous(ctx),
@@ -245,6 +330,7 @@ export const tables = [
245
330
  return {
246
331
  ...schema,
247
332
  attrs: { ...schema.attrs, alignment: { default: null } },
333
+ toMarkdown: cellToMarkdown(schema.toMarkdown),
248
334
  parseDOM: schema.parseDOM?.map((rule) => 'tag' in rule
249
335
  ? {
250
336
  ...rule,
@@ -267,6 +353,7 @@ export const tables = [
267
353
  return {
268
354
  ...schema,
269
355
  attrs: { ...schema.attrs, alignment: { default: null } },
356
+ toMarkdown: cellToMarkdown(schema.toMarkdown),
270
357
  parseDOM: schema.parseDOM?.map((rule) => 'tag' in rule
271
358
  ? {
272
359
  ...rule,
@@ -300,6 +387,7 @@ export const tables = [
300
387
  ].flat();
301
388
  export function createDialect(images, wikiLinks = false, files = false) {
302
389
  return [
390
+ cellBreakKeymap,
303
391
  remarkCodeFences,
304
392
  remarkReferencesPlugin,
305
393
  remarkCalloutsPlugin,
@@ -326,7 +414,7 @@ export function createDialect(images, wikiLinks = false, files = false) {
326
414
  normalizedImages,
327
415
  imageBlocks,
328
416
  autolinkInputRule,
329
- extendListItemSchemaForTask,
417
+ listItems,
330
418
  strikethroughAttr,
331
419
  strikethroughSchema,
332
420
  strikethroughInputRule,
@@ -0,0 +1,19 @@
1
+ import type { Node } from '@milkdown/kit/prose/model';
2
+ import { Selection } from '@milkdown/kit/prose/state';
3
+ import type { EditorView } from '@milkdown/kit/prose/view';
4
+ /**
5
+ * Where blocks dropped at a point go: in place of an empty top-level
6
+ * paragraph at or beside the point, otherwise after the top-level block under the
7
+ * point, or beside a top-level leaf such as an image rather than inside it. The result is a range
8
+ * to replace; an insertion point has `from` equal to `to`.
9
+ */
10
+ export declare function blockDropRange(view: EditorView, x: number, y: number): {
11
+ from: number;
12
+ to: number;
13
+ };
14
+ /**
15
+ * A caret just after inserted blocks that end at `pos`: at the end of the
16
+ * last text block, or a gap cursor after a block that holds no text, such as
17
+ * a rule or table. It never selects a node, so typing never replaces one.
18
+ */
19
+ export declare function caretAfter(doc: Node, pos: number): Selection;
@@ -0,0 +1,47 @@
1
+ import { GapCursor } from '@milkdown/kit/prose/gapcursor';
2
+ import { Selection, TextSelection } from '@milkdown/kit/prose/state';
3
+ /**
4
+ * Where blocks dropped at a point go: in place of an empty top-level
5
+ * paragraph at or beside the point, otherwise after the top-level block under the
6
+ * point, or beside a top-level leaf such as an image rather than inside it. The result is a range
7
+ * to replace; an insertion point has `from` equal to `to`.
8
+ */
9
+ export function blockDropRange(view, x, y) {
10
+ const { doc, selection } = view.state;
11
+ const hit = view.posAtCoords({ left: x, top: y });
12
+ const $pos = doc.resolve(hit?.pos ?? selection.from);
13
+ // Only an empty paragraph is a placeholder; an empty heading or code block
14
+ // was written.
15
+ const empty = (node) => node?.type.name === 'paragraph' && node.content.size === 0;
16
+ if ($pos.depth === 1 && empty($pos.parent))
17
+ return { from: $pos.before(1), to: $pos.after(1) };
18
+ if ($pos.depth > 0)
19
+ return { from: $pos.after(1), to: $pos.after(1) };
20
+ // A point in the margin beside an empty paragraph, as in an empty note.
21
+ if (empty($pos.nodeAfter))
22
+ return { from: $pos.pos, to: $pos.pos + $pos.nodeAfter.nodeSize };
23
+ if (empty($pos.nodeBefore))
24
+ return { from: $pos.pos - $pos.nodeBefore.nodeSize, to: $pos.pos };
25
+ const leaf = hit && hit.inside >= 0 && doc.resolve(hit.inside).depth === 0
26
+ ? doc.nodeAt(hit.inside)
27
+ : null;
28
+ const at = leaf ? hit.inside + leaf.nodeSize : $pos.pos;
29
+ return { from: at, to: at };
30
+ }
31
+ /**
32
+ * A caret just after inserted blocks that end at `pos`: at the end of the
33
+ * last text block, or a gap cursor after a block that holds no text, such as
34
+ * a rule or table. It never selects a node, so typing never replaces one.
35
+ */
36
+ export function caretAfter(doc, pos) {
37
+ const $pos = doc.resolve(pos);
38
+ if ($pos.nodeBefore?.isTextblock)
39
+ return TextSelection.create(doc, pos - 1);
40
+ // `valid` is public at run time but missing from the type declarations.
41
+ const gap = GapCursor;
42
+ if (gap.valid($pos))
43
+ return new GapCursor($pos);
44
+ return (TextSelection.findFrom($pos, -1, true) ??
45
+ TextSelection.findFrom($pos, 1, true) ??
46
+ Selection.near($pos));
47
+ }