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,6 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { Plugin } from '../../plugins.js';
3
3
  import { type FileRowPayload } from './media.js';
4
+ import type { Uploader } from '../uploads.svelte.js';
4
5
  export type { FileRowPayload };
5
6
  export type FilePluginOptions = {
6
7
  /**
@@ -9,6 +10,14 @@ export type FilePluginOptions = {
9
10
  * downloads the file. The first file plugin listed in a view gives its row.
10
11
  */
11
12
  row?: Snippet<[FileRowPayload]>;
13
+ /**
14
+ * Upload a file and answer its URL: an empty file block's Upload, and
15
+ * the pasted and dropped files no other kind takes, each placed as a
16
+ * file block at once (one undo step), its row showing the file's name,
17
+ * size and progress, then filled with the URL this answers. Without
18
+ * it, files are only linked.
19
+ */
20
+ upload?: Uploader;
12
21
  };
13
22
  /**
14
23
  * Notion's file block: an attachment, uploaded through `upload` (or
@@ -3,6 +3,9 @@
3
3
  import EmptySlot from './EmptySlot.svelte';
4
4
  import Control from '../Control.svelte';
5
5
  import { labelsOf, mediaEnglish } from '../../labels.js';
6
+ import { emptyHosts } from './EmptySlot.svelte';
7
+ import UploadPreview from './UploadPreview.svelte';
8
+ import { fileUploads, uploadHooks, type Uploader } from '../uploads.svelte.js';
6
9
  import {
7
10
  claimed,
8
11
  escapeHtml,
@@ -48,8 +51,8 @@
48
51
  * attachment; Notion's): a media kind (`mediaPlugin`) drawn by this
49
52
  * snippet, a link or an uploaded file stored as one data write (a file's
50
53
  * whole data, its source, name and size; a video's or audio track's
51
- * `src`), and with an `upload`, the pasted and dropped files it takes
52
- * (`media.files`, its first listing in a view). A video or an audio track
54
+ * `src`), and with an `upload` (its uploader), the pasted, dropped and
55
+ * picked files it takes (`media.files`). A video or an audio track
53
56
  * exports as its tag in a `figure` and imports from one (bare, or in a
54
57
  * `figure`) with a safe `src` or `source`; the file kind brings its own
55
58
  * (`record`), and its `row` markup (`own`).
@@ -69,11 +72,13 @@
69
72
  return src ? { src } : undefined;
70
73
  }
71
74
  },
72
- own: Pick<MediaView, 'row'> = {}
75
+ own: Pick<MediaView, 'row'> = {},
76
+ upload?: Uploader
73
77
  ): Plugin =>
74
- mediaPlugin(kind, keywords, () => {
78
+ mediaPlugin(kind, keywords, (edytor) => {
75
79
  return {
76
80
  ...own,
81
+ hooks: uploadHooks(edytor, upload),
77
82
  store: (block, value, file) => {
78
83
  const data = sourceData(kind, value, file);
79
84
  if (!data) return 'invalid';
@@ -83,13 +88,16 @@
83
88
  },
84
89
  record: {
85
90
  snippet: source,
86
- // Its files: with the uploads plugin, an Upload and pasted or dropped files.
87
- upload: {
88
- accepts: ACCEPTS[kind],
89
- fallback: kind === 'file',
90
- data: (file, answer) => sourceData(kind, answer, file),
91
- filled: (data) => safeMediaSrc(data.src) !== null
92
- },
91
+ // Its files, with an `upload`: an Upload and pasted or dropped files.
92
+ ...(upload && {
93
+ upload: {
94
+ send: upload,
95
+ accepts: ACCEPTS[kind],
96
+ fallback: kind === 'file',
97
+ data: (file: File, answer: string) => sourceData(kind, answer, file),
98
+ filled: (data: Record<string, unknown>) => safeMediaSrc(data.src) !== null
99
+ }
100
+ }),
93
101
  ...record
94
102
  }
95
103
  };
@@ -103,6 +111,8 @@
103
111
  -->
104
112
  {#snippet source({ block, content }: BlockSnippetPayload<Record<string, unknown>>)}
105
113
  {@const src = safeMediaSrc(block.data.src)}
114
+ {@const pending =
115
+ block.handle && !src ? fileUploads(block.handle.edytor)?.of(block.id) : undefined}
106
116
  {#if src && block.type === 'file'}
107
117
  {@const name =
108
118
  typeof block.data.name === 'string' && block.data.name ? block.data.name : fileNameOf(src)}
@@ -120,10 +130,34 @@
120
130
  size === undefined
121
131
  ? ''
122
132
  : labelsOf(block.handle?.edytor, 'media', mediaEnglish).fileSize(size),
133
+ uploading: false,
134
+ progress: null,
123
135
  link: mediaLink({ draggable: 'false', download: name })
124
136
  }}
125
137
  />
126
138
  </div>
139
+ {:else if !src && pending?.status === 'uploading' && !emptyHosts.has(block.handle!.edytor)}
140
+ <!-- An upload with no empty markup to draw it: the file's name and size at once, its progress. -->
141
+ <div use:block.void data-edytor-media-empty>
142
+ {#if block.type === 'file' && pending.preview}
143
+ <Control
144
+ own={rowOf}
145
+ {initial}
146
+ payload={{
147
+ block: block.handle,
148
+ src: pending.preview,
149
+ name: pending.name,
150
+ size: pending.size,
151
+ sizeLabel: labelsOf(block.handle?.edytor, 'media', mediaEnglish).fileSize(pending.size),
152
+ uploading: true,
153
+ progress: pending.progress,
154
+ link: mediaLink({ draggable: 'false', download: pending.name })
155
+ }}
156
+ />
157
+ {:else}
158
+ <UploadPreview upload={pending} />
159
+ {/if}
160
+ </div>
127
161
  {:else if src && block.type === 'video'}
128
162
  <div use:block.void data-edytor-video>
129
163
  <!-- svelte-ignore a11y_media_has_caption -->
@@ -155,10 +189,12 @@
155
189
  {/snippet}
156
190
 
157
191
  <!-- A file: its name and size, a link downloading it. -->
158
- {#snippet initial({ src, name, sizeLabel, link }: FileRowPayload)}
192
+ {#snippet initial({ src, name, sizeLabel, link, uploading, progress }: FileRowPayload)}
159
193
  <a href={src} target="_blank" rel={LINK_REL} draggable="false" download={name} {@attach link}>
160
194
  <span aria-hidden="true">📎</span>
161
195
  <span data-edytor-file-name>{name}</span>
162
196
  {#if sizeLabel}<span data-edytor-file-size>{sizeLabel}</span>{/if}
163
197
  </a>
198
+ {#if uploading}<progress data-edytor-file-progress value={progress ?? undefined} max="1"
199
+ ></progress>{/if}
164
200
  {/snippet}
@@ -1,4 +1,5 @@
1
1
  import type { BlockDefinition, Plugin } from '../../plugins.js';
2
+ import { type Uploader } from '../uploads.svelte.js';
2
3
  import { type MediaView } from './media.js';
3
4
  type SourceKind = 'video' | 'audio' | 'file';
4
5
  /**
@@ -6,13 +7,13 @@ type SourceKind = 'video' | 'audio' | 'file';
6
7
  * attachment; Notion's): a media kind (`mediaPlugin`) drawn by this
7
8
  * snippet, a link or an uploaded file stored as one data write (a file's
8
9
  * whole data, its source, name and size; a video's or audio track's
9
- * `src`), and with an `upload`, the pasted and dropped files it takes
10
- * (`media.files`, its first listing in a view). A video or an audio track
10
+ * `src`), and with an `upload` (its uploader), the pasted, dropped and
11
+ * picked files it takes (`media.files`). A video or an audio track
11
12
  * exports as its tag in a `figure` and imports from one (bare, or in a
12
13
  * `figure`) with a safe `src` or `source`; the file kind brings its own
13
14
  * (`record`), and its `row` markup (`own`).
14
15
  */
