@sveltia/ui 0.20.1 → 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
+ };