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.
Files changed (116) hide show
  1. package/README.md +14 -72
  2. package/dist/block/block.svelte.d.ts +6 -4
  3. package/dist/block/block.svelte.js +10 -4
  4. package/dist/block/inlineBlock.svelte.d.ts +6 -2
  5. package/dist/block/inlineBlock.svelte.js +7 -2
  6. package/dist/cloudflare/room/context.d.ts +6 -0
  7. package/dist/collaboration/awarenessSelection.d.ts +22 -1
  8. package/dist/collaboration/awarenessSelection.js +42 -0
  9. package/dist/components/Block.svelte +3 -1
  10. package/dist/components/Edytor.svelte +36 -10
  11. package/dist/components/Edytor.svelte.d.ts +8 -1
  12. package/dist/components/InlineBlock.svelte +1 -1
  13. package/dist/crdt/data.d.ts +10 -0
  14. package/dist/crdt/data.js +20 -2
  15. package/dist/crdt/doc/reads.d.ts +7 -0
  16. package/dist/crdt/doc/reads.js +11 -1
  17. package/dist/crdt/document.d.ts +8 -0
  18. package/dist/crdt/document.js +12 -0
  19. package/dist/crdt/edytor-doc.d.ts +18 -0
  20. package/dist/crdt/edytor-doc.js +2 -1
  21. package/dist/crdt/index.d.ts +6 -0
  22. package/dist/crdt/operations.d.ts +1 -1
  23. package/dist/crdt/operations.js +1 -0
  24. package/dist/crdt/props.d.ts +32 -0
  25. package/dist/crdt/props.js +172 -0
  26. package/dist/{plugins/blockHandles → dnd}/dropIndicator.d.ts +2 -2
  27. package/dist/{plugins/blockHandles → dnd}/geometry.d.ts +2 -2
  28. package/dist/dnd/placement.d.ts +200 -0
  29. package/dist/dnd/placement.js +426 -0
  30. package/dist/edytor.svelte.d.ts +17 -4
  31. package/dist/edytor.svelte.js +16 -7
  32. package/dist/events/beforeInputCommands.d.ts +2 -2
  33. package/dist/events/beforeInputCommands.js +8 -4
  34. package/dist/events/onDrop.d.ts +6 -0
  35. package/dist/events/onDrop.js +38 -10
  36. package/dist/events/onPaste.d.ts +2 -1
  37. package/dist/events/onPaste.js +8 -3
  38. package/dist/index.d.ts +2 -2
  39. package/dist/index.js +1 -1
  40. package/dist/labels.d.ts +2 -0
  41. package/dist/labels.js +2 -1
  42. package/dist/plugins/arrowMove/arrowMove.d.ts +1 -1
  43. package/dist/plugins/arrowMove/arrowMove.js +1 -1
  44. package/dist/plugins/blockHandles/BlockHandleController.svelte.d.ts +2 -122
  45. package/dist/plugins/blockHandles/BlockHandleController.svelte.js +29 -387
  46. package/dist/plugins/blockHandles/BlockHandles.svelte +14 -32
  47. package/dist/plugins/blockHandles/blockHandlesPlugin.js +2 -1
  48. package/dist/plugins/code/CodeHeader.svelte +5 -3
  49. package/dist/plugins/code/CodePlugin.svelte +3 -3
  50. package/dist/plugins/code/languages.d.ts +0 -3
  51. package/dist/plugins/code/languages.js +0 -2
  52. package/dist/plugins/columns/ColumnResize.svelte +3 -2
  53. package/dist/plugins/columns/ColumnsPlugin.svelte +1 -1
  54. package/dist/plugins/columns/gaps.d.ts +7 -5
  55. package/dist/plugins/columns/gaps.js +11 -6
  56. package/dist/plugins/columns/resize.svelte.d.ts +3 -3
  57. package/dist/plugins/columns/resize.svelte.js +11 -10
  58. package/dist/plugins/equation/EquationPlugin.svelte +1 -8
  59. package/dist/plugins/equation/EquationPlugin.svelte.d.ts +1 -0
  60. package/dist/plugins/equation/equation.css +7 -0
  61. package/dist/plugins/fileDrop.d.ts +28 -0
  62. package/dist/plugins/fileDrop.js +116 -0
  63. package/dist/plugins/image/ImagePlugin.svelte +42 -33
  64. package/dist/plugins/image/ImagePlugin.svelte.d.ts +13 -1
  65. package/dist/plugins/image/image.css +17 -0
  66. package/dist/plugins/index.d.ts +3 -4
  67. package/dist/plugins/index.js +0 -1
  68. package/dist/plugins/markdownShortcuts.js +5 -5
  69. package/dist/plugins/media/AudioPlugin.svelte +13 -1
  70. package/dist/plugins/media/AudioPlugin.svelte.d.ts +11 -1
  71. package/dist/plugins/media/FilePlugin.svelte +11 -1
  72. package/dist/plugins/media/FilePlugin.svelte.d.ts +9 -0
  73. package/dist/plugins/media/SourceBlock.svelte +48 -12
  74. package/dist/plugins/media/SourceBlock.svelte.d.ts +4 -3
  75. package/dist/plugins/media/UploadPreview.svelte +32 -0
  76. package/dist/plugins/media/UploadPreview.svelte.d.ts +11 -0
  77. package/dist/plugins/media/VideoPlugin.svelte +13 -1
  78. package/dist/plugins/media/VideoPlugin.svelte.d.ts +11 -1
  79. package/dist/plugins/media/empty.svelte.d.ts +13 -4
  80. package/dist/plugins/media/empty.svelte.js +30 -19
  81. package/dist/plugins/media/media.d.ts +7 -0
  82. package/dist/plugins/media/upload.css +23 -0
  83. package/dist/plugins/mention/MentionPlugin.svelte +72 -33
  84. package/dist/plugins/mention/MentionPlugin.svelte.d.ts +11 -2
  85. package/dist/plugins/mention/mention.css +6 -0
  86. package/dist/plugins/pageLink/PageLinkPlugin.svelte +122 -17
  87. package/dist/plugins/pageLink/PageLinkPlugin.svelte.d.ts +13 -1
  88. package/dist/plugins/pageLink/pageLink.css +6 -0
  89. package/dist/plugins/richtext/RichTextPlugin.svelte +14 -7
  90. package/dist/plugins/richtext/RichTextPlugin.svelte.d.ts +1 -0
  91. package/dist/plugins/richtext/richtext.css +6 -0
  92. package/dist/plugins/table/chrome.svelte.js +4 -3
  93. package/dist/plugins/uploads.svelte.d.ts +45 -32
  94. package/dist/plugins/uploads.svelte.js +105 -39
  95. package/dist/plugins.d.ts +40 -14
  96. package/dist/session/attempt.d.ts +16 -6
  97. package/dist/session/attempt.js +1 -0
  98. package/dist/session/bindings.js +34 -31
  99. package/dist/session/commands.js +2 -0
  100. package/dist/session/history.js +1 -0
  101. package/dist/session/inputRules.d.ts +6 -3
  102. package/dist/session/inputRules.js +9 -5
  103. package/dist/session/moves.d.ts +33 -0
  104. package/dist/session/moves.js +71 -1
  105. package/dist/session/navigation.d.ts +7 -0
  106. package/dist/session/navigation.js +19 -10
  107. package/dist/session/props.d.ts +28 -0
  108. package/dist/session/props.js +65 -0
  109. package/dist/session/uploads.d.ts +29 -0
  110. package/dist/session/uploads.js +10 -0
  111. package/dist/themes/notion.css +9 -0
  112. package/package.json +1 -1
  113. /package/dist/{plugins/blockHandles → dnd}/dropIndicator.js +0 -0
  114. /package/dist/{plugins/blockHandles → dnd}/geometry.js +0 -0
  115. /package/dist/{plugins/columns → dnd}/stacking.d.ts +0 -0
  116. /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 { InlineBlockSnippetPayload, Plugin, TriggerContext } from '../../plugins.js';
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 href = sanitizeLinkHref(page.href ?? options.href?.(page)) ?? undefined;
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>, {}, "">;
@@ -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
+ /* One unit, on one line; its look is a theme's. */
4
+ .edytor-page-link {
5
+ white-space: nowrap;
6
+ }
@@ -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>
@@ -1,3 +1,4 @@
1
+ import './richtext.css';
1
2
  import type { Plugin, PlaceholderView } from '../../plugins.js';
