edytor 0.1.0-next.43 → 0.1.0-next.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  Edytor aims to be for Svelte what Slate.js is for React: a heavily customizable editor with an API to build any kind of collaborative rich text editor.
19
19
 
20
- > **Work in progress.** Edytor is a pre-release (`0.1.0-next.43`, `edytor@next` on npm) and not ready for production; the API changes between releases without a compatibility layer. The untagged `edytor@0.0.11` on npm predates the current API. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
20
+ > **Work in progress.** Edytor is a pre-release (`0.1.0-next.44`, `edytor@next` on npm) and not ready for production; the API changes between releases without a compatibility layer. The untagged `edytor@0.0.11` on npm predates the current API. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
21
21
 
22
22
  ## Features
23
23
 
@@ -4,6 +4,14 @@ import { type SelectionInsertionTarget, type SelectionReplacementState } from '.
4
4
  import { type JSONText } from '../utils/json.js';
5
5
  import type { EdytorClipboardFragment } from './types.js';
6
6
  export declare const flowOfText: (text: string, marks?: JSONText["marks"]) => Flow;
7
+ /**
8
+ * A pasted `text/plain` as markdown (`paste.markdown`): `null` unless a line
9
+ * holds block markdown (`hasBlockMarkdown`), else the blocks it reads as
10
+ * (`textToBlocks`). A kind the view does not register is a paragraph of its
11
+ * text, its children after it (a divider is dropped); without a paragraph
12
+ * kind, nothing converts.
13
+ */
14
+ export declare const flowOfMarkdown: (edytor: Pick<Edytor, "blocks">, text: string) => Flow | null;
7
15
  export declare const flowOfFragment: (fragment: EdytorClipboardFragment) => Flow;
8
16
  /**
9
17
  * Place `flow` (`flow.*`) at `at`, over the selected blocks (unless
@@ -4,6 +4,7 @@ import { cloneJsonSafe, jsonBlockToSpec, jsonContentToItems } from '../utils/jso
4
4
  import { id } from '../utils.js';
5
5
  import { caretAt } from '../session/attempt.js';
6
6
  import { isValidEdytorClipboardFragment } from './fragmentData.js';
7
+ import { hasBlockMarkdown, textToBlocks } from './textBlocks.js';
7
8
  // Admission (`flow.shape`): every id is minted fresh here, once.
8
9
  const run = (content) => ({
9
10
  id: id('b'),
@@ -12,6 +13,28 @@ const run = (content) => ({
12
13
  export const flowOfText = (text, marks) => ({
13
14
  lines: text.split(/\r\n|\r|\n/).map((line) => run(line ? [{ text: line, marks }] : []))
14
15
  });
16
+ /**
17
+ * A pasted `text/plain` as markdown (`paste.markdown`): `null` unless a line
18
+ * holds block markdown (`hasBlockMarkdown`), else the blocks it reads as
19
+ * (`textToBlocks`). A kind the view does not register is a paragraph of its
20
+ * text, its children after it (a divider is dropped); without a paragraph
21
+ * kind, nothing converts.
22
+ */
23
+ export const flowOfMarkdown = (edytor, text) => {
24
+ if (!edytor.blocks.has('paragraph') || !hasBlockMarkdown(text))
25
+ return null;
26
+ const known = (block) => {
27
+ const children = (block.children ?? []).flatMap(known);
28
+ if (edytor.blocks.has(block.type))
29
+ return [{ ...block, ...(block.children ? { children } : {}) }];
30
+ const own = block.type === 'divider' || (!block.content?.length && block.children?.length)
31
+ ? []
32
+ : [{ type: 'paragraph', content: block.content ?? [] }];
33
+ return [...own, ...children];
34
+ };
35
+ const blocks = textToBlocks(text).flatMap(known);
36
+ return blocks.length ? { lines: blocks.map((block) => jsonBlockToSpec(block, true)) } : null;
37
+ };
15
38
  export const flowOfFragment = (fragment) => fragment.kind === 'content'
16
39
  ? { lines: [run(fragment.content)] }
