@aicayzer/inkkit 0.0.14 → 0.0.16
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/NOTICE +0 -4
- package/README.md +27 -24
- package/dist/comments.js +7 -0
- package/dist/dialect.js +10 -19
- package/dist/editor.d.ts +3 -0
- package/dist/editor.js +52 -27
- package/dist/highlight.js +109 -22
- package/dist/image-blocks.d.ts +71 -0
- package/dist/image-blocks.js +286 -0
- package/dist/images.d.ts +3 -1
- package/dist/images.js +52 -23
- package/dist/mermaid.js +108 -6
- package/dist/paste-html.d.ts +23 -0
- package/dist/paste-html.js +441 -0
- package/dist/paste.js +39 -52
- package/dist/preserve.d.ts +1 -0
- package/dist/preserve.js +87 -26
- package/dist/search.d.ts +3 -3
- package/dist/search.js +2 -2
- package/dist/style.css +98 -4
- package/dist/text-ranges.js +10 -1
- package/package.json +1 -1
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { keydownHandler } from '@milkdown/kit/prose/keymap';
|
|
2
|
+
import { GapCursor } from '@milkdown/kit/prose/gapcursor';
|
|
3
|
+
import { Fragment, } from '@milkdown/kit/prose/model';
|
|
4
|
+
import { NodeSelection, Plugin, PluginKey, Selection, TextSelection, } from '@milkdown/kit/prose/state';
|
|
5
|
+
import { $nodeSchema, $prose } from '@milkdown/kit/utils';
|
|
6
|
+
export const imageBlockName = 'image_block';
|
|
7
|
+
/** Attributes shared by an inline image and a standalone image block. */
|
|
8
|
+
export const imageAttrs = {
|
|
9
|
+
src: { default: '', validate: 'string' },
|
|
10
|
+
alt: { default: '', validate: 'string' },
|
|
11
|
+
title: { default: '', validate: 'string' },
|
|
12
|
+
inkkitFileRaw: { default: null },
|
|
13
|
+
inkkitFileTarget: { default: null },
|
|
14
|
+
inkkitFileFragment: { default: null },
|
|
15
|
+
inkkitFileWidth: { default: null },
|
|
16
|
+
inkkitFileInTable: { default: false },
|
|
17
|
+
};
|
|
18
|
+
// Remark represents a missing title as null; the image schema accepts strings.
|
|
19
|
+
export function markdownImageAttrs(node) {
|
|
20
|
+
return {
|
|
21
|
+
src: String(node.url ?? ''),
|
|
22
|
+
alt: String(node.alt ?? ''),
|
|
23
|
+
title: String(node.title ?? ''),
|
|
24
|
+
inkkitFileRaw: node.inkkitFileRaw ?? null,
|
|
25
|
+
inkkitFileTarget: node.inkkitFileTarget ?? null,
|
|
26
|
+
inkkitFileFragment: node.inkkitFileFragment ?? null,
|
|
27
|
+
inkkitFileWidth: node.inkkitFileWidth ?? null,
|
|
28
|
+
inkkitFileInTable: node.inkkitFileInTable ?? false,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Whether a node displays an authored image, inline or as its own block. */
|
|
32
|
+
export function isImage(node) {
|
|
33
|
+
return node.type.name === 'image' || node.type.name === imageBlockName;
|
|
34
|
+
}
|
|
35
|
+
export function isImageBlock(node) {
|
|
36
|
+
return node?.type.name === imageBlockName;
|
|
37
|
+
}
|
|
38
|
+
function accepts(parent, index, type) {
|
|
39
|
+
return parent.canReplaceWith(index, index + 1, type);
|
|
40
|
+
}
|
|
41
|
+
/** The image a paragraph holds as its only content, without marks, which makes the paragraph a
|
|
42
|
+
* standalone image block. A linked or emphasised image stays inline so its marks are kept. */
|
|
43
|
+
export function standaloneImage(children) {
|
|
44
|
+
const [image] = children;
|
|
45
|
+
return children.length === 1 &&
|
|
46
|
+
image.type.name === 'image' &&
|
|
47
|
+
!image.marks.length
|
|
48
|
+
? image
|
|
49
|
+
: undefined;
|
|
50
|
+
}
|
|
51
|
+
/** Reads a Markdown paragraph, as an image block when it holds a standalone image and the enclosing
|
|
52
|
+
* block accepts one. */
|
|
53
|
+
export function readParagraph(state, node, paragraph) {
|
|
54
|
+
state.openNode(paragraph);
|
|
55
|
+
if (node.children)
|
|
56
|
+
state.next(node.children);
|
|
57
|
+
else
|
|
58
|
+
state.addText(String(node.value ?? ''));
|
|
59
|
+
const type = state.schema.nodes[imageBlockName];
|
|
60
|
+
const element = state.top();
|
|
61
|
+
const image = standaloneImage(element.content);
|
|
62
|
+
state.close();
|
|
63
|
+
const parent = state.top();
|
|
64
|
+
if (image &&
|
|
65
|
+
type &&
|
|
66
|
+
parent?.type.contentMatch
|
|
67
|
+
.matchFragment(Fragment.fromArray(parent.content))
|
|
68
|
+
?.matchType(type))
|
|
69
|
+
state.addNode(type, image.attrs);
|
|
70
|
+
else {
|
|
71
|
+
state.openNode(paragraph);
|
|
72
|
+
for (const child of element.content)
|
|
73
|
+
state.top().push(child);
|
|
74
|
+
state.closeNode();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// A standalone image is a block with no inside, so no edit can put text beside it. In Markdown it
|
|
78
|
+
// remains a paragraph holding the image, which is how it is read and written.
|
|
79
|
+
export const imageBlockSchema = $nodeSchema(imageBlockName, () => ({
|
|
80
|
+
group: 'block',
|
|
81
|
+
atom: true,
|
|
82
|
+
selectable: true,
|
|
83
|
+
draggable: true,
|
|
84
|
+
marks: '',
|
|
85
|
+
attrs: imageAttrs,
|
|
86
|
+
toDOM: (node) => [
|
|
87
|
+
'p',
|
|
88
|
+
{ class: 'inkkit-image-block' },
|
|
89
|
+
[
|
|
90
|
+
'img',
|
|
91
|
+
{
|
|
92
|
+
src: String(node.attrs.src),
|
|
93
|
+
alt: String(node.attrs.alt),
|
|
94
|
+
title: String(node.attrs.title),
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
],
|
|
98
|
+
parseMarkdown: { match: () => false, runner: () => undefined },
|
|
99
|
+
toMarkdown: {
|
|
100
|
+
match: (node) => node.type.name === imageBlockName,
|
|
101
|
+
runner: (state, node) => {
|
|
102
|
+
state.openNode('paragraph');
|
|
103
|
+
state.next(node.type.schema.nodes.image.create(node.attrs));
|
|
104
|
+
state.closeNode();
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
}));
|
|
108
|
+
// An edit can leave a paragraph holding only an image, which reading its Markdown again would give
|
|
109
|
+
// as an image block. The caret keeps its side of the image.
|
|
110
|
+
const normalise = $prose(() => new Plugin({
|
|
111
|
+
key: new PluginKey('inkkitImageBlockNormalise'),
|
|
112
|
+
appendTransaction(transactions, old, state) {
|
|
113
|
+
const type = state.schema.nodes[imageBlockName];
|
|
114
|
+
if (!type || !transactions.some((tr) => tr.docChanged))
|
|
115
|
+
return null;
|
|
116
|
+
// Compare the documents, as mark and attribute steps change no positions.
|
|
117
|
+
const start = old.doc.content.findDiffStart(state.doc.content);
|
|
118
|
+
const end = old.doc.content.findDiffEnd(state.doc.content);
|
|
119
|
+
if (start == null || !end)
|
|
120
|
+
return null;
|
|
121
|
+
const found = new Map();
|
|
122
|
+
state.doc.nodesBetween(Math.min(start, end.b), Math.max(start, end.b), (node, pos, parent, index) => {
|
|
123
|
+
if (!node.isTextblock)
|
|
124
|
+
return true;
|
|
125
|
+
const children = [];
|
|
126
|
+
node.forEach((child) => children.push(child));
|
|
127
|
+
if (node.type.name === 'paragraph' &&
|
|
128
|
+
standaloneImage(children) &&
|
|
129
|
+
parent &&
|
|
130
|
+
accepts(parent, index, type))
|
|
131
|
+
found.set(pos, node);
|
|
132
|
+
return false;
|
|
133
|
+
});
|
|
134
|
+
if (!found.size)
|
|
135
|
+
return null;
|
|
136
|
+
const tr = state.tr;
|
|
137
|
+
const { head } = state.selection;
|
|
138
|
+
let caret;
|
|
139
|
+
for (const [pos, node] of [...found].sort(([a], [b]) => b - a)) {
|
|
140
|
+
const end = pos + node.nodeSize;
|
|
141
|
+
if (head > pos && head < end)
|
|
142
|
+
caret = head > pos + 1 ? { pos: end, side: 1 } : { pos, side: -1 };
|
|
143
|
+
tr.replaceWith(pos, end, type.create(node.firstChild.attrs));
|
|
144
|
+
}
|
|
145
|
+
if (caret)
|
|
146
|
+
tr.setSelection(new GapCursor(tr.doc.resolve(tr.mapping.map(caret.pos, caret.side))));
|
|
147
|
+
return tr;
|
|
148
|
+
},
|
|
149
|
+
}));
|
|
150
|
+
/** The image block a selection rests beside or selects, as the position for a new paragraph. */
|
|
151
|
+
function newParagraphAt(state) {
|
|
152
|
+
const { selection } = state;
|
|
153
|
+
if (selection instanceof NodeSelection && isImageBlock(selection.node))
|
|
154
|
+
return selection.to;
|
|
155
|
+
if (selection instanceof GapCursor &&
|
|
156
|
+
(isImageBlock(selection.$head.nodeBefore) ||
|
|
157
|
+
isImageBlock(selection.$head.nodeAfter)))
|
|
158
|
+
return selection.head;
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
/** Opens an empty paragraph beside a selected image block, or at a gap beside one. */
|
|
162
|
+
function openParagraph(state) {
|
|
163
|
+
const pos = newParagraphAt(state);
|
|
164
|
+
const paragraph = state.schema.nodes.paragraph;
|
|
165
|
+
if (pos == null || !paragraph)
|
|
166
|
+
return undefined;
|
|
167
|
+
const $pos = state.doc.resolve(pos);
|
|
168
|
+
if (!accepts($pos.parent, $pos.index(), paragraph))
|
|
169
|
+
return undefined;
|
|
170
|
+
const tr = state.tr.insert(pos, paragraph.create());
|
|
171
|
+
return tr.setSelection(TextSelection.create(tr.doc, pos + 1));
|
|
172
|
+
}
|
|
173
|
+
const enter = (state, dispatch) => {
|
|
174
|
+
const tr = openParagraph(state);
|
|
175
|
+
if (!tr)
|
|
176
|
+
return false;
|
|
177
|
+
dispatch?.(tr.scrollIntoView());
|
|
178
|
+
return true;
|
|
179
|
+
};
|
|
180
|
+
/** Backspace or Delete toward an image block selects it before anything is deleted; from a gap beside
|
|
181
|
+
* one toward text, the caret moves into the text. An empty paragraph next to one is removed. */
|
|
182
|
+
function towardImage(dir) {
|
|
183
|
+
return (state, dispatch) => {
|
|
184
|
+
const { selection, doc } = state;
|
|
185
|
+
if (!selection.empty)
|
|
186
|
+
return false;
|
|
187
|
+
const $head = selection.$head;
|
|
188
|
+
if (selection instanceof GapCursor) {
|
|
189
|
+
const node = dir < 0 ? $head.nodeBefore : $head.nodeAfter;
|
|
190
|
+
const other = dir < 0 ? $head.nodeAfter : $head.nodeBefore;
|
|
191
|
+
if (!node || !(isImageBlock(other) || isImageBlock(node)))
|
|
192
|
+
return false;
|
|
193
|
+
const next = isImageBlock(node)
|
|
194
|
+
? NodeSelection.create(doc, dir < 0 ? $head.pos - 1 : $head.pos)
|
|
195
|
+
: Selection.findFrom($head, dir, true);
|
|
196
|
+
if (!next)
|
|
197
|
+
return false;
|
|
198
|
+
dispatch?.(state.tr.setSelection(next).scrollIntoView());
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
if (!$head.parent.isTextblock || $head.depth === 0)
|
|
202
|
+
return false;
|
|
203
|
+
const atEdge = dir < 0
|
|
204
|
+
? $head.parentOffset === 0
|
|
205
|
+
: $head.parentOffset === $head.parent.content.size;
|
|
206
|
+
if (!atEdge)
|
|
207
|
+
return false;
|
|
208
|
+
const index = $head.index(-1) + dir;
|
|
209
|
+
const parent = $head.node(-1);
|
|
210
|
+
const neighbour = index >= 0 && index < parent.childCount ? parent.child(index) : undefined;
|
|
211
|
+
if (!isImageBlock(neighbour))
|
|
212
|
+
return false;
|
|
213
|
+
const block = dir < 0 ? $head.before() - 1 : $head.after();
|
|
214
|
+
// An empty paragraph goes, unless its parent needs it, such as a list item's first line.
|
|
215
|
+
if ($head.parent.content.size === 0 &&
|
|
216
|
+
$head.parent.type.name === 'paragraph' &&
|
|
217
|
+
parent.canReplace($head.index(-1), $head.index(-1) + 1)) {
|
|
218
|
+
const tr = state.tr.delete($head.before(), $head.after());
|
|
219
|
+
dispatch?.(tr
|
|
220
|
+
.setSelection(new GapCursor(tr.doc.resolve($head.before())))
|
|
221
|
+
.scrollIntoView());
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
if (!dispatch)
|
|
225
|
+
return true;
|
|
226
|
+
dispatch(state.tr.setSelection(NodeSelection.create(doc, block)).scrollIntoView());
|
|
227
|
+
return true;
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const keys = keydownHandler({
|
|
231
|
+
Enter: enter,
|
|
232
|
+
'Shift-Enter': enter,
|
|
233
|
+
Backspace: towardImage(-1),
|
|
234
|
+
Delete: towardImage(1),
|
|
235
|
+
});
|
|
236
|
+
const interaction = $prose(() => new Plugin({
|
|
237
|
+
key: new PluginKey('inkkitImageBlocks'),
|
|
238
|
+
props: {
|
|
239
|
+
handleKeyDown: (view, event) => !view.composing && view.editable && keys(view, event),
|
|
240
|
+
handleTextInput(view, _from, _to, text) {
|
|
241
|
+
const tr = openParagraph(view.state);
|
|
242
|
+
if (!tr)
|
|
243
|
+
return false;
|
|
244
|
+
view.dispatch(tr.insertText(text).scrollIntoView());
|
|
245
|
+
return true;
|
|
246
|
+
},
|
|
247
|
+
// A click beside an image card, not on it, puts the caret after the image.
|
|
248
|
+
handleClickOn(view, _pos, node, nodePos, event, direct) {
|
|
249
|
+
if (!direct ||
|
|
250
|
+
!view.editable ||
|
|
251
|
+
!isImageBlock(node) ||
|
|
252
|
+
!(event.target instanceof Element) ||
|
|
253
|
+
event.target.closest('.image'))
|
|
254
|
+
return false;
|
|
255
|
+
view.dispatch(view.state.tr.setSelection(new GapCursor(view.state.doc.resolve(nodePos + node.nodeSize))));
|
|
256
|
+
return true;
|
|
257
|
+
},
|
|
258
|
+
handleDOMEvents: {
|
|
259
|
+
// ProseMirror replaces a selection when composition starts; a selected image is kept and
|
|
260
|
+
// the composed text goes after it.
|
|
261
|
+
compositionstart(view) {
|
|
262
|
+
const { selection } = view.state;
|
|
263
|
+
if (view.editable &&
|
|
264
|
+
selection instanceof NodeSelection &&
|
|
265
|
+
isImageBlock(selection.node))
|
|
266
|
+
view.dispatch(view.state.tr.setSelection(new GapCursor(view.state.doc.resolve(selection.to))));
|
|
267
|
+
return false;
|
|
268
|
+
},
|
|
269
|
+
// Composed text needs a paragraph to land in before the browser writes it, as the gap
|
|
270
|
+
// cursor plugin does for its own gaps.
|
|
271
|
+
beforeinput(view, event) {
|
|
272
|
+
if (event.inputType !== 'insertCompositionText' || !view.editable)
|
|
273
|
+
return false;
|
|
274
|
+
const tr = openParagraph(view.state);
|
|
275
|
+
if (tr)
|
|
276
|
+
view.dispatch(tr);
|
|
277
|
+
return false;
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
}));
|
|
282
|
+
export const imageBlocks = [
|
|
283
|
+
imageBlockSchema,
|
|
284
|
+
normalise,
|
|
285
|
+
interaction,
|
|
286
|
+
].flat();
|
package/dist/images.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ declare class ImageView implements NodeView {
|
|
|
17
17
|
private readonly runtime?;
|
|
18
18
|
private readonly canMutate;
|
|
19
19
|
dom: HTMLElement;
|
|
20
|
+
/** The card or picture, which a standalone image's block row holds. */
|
|
21
|
+
private element;
|
|
20
22
|
private image?;
|
|
21
23
|
private media?;
|
|
22
24
|
private pdf?;
|
|
@@ -45,5 +47,5 @@ declare class ImageView implements NodeView {
|
|
|
45
47
|
private commit;
|
|
46
48
|
private canResize;
|
|
47
49
|
}
|
|
48
|
-
export declare function imageView(adapter?: ImageAdapter, runtime?: FileViewRuntime, canMutate?: () => boolean): import("@milkdown/kit/utils").$View<import("@milkdown/kit/utils").$Node, (node: ProseNode, view: EditorView, getPos: () => number | undefined) => ImageView
|
|
50
|
+
export declare function imageView(adapter?: ImageAdapter, runtime?: FileViewRuntime, canMutate?: () => boolean): import("@milkdown/kit/utils").$View<import("@milkdown/kit/utils").$Node, (node: ProseNode, view: EditorView, getPos: () => number | undefined) => ImageView>[];
|
|
49
51
|
export {};
|
package/dist/images.js
CHANGED
|
@@ -3,6 +3,7 @@ import { closeHistory } from '@milkdown/kit/prose/history';
|
|
|
3
3
|
import { $view } from '@milkdown/kit/utils';
|
|
4
4
|
import { FileResolution } from './files.js';
|
|
5
5
|
import { fileReference } from './linked-syntax.js';
|
|
6
|
+
import { imageBlockSchema } from './image-blocks.js';
|
|
6
7
|
/** A width rides in the alt text, as ``. Other Markdown readers
|
|
7
8
|
* show the picture and keep the number without requiring a private extension. */
|
|
8
9
|
export function splitAlt(alt) {
|
|
@@ -23,6 +24,8 @@ class ImageView {
|
|
|
23
24
|
runtime;
|
|
24
25
|
canMutate;
|
|
25
26
|
dom;
|
|
27
|
+
/** The card or picture, which a standalone image's block row holds. */
|
|
28
|
+
element;
|
|
26
29
|
image;
|
|
27
30
|
media;
|
|
28
31
|
pdf;
|
|
@@ -37,8 +40,15 @@ class ImageView {
|
|
|
37
40
|
this.adapter = adapter;
|
|
38
41
|
this.runtime = runtime;
|
|
39
42
|
this.canMutate = canMutate;
|
|
40
|
-
this.
|
|
41
|
-
this.
|
|
43
|
+
this.element = document.createElement('span');
|
|
44
|
+
this.element.className = 'image';
|
|
45
|
+
if (node.isBlock) {
|
|
46
|
+
this.dom = document.createElement('div');
|
|
47
|
+
this.dom.className = 'inkkit-image-block';
|
|
48
|
+
this.dom.append(this.element);
|
|
49
|
+
}
|
|
50
|
+
else
|
|
51
|
+
this.dom = this.element;
|
|
42
52
|
this.view.dom.addEventListener('inkkit-cancel-resize', this.cancelResize);
|
|
43
53
|
this.view.dom.addEventListener('inkkit-media-policy', this.mediaPolicy);
|
|
44
54
|
if (runtime) {
|
|
@@ -145,11 +155,22 @@ class ImageView {
|
|
|
145
155
|
card(label, status, retry = false) {
|
|
146
156
|
const footer = document.createElement('span');
|
|
147
157
|
footer.className = 'inkkit-file-footer';
|
|
158
|
+
const name = document.createElement('span');
|
|
159
|
+
name.className = 'inkkit-file-name';
|
|
148
160
|
const text = document.createElement('span');
|
|
149
161
|
text.className = 'inkkit-file-label';
|
|
150
162
|
text.textContent = label;
|
|
151
163
|
text.title = label;
|
|
152
|
-
|
|
164
|
+
name.append(text);
|
|
165
|
+
if (this.runtime?.adapter.open) {
|
|
166
|
+
const open = this.button('', this.activate);
|
|
167
|
+
open.classList.add('inkkit-file-open');
|
|
168
|
+
open.setAttribute('aria-label', `${this.runtime.labels.fileOpen} ${label}`);
|
|
169
|
+
open.title = this.runtime.labels.fileOpen;
|
|
170
|
+
open.append(openArrow());
|
|
171
|
+
name.append(open);
|
|
172
|
+
}
|
|
173
|
+
footer.append(name);
|
|
153
174
|
if (status) {
|
|
154
175
|
const description = document.createElement('span');
|
|
155
176
|
description.className = 'inkkit-file-status';
|
|
@@ -159,12 +180,7 @@ class ImageView {
|
|
|
159
180
|
}
|
|
160
181
|
if (retry && this.runtime)
|
|
161
182
|
footer.append(this.button(this.runtime.labels.fileRetry, () => this.resolution?.retry()));
|
|
162
|
-
|
|
163
|
-
const open = this.button(this.runtime.labels.fileOpen, this.activate);
|
|
164
|
-
open.setAttribute('aria-label', `${this.runtime.labels.fileOpen} ${label}`);
|
|
165
|
-
footer.append(open);
|
|
166
|
-
}
|
|
167
|
-
this.dom.append(footer);
|
|
183
|
+
this.element.append(footer);
|
|
168
184
|
}
|
|
169
185
|
failPreview() {
|
|
170
186
|
this.previewFailed = true;
|
|
@@ -183,11 +199,11 @@ class ImageView {
|
|
|
183
199
|
}
|
|
184
200
|
: splitAlt(String(this.node.attrs.alt ?? ''));
|
|
185
201
|
this.width = width;
|
|
186
|
-
this.
|
|
202
|
+
this.element.textContent = '';
|
|
187
203
|
this.image = undefined;
|
|
188
204
|
if (this.runtime) {
|
|
189
|
-
this.
|
|
190
|
-
this.
|
|
205
|
+
this.element.classList.add('inkkit-file');
|
|
206
|
+
this.element.oncontextmenu = (event) => {
|
|
191
207
|
if (!this.runtime?.adapter.contextMenu || !this.active())
|
|
192
208
|
return;
|
|
193
209
|
const captured = this.runtime.capture();
|
|
@@ -205,11 +221,11 @@ class ImageView {
|
|
|
205
221
|
const label = state.status === 'resolved'
|
|
206
222
|
? state.presentation.label || alt || reference.reference
|
|
207
223
|
: alt || reference.reference;
|
|
208
|
-
this.
|
|
209
|
-
this.
|
|
224
|
+
this.element.setAttribute('aria-label', label);
|
|
225
|
+
this.element.dataset.inkkitFileState = this.previewFailed
|
|
210
226
|
? 'error'
|
|
211
227
|
: state.status;
|
|
212
|
-
this.
|
|
228
|
+
this.element.dataset.inkkitFileKind =
|
|
213
229
|
state.status === 'resolved' ? state.presentation.kind : 'file';
|
|
214
230
|
if (this.previewFailed) {
|
|
215
231
|
this.card(label, this.runtime.labels.filePreviewUnavailable, true);
|
|
@@ -247,7 +263,7 @@ class ImageView {
|
|
|
247
263
|
});
|
|
248
264
|
media.src = presentation.url;
|
|
249
265
|
this.media = media;
|
|
250
|
-
this.
|
|
266
|
+
this.element.append(media);
|
|
251
267
|
this.card(label);
|
|
252
268
|
}
|
|
253
269
|
else if (presentation.kind === 'pdf') {
|
|
@@ -263,7 +279,7 @@ class ImageView {
|
|
|
263
279
|
});
|
|
264
280
|
pdf.src = presentation.url;
|
|
265
281
|
this.pdf = pdf;
|
|
266
|
-
this.
|
|
282
|
+
this.element.append(pdf);
|
|
267
283
|
this.card(label, this.runtime.labels.filePDFPreview);
|
|
268
284
|
}
|
|
269
285
|
else
|
|
@@ -272,11 +288,11 @@ class ImageView {
|
|
|
272
288
|
}
|
|
273
289
|
const presentation = this.adapter?.presentation(String(this.node.attrs.src ?? ''));
|
|
274
290
|
if (!presentation) {
|
|
275
|
-
this.
|
|
276
|
-
this.
|
|
291
|
+
this.element.classList.add('image-absent');
|
|
292
|
+
this.element.textContent = alt || String(this.node.attrs.src ?? '');
|
|
277
293
|
return;
|
|
278
294
|
}
|
|
279
|
-
this.
|
|
295
|
+
this.element.classList.remove('image-absent');
|
|
280
296
|
this.renderImage(presentation.url, alt, width);
|
|
281
297
|
}
|
|
282
298
|
renderImage(url, alt, width) {
|
|
@@ -295,11 +311,12 @@ class ImageView {
|
|
|
295
311
|
this.image = image;
|
|
296
312
|
const preview = document.createElement('span');
|
|
297
313
|
preview.className = 'inkkit-image-preview';
|
|
314
|
+
preview.append(image);
|
|
315
|
+
// The grip sits on the whole card, so it centres on the picture and its name together.
|
|
298
316
|
const handle = document.createElement('span');
|
|
299
317
|
handle.className = 'image-handle';
|
|
300
318
|
handle.addEventListener('pointerdown', this.startResize);
|
|
301
|
-
|
|
302
|
-
this.dom.append(preview);
|
|
319
|
+
this.element.append(preview, handle);
|
|
303
320
|
}
|
|
304
321
|
startResize = (event) => {
|
|
305
322
|
event.preventDefault();
|
|
@@ -364,6 +381,18 @@ class ImageView {
|
|
|
364
381
|
return this.view.editable && !this.view.composing && this.canMutate();
|
|
365
382
|
}
|
|
366
383
|
}
|
|
384
|
+
const svg = 'http://www.w3.org/2000/svg';
|
|
385
|
+
function openArrow() {
|
|
386
|
+
const icon = document.createElementNS(svg, 'svg');
|
|
387
|
+
icon.setAttribute('viewBox', '0 0 12 12');
|
|
388
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
389
|
+
icon.setAttribute('focusable', 'false');
|
|
390
|
+
const path = document.createElementNS(svg, 'path');
|
|
391
|
+
path.setAttribute('d', 'M4 2.5h5.5V8M9.5 2.5 2.5 9.5');
|
|
392
|
+
icon.append(path);
|
|
393
|
+
return icon;
|
|
394
|
+
}
|
|
367
395
|
export function imageView(adapter, runtime, canMutate = () => true) {
|
|
368
|
-
|
|
396
|
+
const view = (schema) => $view(schema.node, () => (node, view, getPos) => new ImageView(node, view, getPos, adapter, runtime, canMutate));
|
|
397
|
+
return [view(imageSchema), view(imageBlockSchema)];
|
|
369
398
|
}
|
package/dist/mermaid.js
CHANGED
|
@@ -7,15 +7,107 @@ import { InkKitError } from './types.js';
|
|
|
7
7
|
export function isMermaid(node) {
|
|
8
8
|
return node.type.name === 'code_block' && node.attrs.language === 'mermaid';
|
|
9
9
|
}
|
|
10
|
+
const refusal = 'HTML, styles, actions, resources and diagram configuration are disabled. The source is retained.';
|
|
11
|
+
// CSS functions that can load an image or display another element. Mermaid's
|
|
12
|
+
// own styles use none of them apart from url() for in-document markers.
|
|
13
|
+
const cssResource = /url\s*\(\s*[^#\s]|(?:image-set|cross-fade|image|element|src)\s*\(/i;
|
|
10
14
|
export function validateDiagram(source) {
|
|
11
15
|
if (source.length > 30000)
|
|
12
16
|
throw new Error('The diagram exceeds the 30,000 character rendering limit.');
|
|
13
17
|
if (!/^\s*(?:%%[^\n]*\n\s*)*(?:flowchart|graph|sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|pie)\b/.test(source))
|
|
14
18
|
throw new Error('This diagram type is not supported. The source is retained.');
|
|
15
|
-
// Mermaid can load image bytes before sanitising its result
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
// Mermaid can load image bytes before sanitising its result, so resource and
|
|
20
|
+
// configuration syntax is refused anywhere in the source, labels included.
|
|
21
|
+
// Statements are checked separately, in Mermaid's parse result.
|
|
22
|
+
if (/%%\{|^\s*---|<\s*[a-z!/?]|&(?:#\w+|[a-z]+);|@\{|\$\$|(?:https?|data|file|javascript|vbscript|blob):|\/\/|url\s*\(|image-set\s*\(|cross-fade\s*\(|@import|\\/im.test(source))
|
|
23
|
+
throw new Error(refusal);
|
|
24
|
+
}
|
|
25
|
+
const entries = (value) => value instanceof Map ? [...value.values()] : Array.isArray(value) ? value : [];
|
|
26
|
+
const filled = (value) => value instanceof Map
|
|
27
|
+
? value.size > 0
|
|
28
|
+
: Array.isArray(value)
|
|
29
|
+
? value.length > 0
|
|
30
|
+
: typeof value === 'object' && value !== null
|
|
31
|
+
? Object.keys(value).length > 0
|
|
32
|
+
: Boolean(value);
|
|
33
|
+
// In strict mode a flowchart records a click handler only as this class.
|
|
34
|
+
const clickable = (classes) => (Array.isArray(classes) ? classes : String(classes ?? '').split(/\s+/))
|
|
35
|
+
.map(String)
|
|
36
|
+
.includes('clickable');
|
|
37
|
+
function statementIn(node, kinds, seen = new Set()) {
|
|
38
|
+
if (typeof node !== 'object' || node === null || seen.has(node))
|
|
39
|
+
return false;
|
|
40
|
+
seen.add(node);
|
|
41
|
+
if (kinds.includes(String(node.stmt)))
|
|
42
|
+
return true;
|
|
43
|
+
return Object.values(node).some((value) => statementIn(value, kinds, seen));
|
|
44
|
+
}
|
|
45
|
+
// Refuses styles, class definitions, link styles, links, click handlers,
|
|
46
|
+
// tooltips, icons and images wherever the grammar accepted them. The names are
|
|
47
|
+
// those of the pinned Mermaid's diagram databases; if one is missing, the
|
|
48
|
+
// diagram is refused rather than passed unchecked.
|
|
49
|
+
function refuseDirectives({ type, db }) {
|
|
50
|
+
const store = db;
|
|
51
|
+
const field = (name) => {
|
|
52
|
+
if (!(name in store))
|
|
53
|
+
throw new Error('The diagram could not be checked. The source is retained.');
|
|
54
|
+
return store[name];
|
|
55
|
+
};
|
|
56
|
+
const read = (name) => {
|
|
57
|
+
const accessor = field(name);
|
|
58
|
+
if (typeof accessor !== 'function')
|
|
59
|
+
throw new Error('The diagram could not be checked. The source is retained.');
|
|
60
|
+
return accessor.call(db);
|
|
61
|
+
};
|
|
62
|
+
let found;
|
|
63
|
+
switch (type) {
|
|
64
|
+
case 'flowchart-v2': {
|
|
65
|
+
const edges = read('getEdges');
|
|
66
|
+
found =
|
|
67
|
+
filled(read('getClasses')) ||
|
|
68
|
+
filled(field('tooltips')) ||
|
|
69
|
+
filled(edges.defaultStyle) ||
|
|
70
|
+
edges.defaultInterpolate !== undefined ||
|
|
71
|
+
entries(read('getVertices')).some((vertex) => filled(vertex.styles) ||
|
|
72
|
+
Boolean(vertex.link || vertex.haveCallback || vertex.icon || vertex.img) ||
|
|
73
|
+
clickable(vertex.classes)) ||
|
|
74
|
+
entries(edges).some((edge) => filled(edge.style) ||
|
|
75
|
+
edge.interpolate !== undefined ||
|
|
76
|
+
clickable(edge.classes)) ||
|
|
77
|
+
entries(read('getSubGraphs')).some((graph) => clickable(graph.classes));
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case 'stateDiagram':
|
|
81
|
+
found =
|
|
82
|
+
statementIn(field('rootDoc'), ['style', 'classDef', 'click']) ||
|
|
83
|
+
filled(read('getClasses')) ||
|
|
84
|
+
filled(read('getLinks')) ||
|
|
85
|
+
entries(read('getStates')).some((state) => filled(state.styles) || filled(state.textStyles));
|
|
86
|
+
break;
|
|
87
|
+
case 'classDiagram':
|
|
88
|
+
found =
|
|
89
|
+
filled(field('styleClasses')) ||
|
|
90
|
+
entries(read('getClasses')).some((node) => filled(node.styles) ||
|
|
91
|
+
Boolean(node.link || node.haveCallback || node.tooltip) ||
|
|
92
|
+
clickable(node.cssClasses));
|
|
93
|
+
break;
|
|
94
|
+
case 'er':
|
|
95
|
+
found =
|
|
96
|
+
filled(read('getClasses')) ||
|
|
97
|
+
entries(read('getEntities')).some((entity) => filled(entity.cssStyles)) ||
|
|
98
|
+
entries(read('getSubGraphs')).some((graph) => filled(graph.cssStyles));
|
|
99
|
+
break;
|
|
100
|
+
case 'sequence':
|
|
101
|
+
found = entries(read('getActors')).some((actor) => filled(actor.links) || filled(actor.properties));
|
|
102
|
+
break;
|
|
103
|
+
case 'pie':
|
|
104
|
+
found = false;
|
|
105
|
+
break;
|
|
106
|
+
default:
|
|
107
|
+
throw new Error('This diagram type is not supported. The source is retained.');
|
|
108
|
+
}
|
|
109
|
+
if (found)
|
|
110
|
+
throw new Error(refusal);
|
|
19
111
|
}
|
|
20
112
|
let sequence = 0;
|
|
21
113
|
let queue = Promise.resolve();
|
|
@@ -34,11 +126,13 @@ export function safeDiagramSVG(svg) {
|
|
|
34
126
|
for (const element of [root, ...root.querySelectorAll('*')]) {
|
|
35
127
|
for (const attribute of [...element.attributes]) {
|
|
36
128
|
if (/^on/i.test(attribute.name) ||
|
|
37
|
-
/(?:https?|data|file|javascript|blob):|@import
|
|
129
|
+
/(?:https?|data|file|javascript|blob):|@import|\\/i.test(attribute.value) ||
|
|
130
|
+
cssResource.test(attribute.value))
|
|
38
131
|
element.removeAttribute(attribute.name);
|
|
39
132
|
}
|
|
40
133
|
if (element.tagName.toLowerCase() === 'style' &&
|
|
41
|
-
/@import|(?:https?|data|file)
|
|
134
|
+
(/@import|(?:https?|data|file):|\\/i.test(element.textContent ?? '') ||
|
|
135
|
+
cssResource.test(element.textContent ?? '')))
|
|
42
136
|
element.remove();
|
|
43
137
|
}
|
|
44
138
|
root.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
@@ -98,6 +192,14 @@ export function renderDiagram(source) {
|
|
|
98
192
|
'suppressErrorRendering',
|
|
99
193
|
],
|
|
100
194
|
});
|
|
195
|
+
// getDiagramFromText is the pinned Mermaid's own parser, which render runs
|
|
196
|
+
// again on the same text; the public parse does not return the diagram. It
|
|
197
|
+
// skips render's configuration step, so the two parse alike only because
|
|
198
|
+
// the source check refuses %%{ directives and front matter.
|
|
199
|
+
refuseDirectives(await mermaid.mermaidAPI.getDiagramFromText(source));
|
|
200
|
+
// Mermaid measures text while laying out, so the holder must be attached
|
|
201
|
+
// and rendered. Such a holder can load CSS resources before sanitising,
|
|
202
|
+
// which is why the source and the parse result are checked first.
|
|
101
203
|
const holder = document.createElement('div');
|
|
102
204
|
holder.style.cssText =
|
|
103
205
|
'position:fixed;left:-100000px;top:0;width:1200px;pointer-events:none';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare function isRichElement(element: Element): boolean;
|
|
2
|
+
/** Whether normalisation enters the element, so its content may be rewritten. */
|
|
3
|
+
export declare function isRewrittenElement(element: Element): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Whether the element's own inline style makes a browser keep the whitespace
|
|
6
|
+
* of its content, as copied editor code does with `white-space: pre`. The
|
|
7
|
+
* declarations are parsed, so case, spacing, comments, quoted text and
|
|
8
|
+
* `!important` do not matter. Style sheets are not consulted.
|
|
9
|
+
*/
|
|
10
|
+
export declare function keepsWhitespace(element: Element): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Rewrites common web-page elements to ones the importer already understands.
|
|
13
|
+
* Elements that will stay literal source are not entered, so their content is
|
|
14
|
+
* preserved exactly.
|
|
15
|
+
*/
|
|
16
|
+
export declare function normalizeClipboardHTML(root: Node): void;
|
|
17
|
+
/**
|
|
18
|
+
* Collapses source whitespace the way a browser renders it: runs become one
|
|
19
|
+
* space, and none remains at the edges of a block or beside a line break.
|
|
20
|
+
* `pre`, `code`, elements that stay literal source and elements whose style
|
|
21
|
+
* keeps whitespace are not entered.
|
|
22
|
+
*/
|
|
23
|
+
export declare function collapseClipboardWhitespace(root: Node): void;
|