@sveltia/ui 0.20.2 → 0.21.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.
@@ -1,3 +1,5 @@
1
- export function initEditor(): import("lexical").LexicalEditor;
1
+ export function initEditor({ components }?: {
2
+ components?: import("../../typedefs").TextEditorComponent[] | undefined;
3
+ } | undefined): import("lexical").LexicalEditor;
2
4
  export function convertMarkdown(editor: import("lexical").LexicalEditor, value: string): Promise<void>;
3
5
  export function focusEditor(editor: import("lexical").LexicalEditor): Promise<void>;
@@ -43,6 +43,8 @@ import {
43
43
  } from 'lexical';
44
44
  import { blockButtonTypes, textFormatButtonTypes } from '.';
45
45
 
46
+ const allTransformers = [...TRANSFORMERS];
47
+
46
48
  /**
47
49
  * Lexical editor configuration.
48
50
  * @type {import('lexical').CreateEditorArgs}
@@ -78,27 +80,28 @@ const editorConfig = {
78
80
  };
79
81
 
80
82
  /**
81
- * Listen to changes on the editor.
82
- * @param {import('lexical').LexicalEditor} editor - Editor instance.
83
+ * Get the current selection’s block and inline level types.
84
+ * @returns {{ blockType: import('../../typedefs').TextEditorBlockType,
85
+ * inlineTypes: import('../../typedefs').TextEditorInlineType[] }} Types.
83
86
  */