2
3
  import type { Edytor } from '../../edytor.svelte.js';
3
4
  import { richTextOperations } from './richTextOperations.js';
@@ -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 grid's left edge, at the middle of the row it stands
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: row.layout.left, y: r.top + r.height / 2 }]
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: layout.left - 9, top: row.top + Math.min(row.height, 28) };
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 { Plugin, PluginOperations } from '../plugins.js';
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
- /** The file, shown while it uploads (a `blob:` URL; `null` where there is none). */
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
- * and the data an upload's answer writes. The view's uploader, if the
33
- * uploads plugin is listed, sends them.
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 uploads plugin's:
49
- * its uploader (`upload`, an empty block's Upload too) and the uploads of
50
- * pasted and dropped files, session state, never in the document. A kind
51
- * takes files by its record's `upload`; a paste or a drop (a drop replays
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
- /** The view's uploader. */
69
- readonly upload: Uploader;
70
- constructor(edytor: Edytor,
71
- /** The view's uploader. */
72
- upload: Uploader);
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
- /** Place a block per file at the selection (one paste), then upload each. */
82
- insert: (files: readonly File[]) => void;
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 it lists the uploads plugin. */
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
- * File uploads (Notion's): the view's one uploader, for every kind whose
107
- * record takes files (`upload`: the image, file, video and audio kinds).
108
- * Without it, only links are embedded. The first listed claims the view.
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 createUploadsPlugin: ({ upload }: UploadsOptions) => Plugin;
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 uploads plugin's:
7
- * its uploader (`upload`, an empty block's Upload too) and the uploads of
8
- * pasted and dropped files, session state, never in the document. A kind
9
- * takes files by its record's `upload`; a paste or a drop (a drop replays
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
- /** Place a block per file at the selection (one paste), then upload each. */
55
- insert = (files) => {
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
- edytor.dispatcher.run('insertFromPaste', () => pasteFlow(edytor, { lines }));
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
- const preview = kind.preview && typeof URL.createObjectURL === 'function'
70
- ? URL.createObjectURL(file)
71
- : null;
72
- this.#uploads.set(id, { status: 'uploading', preview, progress: null });
73
- void this.#send(id, kind, file, preview);
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
- #send = async (id, kind, file, preview) => {
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.upload(file, report));
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
- return this.#uploads.delete(id);
132
- if (!data)
133
- return this.#uploads.set(id, { status: 'failed' });
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
- return this.#uploads.set(id, { status: 'landed', data });
137
- this.#write(id, data);
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
- const views = new WeakMap();
154
- /** The view's uploads, when it lists the uploads plugin. */
155
- export const fileUploads = (edytor) => edytor && views.get(edytor);
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
- * File uploads (Notion's): the view's one uploader, for every kind whose
158
- * record takes files (`upload`: the image, file, video and audio kinds).
159
- * Without it, only links are embedded. The first listed claims the view.
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 createUploadsPlugin = ({ upload }) => (edytor) => {
162
- if (!edytor || views.has(edytor))
227
+ export const uploadHooks = (edytor, upload) => {
228
+ if (!edytor || !upload || viewUploads(edytor))
163
229
  return {};
164
- const uploads = new FileUploads(edytor, upload);
165
- views.set(edytor, uploads);
230
+ const uploads = new FileUploads(edytor);
231
+ claimUploads(edytor, uploads);
166
232
  return { onPaste: uploads.paste, onEdytorAttached: () => uploads.destroy };
167
233
  };