edytor 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -72
- package/dist/block/block.svelte.d.ts +6 -4
- package/dist/block/block.svelte.js +10 -4
- package/dist/block/inlineBlock.svelte.d.ts +6 -2
- package/dist/block/inlineBlock.svelte.js +7 -2
- package/dist/cloudflare/room/context.d.ts +6 -0
- package/dist/collaboration/awarenessSelection.d.ts +22 -1
- package/dist/collaboration/awarenessSelection.js +42 -0
- package/dist/components/Block.svelte +3 -1
- package/dist/components/Edytor.svelte +36 -10
- package/dist/components/Edytor.svelte.d.ts +8 -1
- package/dist/components/InlineBlock.svelte +1 -1
- package/dist/crdt/data.d.ts +10 -0
- package/dist/crdt/data.js +20 -2
- package/dist/crdt/doc/reads.d.ts +7 -0
- package/dist/crdt/doc/reads.js +11 -1
- package/dist/crdt/document.d.ts +8 -0
- package/dist/crdt/document.js +12 -0
- package/dist/crdt/edytor-doc.d.ts +18 -0
- package/dist/crdt/edytor-doc.js +2 -1
- package/dist/crdt/index.d.ts +6 -0
- package/dist/crdt/operations.d.ts +1 -1
- package/dist/crdt/operations.js +1 -0
- package/dist/crdt/props.d.ts +32 -0
- package/dist/crdt/props.js +172 -0
- package/dist/{plugins/blockHandles → dnd}/dropIndicator.d.ts +2 -2
- package/dist/{plugins/blockHandles → dnd}/geometry.d.ts +2 -2
- package/dist/dnd/placement.d.ts +200 -0
- package/dist/dnd/placement.js +426 -0
- package/dist/edytor.svelte.d.ts +17 -4
- package/dist/edytor.svelte.js +16 -7
- package/dist/events/beforeInputCommands.d.ts +2 -2
- package/dist/events/beforeInputCommands.js +8 -4
- package/dist/events/onDrop.d.ts +6 -0
- package/dist/events/onDrop.js +38 -10
- package/dist/events/onPaste.d.ts +2 -1
- package/dist/events/onPaste.js +8 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/labels.d.ts +2 -0
- package/dist/labels.js +2 -1
- package/dist/plugins/arrowMove/arrowMove.d.ts +1 -1
- package/dist/plugins/arrowMove/arrowMove.js +1 -1
- package/dist/plugins/blockHandles/BlockHandleController.svelte.d.ts +2 -122
- package/dist/plugins/blockHandles/BlockHandleController.svelte.js +29 -387
- package/dist/plugins/blockHandles/BlockHandles.svelte +14 -32
- package/dist/plugins/blockHandles/blockHandlesPlugin.js +2 -1
- package/dist/plugins/code/CodeHeader.svelte +5 -3
- package/dist/plugins/code/CodePlugin.svelte +3 -3
- package/dist/plugins/code/languages.d.ts +0 -3
- package/dist/plugins/code/languages.js +0 -2
- package/dist/plugins/columns/ColumnResize.svelte +3 -2
- package/dist/plugins/columns/ColumnsPlugin.svelte +1 -1
- package/dist/plugins/columns/gaps.d.ts +7 -5
- package/dist/plugins/columns/gaps.js +11 -6
- package/dist/plugins/columns/resize.svelte.d.ts +3 -3
- package/dist/plugins/columns/resize.svelte.js +11 -10
- package/dist/plugins/equation/EquationPlugin.svelte +1 -8
- package/dist/plugins/equation/EquationPlugin.svelte.d.ts +1 -0
- package/dist/plugins/equation/equation.css +7 -0
- package/dist/plugins/fileDrop.d.ts +28 -0
- package/dist/plugins/fileDrop.js +116 -0
- package/dist/plugins/image/ImagePlugin.svelte +42 -33
- package/dist/plugins/image/ImagePlugin.svelte.d.ts +13 -1
- package/dist/plugins/image/image.css +17 -0
- package/dist/plugins/index.d.ts +3 -4
- package/dist/plugins/index.js +0 -1
- package/dist/plugins/markdownShortcuts.js +5 -5
- package/dist/plugins/media/AudioPlugin.svelte +13 -1
- package/dist/plugins/media/AudioPlugin.svelte.d.ts +11 -1
- package/dist/plugins/media/FilePlugin.svelte +11 -1
- package/dist/plugins/media/FilePlugin.svelte.d.ts +9 -0
- package/dist/plugins/media/SourceBlock.svelte +48 -12
- package/dist/plugins/media/SourceBlock.svelte.d.ts +4 -3
- package/dist/plugins/media/UploadPreview.svelte +32 -0
- package/dist/plugins/media/UploadPreview.svelte.d.ts +11 -0
- package/dist/plugins/media/VideoPlugin.svelte +13 -1
- package/dist/plugins/media/VideoPlugin.svelte.d.ts +11 -1
- package/dist/plugins/media/empty.svelte.d.ts +13 -4
- package/dist/plugins/media/empty.svelte.js +30 -19
- package/dist/plugins/media/media.d.ts +7 -0
- package/dist/plugins/media/upload.css +23 -0
- package/dist/plugins/mention/MentionPlugin.svelte +72 -33
- package/dist/plugins/mention/MentionPlugin.svelte.d.ts +11 -2
- package/dist/plugins/mention/mention.css +6 -0
- package/dist/plugins/pageLink/PageLinkPlugin.svelte +122 -17
- package/dist/plugins/pageLink/PageLinkPlugin.svelte.d.ts +13 -1
- package/dist/plugins/pageLink/pageLink.css +6 -0
- package/dist/plugins/richtext/RichTextPlugin.svelte +14 -7
- package/dist/plugins/richtext/RichTextPlugin.svelte.d.ts +1 -0
- package/dist/plugins/richtext/richtext.css +6 -0
- package/dist/plugins/table/chrome.svelte.js +4 -3
- package/dist/plugins/uploads.svelte.d.ts +45 -32
- package/dist/plugins/uploads.svelte.js +105 -39
- package/dist/plugins.d.ts +40 -14
- package/dist/session/attempt.d.ts +16 -6
- package/dist/session/attempt.js +1 -0
- package/dist/session/bindings.js +34 -31
- package/dist/session/commands.js +2 -0
- package/dist/session/history.js +1 -0
- package/dist/session/inputRules.d.ts +6 -3
- package/dist/session/inputRules.js +9 -5
- package/dist/session/moves.d.ts +33 -0
- package/dist/session/moves.js +71 -1
- package/dist/session/navigation.d.ts +7 -0
- package/dist/session/navigation.js +19 -10
- package/dist/session/props.d.ts +28 -0
- package/dist/session/props.js +65 -0
- package/dist/session/uploads.d.ts +29 -0
- package/dist/session/uploads.js +10 -0
- package/dist/themes/notion.css +9 -0
- package/package.json +1 -1
- /package/dist/{plugins/blockHandles → dnd}/dropIndicator.js +0 -0
- /package/dist/{plugins/blockHandles → dnd}/geometry.js +0 -0
- /package/dist/{plugins/columns → dnd}/stacking.d.ts +0 -0
- /package/dist/{plugins/columns → dnd}/stacking.js +0 -0
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
<svelte:options runes />
|
|
2
2
|
|
|
3
3
|
<script module lang="ts">
|
|
4
|
+
import './pageLink.css';
|
|
4
5
|
import type { Snippet } from 'svelte';
|
|
5
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
InlineBlockSnippetPayload,
|
|
8
|
+
Plugin,
|
|
9
|
+
PluginOperations,
|
|
10
|
+
TriggerContext
|
|
11
|
+
} from '../../plugins.js';
|
|
12
|
+
import type { Edytor } from '../../edytor.svelte.js';
|
|
13
|
+
import type { TextAnchor } from '../../selection/selection.svelte.js';
|
|
6
14
|
import type { TriggerMenuController } from '../triggers/TriggerController.svelte.js';
|
|
7
15
|
import { withRulesAndTriggers } from '../triggers/triggers.js';
|
|
8
16
|
import { pageLinkEnglish, labelsOf } from '../../labels.js';
|
|
@@ -31,6 +39,18 @@
|
|
|
31
39
|
) => readonly PageLinkItem[] | Promise<readonly PageLinkItem[]>;
|
|
32
40
|
/** A page's URL when the search answers none (`/pages/<id>`, say). */
|
|
33
41
|
href?: (page: PageLinkItem) => string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* The page a pasted text names (your app's URL for it, `page:roadmap`…),
|
|
44
|
+
* at once or as a promise; `null` when it names none. Asked for a plain
|
|
45
|
+
* text paste at a caret that is one item (trimmed, one line): a page
|
|
46
|
+
* turns the pasted text into a `pageLink` atom, an undo step of its own
|
|
47
|
+
* after the paste, so undo gives the text back. A promise lets the paste
|
|
48
|
+
* land as it would without this option and swaps it when it answers, if
|
|
49
|
+
* the text is still there.
|
|
50
|
+
*/
|
|
51
|
+
fromPaste?: (
|
|
52
|
+
text: string
|
|
53
|
+
) => PageLinkItem | null | undefined | Promise<PageLinkItem | null | undefined>;
|
|
34
54
|
/** The text that opens the menu (default `[[`). */
|
|
35
55
|
char?: string;
|
|
36
56
|
/**
|
|
@@ -42,11 +62,107 @@
|
|
|
42
62
|
menu?: Snippet<[TriggerMenuController<PageLinkItem>]>;
|
|
43
63
|
};
|
|
44
64
|
|
|
65
|
+
/** The atom for `page`, its href sanitized. */
|
|
66
|
+
const atomOf = (page: PageLinkItem, options: PageLinkPluginOptions) => {
|
|
67
|
+
const href = sanitizeLinkHref(page.href ?? options.href?.(page)) ?? undefined;
|
|
68
|
+
const data: PageLinkData = {
|
|
69
|
+
id: page.id,
|
|
70
|
+
title: page.title,
|
|
71
|
+
...(href ? { href } : {}),
|
|
72
|
+
...(page.icon ? { icon: page.icon } : {})
|
|
73
|
+
};
|
|
74
|
+
return { type: 'pageLink', data };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const isPromise = <T,>(value: unknown): value is Promise<T> =>
|
|
78
|
+
typeof (value as Promise<T> | null)?.then === 'function';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Swap the pasted `item`, where the paste put it (right after `start`,
|
|
82
|
+
* past leading spaces), for `page`'s atom: one `autolink` step, so undo
|
|
83
|
+
* gives the text back. Nothing when the view is readonly or the text is
|
|
84
|
+
* no longer there; the caret follows the atom when it was at the text's end.
|
|
85
|
+
*/
|
|
86
|
+
const swapPasted = (
|
|
87
|
+
edytor: Edytor,
|
|
88
|
+
start: TextAnchor,
|
|
89
|
+
item: string,
|
|
90
|
+
page: PageLinkItem,
|
|
91
|
+
options: PageLinkPluginOptions
|
|
92
|
+
) => {
|
|
93
|
+
if (edytor.readonly) return;
|
|
94
|
+
const at = edytor.selection.resolveTextAnchor(start);
|
|
95
|
+
if (!at) return;
|
|
96
|
+
const { text } = at;
|
|
97
|
+
const content = text.stringContent;
|
|
98
|
+
let from = at.offset;
|
|
99
|
+
while (from < content.length && /\s/.test(content[from])) from++;
|
|
100
|
+
if (content.slice(from, from + item.length) !== item) return;
|
|
101
|
+
const block = text.parent;
|
|
102
|
+
const offset = text.segStart + from;
|
|
103
|
+
const caret = edytor.selection.caret;
|
|
104
|
+
const follows = caret?.block === block && caret.offset === offset + item.length;
|
|
105
|
+
edytor.dispatcher.run('autolink', () => {
|
|
106
|
+
if (edytor.deleteText(block.id, offset, item.length).status !== 'applied') return;
|
|
107
|
+
edytor.insertInline(block.id, offset, atomOf(page, options));
|
|
108
|
+
if (follows) edytor.dispatcher.caret({ block, offset: offset + 1 });
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The paste hook of `fromPaste` (it runs before the other plugins'
|
|
114
|
+
* `onPaste`, `pasteFirst`): a plain text paste at a caret in a text,
|
|
115
|
+
* outside islands and voids, that is one item. A page answered at once
|
|
116
|
+
* claims the paste (the text, then the swap); a promise claims nothing
|
|
117
|
+
* (the rich text link, the URL menu or the plain paste take it) and swaps
|
|
118
|
+
* what landed when it answers a page.
|
|
119
|
+
*/
|
|
120
|
+
const pasteHook = (edytor: Edytor, options: PageLinkPluginOptions): PluginOperations['onPaste'] =>
|
|
121
|
+
options.fromPaste &&
|
|
122
|
+
(({ e, prevent, at }) => {
|
|
123
|
+
const { fromPaste } = options;
|
|
124
|
+
if (!fromPaste || at || edytor.readonly) return;
|
|
125
|
+
const plain = e.clipboardData?.getData('text/plain') ?? '';
|
|
126
|
+
const item = plain.trim();
|
|
127
|
+
if (!item || /[\r\n]/.test(item)) return;
|
|
128
|
+
const { value, projection } = edytor.selection;
|
|
129
|
+
if (value.kind !== 'text' || !projection.isCollapsed) return;
|
|
130
|
+
if (projection.islandRoot !== null || projection.voidRoot !== null) return;
|
|
131
|
+
const { startText: text, yStart } = edytor.selection.textRange();
|
|
132
|
+
if (!text) return;
|
|
133
|
+
const start = edytor.selection.createTextAnchor(text, yStart, 'left');
|
|
134
|
+
if (!start) return;
|
|
135
|
+
let answer: ReturnType<typeof fromPaste>;
|
|
136
|
+
try {
|
|
137
|
+
answer = fromPaste(item);
|
|
138
|
+
} catch {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (isPromise<PageLinkItem | null | undefined>(answer)) {
|
|
142
|
+
answer.then(
|
|
143
|
+
(page) => page && swapPasted(edytor, start, item, page, options),
|
|
144
|
+
() => {}
|
|
145
|
+
);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const page = answer;
|
|
149
|
+
if (!page) return;
|
|
150
|
+
prevent(() => {
|
|
151
|
+
edytor.dispatcher.run('insertFromPaste', () => {
|
|
152
|
+
edytor.insertText(text.parent.id, text.segStart + yStart, item);
|
|
153
|
+
edytor.dispatcher.caret(text, yStart + item.length);
|
|
154
|
+
});
|
|
155
|
+
if (edytor.dispatcher.last?.status === 'applied')
|
|
156
|
+
swapPasted(edytor, start, item, page, options);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
45
160
|
/**
|
|
46
161
|
* Links to your app's pages, as Notion's `[[`: it opens a menu of the pages
|
|
47
162
|
* your app's `search(query)` answers; picking one replaces `[[query` with
|
|
48
163
|
* a `pageLink` atom (`{ id, title, href, icon }`), an `<a>` to the page,
|
|
49
|
-
* and puts the caret after it, in one undo step.
|
|
164
|
+
* and puts the caret after it, in one undo step. With `fromPaste`, a
|
|
165
|
+
* pasted text your app recognizes becomes a page link too.
|
|
50
166
|
*/
|
|
51
167
|
export const createPageLinkPlugin =
|
|
52
168
|
(options: PageLinkPluginOptions): Plugin =>
|
|
@@ -54,6 +170,9 @@
|
|
|
54
170
|
withRulesAndTriggers(
|
|
55
171
|
edytor,
|
|
56
172
|
{
|
|
173
|
+
...(edytor && options.fromPaste
|
|
174
|
+
? { onPaste: pasteHook(edytor, options), pasteFirst: true }
|
|
175
|
+
: {}),
|
|
57
176
|
inlineBlocks: {
|
|
58
177
|
pageLink: {
|
|
59
178
|
snippet: pageLink,
|
|
@@ -73,14 +192,7 @@
|
|
|
73
192
|
key: (page: PageLinkItem) => page.id,
|
|
74
193
|
menu: options.menu,
|
|
75
194
|
onPick: (page: PageLinkItem, { edytor, block, from, caret }) => {
|
|
76
|
-
const
|
|
77
|
-
const data: PageLinkData = {
|
|
78
|
-
id: page.id,
|
|
79
|
-
title: page.title,
|
|
80
|
-
...(href ? { href } : {}),
|
|
81
|
-
...(page.icon ? { icon: page.icon } : {})
|
|
82
|
-
};
|
|
83
|
-
const atom = { type: 'pageLink', data };
|
|
195
|
+
const atom = atomOf(page, options);
|
|
84
196
|
const written = edytor.insertInline(block.id, from, atom).status === 'applied';
|
|
85
197
|
if (written) caret(from + 1);
|
|
86
198
|
return written;
|
|
@@ -104,10 +216,3 @@
|
|
|
104
216
|
></a
|
|
105
217
|
>
|
|
106
218
|
{/snippet}
|
|
107
|
-
|
|
108
|
-
<style>
|
|
109
|
-
/* One unit, on one line; its look is a theme's. */
|
|
110
|
-
.edytor-page-link {
|
|
111
|
-
white-space: nowrap;
|
|
112
|
-
}
|
|
113
|
-
</style>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import './pageLink.css';
|
|
1
2
|
import type { Snippet } from 'svelte';
|
|
2
3
|
import type { Plugin, TriggerContext } from '../../plugins.js';
|
|
3
4
|
import type { TriggerMenuController } from '../triggers/TriggerController.svelte.js';
|
|
@@ -24,6 +25,16 @@ export type PageLinkPluginOptions = {
|
|
|
24
25
|
search: (query: string, ctx: TriggerContext) => readonly PageLinkItem[] | Promise<readonly PageLinkItem[]>;
|
|
25
26
|
/** A page's URL when the search answers none (`/pages/<id>`, say). */
|
|
26
27
|
href?: (page: PageLinkItem) => string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* The page a pasted text names (your app's URL for it, `page:roadmap`…),
|
|
30
|
+
* at once or as a promise; `null` when it names none. Asked for a plain
|
|
31
|
+
* text paste at a caret that is one item (trimmed, one line): a page
|
|
32
|
+
* turns the pasted text into a `pageLink` atom, an undo step of its own
|
|
33
|
+
* after the paste, so undo gives the text back. A promise lets the paste
|
|
34
|
+
* land as it would without this option and swaps it when it answers, if
|
|
35
|
+
* the text is still there.
|
|
36
|
+
*/
|
|
37
|
+
fromPaste?: (text: string) => PageLinkItem | null | undefined | Promise<PageLinkItem | null | undefined>;
|
|
27
38
|
/** The text that opens the menu (default `[[`). */
|
|
28
39
|
char?: string;
|
|
29
40
|
/**
|
|
@@ -38,7 +49,8 @@ export type PageLinkPluginOptions = {
|
|
|
38
49
|
* Links to your app's pages, as Notion's `[[`: it opens a menu of the pages
|
|
39
50
|
* your app's `search(query)` answers; picking one replaces `[[query` with
|
|
40
51
|
* a `pageLink` atom (`{ id, title, href, icon }`), an `<a>` to the page,
|
|
41
|
-
* and puts the caret after it, in one undo step.
|
|
52
|
+
* and puts the caret after it, in one undo step. With `fromPaste`, a
|
|
53
|
+
* pasted text your app recognizes becomes a page link too.
|
|
42
54
|
*/
|
|
43
55
|
export declare const createPageLinkPlugin: (options: PageLinkPluginOptions) => Plugin;
|
|
44
56
|
declare const PageLinkPlugin: import("svelte").Component<Record<string, never>, {}, "">;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script module lang="ts">
|
|
2
|
+
import './richtext.css';
|
|
2
3
|
import type { Plugin, BlockSnippetPayload, KindPreset, PlaceholderView } from '../../plugins.js';
|
|
3
4
|
import { branded } from '../brand.js';
|
|
4
5
|
import type { Edytor } from '../../edytor.svelte.js';
|
|
@@ -32,6 +33,9 @@
|
|
|
32
33
|
iconOf
|
|
33
34
|
} from './callouts.js';
|
|
34
35
|
import { listen } from '../../utils/listen.js';
|
|
36
|
+
import { onLastLine } from '../../session/navigation.js';
|
|
37
|
+
import { bodyShows } from '../../selection/visibility.js';
|
|
38
|
+
import { openBody } from './body.js';
|
|
35
39
|
import type { Snippet } from 'svelte';
|
|
36
40
|
import {
|
|
37
41
|
richTextControls,
|
|
@@ -262,6 +266,16 @@
|
|
|
262
266
|
'mod+shift+s': setMarkAndSelect('strike'),
|
|
263
267
|
'mod+shift+x': setMarkAndSelect('strike'),
|
|
264
268
|
'mod+shift+h': setMarkAndSelect('color', 'red'),
|
|
269
|
+
// ArrowDown on the last line of a header whose body shows and is empty
|
|
270
|
+
// (an open toggle, a callout) goes into that body, as its hint's click
|
|
271
|
+
// does (`body.down`): one command, one undo step. Elsewhere the key
|
|
272
|
+
// moves as usual.
|
|
273
|
+
arrowdown: ({ prevent }) => {
|
|
274
|
+
const header = onLastLine(edytor);
|
|
275
|
+
if (!header || header.hasChildren || !bodyShows(header)) return;
|
|
276
|
+
if (!edytor.dispatcher.permits()) return;
|
|
277
|
+
prevent(() => void openBody(header));
|
|
278
|
+
},
|
|
265
279
|
// Mod+Enter checks or unchecks each shown to-do it is in (Notion): the
|
|
266
280
|
// selected blocks', else the caret's; the toggles among them open or
|
|
267
281
|
// close as with the built-in binding, in the same key.
|
|
@@ -760,10 +774,3 @@
|
|
|
760
774
|
{#snippet list({ children }: BlockSnippetPayload)}<!--
|
|
761
775
|
-->{#if children}<div>{@render children()}</div>{/if}<!--
|
|
762
776
|
-->{/snippet}
|
|
763
|
-
|
|
764
|
-
<style>
|
|
765
|
-
/* An empty body's hint: chrome, not text (`body.hint`); its look is a theme's. */
|
|
766
|
-
:global([data-edytor-empty-body]) {
|
|
767
|
-
user-select: none;
|
|
768
|
-
}
|
|
769
|
-
</style>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/* Loaded by the plugin's module (a component's own <style> is dropped when only
|
|
2
|
+
its module is used: the plugin factory, never the component). */
|
|
3
|
+
/* An empty body's hint: chrome, not text (`body.hint`); its look is a theme's. */
|
|
4
|
+
[data-edytor-empty-body] {
|
|
5
|
+
user-select: none;
|
|
6
|
+
}
|
|
@@ -197,7 +197,8 @@ export class TableChrome extends ChromeList {
|
|
|
197
197
|
}
|
|
198
198
|
/**
|
|
199
199
|
* Where the grips stand, layer-relative, the point each centers on: the
|
|
200
|
-
* row grip on the
|
|
200
|
+
* row grip on the left edge of the part of the grid its box shows
|
|
201
|
+
* (`table.overflow`: the box's edge once the grid is scrolled), at the middle of the row it stands
|
|
201
202
|
* for; the column grip on its top edge, at the middle of the column it
|
|
202
203
|
* stands for, while that middle shows (the grip a drag started from
|
|
203
204
|
* stays, its source, even scrolled out of view).
|
|
@@ -211,7 +212,7 @@ export class TableChrome extends ChromeList {
|
|
|
211
212
|
const x = c ? c.left + c.width / 2 : 0;
|
|
212
213
|
return [
|
|
213
214
|
...(row && r
|
|
214
|
-
? [{ ...row, kind: 'row', x:
|
|
215
|
+
? [{ ...row, kind: 'row', x: this.span.left, y: r.top + r.height / 2 }]
|
|
215
216
|
: []),
|
|
216
217
|
...(column && (this.moving?.kind === 'column' || this.inView(x))
|
|
217
218
|
? [{ ...column, kind: 'column', x, y: column.layout.top }]
|
|
@@ -248,7 +249,7 @@ export class TableChrome extends ChromeList {
|
|
|
248
249
|
const { layout, index } = at;
|
|
249
250
|
if (kind === 'row') {
|
|
250
251
|
const row = layout.rows[index];
|
|
251
|
-
return { left:
|
|
252
|
+
return { left: this.span.left - 9, top: row.top + Math.min(row.height, 28) };
|
|
252
253
|
}
|
|
253
254
|
const column = layout.columns[index];
|
|
254
255
|
return { left: column.left + column.width / 2 - 12, top: layout.top + 10 };
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Edytor } from '../edytor.svelte.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { PluginDefinitions, PluginOperations } from '../plugins.js';
|
|
3
|
+
import type { DropAt } from '../session/attempt.js';
|
|
4
|
+
import { type FileDropPort, type ViewUploads } from '../session/uploads.js';
|
|
3
5
|
/** What an `upload` may report while it runs. */
|
|
4
6
|
export type UploadReport = {
|
|
5
7
|
/** The share sent so far, `0` to `1`: the block shows it as a bar. */
|
|
@@ -17,8 +19,14 @@ export type Uploader = (file: File, report: UploadReport) => Promise<string>;
|
|
|
17
19
|
*/
|
|
18
20
|
export type FileUpload = {
|
|
19
21
|
readonly status: 'uploading';
|
|
20
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* The file, shown while it uploads: its object URL (`blob:`), this
|
|
24
|
+
* view's only, never stored; `null` where there is none.
|
|
25
|
+
*/
|
|
21
26
|
readonly preview: string | null;
|
|
27
|
+
/** The file's name and size in bytes, shown at once. */
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly size: number;
|
|
22
30
|
/** The share sent, `0` to `1`; `null` until `upload` reports one. */
|
|
23
31
|
readonly progress: number | null;
|
|
24
32
|
} | {
|
|
@@ -29,10 +37,12 @@ export type FileUpload = {
|
|
|
29
37
|
};
|
|
30
38
|
/**
|
|
31
39
|
* What files a kind takes (its record's `upload`, `BlockDefinition`): which,
|
|
32
|
-
*
|
|
33
|
-
*
|
|
40
|
+
* who sends them (`send`, its plugin's `upload` option) and the data an
|
|
41
|
+
* upload's answer writes. A kind without it takes no files.
|
|
34
42
|
*/
|
|
35
43
|
export type KindUpload = {
|
|
44
|
+
/** Upload a file of this kind and answer its URL (its plugin's `upload`). */
|
|
45
|
+
send: Uploader;
|
|
36
46
|
/** Whether it takes `file`. */
|
|
37
47
|
accepts: (file: File) => boolean;
|
|
38
48
|
/** It takes any file no other kind takes (the file block): asked last. */
|
|
@@ -41,14 +51,13 @@ export type KindUpload = {
|
|
|
41
51
|
data: (file: File, answer: string) => Record<string, unknown> | null;
|
|
42
52
|
/** Whether the block has a source already (a peer's, a link's): its upload then writes nothing. */
|
|
43
53
|
filled: (data: Record<string, unknown>) => boolean;
|
|
44
|
-
/** Show the file while it uploads (an image's `blob:` preview). */
|
|
45
|
-
preview?: boolean;
|
|
46
54
|
};
|
|
47
55
|
/**
|
|
48
|
-
* The view's uploads (`media.files`), one per view, the
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* takes files by its record's `upload
|
|
56
|
+
* The view's uploads (`media.files`), one per view, claimed by the first
|
|
57
|
+
* media plugin listed with an `upload`: the uploads of pasted, dropped and
|
|
58
|
+
* picked files (an empty block's Upload), session state, never in the
|
|
59
|
+
* document. A kind takes files by its record's `upload`, and sends them
|
|
60
|
+
* with its `send` (its plugin's uploader); a paste or a drop (a drop replays
|
|
52
61
|
* the paste hooks) of files the kinds take places one empty block per
|
|
53
62
|
* file, each of the kind that takes it (`fallback` last), in the
|
|
54
63
|
* clipboard's order, as one paste (one command, one undo step), and each
|
|
@@ -62,14 +71,14 @@ export type KindUpload = {
|
|
|
62
71
|
* (`fill`, asked by the empty block's render), so a redo brings it back
|
|
63
72
|
* whole.
|
|
64
73
|
*/
|
|
65
|
-
export declare class FileUploads {
|
|
74
|
+
export declare class FileUploads implements ViewUploads {
|
|
66
75
|
#private;
|
|
67
76
|
private edytor;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
|
|
77
|
+
constructor(edytor: Edytor);
|
|
78
|
+
/** The kind that takes `file`, if any. */
|
|
79
|
+
kindOf: (file: File) => string | undefined;
|
|
80
|
+
/** Files dragged from outside: the drop indicator and the placement (`fileDrop.ts`). */
|
|
81
|
+
readonly drop: FileDropPort;
|
|
73
82
|
/** The upload of block `id` in this view (reactive), if any. */
|
|
74
83
|
of: (id: string) => FileUpload | undefined;
|
|
75
84
|
/**
|
|
@@ -78,8 +87,20 @@ export declare class FileUploads {
|
|
|
78
87
|
* clipboard).
|
|
79
88
|
*/
|
|
80
89
|
paste: NonNullable<PluginOperations['onPaste']>;
|
|
81
|
-
/**
|
|
82
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Place a block per file at the selection (one paste), or at a drop's
|
|
92
|
+
* placement (`at`: beside, before, after or inside a block, one command),
|
|
93
|
+
* then upload each.
|
|
94
|
+
*/
|
|
95
|
+
insert: (files: readonly File[], at?: DropAt) => void;
|
|
96
|
+
/**
|
|
97
|
+
* Upload `file`, picked on the empty block `id` (its Upload), with its
|
|
98
|
+
* kind's uploader, the block showing it meanwhile, and hand the answer to
|
|
99
|
+
* `land` before the preview goes (so the block never shows nothing
|
|
100
|
+
* between them): `false` when it failed (the block then shows no
|
|
101
|
+
* upload; the caller says why).
|
|
102
|
+
*/
|
|
103
|
+
pick: (id: string, file: File, land: (answer: string) => void) => Promise<boolean>;
|
|
83
104
|
/** Forget the block's upload (a source was embedded: its error, or held data, no longer applies). */
|
|
84
105
|
clear: (id: string) => void;
|
|
85
106
|
/**
|
|
@@ -91,20 +112,12 @@ export declare class FileUploads {
|
|
|
91
112
|
/** Release every preview. */
|
|
92
113
|
destroy: () => void;
|
|
93
114
|
}
|
|
94
|
-
/** The view's uploads, when
|
|
115
|
+
/** The view's uploads, when a media plugin with an `upload` is listed. */
|
|
95
116
|
export declare const fileUploads: (edytor: Edytor | undefined) => FileUploads | undefined;
|
|
96
|
-
export type UploadsOptions = {
|
|
97
|
-
/**
|
|
98
|
-
* Upload a file and answer its URL: an empty image or media block's
|
|
99
|
-
* Upload, and the pasted and dropped files a kind takes, each placed as a
|
|
100
|
-
* block of that kind at once (one undo step) and filled with the URL this
|
|
101
|
-
* answers (the block shows the progress it reports).
|
|
102
|
-
*/
|
|
103
|
-
upload: Uploader;
|
|
104
|
-
};
|
|
105
117
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
118
|
+
* The hooks of a media plugin given an `upload` (its kind's record carries
|
|
119
|
+
* it, `KindUpload.send`): the first such plugin listed in a view claims the
|
|
120
|
+
* view's one `FileUploads` and runs its paste hook (a drop replays it) and
|
|
121
|
+
* teardown; the others add none.
|
|
109
122
|
*/
|
|
110
|
-
export declare const
|
|
123
|
+
export declare const uploadHooks: (edytor: Edytor | undefined, upload: Uploader | undefined) => PluginOperations & PluginDefinitions;
|
|
@@ -2,11 +2,15 @@ import { SvelteMap } from 'svelte/reactivity';
|
|
|
2
2
|
import { pasteFlow } from '../clipboard/insertClipboardFragment.js';
|
|
3
3
|
import { jsonBlockToSpec } from '../utils/json.js';
|
|
4
4
|
import { id as mint } from '../utils.js';
|
|
5
|
+
import { placeNew } from '../session/moves.js';
|
|
6
|
+
import { claimUploads, viewUploads } from '../session/uploads.js';
|
|
7
|
+
import { fileDragEnd, fileDragOver, fileDropAt } from './fileDrop.js';
|
|
5
8
|
/**
|
|
6
|
-
* The view's uploads (`media.files`), one per view, the
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* takes files by its record's `upload
|
|
9
|
+
* The view's uploads (`media.files`), one per view, claimed by the first
|
|
10
|
+
* media plugin listed with an `upload`: the uploads of pasted, dropped and
|
|
11
|
+
* picked files (an empty block's Upload), session state, never in the
|
|
12
|
+
* document. A kind takes files by its record's `upload`, and sends them
|
|
13
|
+
* with its `send` (its plugin's uploader); a paste or a drop (a drop replays
|
|
10
14
|
* the paste hooks) of files the kinds take places one empty block per
|
|
11
15
|
* file, each of the kind that takes it (`fallback` last), in the
|
|
12
16
|
* clipboard's order, as one paste (one command, one undo step), and each
|
|
@@ -22,13 +26,9 @@ import { id as mint } from '../utils.js';
|
|
|
22
26
|
*/
|
|
23
27
|
export class FileUploads {
|
|
24
28
|
edytor;
|
|
25
|
-
upload;
|
|
26
29
|
#uploads = new SvelteMap();
|
|
27
|
-
constructor(edytor
|
|
28
|
-
/** The view's uploader. */
|
|
29
|
-
upload) {
|
|
30
|
+
constructor(edytor) {
|
|
30
31
|
this.edytor = edytor;
|
|
31
|
-
this.upload = upload;
|
|
32
32
|
}
|
|
33
33
|
/** The kinds that take files (their records' `upload`), in registration order. */
|
|
34
34
|
get #kinds() {
|
|
@@ -37,6 +37,14 @@ export class FileUploads {
|
|
|
37
37
|
/** The kind that takes `file`, if any. */
|
|
38
38
|
#kindOf = (file) => this.#kinds.find((kind) => !kind.fallback && kind.accepts(file)) ??
|
|
39
39
|
this.#kinds.find((kind) => kind.fallback && kind.accepts(file));
|
|
40
|
+
/** The kind that takes `file`, if any. */
|
|
41
|
+
kindOf = (file) => this.#kindOf(file)?.type;
|
|
42
|
+
/** Files dragged from outside: the drop indicator and the placement (`fileDrop.ts`). */
|
|
43
|
+
drop = {
|
|
44
|
+
over: (event) => fileDragOver(this.edytor, event) !== null,
|
|
45
|
+
at: (event) => fileDropAt(this.edytor, event),
|
|
46
|
+
end: () => fileDragEnd(this.edytor)
|
|
47
|
+
};
|
|
40
48
|
/** The upload of block `id` in this view (reactive), if any. */
|
|
41
49
|
of = (id) => this.#uploads.get(id);
|
|
42
50
|
/**
|
|
@@ -44,15 +52,19 @@ export class FileUploads {
|
|
|
44
52
|
* shows text (an office app puts a picture of copied text on the
|
|
45
53
|
* clipboard).
|
|
46
54
|
*/
|
|
47
|
-
paste = ({ e, prevent }) => {
|
|
55
|
+
paste = ({ e, prevent, at }) => {
|
|
48
56
|
if (this.edytor.readonly)
|
|
49
57
|
return;
|
|
50
58
|
const files = Array.from(e.clipboardData?.files ?? []).filter((file) => this.#kindOf(file));
|
|
51
59
|
if (files.length && !showsText(e.clipboardData?.getData('text/html')))
|
|
52
|
-
prevent(() => this.insert(files));
|
|
60
|
+
prevent(() => this.insert(files, at));
|
|
53
61
|
};
|
|
54
|
-
/**
|
|
55
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Place a block per file at the selection (one paste), or at a drop's
|
|
64
|
+
* placement (`at`: beside, before, after or inside a block, one command),
|
|
65
|
+
* then upload each.
|
|
66
|
+
*/
|
|
67
|
+
insert = (files, at) => {
|
|
56
68
|
const { edytor } = this;
|
|
57
69
|
const placed = files.flatMap((file) => {
|
|
58
70
|
const kind = this.#kindOf(file);
|
|
@@ -61,16 +73,38 @@ export class FileUploads {
|
|
|
61
73
|
if (!placed.length)
|
|
62
74
|
return;
|
|
63
75
|
const lines = placed.map(({ kind, id }) => jsonBlockToSpec({ id, type: kind.type, data: {}, content: [] }));
|
|
64
|
-
|
|
76
|
+
const target = at && edytor.idToBlock.get(at.block);
|
|
77
|
+
if (at && target)
|
|
78
|
+
edytor.dispatcher.run('insertFromDrop', () => placeNew(edytor, placed.map(({ kind, id }) => ({ id, type: kind.type })), target, at.position));
|
|
79
|
+
else
|
|
80
|
+
edytor.dispatcher.run('insertFromPaste', () => pasteFlow(edytor, { lines }));
|
|
65
81
|
for (const { file, kind, id } of placed) {
|
|
66
82
|
// Refused, or placed as no such block (a code line takes plain lines, `flow.lines`).
|
|
67
83
|
if (!edytor.facade.isVisibleBlock(id) || edytor.facade.blockTypeOf(id) !== kind.type)
|
|
68
84
|
continue;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
85
|
+
void this.#send(id, kind, file);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Upload `file`, picked on the empty block `id` (its Upload), with its
|
|
90
|
+
* kind's uploader, the block showing it meanwhile, and hand the answer to
|
|
91
|
+
* `land` before the preview goes (so the block never shows nothing
|
|
92
|
+
* between them): `false` when it failed (the block then shows no
|
|
93
|
+
* upload; the caller says why).
|
|
94
|
+
*/
|
|
95
|
+
pick = async (id, file, land) => {
|
|
96
|
+
const kind = this.edytor.idToBlock.get(id)?.definition.upload;
|
|
97
|
+
if (!kind)
|
|
98
|
+
return false;
|
|
99
|
+
try {
|
|
100
|
+
land(await this.#run(id, kind, file));
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
this.#settle(id);
|
|
74
108
|
}
|
|
75
109
|
};
|
|
76
110
|
/** Forget the block's upload (a source was embedded: its error, or held data, no longer applies). */
|
|
@@ -104,7 +138,21 @@ export class FileUploads {
|
|
|
104
138
|
URL.revokeObjectURL(upload.preview);
|
|
105
139
|
this.#uploads.clear();
|
|
106
140
|
};
|
|
107
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Send `file` with the kind's uploader, block `id` showing it meanwhile
|
|
143
|
+
* (`uploading`: its object URL, name, size and progress): the answer.
|
|
144
|
+
* The object URL stays until `#settle` (after the answer is stored, so
|
|
145
|
+
* the block never shows nothing between them).
|
|
146
|
+
*/
|
|
147
|
+
#run = (id, kind, file) => {
|
|
148
|
+
const preview = typeof URL.createObjectURL === 'function' ? URL.createObjectURL(file) : null;
|
|
149
|
+
this.#uploads.set(id, {
|
|
150
|
+
status: 'uploading',
|
|
151
|
+
preview,
|
|
152
|
+
name: file.name,
|
|
153
|
+
size: file.size,
|
|
154
|
+
progress: null
|
|
155
|
+
});
|
|
108
156
|
const report = {
|
|
109
157
|
progress: (fraction) => {
|
|
110
158
|
const upload = this.#uploads.get(id);
|
|
@@ -113,28 +161,43 @@ export class FileUploads {
|
|
|
113
161
|
this.#uploads.set(id, { ...upload, progress: Math.min(1, Math.max(0, fraction)) });
|
|
114
162
|
}
|
|
115
163
|
};
|
|
164
|
+
return kind.send(file, report);
|
|
165
|
+
};
|
|
166
|
+
/** The upload of `id` ended: revoke its object URL and forget it, if it still runs. */
|
|
167
|
+
#settle = (id) => {
|
|
168
|
+
const upload = this.#uploads.get(id);
|
|
169
|
+
if (upload?.status !== 'uploading')
|
|
170
|
+
return;
|
|
171
|
+
if (upload.preview)
|
|
172
|
+
URL.revokeObjectURL(upload.preview);
|
|
173
|
+
this.#uploads.delete(id);
|
|
174
|
+
};
|
|
175
|
+
#send = async (id, kind, file) => {
|
|
116
176
|
let data;
|
|
117
177
|
try {
|
|
118
|
-
data = kind.data(file, await this
|
|
178
|
+
data = kind.data(file, await this.#run(id, kind, file));
|
|
119
179
|
}
|
|
120
180
|
catch {
|
|
121
181
|
data = null;
|
|
122
182
|
}
|
|
123
183
|
const { edytor } = this;
|
|
124
|
-
if (preview)
|
|
125
|
-
URL.revokeObjectURL(preview);
|
|
126
184
|
if (edytor.destroyed)
|
|
127
185
|
return;
|
|
128
186
|
const block = edytor.idToBlock.get(id);
|
|
187
|
+
const upload = this.#uploads.get(id);
|
|
188
|
+
const preview = upload?.status === 'uploading' ? upload.preview : null;
|
|
129
189
|
// Given a source meanwhile (a peer, the link field): nothing to fill.
|
|
130
190
|
if (block?.isInTree && kind.filled(block.data))
|
|
131
|
-
|
|
132
|
-
if (!data)
|
|
133
|
-
|
|
191
|
+
this.#uploads.delete(id);
|
|
192
|
+
else if (!data)
|
|
193
|
+
this.#uploads.set(id, { status: 'failed' });
|
|
134
194
|
// Not shown (undone, deleted): held until it shows again (`fill`).
|
|
135
|
-
if (!block?.isInTree)
|
|
136
|
-
|
|
137
|
-
|
|
195
|
+
else if (!block?.isInTree)
|
|
196
|
+
this.#uploads.set(id, { status: 'landed', data });
|
|
197
|
+
else
|
|
198
|
+
this.#write(id, data);
|
|
199
|
+
if (preview)
|
|
200
|
+
URL.revokeObjectURL(preview);
|
|
138
201
|
};
|
|
139
202
|
/** The data's write, outside the history; refused (readonly), it is held for the next show. */
|
|
140
203
|
#write = (id, data) => {
|
|
@@ -150,18 +213,21 @@ export class FileUploads {
|
|
|
150
213
|
const showsText = (html) => !!html &&
|
|
151
214
|
typeof DOMParser !== 'undefined' &&
|
|
152
215
|
!!new DOMParser().parseFromString(html, 'text/html').body.textContent?.trim();
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
216
|
+
/** The view's uploads, when a media plugin with an `upload` is listed. */
|
|
217
|
+
export const fileUploads = (edytor) => {
|
|
218
|
+
const uploads = viewUploads(edytor);
|
|
219
|
+
return uploads instanceof FileUploads ? uploads : undefined;
|
|
220
|
+
};
|
|
156
221
|
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
222
|
+
* The hooks of a media plugin given an `upload` (its kind's record carries
|
|
223
|
+
* it, `KindUpload.send`): the first such plugin listed in a view claims the
|
|
224
|
+
* view's one `FileUploads` and runs its paste hook (a drop replays it) and
|
|
225
|
+
* teardown; the others add none.
|
|
160
226
|
*/
|
|
161
|
-
export const
|
|
162
|
-
if (!edytor ||
|
|
227
|
+
export const uploadHooks = (edytor, upload) => {
|
|
228
|
+
if (!edytor || !upload || viewUploads(edytor))
|
|
163
229
|
return {};
|
|
164
|
-
const uploads = new FileUploads(edytor
|
|
165
|
-
|
|
230
|
+
const uploads = new FileUploads(edytor);
|
|
231
|
+
claimUploads(edytor, uploads);
|
|
166
232
|
return { onPaste: uploads.paste, onEdytorAttached: () => uploads.destroy };
|
|
167
233
|
};
|