17
40
  : {
@@ -19,5 +19,7 @@ export type TextToBlocksOptions = {
19
19
  /** The kind of a paragraph (default `paragraph`). */
20
20
  type?: string;
21
21
  };
22
+ /** Whether `text` holds block markdown (one line of it is enough), as a paste reads it. */
23
+ export declare const hasBlockMarkdown: (text: string) => boolean;
22
24
  /** `text` as blocks (see the module comment). */
23
25
  export declare const textToBlocks: (text: string, { markdown, type }?: TextToBlocksOptions) => JSONBlock[];
@@ -21,6 +21,10 @@ const runs = (text) => {
21
21
  out.push({ text: text.slice(at) });
22
22
  return out;
23
23
  };
24
+ /** A line that opens block markdown: a heading, a list or to-do item, a quote, a fence, a divider. */
25
+ const BLOCK_LINE = /^\s*(?:#{1,6}\s|[-*+]\s|\d+[.)]\s|>|```|(?:-{3,}|\*{3,}|_{3,})\s*$)/;
26
+ /** Whether `text` holds block markdown (one line of it is enough), as a paste reads it. */
27
+ export const hasBlockMarkdown = (text) => text.split(/\r?\n/).some((line) => BLOCK_LINE.test(line));
24
28
  /** One line's kind, data and text, or `null` for a paragraph line. */
25
29
  const lineKind = (line) => {
26
30
  let m = /^(#{1,6})\s+(.*)$/.exec(line);
@@ -1,6 +1,7 @@
1
1
  import { vetoable } from '../utils.js';
2
2
  import { readEdytorClipboardFragment } from '../clipboard/clipboard.js';
3
- import { flowOfFragment, pasteFlow } from '../clipboard/insertClipboardFragment.js';
3
+ import { flowOfFragment, flowOfMarkdown, pasteFlow } from '../clipboard/insertClipboardFragment.js';
4
+ import { inCodeLines } from '../session/inputRules.js';
4
5
  import { flowOfHtml } from '../clipboard/htmlFlow.js';
5
6
  import { getDomSelectionSnapshot } from '../selection/domSelection.js';
6
7
  import { getYIndex } from '../selection/selection.utils.js';
@@ -100,6 +101,16 @@ export async function onPaste(e) {
100
101
  e.preventDefault();
101
102
  return this.dispatcher.run('insertFromPaste', () => pasteFlow(this, flow));
102
103
  }
104
+ // Markdown text (`paste.markdown`): its blocks, except in a code block's lines.
105
+ const start = this.selection.projection.start?.block;
106
+ const at = start ? this.idToBlock.get(start) : undefined;
107
+ const markdown = at && inCodeLines(this, at)
108
+ ? null
109
+ : flowOfMarkdown(this, e.clipboardData?.getData('text/plain') ?? '');
110
+ if (markdown) {
111
+ e.preventDefault();
112
+ return this.dispatcher.run('insertFromPaste', () => pasteFlow(this, markdown));
113
+ }
103
114
  }
104
115
  if (!e.clipboardData?.getData('text/plain') &&
105
116
  !firstUriListEntry(e.clipboardData?.getData('text/uri-list'))) {
package/dist/index.d.ts CHANGED
@@ -61,6 +61,7 @@ export type { TextOperations } from './text/text.utils.js';
61
61
  export type { BlockBeside, BlockOperations } from './block/block.utils.js';
62
62
  export type { TextInsertionPayload, TriggerRange } from './plugins/triggers/TriggerController.svelte.js';
63
63
  export type { RichTextLink, RichTextMark } from './plugins/richtext/richTextOperations.js';
64
+ export type { UploadReport, Uploader } from './plugins/uploads.svelte.js';
64
65
  export type { AwarenessLike, PresenceWriter } from './collaboration/awarenessSelection.js';
65
66
  export type { DataPatch } from './crdt/data.js';
66
67
  export type { AnchorAffinity, BlockRole, DocAnchor, TextRange } from './crdt/doc/types.js';
package/dist/labels.d.ts CHANGED
@@ -211,6 +211,10 @@ export type MediaLabels = {
211
211
  video: MediaKindLabels;
212
212
  audio: MediaKindLabels;
213
213
  upload: string;
214
+ /** A pasted or dropped file's block while it uploads. */
215
+ uploading: string;
216
+ /** A pasted or dropped file whose upload failed. */
217
+ uploadFailed: string;
214
218
  /** A file's size, in bytes. */
215
219
  fileSize: (bytes: number) => string;
216
220
  /** The menu a pasted link opens. */
package/dist/labels.js CHANGED
@@ -239,6 +239,8 @@ export const englishLabels = frozen({
239
239
  invalid: "That doesn't look like an audio link or upload."
240
240
  },
241
241
  upload: 'Upload',
242
+ uploading: 'Uploading…',
243
+ uploadFailed: 'The upload failed: try again or paste a link.',
242
244
  fileSize: bytes,
243
245
  pasteAs: 'Paste as',
244
246
  pasteLink: 'Link'
@@ -8,7 +8,7 @@
8
8
  oversizedInlineImage,
9
9
  safeImageSrc
10
10
  } from './image.js';
11
- import type { ImageUploads } from './uploads.svelte.js';
11
+ import type { FileUploads, Uploader } from '../uploads.svelte.js';
12
12
 
13
13
  /**
14
14
  * Notion's empty image: "Add an image", then a link field (and Upload with
@@ -27,8 +27,8 @@
27
27
  landed = false
28
28
  }: {
29
29
  block: Block | undefined;
30
- upload?: (file: File) => Promise<string>;
31
- uploads?: ImageUploads;
30
+ upload?: Uploader;
31
+ uploads?: FileUploads;
32
32
  error?: 'upload' | null;
33
33
  landed?: boolean;
34
34
  } = $props();
@@ -94,7 +94,7 @@
94
94
  const file = event.currentTarget.files?.[0];
95
95
  if (!file || block?.edytor.readonly !== false) return;
96
96
  try {
97
- embed(await upload(file));
97
+ embed(await upload(file, { progress: () => {} }));
98
98
  } catch {
99
99
  failed = 'upload';
100
100
  }
@@ -1,9 +1,9 @@
1
1
  import type { Block } from '../../block/block.svelte.js';
2
- import type { ImageUploads } from './uploads.svelte.js';
2
+ import type { FileUploads, Uploader } from '../uploads.svelte.js';
3
3
  type $$ComponentProps = {
4
4
  block: Block | undefined;
5
- upload?: (file: File) => Promise<string>;
6
- uploads?: ImageUploads;
5
+ upload?: Uploader;
6
+ uploads?: FileUploads;
7
7
  error?: 'upload' | null;
8
8
  landed?: boolean;
9
9
  };
@@ -19,7 +19,7 @@
19
19
  type ImageAlign
20
20
  } from './image.js';
21
21
  import { ImageControls } from './controls.svelte.js';
22
- import { ImageUploads } from './uploads.svelte.js';
22
+ import { fileUploads, type FileUploads, type Uploader } from '../uploads.svelte.js';
23
23
  import { onPress } from '../../events/onFocus.js';
24
24
  import { keywordsOf, labelsWith, type PartialLabels } from '../../labels.js';
25
25
 
@@ -37,9 +37,10 @@
37
37
  * Upload a file and answer its URL. Adds an Upload button to the empty
38
38
  * block, and claims pasted and dropped image files: each is placed as an
39
39
  * image block at once (one undo step) and filled with the URL this
40
- * answers. Without it only links are embedded.
40
+ * answers (the block shows the progress it reports). Without it only
41
+ * links are embedded.
41
42
  */
42
- upload?: (file: File) => Promise<string>;
43
+ upload?: Uploader;
43
44
  /** The words the block, its empty panel and its chrome show, over the English ones. */
44
45
  labels?: PartialLabels<'image'>;
45
46
  /**
@@ -57,9 +58,9 @@
57
58
 
58
59
  /** One view's image state: its plugin's `upload`, the chrome, the uploads in flight. */
59
60
  type View = {
60
- upload?: (file: File) => Promise<string>;
61
+ upload?: Uploader;
61
62
  controls: ImageControls;
62
- uploads?: ImageUploads;
63
+ uploads?: FileUploads;
63
64
  };
64
65
  /** By view; the first image plugin of a view owns it, as its kind's definition (first wins). */
65
66
  const views = new WeakMap<Edytor, View>();
@@ -68,17 +69,6 @@
68
69
  /** Recognize any image plugin instance (the component's default yields to yours). */
69
70
  export const isImagePlugin = (plugin: Plugin) => imagePlugins.has(plugin);
70
71
 
71
- /**
72
- * Whether a pasted `html` shows nothing but images (a copied image's
73
- * `<img>`), so the clipboard's files are the content. HTML with text (an
74
- * office app also puts a picture of the copied text on the clipboard) is
75
- * imported instead.
76
- */
77
- const onlyImages = (html: string | undefined) =>
78
- !html ||
79
- typeof DOMParser === 'undefined' ||
80
- !new DOMParser().parseFromString(html, 'text/html').body.textContent?.trim();
81
-
82
72
  /**
83
73
  * Notion's image block: an "Add an image" panel until it has a source (a
84
74
  * pasted link, or an upload when `upload` is given), then the image with
@@ -93,20 +83,30 @@
93
83
  const own: View = {
94
84
  upload,
95
85
  controls: new ImageControls(edytor, labels),
96
- uploads: upload ? new ImageUploads(edytor, upload) : undefined
86
+ uploads: upload ? fileUploads(edytor) : undefined
97
87
  };
98
88
  // The records read with no view (`plugin(undefined)`) keep no state.
99
- if (edytor && !views.has(edytor)) views.set(edytor, own);
89
+ if (edytor && !views.has(edytor)) {
90
+ views.set(edytor, own);
91
+ if (upload)
92
+ own.uploads?.register({
93
+ type: 'image',
94
+ accepts: isImageFile,
95
+ upload,
96
+ data: (_file, answer) => {
97
+ const src = storableImageSrc(answer);
98
+ return src ? { src } : null;
99
+ },
100
+ filled: (data) => safeImageSrc(data.src) !== null,
101
+ preview: true
102
+ });
103
+ }
100
104
  const owns = () => views.get(edytor) === own;
101
105
  return {
102
- // A pasted or dropped image file (a drop replays the paste hooks):
103
- // placed and uploaded, unless the HTML beside it shows text.
104
- onPaste: ({ e, prevent }) => {
105
- const uploads = own.uploads;
106
- if (!uploads || !owns() || edytor.readonly) return;
107
- const files = Array.from(e.clipboardData?.files ?? []).filter(isImageFile);
108
- if (files.length && onlyImages(e.clipboardData?.getData('text/html')))
109
- prevent(() => uploads.insert(files));
106
+ // Pasted or dropped files (a drop replays the paste hooks): the
107
+ // view's uploads place a block per file of every kind that uploads.
108
+ onPaste: (payload) => {
109
+ if (owns()) own.uploads?.paste(payload);
110
110
  },
111
111
  // The chrome: in the overlay, for the image under the pointer.
112
112
  onEdytorAttached: ({ node }) => {
@@ -206,9 +206,10 @@
206
206
  {:else if upload?.status === 'uploading'}
207
207
  <div use:block.void data-edytor-image data-edytor-image-uploading data-align="center">
208
208
  {#if upload.preview}<img src={upload.preview} alt="" draggable="false" />{/if}
209
- <span data-edytor-image-progress role="status"
210
- >{imageLabels.of(block.handle?.edytor).uploading}</span
211
- >
209
+ <span data-edytor-image-progress role="status">
210
+ <progress value={upload.progress ?? undefined} max="1"></progress>
211
+ {imageLabels.of(block.handle?.edytor).uploading}
212
+ </span>
212
213
  </div>
213
214
  {:else}
214
215
  <div use:block.void data-edytor-image-empty>
@@ -1,5 +1,6 @@
1
1
  import type { Plugin } from '../../plugins.js';
2
2
  import { MAX_INLINE_IMAGE_BYTES, MIN_IMAGE_WIDTH, oversizedInlineImage, safeImageSrc, storableImageSrc, type ImageAlign } from './image.js';
3
+ import { type Uploader } from '../uploads.svelte.js';
3
4
  import { type PartialLabels } from '../../labels.js';
4
5
  export { MAX_INLINE_IMAGE_BYTES, MIN_IMAGE_WIDTH, oversizedInlineImage, safeImageSrc, storableImageSrc, type ImageAlign };
5
6
  export type ImagePluginOptions = {
@@ -7,9 +8,10 @@ export type ImagePluginOptions = {
7
8
  * Upload a file and answer its URL. Adds an Upload button to the empty
8
9
  * block, and claims pasted and dropped image files: each is placed as an
9
10
  * image block at once (one undo step) and filled with the URL this
10
- * answers. Without it only links are embedded.
11
+ * answers (the block shows the progress it reports). Without it only
12
+ * links are embedded.
11
13
  */
12
- upload?: (file: File) => Promise<string>;
14
+ upload?: Uploader;
13
15
  /** The words the block, its empty panel and its chrome show, over the English ones. */
14
16
  labels?: PartialLabels<'image'>;
15
17
  /**
@@ -11,6 +11,7 @@
11
11
  mediaLabels,
12
12
  mediaSourceOf,
13
13
  safeMediaSrc,
14
+ mediaUploads,
14
15
  type MediaPluginOptions
15
16
  } from './media.js';
16
17
 
@@ -32,10 +33,13 @@
32
33
  (options: MediaPluginOptions = {}): Plugin =>
33
34
  (edytor) => {
34
35
  // First wins, as for the kind: a second listing never replaces it.
35
- if (!uploadOf.has(edytor)) uploadOf.set(edytor, options.upload);
36
+ const first = !!edytor && !uploadOf.has(edytor);
37
+ if (first) uploadOf.set(edytor, options.upload);
38
+ const onPaste = first ? mediaUploads(edytor, 'audio', options.upload) : undefined;
36
39
  const labels = labelsWith('media', options.labels);
37
40
  mediaLabels.audio.claim(edytor, labels);
38
41
  return {
42
+ ...(onPaste ? { onPaste } : {}),
39
43
  blocks: {
40
44
  audio: {
41
45
  ...mediaKinds.audio,
@@ -10,6 +10,7 @@
10
10
  fileNameOf,
11
11
  mediaLabels,
12
12
  safeMediaSrc,
13
+ mediaUploads,
13
14
  type MediaPluginOptions
14
15
  } from './media.js';
15
16
 
@@ -32,10 +33,13 @@
32
33
  (options: MediaPluginOptions = {}): Plugin =>
33
34
  (edytor) => {
34
35
  // First wins, as for the kind: a second listing never replaces it.
35
- if (!uploadOf.has(edytor)) uploadOf.set(edytor, options.upload);
36
+ const first = !!edytor && !uploadOf.has(edytor);
37
+ if (first) uploadOf.set(edytor, options.upload);
38
+ const onPaste = first ? mediaUploads(edytor, 'file', options.upload) : undefined;
36
39
  const labels = labelsWith('media', options.labels);
37
40
  mediaLabels.file.claim(edytor, labels);
38
41
  return {
42
+ ...(onPaste ? { onPaste } : {}),
39
43
  blocks: {
40
44
  file: {
41
45
  ...mediaKinds.file,
@@ -1,8 +1,9 @@
1
1
  <script lang="ts">
2
- import { getContext } from 'svelte';
2
+ import { getContext, untrack } from 'svelte';
3
3
  import type { Block } from '../../block/block.svelte.js';
4
4
  import type { Edytor } from '../../edytor.svelte.js';
5
5
  import { mediaLabels, type MediaKind } from './media.js';
6
+ import { fileUploads, type Uploader } from '../uploads.svelte.js';
6
7
 
7
8
  /**
8
9
  * A media block's empty state (the image's pattern, Notion): an "Add …"
@@ -11,7 +12,10 @@
11
12
  * upload fills `src`, and only a block still empty when it lands). A
12
13
  * readonly view, or a suggestion's preview (no `block`), shows a passive
13
14
  * placeholder: no control may run `upload` (or a bookmark's `unfurl`) for
14
- * a write the view would refuse.
15
+ * a write the view would refuse. A pasted or dropped file (`media.files`)
16
+ * shows its upload and progress here; a failed one opens the panel with
17
+ * its error; data answered while the block was not shown fills it as soon
18
+ * as it shows.
15
19
  */
16
20
  let {
17
21
  block,
@@ -28,7 +32,7 @@
28
32
  icon: string;
29
33
  /** The file picker's `accept`. */
30
34
  accept?: string;
31
- upload?: (file: File) => Promise<string>;
35
+ upload?: Uploader;
32
36
  link: (value: string) => boolean;
33
37
  file?: (file: File, src: string) => boolean;
34
38
  } = $props();
@@ -39,16 +43,41 @@
39
43
  /** The button and placeholder text ("Embed a link"); the file's names its upload. */
40
44
  const label = $derived(upload && 'addOrUpload' in words ? words.addOrUpload : words.add);
41
45
  const { placeholder, submit, invalid } = $derived(words);
46
+ /** The upload of a pasted or dropped file into this block, if any. */
47
+ const uploads = $derived(block ? fileUploads(block.edytor) : undefined);
48
+ const pending = $derived(block ? uploads?.of(block.id) : undefined);
49
+ // After the flush that shows the block: never inside the transaction that showed it.
50
+ $effect(() => {
51
+ if (pending?.status === 'landed' && block && !block.edytor.readonly) {
52
+ const id = block.id;
53
+ untrack(() => uploads?.fill(id));
54
+ }
55
+ });
42
56
  let draft = $state('');
43
57
  let open = $state(false);
44
- let failed = $state(false);
58
+ let failed = $state<'invalid' | 'upload' | false>(false);
59
+ // The panel opens on a failed upload, with its error; the user closes it.
60
+ $effect(() => {
61
+ if (pending?.status === 'failed')
62
+ untrack(() => {
63
+ open = true;
64
+ failed = 'upload';
65
+ });
66
+ });
45
67
 
46
68
  const embed = (value: string) => {
47
- failed = !link(value);
69
+ failed = link(value) ? false : 'invalid';
70
+ if (!failed && block) uploads?.clear(block.id);
48
71
  };
49
72
  </script>
50
73
 
51
- {#if !block || block.edytor.readonly}
74
+ {#if pending?.status === 'uploading'}
75
+ <div data-edytor-media-uploading role="status">
76
+ <span aria-hidden="true">{icon}</span>
77
+ <progress value={pending.progress ?? undefined} max="1"></progress>
78
+ {labels.uploading}
79
+ </div>
80
+ {:else if !block || block.edytor.readonly}
52
81
  <div data-edytor-media-placeholder><span aria-hidden="true">{icon}</span> {label}</div>
53
82
  {:else}
54
83
  <button
@@ -84,9 +113,9 @@
84
113
  const { edytor, id, type } = block;
85
114
  let src: string;
86
115
  try {
87
- src = await upload(picked);
116
+ src = await upload(picked, { progress: () => {} });
88
117
  } catch {
89
- failed = true;
118
+ failed = 'upload';
90
119
  return;
91
120
  }
92
121
  // Write only to the block as it was picked from: still in the
@@ -95,13 +124,15 @@
95
124
  const live = edytor.idToBlock.get(id);
96
125
  if (!live?.isInTree || live.type !== type || edytor.readonly) return;
97
126
  if (edytor.facade.blockDataOf(id)?.src !== undefined) return;
98
- failed = !(file ? file(picked, src) : link(src));
127
+ failed = (file ? file(picked, src) : link(src)) ? false : 'invalid';
99
128
  }}
100
129
  />
101
130
  </label>
102
131
  {/if}
103
- {#if failed}
104
- <small data-edytor-media-error>{invalid}</small>
132
+ {#if failed === 'upload'}
133
+ <small data-edytor-media-error="upload">{labels.uploadFailed}</small>
134
+ {:else if failed}
135
+ <small data-edytor-media-error="invalid">{invalid}</small>
105
136
  {/if}
106
137
  </div>
107
138
  {/if}
@@ -1,5 +1,6 @@
1
1
  import type { Block } from '../../block/block.svelte.js';
2
2
  import { type MediaKind } from './media.js';
3
+ import { type Uploader } from '../uploads.svelte.js';
3
4
  type $$ComponentProps = {
4
5
  block: Block | undefined;
5
6
  /** Whose words it shows, in the labels of the view rendering it (a suggestion's preview included). */
@@ -7,7 +8,7 @@ type $$ComponentProps = {
7
8
  icon: string;
8
9
  /** The file picker's `accept`. */
9
10
  accept?: string;
10
- upload?: (file: File) => Promise<string>;
11
+ upload?: Uploader;
11
12
  link: (value: string) => boolean;
12
13
  file?: (file: File, src: string) => boolean;
13
14
  };
@@ -11,6 +11,7 @@
11
11
  mediaLabels,
12
12
  mediaSourceOf,
13
13
  safeMediaSrc,
14
+ mediaUploads,
14
15
  type MediaPluginOptions
15
16
  } from './media.js';
16
17
 
@@ -33,10 +34,13 @@
33
34
  (options: MediaPluginOptions = {}): Plugin =>
34
35
  (edytor) => {
35
36
  // First wins, as for the kind: a second listing never replaces it.
36
- if (!uploadOf.has(edytor)) uploadOf.set(edytor, options.upload);
37
+ const first = !!edytor && !uploadOf.has(edytor);
38
+ if (first) uploadOf.set(edytor, options.upload);
39
+ const onPaste = first ? mediaUploads(edytor, 'video', options.upload) : undefined;
37
40
  const labels = labelsWith('media', options.labels);
38
41
  mediaLabels.video.claim(edytor, labels);
39
42
  return {
43
+ ...(onPaste ? { onPaste } : {}),
40
44
  blocks: {
41
45
  video: {
42
46
  ...mediaKinds.video,
@@ -1,3 +1,6 @@
1
+ import type { Edytor } from '../../edytor.svelte.js';
2
+ import type { PluginOperations } from '../../plugins.js';
3
+ import { type Uploader } from '../uploads.svelte.js';
1
4
  import { viewLabels, type PartialLabels } from '../../labels.js';
2
5
  /** The media kinds a view shows the labels of. */
3
6
  export type MediaKind = 'embed' | 'bookmark' | 'file' | 'video' | 'audio';
@@ -22,8 +25,13 @@ export declare const safeWebUrl: (value: unknown) => string | null;
22
25
  export declare const safeMediaSrc: (value: unknown) => string | null;
23
26
  /** The file, video and audio plugins' options. */
24
27
  export type MediaPluginOptions = {
25
- /** Upload a picked file and answer its URL; without it only links are embedded. */
26
- upload?: (file: File) => Promise<string>;
28
+ /**
29
+ * Upload a file and answer its URL: the empty block's Upload button, and
30
+ * the pasted and dropped files the kind takes (`media.files`), each shown
31
+ * uploading with the progress it reports. Without it only links are
32
+ * embedded.
33
+ */
34
+ upload?: Uploader;
27
35
  /** The words the block shows (its empty panel, its menu row), over the English ones. */
28
36
  labels?: PartialLabels<'media'>;
29
37
  /** The slash menu's keywords of its command (`block.<kind>`), which replace its own. */
@@ -80,3 +88,10 @@ export declare const claimed: (element: HTMLElement, tag: string) => HTMLElement
80
88
  export declare const mediaSourceOf: (element: HTMLElement | undefined) => string | null;
81
89
  /** HTML-escape `value` for an attribute or text of an export. */
82
90
  export declare const escapeHtml: (value: string) => string;
91
+ /**
92
+ * Register `type`'s uploads on the view's (`media.files`): with an
93
+ * `upload`, the files it takes (the file kind: any no other kind takes)
94
+ * become its blocks. Answers the paste hook to list, or `undefined`
95
+ * without an `upload` or a view.
96
+ */
97
+ export declare const mediaUploads: (edytor: Edytor | undefined, type: "video" | "audio" | "file", upload: Uploader | undefined) => PluginOperations["onPaste"];
@@ -1,4 +1,5 @@
1
1
  import { sanitizeLinkHref } from '../richtext/richTextOperations.js';
2
+ import { fileUploads } from '../uploads.svelte.js';
2
3
  import { viewLabels } from '../../labels.js';
3
4
  /**
4
5
  * Each view's media labels, by kind: the first plugin of a kind listed in a
@@ -223,3 +224,36 @@ export const claimed = (element, tag) => {
223
224
  export const mediaSourceOf = (element) => safeMediaSrc(element?.getAttribute('src') ?? element?.querySelector('source[src]')?.getAttribute('src'));
224
225
  /** HTML-escape `value` for an attribute or text of an export. */
225
226
  export const escapeHtml = (value) => value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
227
+ /** The files each media kind takes: a video, an audio track, any other file. */
228
+ const ACCEPTS = {
229
+ video: (file) => /^video\//i.test(file.type),
230
+ audio: (file) => /^audio\//i.test(file.type),
231
+ file: () => true
232
+ };
233
+ /**
234
+ * Register `type`'s uploads on the view's (`media.files`): with an
235
+ * `upload`, the files it takes (the file kind: any no other kind takes)
236
+ * become its blocks. Answers the paste hook to list, or `undefined`
237
+ * without an `upload` or a view.
238
+ */
239
+ export const mediaUploads = (edytor, type, upload) => {
240
+ const uploads = upload ? fileUploads(edytor) : undefined;
241
+ if (!uploads || !upload)
242
+ return undefined;
243
+ uploads.register({
244
+ type,
245
+ accepts: ACCEPTS[type],
246
+ fallback: type === 'file',
247
+ upload,
248
+ data: (file, answer) => {
249
+ const src = safeMediaSrc(answer);
250
+ if (!src)
251
+ return null;
252
+ return type === 'file'
253
+ ? { src, name: file.name || fileNameOf(src), size: file.size }
254
+ : { src };
255
+ },
256
+ filled: (data) => safeMediaSrc(data.src) !== null
257
+ });
258
+ return uploads.paste;
259
+ };
@@ -0,0 +1,92 @@
1
+ import type { Edytor } from '../edytor.svelte.js';
2
+ import type { PluginOperations } from '../plugins.js';
3
+ /** What an `upload` may report while it runs. */
4
+ export type UploadReport = {
5
+ /** The share sent so far, `0` to `1`: the block shows it as a bar. */
6
+ progress(fraction: number): void;
7
+ };
8
+ /**
9
+ * Upload a file and answer its URL. `report.progress` (optional to call)
10
+ * draws the block's bar; without it the bar is indeterminate.
11
+ */
12
+ export type Uploader = (file: File, report: UploadReport) => Promise<string>;
13
+ /**
14
+ * A block's upload in this view: running (its preview and progress),
15
+ * failed, or answered while the block was not shown (`landed`: an undone
16
+ * paste), its data held until the block shows again.
17
+ */
18
+ export type FileUpload = {
19
+ readonly status: 'uploading';
20
+ /** The file, shown while it uploads (a `blob:` URL; `null` where there is none). */
21
+ readonly preview: string | null;
22
+ /** The share sent, `0` to `1`; `null` until `upload` reports one. */
23
+ readonly progress: number | null;
24
+ } | {
25
+ readonly status: 'failed';
26
+ } | {
27
+ readonly status: 'landed';
28
+ readonly data: Record<string, unknown>;
29
+ };
30
+ /** A kind that takes files: which, through what upload, and the data an upload writes. */
31
+ export type UploadKind = {
32
+ type: string;
33
+ /** Whether it takes `file`. */
34
+ accepts: (file: File) => boolean;
35
+ /** It takes any file no other kind takes (the file block): asked last. */
36
+ fallback?: boolean;
37
+ upload: Uploader;
38
+ /** The data the answered URL writes (`null`: no source this kind stores). */
39
+ data: (file: File, answer: string) => Record<string, unknown> | null;
40
+ /** Whether the block has a source already (a peer's, a link's): its upload then writes nothing. */
41
+ filled: (data: Record<string, unknown>) => boolean;
42
+ /** Show the file while it uploads (an image's `blob:` preview). */
43
+ preview?: boolean;
44
+ };
45
+ /**
46
+ * The view's uploads of pasted and dropped files (`media.files`), one per
47
+ * view: session state, never in the document. The kinds given an `upload`
48
+ * register here; a paste or a drop (a drop replays the paste hooks) of
49
+ * files the kinds take places one empty block per file, each of the kind
50
+ * that takes it (`fallback` last), in the clipboard's order, as one paste
51
+ * (one command, one undo step), and each block shows its upload (`of`).
52
+ * The data an upload answers fills the block outside the history
53
+ * (`dispatcher.outside`), so the paste stays the one step that undoes it.
54
+ * A failed upload, or an answer that is no source, leaves the block empty
55
+ * with its error until a source is embedded (`clear`). Data answered while
56
+ * the block is not shown (its paste undone, the block deleted) is held
57
+ * (`landed`) and written when the block shows again with no source
58
+ * (`fill`, asked by the empty block's render), so a redo brings it back
59
+ * whole.
60
+ */
61
+ export declare class FileUploads {
62
+ #private;
63
+ private edytor;
64
+ constructor(edytor: Edytor);
65
+ /** Take files for `kind` (the first registration of a type wins, as its definition). */
66
+ register: (kind: UploadKind) => void;
67
+ /** The kind that takes `file`, if any. */
68
+ kindOf: (file: File) => UploadKind | undefined;
69
+ /** The upload of block `id` in this view (reactive), if any. */
70
+ of: (id: string) => FileUpload | undefined;
71
+ /**
72
+ * The paste hook of every kind that registered (the first one asked
73
+ * claims for them all): the files a kind takes, unless the HTML beside
74
+ * them shows text (an office app puts a picture of copied text on the
75
+ * clipboard).
76
+ */
77
+ paste: NonNullable<PluginOperations['onPaste']>;
78
+ /** Place a block per file at the selection (one paste), then upload each. */
79
+ insert: (files: readonly File[]) => void;
80
+ /** Forget the block's upload (a source was embedded: its error, or held data, no longer applies). */
81
+ clear: (id: string) => void;
82
+ /**
83
+ * The block shows again with no source: write the data its upload
84
+ * answered while it was not shown. Asked by the empty block's render,
85
+ * never inside a transaction.
86
+ */
87
+ fill: (id: string) => boolean | undefined;
88
+ /** Release every preview. */
89
+ destroy: () => void;
90
+ }
91
+ /** The view's file uploads (made at the first ask; `undefined` with no view). */
92
+ export declare const fileUploads: (edytor: Edytor | undefined) => FileUploads | undefined;
@@ -0,0 +1,160 @@
1
+ import { SvelteMap } from 'svelte/reactivity';
2
+ import { pasteFlow } from '../clipboard/insertClipboardFragment.js';
3
+ import { jsonBlockToSpec } from '../utils/json.js';
4
+ import { id as mint } from '../utils.js';
5
+ /**
6
+ * The view's uploads of pasted and dropped files (`media.files`), one per
7
+ * view: session state, never in the document. The kinds given an `upload`
8
+ * register here; a paste or a drop (a drop replays the paste hooks) of
9
+ * files the kinds take places one empty block per file, each of the kind
10
+ * that takes it (`fallback` last), in the clipboard's order, as one paste
11
+ * (one command, one undo step), and each block shows its upload (`of`).
12
+ * The data an upload answers fills the block outside the history
13
+ * (`dispatcher.outside`), so the paste stays the one step that undoes it.
14
+ * A failed upload, or an answer that is no source, leaves the block empty
15
+ * with its error until a source is embedded (`clear`). Data answered while
16
+ * the block is not shown (its paste undone, the block deleted) is held
17
+ * (`landed`) and written when the block shows again with no source
18
+ * (`fill`, asked by the empty block's render), so a redo brings it back
19
+ * whole.
20
+ */
21
+ export class FileUploads {
22
+ edytor;
23
+ #kinds = [];
24
+ #uploads = new SvelteMap();
25
+ constructor(edytor) {
26
+ this.edytor = edytor;
27
+ }
28
+ /** Take files for `kind` (the first registration of a type wins, as its definition). */
29
+ register = (kind) => {
30
+ if (!this.#kinds.some(({ type }) => type === kind.type))
31
+ this.#kinds.push(kind);
32
+ };
33
+ /** The kind that takes `file`, if any. */
34
+ kindOf = (file) => this.#kinds.find((kind) => !kind.fallback && kind.accepts(file)) ??
35
+ this.#kinds.find((kind) => kind.fallback && kind.accepts(file));
36
+ /** The upload of block `id` in this view (reactive), if any. */
37
+ of = (id) => this.#uploads.get(id);
38
+ /**
39
+ * The paste hook of every kind that registered (the first one asked
40
+ * claims for them all): the files a kind takes, unless the HTML beside
41
+ * them shows text (an office app puts a picture of copied text on the
42
+ * clipboard).
43
+ */
44
+ paste = ({ e, prevent }) => {
45
+ if (this.edytor.readonly || !this.#kinds.length)
46
+ return;
47
+ const files = Array.from(e.clipboardData?.files ?? []).filter((file) => this.kindOf(file));
48
+ if (files.length && !showsText(e.clipboardData?.getData('text/html')))
49
+ prevent(() => this.insert(files));
50
+ };
51
+ /** Place a block per file at the selection (one paste), then upload each. */
52
+ insert = (files) => {
53
+ const { edytor } = this;
54
+ const placed = files.flatMap((file) => {
55
+ const kind = this.kindOf(file);
56
+ return kind ? [{ file, kind, id: mint('b') }] : [];
57
+ });
58
+ if (!placed.length)
59
+ return;
60
+ const lines = placed.map(({ kind, id }) => jsonBlockToSpec({ id, type: kind.type, data: {}, content: [] }));
61
+ edytor.dispatcher.run('insertFromPaste', () => pasteFlow(edytor, { lines }));
62
+ for (const { file, kind, id } of placed) {
63
+ // Refused, or placed as no such block (a code line takes plain lines, `flow.lines`).
64
+ if (!edytor.facade.isVisibleBlock(id) || edytor.facade.blockTypeOf(id) !== kind.type)
65
+ continue;
66
+ const preview = kind.preview && typeof URL.createObjectURL === 'function'
67
+ ? URL.createObjectURL(file)
68
+ : null;
69
+ this.#uploads.set(id, { status: 'uploading', preview, progress: null });
70
+ void this.#send(id, kind, file, preview);
71
+ }
72
+ };
73
+ /** Forget the block's upload (a source was embedded: its error, or held data, no longer applies). */
74
+ clear = (id) => {
75
+ const upload = this.#uploads.get(id);
76
+ if (upload?.status === 'uploading')
77
+ return;
78
+ this.#uploads.delete(id);
79
+ };
80
+ /**
81
+ * The block shows again with no source: write the data its upload
82
+ * answered while it was not shown. Asked by the empty block's render,
83
+ * never inside a transaction.
84
+ */
85
+ fill = (id) => {
86
+ const upload = this.#uploads.get(id);
87
+ if (upload?.status !== 'landed')
88
+ return;
89
+ const block = this.edytor.idToBlock.get(id);
90
+ if (!block?.isInTree || this.edytor.destroyed)
91
+ return;
92
+ const kind = this.#kinds.find(({ type }) => type === block.type);
93
+ if (!kind || kind.filled(block.data))
94
+ return this.#uploads.delete(id);
95
+ this.#write(id, upload.data);
96
+ };
97
+ /** Release every preview. */
98
+ destroy = () => {
99
+ for (const upload of this.#uploads.values())
100
+ if (upload.status === 'uploading' && upload.preview)
101
+ URL.revokeObjectURL(upload.preview);
102
+ this.#uploads.clear();
103
+ };
104
+ #send = async (id, kind, file, preview) => {
105
+ const report = {
106
+ progress: (fraction) => {
107
+ const upload = this.#uploads.get(id);
108
+ if (upload?.status !== 'uploading' || !Number.isFinite(fraction))
109
+ return;
110
+ this.#uploads.set(id, { ...upload, progress: Math.min(1, Math.max(0, fraction)) });
111
+ }
112
+ };
113
+ let data;
114
+ try {
115
+ data = kind.data(file, await kind.upload(file, report));
116
+ }
117
+ catch {
118
+ data = null;
119
+ }
120
+ const { edytor } = this;
121
+ if (preview)
122
+ URL.revokeObjectURL(preview);
123
+ if (edytor.destroyed)
124
+ return;
125
+ const block = edytor.idToBlock.get(id);
126
+ // Given a source meanwhile (a peer, the link field): nothing to fill.
127
+ if (block?.isInTree && kind.filled(block.data))
128
+ return this.#uploads.delete(id);
129
+ if (!data)
130
+ return this.#uploads.set(id, { status: 'failed' });
131
+ // Not shown (undone, deleted): held until it shows again (`fill`).
132
+ if (!block?.isInTree)
133
+ return this.#uploads.set(id, { status: 'landed', data });
134
+ this.#write(id, data);
135
+ };
136
+ /** The data's write, outside the history; refused (readonly), it is held for the next show. */
137
+ #write = (id, data) => {
138
+ const { edytor } = this;
139
+ const block = edytor.idToBlock.get(id);
140
+ edytor.dispatcher.outside(() => block.patchData({ ops: Object.entries(data).map(([key, value]) => ({ path: [key], value })) }));
141
+ if (edytor.dispatcher.last?.status === 'applied')
142
+ this.#uploads.delete(id);
143
+ else
144
+ this.#uploads.set(id, { status: 'landed', data });
145
+ };
146
+ }
147
+ /** Whether pasted `html` shows text (then it is imported, its files a picture of it). */
148
+ const showsText = (html) => !!html &&
149
+ typeof DOMParser !== 'undefined' &&
150
+ !!new DOMParser().parseFromString(html, 'text/html').body.textContent?.trim();
151
+ const views = new WeakMap();
152
+ /** The view's file uploads (made at the first ask; `undefined` with no view). */
153
+ export const fileUploads = (edytor) => {
154
+ if (!edytor)
155
+ return undefined;
156
+ let uploads = views.get(edytor);
157
+ if (!uploads)
158
+ views.set(edytor, (uploads = new FileUploads(edytor)));
159
+ return uploads;
160
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "edytor",
3
3
  "license": "MIT",
4
- "version": "0.1.0-next.43",
4
+ "version": "0.1.0-next.44",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/beynar/edytor.git"
@@ -1,50 +0,0 @@
1
- import type { Edytor } from '../../edytor.svelte.js';
2
- /**
3
- * An image block's upload in this view: waiting for its URL, failed, or
4
- * answered while the block was not shown (`landed`: an undone paste), its
5
- * URL held until the block shows again.
6
- */
7
- export type ImageUpload = {
8
- readonly status: 'uploading';
9
- /** The file, shown while it uploads (a `blob:` URL; `null` where there is none). */
10
- readonly preview: string | null;
11
- } | {
12
- readonly status: 'failed';
13
- } | {
14
- readonly status: 'landed';
15
- readonly src: string;
16
- };
17
- /**
18
- * The uploads of pasted and dropped image files, one per view: session
19
- * state, never in the document. `insert` places one empty image block per
20
- * file at the selection, as a paste places an image line (`flow.apart`, one
21
- * command, one undo step), and the block shows the file uploading (`of`);
22
- * the URL `upload` answers fills its `src` outside the history
23
- * (`dispatcher.outside`), so the paste stays the one step that undoes it. A
24
- * peer sees the empty block until the URL lands. A failed upload, or an
25
- * answer that is no image source, leaves the block empty with its error
26
- * until a source is embedded (`clear`). A URL answered while the block is
27
- * not shown (its paste undone, the block deleted) is held (`landed`) and
28
- * written when the block shows again with no source (`fill`, asked by the
29
- * empty block's render), so a redo brings the image back whole.
30
- */
31
- export declare class ImageUploads {
32
- #private;
33
- private edytor;
34
- private upload;
35
- constructor(edytor: Edytor, upload: (file: File) => Promise<string>);
36
- /** The upload of block `id` in this view (reactive), if any. */
37
- of: (id: string) => ImageUpload | undefined;
38
- /** Place an image block per file at the selection (one paste), then upload each. */
39
- insert: (files: readonly File[]) => void;
40
- /** Forget the block's upload (a source was embedded: its error, or a held URL, no longer applies). */
41
- clear: (id: string) => void;
42
- /**
43
- * The block shows again with no source: write the URL its upload
44
- * answered while it was not shown. Asked by the empty block's render,
45
- * never inside a transaction.
46
- */
47
- fill: (id: string) => boolean | undefined;
48
- /** Release every preview. */
49
- destroy: () => void;
50
- }
@@ -1,111 +0,0 @@
1
- import { SvelteMap } from 'svelte/reactivity';
2
- import { pasteFlow } from '../../clipboard/insertClipboardFragment.js';
3
- import { jsonBlockToSpec } from '../../utils/json.js';
4
- import { id as mint } from '../../utils.js';
5
- import { safeImageSrc, storableImageSrc } from './image.js';
6
- /**
7
- * The uploads of pasted and dropped image files, one per view: session
8
- * state, never in the document. `insert` places one empty image block per
9
- * file at the selection, as a paste places an image line (`flow.apart`, one
10
- * command, one undo step), and the block shows the file uploading (`of`);
11
- * the URL `upload` answers fills its `src` outside the history
12
- * (`dispatcher.outside`), so the paste stays the one step that undoes it. A
13
- * peer sees the empty block until the URL lands. A failed upload, or an
14
- * answer that is no image source, leaves the block empty with its error
15
- * until a source is embedded (`clear`). A URL answered while the block is
16
- * not shown (its paste undone, the block deleted) is held (`landed`) and
17
- * written when the block shows again with no source (`fill`, asked by the
18
- * empty block's render), so a redo brings the image back whole.
19
- */
20
- export class ImageUploads {
21
- edytor;
22
- upload;
23
- #uploads = new SvelteMap();
24
- constructor(edytor, upload) {
25
- this.edytor = edytor;
26
- this.upload = upload;
27
- }
28
- /** The upload of block `id` in this view (reactive), if any. */
29
- of = (id) => this.#uploads.get(id);
30
- /** Place an image block per file at the selection (one paste), then upload each. */
31
- insert = (files) => {
32
- const { edytor } = this;
33
- if (!files.length)
34
- return;
35
- const lines = files.map(() => jsonBlockToSpec({ id: mint('b'), type: 'image', data: {}, content: [] }));
36
- edytor.dispatcher.run('insertFromPaste', () => pasteFlow(edytor, { lines }));
37
- files.forEach((file, index) => {
38
- const id = lines[index].id;
39
- // Refused, or placed as no image (a code line takes plain lines, `flow.lines`).
40
- if (!edytor.facade.isVisibleBlock(id) || edytor.facade.blockTypeOf(id) !== 'image')
41
- return;
42
- const preview = typeof URL.createObjectURL === 'function' ? URL.createObjectURL(file) : null;
43
- this.#uploads.set(id, { status: 'uploading', preview });
44
- void this.#send(id, file, preview);
45
- });
46
- };
47
- /** Forget the block's upload (a source was embedded: its error, or a held URL, no longer applies). */
48
- clear = (id) => {
49
- const upload = this.#uploads.get(id);
50
- if (upload?.status === 'uploading')
51
- return;
52
- this.#uploads.delete(id);
53
- };
54
- /**
55
- * The block shows again with no source: write the URL its upload
56
- * answered while it was not shown. Asked by the empty block's render,
57
- * never inside a transaction.
58
- */
59
- fill = (id) => {
60
- const upload = this.#uploads.get(id);
61
- if (upload?.status !== 'landed')
62
- return;
63
- const block = this.edytor.idToBlock.get(id);
64
- if (!block?.isInTree || this.edytor.destroyed)
65
- return;
66
- if (safeImageSrc(block.data.src))
67
- return this.#uploads.delete(id);
68
- this.#write(id, upload.src);
69
- };
70
- /** Release every preview. */
71
- destroy = () => {
72
- for (const upload of this.#uploads.values())
73
- if (upload.status === 'uploading' && upload.preview)
74
- URL.revokeObjectURL(upload.preview);
75
- this.#uploads.clear();
76
- };
77
- #send = async (id, file, preview) => {
78
- let src;
79
- try {
80
- src = storableImageSrc(await this.upload(file));
81
- }
82
- catch {
83
- src = null;
84
- }
85
- const { edytor } = this;
86
- if (preview)
87
- URL.revokeObjectURL(preview);
88
- if (edytor.destroyed)
89
- return;
90
- const block = edytor.idToBlock.get(id);
91
- // Given a source meanwhile (a peer, the link field): nothing to fill.
92
- if (block?.isInTree && safeImageSrc(block.data.src))
93
- return this.#uploads.delete(id);
94
- if (!src)
95
- return this.#uploads.set(id, { status: 'failed' });
96
- // Not shown (undone, deleted): held until it shows again (`fill`).
97
- if (!block?.isInTree)
98
- return this.#uploads.set(id, { status: 'landed', src });
99
- this.#write(id, src);
100
- };
101
- /** The URL's write, outside the history; refused (readonly), it is held for the next show. */
102
- #write = (id, src) => {
103
- const { edytor } = this;
104
- const block = edytor.idToBlock.get(id);
105
- edytor.dispatcher.outside(() => (block.data.src = src));
106
- if (edytor.dispatcher.last?.status === 'applied')
107
- this.#uploads.delete(id);
108
- else
109
- this.#uploads.set(id, { status: 'landed', src });
110
- };
111
- }