15
- export declare const sourcePlugin: (kind: SourceKind, keywords: string[], record?: Partial<BlockDefinition>, own?: Pick<MediaView, "row">) => Plugin;
16
+ export declare const sourcePlugin: (kind: SourceKind, keywords: string[], record?: Partial<BlockDefinition>, own?: Pick<MediaView, "row">, upload?: Uploader) => Plugin;
16
17
  interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
17
18
  new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
18
19
  $$bindings?: Bindings;
@@ -0,0 +1,32 @@
1
+ <script module lang="ts">
2
+ import './upload.css';
3
+ </script>
4
+
5
+ <script lang="ts">
6
+ import { getContext } from 'svelte';
7
+ import type { Edytor } from '../../edytor.svelte.js';
8
+ import type { FileUpload } from '../uploads.svelte.js';
9
+ import { labelsOf, mediaEnglish } from '../../labels.js';
10
+
11
+ /**
12
+ * A file uploading, the plugin's own markup when the view has no empty
13
+ * markup to draw it: an image at once from its object URL (this view's
14
+ * only, never stored) with the progress over it; another file's name and
15
+ * size, then the progress (indeterminate until `upload` reports one).
16
+ */
17
+ let {
18
+ upload,
19
+ image = false
20
+ }: { upload: Extract<FileUpload, { status: 'uploading' }>; image?: boolean } = $props();
21
+ const labels = labelsOf(getContext<Edytor>('edytor'), 'media', mediaEnglish);
22
+ </script>
23
+
24
+ <div data-edytor-upload data-edytor-upload-image={image ? '' : undefined} role="status">
25
+ {#if image && upload.preview}
26
+ <img src={upload.preview} alt={upload.name} draggable="false" />
27
+ {:else}
28
+ <span data-edytor-file-name>{upload.name}</span>
29
+ <span data-edytor-file-size>{labels.fileSize(upload.size)}</span>
30
+ {/if}
31
+ <progress value={upload.progress ?? undefined} max="1" aria-label={labels.uploading}></progress>
32
+ </div>
@@ -0,0 +1,11 @@
1
+ import './upload.css';
2
+ import type { FileUpload } from '../uploads.svelte.js';
3
+ type $$ComponentProps = {
4
+ upload: Extract<FileUpload, {
5
+ status: 'uploading';
6
+ }>;
7
+ image?: boolean;
8
+ };
9
+ declare const UploadPreview: import("svelte").Component<$$ComponentProps, {}, "">;
10
+ type UploadPreview = ReturnType<typeof UploadPreview>;
11
+ export default UploadPreview;
@@ -1,6 +1,17 @@
1
1
  <script module lang="ts">
2
2
  import type { Plugin } from '../../plugins.js';
3
3
  import { sourcePlugin } from './SourceBlock.svelte';
4
+ import type { Uploader } from '../uploads.svelte.js';
5
+
6
+ export type VideoPluginOptions = {
7
+ /**
8
+ * Upload a video file and answer its URL: an empty block's Upload, and
9
+ * the pasted and dropped video files, each placed as a block at once
10
+ * (one undo step), showing its name and progress, then filled with the
11
+ * URL this answers. Without it, videos are only linked.
12
+ */
13
+ upload?: Uploader;
14
+ };
4
15
 
5
16
  /**
6
17
  * Notion's video block: a video file (an `http(s)` link, or an upload
@@ -8,5 +19,6 @@
8
19
  * A YouTube or Vimeo link is an embed (`createEmbedPlugin()`). Void: its only
9
20
  * text is the caption.
10
21
  */
11
- export const createVideoPlugin = (): Plugin => sourcePlugin('video', ['movie', 'mp4', 'clip']);
22
+ export const createVideoPlugin = ({ upload }: VideoPluginOptions = {}): Plugin =>
23
+ sourcePlugin('video', ['movie', 'mp4', 'clip'], undefined, {}, upload);
12
24
  </script>
@@ -1,11 +1,21 @@
1
1
  import type { Plugin } from '../../plugins.js';
2
+ import type { Uploader } from '../uploads.svelte.js';
3
+ export type VideoPluginOptions = {
4
+ /**
5
+ * Upload a video file and answer its URL: an empty block's Upload, and
6
+ * the pasted and dropped video files, each placed as a block at once
7
+ * (one undo step), showing its name and progress, then filled with the
8
+ * URL this answers. Without it, videos are only linked.
9
+ */
10
+ upload?: Uploader;
11
+ };
2
12
  /**
3
13
  * Notion's video block: a video file (an `http(s)` link, or an upload
4
14
  * through `upload`) in the browser's player, with an editable caption.
5
15
  * A YouTube or Vimeo link is an embed (`createEmbedPlugin()`). Void: its only
6
16
  * text is the caption.
7
17
  */
8
- export declare const createVideoPlugin: () => Plugin;
18
+ export declare const createVideoPlugin: ({ upload }?: VideoPluginOptions) => Plugin;
9
19
  interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
10
20
  new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
11
21
  $$bindings?: Bindings;
@@ -52,16 +52,25 @@ export declare class MediaEmptyController {
52
52
  * block). Show a passive placeholder then.
53
53
  */
54
54
  get readonly(): boolean;
55
- /** The view lists the uploads plugin and the kind takes files: offer Upload. */
55
+ /** The kind's plugin was given an `upload`: offer Upload. */
56
56
  get canUpload(): boolean;
57
57
  /** The upload of a pasted or dropped file into this block, if any (its `status`, `progress`, `preview`). */
58
58
  get pending(): FileUpload | undefined;
59
+ /**
60
+ * The file uploading into this block, shown at once: its object URL
61
+ * (this view's only, never stored; revoked once the upload ends), else `null`.
62
+ */
63
+ get preview(): string | null;
64
+ /** The share of the upload sent, `0` to `1`; `null` before a report, or with no upload. */
65
+ get progress(): number | null;
59
66
  /** Embed `value` (default the `draft`): the block takes the link, or `failed` says why not. */
60
67
  embed: (value?: string) => void;
61
68
  /**
62
- * Upload `file` with the view's uploader (`createUploadsPlugin`) and store the URL it answers,
63
- * only on the block as it was picked from: still in the tree, of its kind
64
- * and empty (a peer may have set a source, or deleted it, meanwhile).
69
+ * Upload `file` with its kind's uploader (its plugin's `upload`), the
70
+ * block showing it meanwhile (`pending`, `preview`, `progress`), and
71
+ * store the URL it answers, only on the block as it was picked from:
72
+ * still in the tree, of its kind and empty (a peer may have set a
73
+ * source, or deleted it, meanwhile).
65
74
  */
66
75
  upload: (picked: File) => Promise<void>;
67
76
  /** The link field (`{@attach empty.field}`): Enter embeds its `draft`. */
@@ -54,7 +54,7 @@ export class MediaEmptyController {
54
54
  get readonly() {
55
55
  return !this.block || this.block.edytor.readonly;
56
56
  }
57
- /** The view lists the uploads plugin and the kind takes files: offer Upload. */
57
+ /** The kind's plugin was given an `upload`: offer Upload. */
58
58
  get canUpload() {
59
59
  const { block } = this;
60
60
  return Boolean(block && fileUploads(block.edytor) && block.definition.upload);
@@ -64,6 +64,19 @@ export class MediaEmptyController {
64
64
  const { block } = this;
65
65
  return block ? fileUploads(block.edytor)?.of(block.id) : undefined;
66
66
  }
67
+ /**
68
+ * The file uploading into this block, shown at once: its object URL
69
+ * (this view's only, never stored; revoked once the upload ends), else `null`.
70
+ */
71
+ get preview() {
72
+ const { pending } = this;
73
+ return pending?.status === 'uploading' ? pending.preview : null;
74
+ }
75
+ /** The share of the upload sent, `0` to `1`; `null` before a report, or with no upload. */
76
+ get progress() {
77
+ const { pending } = this;
78
+ return pending?.status === 'uploading' ? pending.progress : null;
79
+ }
67
80
  /** Store `value` on the block: why it did not take it, or `null`. */
68
81
  #store = (value, file) => this.block && this.view ? this.view.store(this.block, value, file) : 'invalid';
69
82
  /** Embed `value` (default the `draft`): the block takes the link, or `failed` says why not. */
@@ -73,30 +86,28 @@ export class MediaEmptyController {
73
86
  fileUploads(this.block.edytor)?.clear(this.block.id);
74
87
  };
75
88
  /**
76
- * Upload `file` with the view's uploader (`createUploadsPlugin`) and store the URL it answers,
77
- * only on the block as it was picked from: still in the tree, of its kind
78
- * and empty (a peer may have set a source, or deleted it, meanwhile).
89
+ * Upload `file` with its kind's uploader (its plugin's `upload`), the
90
+ * block showing it meanwhile (`pending`, `preview`, `progress`), and
91
+ * store the URL it answers, only on the block as it was picked from:
92
+ * still in the tree, of its kind and empty (a peer may have set a
93
+ * source, or deleted it, meanwhile).
79
94
  */
80
95
  upload = async (picked) => {
81
96
  const { block } = this;
82
- const upload = this.canUpload && fileUploads(block?.edytor)?.upload;
83
- if (!upload || !block || block.edytor.readonly)
97
+ const uploads = this.canUpload && fileUploads(block?.edytor);
98
+ if (!uploads || !block || block.edytor.readonly)
84
99
  return;
85
100
  const { edytor, id, type } = block;
86
- let src;
87
- try {
88
- src = await upload(picked, { progress: () => { } });
89
- }
90
- catch {
101
+ const stored = await uploads.pick(id, picked, (src) => {
102
+ const live = edytor.idToBlock.get(id);
103
+ if (!live?.isInTree || live.type !== type || edytor.readonly)
104
+ return;
105
+ if (edytor.facade.blockDataOf(id)?.src !== undefined)
106
+ return;
107
+ this.failed = this.#store(src, picked);
108
+ });
109
+ if (!stored)
91
110
  this.failed = 'upload';
92
- return;
93
- }
94
- const live = edytor.idToBlock.get(id);
95
- if (!live?.isInTree || live.type !== type || edytor.readonly)
96
- return;
97
- if (edytor.facade.blockDataOf(id)?.src !== undefined)
98
- return;
99
- this.failed = this.#store(src, picked);
100
111
  };
101
112
  /** The link field (`{@attach empty.field}`): Enter embeds its `draft`. */
102
113
  field = (node) => {
@@ -49,6 +49,13 @@ export type FileRowPayload = {
49
49
  size: number | undefined;
50
50
  /** That size in the view's words (`media.fileSize`), `''` without one. */
51
51
  sizeLabel: string;
52
+ /**
53
+ * The file is uploading (then `src` is its object URL, this view's only,
54
+ * never stored): the row shows it at once, with `progress`.
55
+ */
56
+ uploading: boolean;
57
+ /** The share sent while it uploads, `0` to `1`; `null` before a report or once stored. */
58
+ progress: number | null;
52
59
  /**
53
60
  * The row's link (`<a href={row.src} {@attach row.link}>`): it downloads
54
61
  * the file under its name, in a new tab without the opener, never dragged
@@ -0,0 +1,23 @@
1
+ /* Loaded by the preview's module: a file uploading, drawn by the plugin when
2
+ the view has no empty markup (`createMediaEmptyPlugin`). */
3
+ [data-edytor-upload] {
4
+ display: flex;
5
+ position: relative;
6
+ flex-direction: column;
7
+ gap: 6px;
8
+ }
9
+ [data-edytor-upload] img {
10
+ display: block;
11
+ max-width: 100%;
12
+ }
13
+ [data-edytor-upload] progress {
14
+ width: 100%;
15
+ height: 4px;
16
+ }
17
+ [data-edytor-upload-image] progress {
18
+ position: absolute;
19
+ right: 8px;
20
+ bottom: 8px;
21
+ left: 8px;
22
+ width: auto;
23
+ }
@@ -1,8 +1,15 @@
1
1
  <svelte:options runes />
2
2
 
3
3
  <script module lang="ts">
4
+ import './mention.css';
4
5
  import type { Snippet } from 'svelte';
5
- import type { InlineBlockSnippetPayload, Plugin, TriggerContext } from '../../plugins.js';
6
+ import type {
7
+ InlineBlockDefinition,
8
+ InlineBlockSnippetPayload,
9
+ InlineBlockView,
10
+ Plugin,
11
+ TriggerContext
12
+ } from '../../plugins.js';
6
13
  import type { TriggerMenuController } from '../triggers/TriggerController.svelte.js';
7
14
  import { withRulesAndTriggers } from '../triggers/triggers.js';
8
15
 
@@ -18,7 +25,7 @@
18
25
  avatar?: string;
19
26
  };
20
27
 
21
- /** What a `mention` atom stores. */
28
+ /** What a mention atom stores (whatever its kind). */
22
29
  export type MentionData = { id: string; label: string };
23
30
 
24
31
  export type MentionPluginOptions = {
@@ -27,8 +34,15 @@
27
34
  query: string,
28
35
  ctx: TriggerContext
29
36
  ) => readonly MentionItem[] | Promise<readonly MentionItem[]>;
30
- /** The character that opens the menu (default `@`). */
37
+ /** The character that opens the menu, and that the atom shows before its label (default `@`). */
31
38
  char?: string;
39
+ /**
40
+ * The inline kind the atoms are (default `mention`), and the name of the
41
+ * trigger menu (`controller.trigger.name`). List the plugin once per kind
42
+ * to mention several things (`@` people, `$` tickers as `ticker`); two
43
+ * instances of one kind: the first listed defines it.
44
+ */
45
+ kind?: string;
32
46
  /**
33
47
  * The menu's markup and its words; it renders while `controller.isOpen`,
34
48
  * at the caret (without it, nothing shows). Keep its ARIA with `{@attach
@@ -38,40 +52,71 @@
38
52
  menu?: Snippet<[TriggerMenuController<MentionItem>]>;
39
53
  };
40
54
 
55
+ /** A mention kind's record: its `char` read by the atom's markup. */
56
+ type MentionKind = InlineBlockDefinition & { char: string };
57
+
58
+ /** The char of the atom's kind (the first listed instance's); `@` with no view (a suggestion). */
59
+ const charOf = (atom: InlineBlockView) =>
60
+ (atom.handle?.edytor.inlineBlocks.get(atom.type) as Partial<MentionKind> | undefined)?.char ??
61
+ '@';
62
+
63
+ const escape = (value: string) =>
64
+ value.replace(
65
+ /[&<>"]/g,
66
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!
67
+ );
68
+
41
69
  /**
42
70
  * People mentions, as in Notion: `@` opens a menu of the people your app's
43
71
  * `items(query)` answers; picking one replaces `@query` with a `mention`
44
72
  * atom (`{ id, label }`) and puts the caret after it, in one undo step.
73
+ * `char` and `kind` make another one (`$` tickers as `ticker` atoms).
45
74
  */
46
- export const createMentionPlugin =
47
- (options: MentionPluginOptions): Plugin =>
48
- (edytor) =>
75
+ export const createMentionPlugin = (options: MentionPluginOptions): Plugin => {
76
+ const char = options.char ?? '@';
77
+ const kind = options.kind ?? 'mention';
78
+ const labelOf = (data: unknown) => {
79
+ const label = (data as Partial<MentionData> | undefined)?.label;
80
+ return typeof label === 'string' ? label : '';
81
+ };
82
+ const atom: MentionKind = {
83
+ snippet: mention,
84
+ char,
85
+ plain: (data) => char + labelOf(data),
86
+ html: (data) => {
87
+ const id = (data as Partial<MentionData> | undefined)?.id;
88
+ return (
89
+ `<span data-edytor-mention="${escape(kind)}"` +
90
+ (typeof id === 'string' ? ` data-mention-id="${escape(id)}"` : '') +
91
+ `>${escape(char + labelOf(data))}</span>`
92
+ );
93
+ },
94
+ parse: (element) => {
95
+ // A bare `data-edytor-mention` (an older export) is a `mention`.
96
+ const own = element.dataset.edytorMention;
97
+ if (own === undefined || (own || 'mention') !== kind) return undefined;
98
+ const text = element.textContent ?? '';
99
+ const label = text.startsWith(char) ? text.slice(char.length) : text;
100
+ return { id: element.dataset.mentionId ?? '', label };
101
+ }
102
+ };
103
+ return (edytor) =>
49
104
  withRulesAndTriggers(
50
105
  edytor,
51
- {
52
- inlineBlocks: {
53
- mention: {
54
- snippet: mention,
55
- plain: (data) => {
56
- const label = (data as Partial<MentionData> | undefined)?.label;
57
- return typeof label === 'string' && label ? `@${label}` : '@';
58
- }
59
- }
60
- }
61
- },
106
+ { inlineBlocks: { [kind]: atom } },
62
107
  {
63
108
  triggers: [
64
109
  {
65
- char: options.char ?? '@',
110
+ char,
111
+ name: kind,
66
112
  items: (query, ctx) => options.items(query, ctx),
67
- label: (person: MentionItem) => person.label,
68
- key: (person: MentionItem) => person.id,
113
+ label: (item: MentionItem) => item.label,
114
+ key: (item: MentionItem) => item.id,
69
115
  menu: options.menu,
70
- onPick: (person: MentionItem, { edytor, block, from, caret }) => {
71
- const data: MentionData = { id: person.id, label: person.label };
116
+ onPick: (item: MentionItem, { edytor, block, from, caret }) => {
117
+ const data: MentionData = { id: item.id, label: item.label };
72
118
  const written =
73
- edytor.insertInline(block.id, from, { type: 'mention', data }).status ===
74
- 'applied';
119
+ edytor.insertInline(block.id, from, { type: kind, data }).status === 'applied';
75
120
  if (written) caret(from + 1);
76
121
  return written;
77
122
  }
@@ -79,20 +124,14 @@
79
124
  ]
80
125
  }
81
126
  );
127
+ };
82
128
  </script>
83
129
 
84
130
  {#snippet mention({ block }: InlineBlockSnippetPayload<Partial<MentionData>>)}
85
131
  <span
86
132
  class="edytor-mention"
87
133
  data-selected={block.selected ? 'true' : undefined}
88
- data-edytor-mention
89
- data-mention-id={block.data.id}>@{block.data.label ?? ''}</span
134
+ data-edytor-mention={block.type}
135
+ data-mention-id={block.data.id}>{charOf(block)}{block.data.label ?? ''}</span
90
136
  >
91
137
  {/snippet}
92
-
93
- <style>
94
- /* One unit, on one line; its look is a theme's. */
95
- .edytor-mention {
96
- white-space: nowrap;
97
- }
98
- </style>
@@ -1,3 +1,4 @@
1
+ import './mention.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';
@@ -12,7 +13,7 @@ export type MentionItem = {
12
13
  /** An avatar image URL (http, https or data). */
13
14
  avatar?: string;
14
15
  };
15
- /** What a `mention` atom stores. */
16
+ /** What a mention atom stores (whatever its kind). */
16
17
  export type MentionData = {
17
18
  id: string;
18
19
  label: string;
@@ -20,8 +21,15 @@ export type MentionData = {
20
21
  export type MentionPluginOptions = {
21
22
  /** The people matching `query` (the text typed after `@`), at once or as a promise. */
22
23
  items: (query: string, ctx: TriggerContext) => readonly MentionItem[] | Promise<readonly MentionItem[]>;
23
- /** The character that opens the menu (default `@`). */
24
+ /** The character that opens the menu, and that the atom shows before its label (default `@`). */
24
25
  char?: string;
26
+ /**
27
+ * The inline kind the atoms are (default `mention`), and the name of the
28
+ * trigger menu (`controller.trigger.name`). List the plugin once per kind
29
+ * to mention several things (`@` people, `$` tickers as `ticker`); two
30
+ * instances of one kind: the first listed defines it.
31
+ */
32
+ kind?: string;
25
33
  /**
26
34
  * The menu's markup and its words; it renders while `controller.isOpen`,
27
35
  * at the caret (without it, nothing shows). Keep its ARIA with `{@attach
@@ -34,6 +42,7 @@ export type MentionPluginOptions = {
34
42
  * People mentions, as in Notion: `@` opens a menu of the people your app's
35
43
  * `items(query)` answers; picking one replaces `@query` with a `mention`
36
44
  * atom (`{ id, label }`) and puts the caret after it, in one undo step.
45
+ * `char` and `kind` make another one (`$` tickers as `ticker` atoms).
37
46
  */
38
47
  export declare const createMentionPlugin: (options: MentionPluginOptions) => Plugin;
39
48
  declare const MentionPlugin: 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-mention {
5
+ white-space: nowrap;
6
+ }