84
- const onEditorUpdate = (editor) => {
87
+ const getSelectionTypes = () => {
85
88
  const selection = getSelection();
86
89
 
87
90
  if (!isRangeSelection(selection)) {
88
- return;
91
+ return { blockType: 'paragraph', inlineTypes: [] };
89
92
  }
90
93
 
91
94
  const anchor = selection.anchor.getNode();
92
95
  /** @type {ElementNode | null} */
93
96
  let parent = null;
94
97
  /** @type {import('../../typedefs').TextEditorInlineType[]} */
95
- const selectionInlineTypes = textFormatButtonTypes.filter((type) => selection.hasFormat(type));
98
+ const inlineTypes = textFormatButtonTypes.filter((type) => selection.hasFormat(type));
96
99
 
97
100
  if (anchor.getType() !== 'root') {
98
101
  parent = anchor instanceof ElementNode ? anchor : getNearestNodeOfType(anchor, ElementNode);
99
102
 
100
103
  if (isLinkNode(parent)) {
101
- selectionInlineTypes.push('link');
104
+ inlineTypes.push('link');
102
105
  parent = getNearestNodeOfType(parent, ElementNode);
103
106
  }
104
107
 
@@ -107,7 +110,7 @@ const onEditorUpdate = (editor) => {
107
110
  }
108
111
  }
109
112
 
110
- const selectionBlockType = /** @type {import('../../typedefs').TextEditorBlockType} */ (
113
+ const blockType = /** @type {import('../../typedefs').TextEditorBlockType} */ (
111
114
  (() => {
112
115
  if (!parent) {
113
116
  return 'paragraph';
@@ -135,15 +138,25 @@ const onEditorUpdate = (editor) => {
135
138
  })()
136
139
  );
137
140
 
141
+ return { blockType, inlineTypes };
142
+ };
143
+
144
+ /**
145
+ * Listen to changes made on the editor and trigger the Update event.
146
+ * @param {import('lexical').LexicalEditor} editor - Editor instance.
147
+ */
148
+ const onEditorUpdate = (editor) => {
149
+ const { blockType, inlineTypes } = getSelectionTypes();
150
+
138
151
  editor.getRootElement()?.dispatchEvent(
139
152
  new CustomEvent('Update', {
140
153
  detail: {
141
154
  value: convertToMarkdownString(
142
155
  // Use underscores for italic text in Markdown instead of asterisks
143
- TRANSFORMERS.filter((/** @type {any} */ { tag }) => tag !== '*'),
156
+ allTransformers.filter((/** @type {any} */ { tag }) => tag !== '*'),
144
157
  ),
145
- selectionBlockType,
146
- selectionInlineTypes,
158
+ selectionBlockType: blockType,
159
+ selectionInlineTypes: inlineTypes,
147
160
  },
148
161
  }),
149
162
  );
@@ -151,9 +164,16 @@ const onEditorUpdate = (editor) => {
151
164
 
152
165
  /**
153
166
  * Initialize the Lexical editor.
167
+ * @param {object} [options] - Options.
168
+ * @param {import('../../typedefs').TextEditorComponent[]} [options.components] - Editor components.
154
169
  * @returns {import('lexical').LexicalEditor} Editor instance.
155
170
  */
156
- export const initEditor = () => {
171
+ export const initEditor = ({ components } = {}) => {
172
+ components?.forEach(({ node, transformer }) => {
173
+ /** @type {any[]} */ (editorConfig.nodes).unshift(node);
174
+ allTransformers.unshift(transformer);
175
+ });
176
+
157
177
  const editor = createEditor(editorConfig);
158
178
 
159
179
  registerRichText(editor);
@@ -254,10 +274,10 @@ export const convertMarkdown = async (editor, value) =>
254
274
  new Promise((resolve, reject) => {
255
275
  editor.update(() => {
256
276
  try {
257
- convertFromMarkdownString(value, TRANSFORMERS);
277
+ convertFromMarkdownString(value, allTransformers);
258
278
  resolve(void 0);
259
- } catch {
260
- reject(new Error('Failed to convert Markdown'));
279
+ } catch (ex) {
280
+ reject(new Error('Failed to convert Markdown', { cause: ex }));
261
281
  }
262
282
  });
263
283
  });
@@ -1,5 +1,4 @@
1
1
  <script>
2
- import { $createParagraphNode as createParagraphNode, $getRoot as getRoot } from 'lexical';
3
2
  import { getContext, onMount } from 'svelte';
4
3
 
5
4
  /**
@@ -97,11 +96,6 @@
97
96
  $effect(() => {
98
97
  if ($editor && lexicalRoot) {
99
98
  $editor.setRootElement(lexicalRoot);
100
- // We should avoid an empty editor; there should be at least one `<p>`
101
- // @see https://github.com/facebook/lexical/issues/2308
102
- $editor.update(() => {
103
- getRoot().append(createParagraphNode());
104
- });
105
99
  }
106
100
  });
107
101
  </script>
@@ -22,6 +22,7 @@
22
22
  * @property {import('../../typedefs').TextEditorMode[]} [modes] - Enabled modes.
23
23
  * @property {(import('../../typedefs').TextEditorBlockType |
24
24
  * import('../../typedefs').TextEditorInlineType)[]} [buttons] - Enabled buttons.
25
+ * @property {import('../../typedefs').TextEditorComponent[]} [components] - Editor components.
25
26
  * @property {string} [class] - The `class` attribute on the wrapper element.
26
27
  * @property {boolean} [hidden] - Whether to hide the widget.
27
28
  * @property {boolean} [disabled] - Whether to disable the widget. An alias of the `aria-disabled`
@@ -44,6 +45,7 @@
44
45
  flex = false,
45
46
  modes = ['rich-text', 'plain-text'],
46
47
  buttons = [...inlineButtonTypes, ...blockButtonTypes],
48
+ components = [],
47
49
  hidden = false,
48
50
  disabled = false,
49
51
  readonly = false,
@@ -69,7 +71,7 @@
69
71
  * restore the original value when there is an error while conversion.
70
72
  */
71
73
  const convertMarkdown = async () => {
72
- if (!$editor) {
74
+ if (!$editor?.getRootElement()) {
73
75
  return;
74
76
  }
75
77
 
@@ -80,16 +82,16 @@
80
82
  // string if the `value` is `undefined`
81
83
  // @see https://github.com/facebook/lexical/issues/2308
82
84
  await convertMarkdownToLexical($editor, inputValue ?? '');
83
- } catch {
85
+ } catch (ex) {
84
86
  $hasConverterError = true;
85
87
  inputValue = originalValue;
88
+ // eslint-disable-next-line no-console
89
+ console.error(ex);
86
90
  }
87
91
  };
88
92
 
89
93
  $effect(() => {
90
- if (!$editor) {
91
- return;
92
- }
94
+ void $editor;
93
95
 
94
96
  const newValue = value;
95
97
 
@@ -129,7 +131,7 @@
129
131
 
130
132
  // The editor has to be initialized in the browser
131
133
  onMount(() => {
132
- $editor = initEditor();
134
+ $editor = initEditor({ components });
133
135
  });
134
136
 
135
137
  setContext(
@@ -143,6 +145,7 @@
143
145
  useRichText,
144
146
  hasConverterError,
145
147
  enabledButtons: buttons,
148
+ components,
146
149
  convertMarkdown,
147
150
  }),
148
151
  );
@@ -21,6 +21,10 @@ declare const TextEditor: import("svelte").Component<{
21
21
  * - Enabled buttons.
22
22
  */
23
23
  buttons?: (import("../../typedefs").TextEditorBlockType | import("../../typedefs").TextEditorInlineType)[] | undefined;
24
+ /**
25
+ * - Editor components.
26
+ */
27
+ components?: import("../../typedefs").TextEditorComponent[] | undefined;
24
28
  /**
25
29
  * - The `class` attribute on the wrapper element.
26
30
  */
@@ -71,6 +75,10 @@ type Props = {
71
75
  * - Enabled buttons.
72
76
  */
73
77
  buttons?: (import("../../typedefs").TextEditorBlockType | import("../../typedefs").TextEditorInlineType)[] | undefined;
78
+ /**
79
+ * - Editor components.
80
+ */
81
+ components?: import("../../typedefs").TextEditorComponent[] | undefined;
74
82
  /**
75
83
  * - The `class` attribute on the wrapper element.
76
84
  */
@@ -12,7 +12,9 @@
12
12
  import Menu from '../../menu/menu.svelte';
13
13
  import Toolbar from '../../toolbar/toolbar.svelte';
14
14
  import FormatTextButton from './format-text-button.svelte';
15
+ import InsertImageButton from './insert-image-button.svelte';
15
16
  import InsertLinkButton from './insert-link-button.svelte';
17
+ import InsertMenuButton from './insert-menu-button.svelte';
16
18
  import ToggleBlockMenuItem from './toggle-block-menu-item.svelte';
17
19
 
18
20
  /**
@@ -45,9 +47,13 @@
45
47
  useRichText,
46
48
  hasConverterError,
47
49
  enabledButtons,
50
+ components,
48
51
  convertMarkdown,
49
52
  } = getContext('state');
50
53
 
54
+ const imageComponent = $derived(components.find(({ id }) => id === 'image'));
55
+ const otherComponents = $derived(components.filter(({ id }) => id !== 'image'));
56
+
51
57
  /**
52
58
  * Enabled block level buttons.
53
59
  * @type {import('../../../typedefs').TextEditorBlockType[]}
@@ -100,6 +106,15 @@
100
106
  {/each}
101
107
  </ButtonGroup>
102
108
  {/if}
109
+ {#if components.length}
110
+ <Divider orientation="vertical" />
111
+ {#if imageComponent}
112
+ <InsertImageButton component={imageComponent} />
113
+ {/if}
114
+ {#if otherComponents.length}
115
+ <InsertMenuButton components={otherComponents} />
116
+ {/if}
117
+ {/if}
103
118
  <Spacer flex />
104
119
  {#if modes.length > 1}
105
120
  <Button
@@ -0,0 +1,43 @@
1
+ <script>
2
+ import {
3
+ $createParagraphNode as createParagraphNode,
4
+ $insertNodes as insertNodes,
5
+ } from 'lexical';
6
+ import { getContext } from 'svelte';
7
+ import Button from '../../button/button.svelte';
8
+ import Icon from '../../icon/icon.svelte';
9
+
10
+ /**
11
+ * @typedef {object} Props
12
+ * @property {import('../../../typedefs').TextEditorComponent} component - Image editor component.
13
+ */
14
+
15
+ /** @type {Props} */
16
+ let {
17
+ /* eslint-disable prefer-const */
18
+ component,
19
+ /* eslint-enable prefer-const */
20
+ } = $props();
21
+
22
+ /**
23
+ * Text editor state.
24
+ * @type {import('../../../typedefs').TextEditorState}
25
+ */
26
+ const { editor, editorId, useRichText } = getContext('state');
27
+ </script>
28
+
29
+ <Button
30
+ iconic
31
+ aria-label={component.label}
32
+ aria-controls="{$editorId}-lexical-root"
33
+ disabled={!$useRichText}
34
+ onclick={() => {
35
+ $editor.update(() => {
36
+ insertNodes([component.createNode(), createParagraphNode()]);
37
+ });
38
+ }}
39
+ >
40
+ {#snippet startIcon()}
41
+ <Icon name={component.icon} />
42
+ {/snippet}
43
+ </Button>
@@ -0,0 +1,17 @@
1
+ export default InsertImageButton;
2
+ type InsertImageButton = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<Props>): void;
5
+ };
6
+ declare const InsertImageButton: import("svelte").Component<{
7
+ /**
8
+ * - Image editor component.
9
+ */
10
+ component: import("../../../typedefs").TextEditorComponent;
11
+ }, {}, "">;
12
+ type Props = {
13
+ /**
14
+ * - Image editor component.
15
+ */
16
+ component: import("../../../typedefs").TextEditorComponent;
17
+ };
@@ -0,0 +1,53 @@
1
+ <script>
2
+ import { $insertNodes as insertNodes } from 'lexical';
3
+ import { getContext } from 'svelte';
4
+ import { _ } from 'svelte-i18n';
5
+ import Icon from '../../icon/icon.svelte';
6
+ import MenuButton from '../../menu/menu-button.svelte';
7
+ import MenuItem from '../../menu/menu-item.svelte';
8
+ import Menu from '../../menu/menu.svelte';
9
+
10
+ /**
11
+ * @typedef {object} Props
12
+ * @property {import('../../../typedefs').TextEditorComponent[]} components - Editor components.
13
+ */
14
+
15
+ /** @type {Props} */
16
+ let {
17
+ /* eslint-disable prefer-const */
18
+ components,
19
+ /* eslint-enable prefer-const */
20
+ } = $props();
21
+
22
+ /**
23
+ * Text editor state.
24
+ * @type {import('../../../typedefs').TextEditorState}
25
+ */
26
+ const { editor, useRichText } = getContext('state');
27
+ </script>
28
+
29
+ <MenuButton disabled={!$useRichText} label={$_('_sui.insert')}>
30
+ {#snippet endIcon()}
31
+ <Icon name="arrow_drop_down" class="small-arrow" />
32
+ {/snippet}
33
+ {#snippet popup()}
34
+ <Menu>
35
+ {#each components as { id, label, icon, createNode } (id)}
36
+ <MenuItem
37
+ {label}
38
+ onclick={() => {
39
+ $editor.update(() => {
40
+ insertNodes([createNode()]);
41
+ });
42
+ }}
43
+ >
44
+ {#snippet startIcon()}
45
+ {#if icon}
46
+ <Icon name={icon} />
47
+ {/if}
48
+ {/snippet}
49
+ </MenuItem>
50
+ {/each}
51
+ </Menu>
52
+ {/snippet}
53
+ </MenuButton>
@@ -0,0 +1,17 @@
1
+ export default InsertMenuButton;
2
+ type InsertMenuButton = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<Props>): void;
5
+ };
6
+ declare const InsertMenuButton: import("svelte").Component<{
7
+ /**
8
+ * - Editor components.
9
+ */
10
+ components: import("../../../typedefs").TextEditorComponent[];
11
+ }, {}, "">;
12
+ type Props = {
13
+ /**
14
+ * - Editor components.
15
+ */
16
+ components: import("../../../typedefs").TextEditorComponent[];
17
+ };
@@ -549,6 +549,33 @@ export type TextEditorBlockType = "paragraph" | "heading-1" | "heading-2" | "hea
549
549
  export type TextEditorFormatType = "bold" | "italic" | "code";
550
550
  export type TextEditorInlineType = TextEditorFormatType | "link";
551
551
  export type TextEditorMode = "rich-text" | "plain-text";
552
+ export type TextEditorComponent = {
553
+ /**
554
+ * - Component ID.
555
+ */
556
+ id: string;
557
+ /**
558
+ * - Component label.
559
+ */
560
+ label: string;
561
+ /**
562
+ * - Material Symbols icon name.
563
+ */
564
+ icon?: string | undefined;
565
+ /**
566
+ * - Lexical node class implementation.
567
+ */
568
+ node: import("lexical").LexicalNode;
569
+ /**
570
+ * - Function
571
+ * to create a new node instance.
572
+ */
573
+ createNode: (props?: Record<string, any>) => import("lexical").LexicalNode;
574
+ /**
575
+ * - Node transformer.
576
+ */
577
+ transformer: import("@lexical/markdown").Transformer;
578
+ };
552
579
  export type TextEditorState = {
553
580
  /**
554
581
  * - Lexical
@@ -588,6 +615,10 @@ export type TextEditorState = {
588
615
  * the editor.
589
616
  */
590
617
  enabledButtons: (TextEditorBlockType | TextEditorInlineType)[];
618
+ /**
619
+ * - Editor components.
620
+ */
621
+ components: TextEditorComponent[];
591
622
  /**
592
623
  * - Function to trigger the Lexical converter.
593
624
  */
@@ -227,6 +227,17 @@
227
227
  * @typedef {'rich-text' | 'plain-text'} TextEditorMode
228
228
  */
229
229
 
230
+ /**
231
+ * @typedef {object} TextEditorComponent
232
+ * @property {string} id - Component ID.
233
+ * @property {string} label - Component label.
234
+ * @property {string} [icon] - Material Symbols icon name.
235
+ * @property {import('lexical').LexicalNode} node - Lexical node class implementation.
236
+ * @property {(props?: Record<string, any>) => import('lexical').LexicalNode} createNode - Function
237
+ * to create a new node instance.
238
+ * @property {import('@lexical/markdown').Transformer} transformer - Node transformer.
239
+ */
240
+
230
241
  /**
231
242
  * @typedef {object} TextEditorState
232
243
  * @property {import('svelte/store').Writable<import('lexical').LexicalEditor>} editor - Lexical
@@ -243,6 +254,7 @@
243
254
  * error while converting Markdown to Lexical nodes.
244
255
  * @property {(TextEditorBlockType | TextEditorInlineType)[]} enabledButtons - Enabled buttons for
245
256
  * the editor.
257
+ * @property {TextEditorComponent[]} components - Editor components.
246
258
  * @property {Function} convertMarkdown - Function to trigger the Lexical converter.
247
259
  */
248
260
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sveltia/ui",
3
- "version": "0.20.2",
3
+ "version": "0.21.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -38,16 +38,16 @@
38
38
  "@lexical/selection": "^0.21.0",
39
39
  "@lexical/table": "^0.21.0",
40
40
  "@lexical/utils": "^0.21.0",
41
- "@sveltia/utils": "^0.5.0",
41
+ "@sveltia/utils": "^0.6.0",
42
42
  "lexical": "^0.21.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "svelte": "^5.0.0"
46
46
  },
47
47
  "devDependencies": {
48
- "@playwright/test": "^1.49.0",
48
+ "@playwright/test": "^1.49.1",
49
49
  "@sveltejs/adapter-auto": "^3.3.1",
50
- "@sveltejs/kit": "^2.9.0",
50
+ "@sveltejs/kit": "^2.10.1",
51
51
  "@sveltejs/package": "^2.3.7",
52
52
  "@sveltejs/vite-plugin-svelte": "5.0.1",
53
53
  "cspell": "^8.16.1",
@@ -55,7 +55,7 @@
55
55
  "eslint-config-airbnb-base": "^15.0.0",
56
56
  "eslint-config-prettier": "^9.1.0",
57
57
  "eslint-plugin-import": "^2.31.0",
58
- "eslint-plugin-jsdoc": "^50.6.0",
58
+ "eslint-plugin-jsdoc": "^50.6.1",
59
59
  "eslint-plugin-svelte": "^2.46.1",
60
60
  "postcss": "^8.4.49",
61
61
  "postcss-html": "^1.7.0",
@@ -65,7 +65,7 @@
65
65
  "stylelint": "^16.11.0",
66
66
  "stylelint-config-recommended-scss": "^14.1.0",
67
67
  "stylelint-scss": "^6.10.0",
68
- "svelte": "5.7.1",
68
+ "svelte": "5.11.2",
69
69
  "svelte-check": "^4.1.1",
70
70
  "svelte-i18n": "^4.0.1",
71
71
  "svelte-preprocess": "^6.0.3",