@sveltia/ui 0.78.2 → 0.78.3

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.
@@ -5,6 +5,7 @@ export function isSafeLinkURL(url: string): boolean;
5
5
  export function getSelectionTypes(): TextEditorSelectionState;
6
6
  export function exportMarkdown(enabledTransformers: Transformer[]): string;
7
7
  export function onEditorUpdate(editor: LexicalEditor, enabledTransformers: Transformer[], cachedValue?: string): string;
8
+ export function getComponentMarkdown({ node, createNode, transformer, createMarkdown }: TextEditorComponent): string;
8
9
  export function initEditor({ enabledButtons, components, useMarkdownShortcuts, isCodeEditor, defaultLanguage, }: TextEditorConfig): InitEditorResult;
9
10
  export function loadCodeHighlighter(lang: string): Promise<void>;
10
11
  export function convertMarkdownToLexical(editor: LexicalEditor, value: string, enabledTransformers: Transformer[]): Promise<string>;
@@ -26,4 +27,5 @@ export type InitEditorResult = {
26
27
  import type { LexicalEditor } from 'lexical';
27
28
  import type { TextEditorSelectionState } from '../../typedefs';
28
29
  import type { Transformer } from '@lexical/markdown';
30
+ import type { TextEditorComponent } from '../../typedefs';
29
31
  import type { TextEditorConfig } from '../../typedefs';
@@ -91,6 +91,7 @@ import { TABLE } from './transformers/table.js';
91
91
  * @import { Transformer } from '@lexical/markdown';
92
92
  * @import {
93
93
  * TextEditorBlockType,
94
+ * TextEditorComponent,
94
95
  * TextEditorConfig,
95
96
  * TextEditorInlineType,
96
97
  * TextEditorNodeType,
@@ -479,6 +480,54 @@ export const onEditorUpdate = (editor, enabledTransformers, cachedValue) => {
479
480
  return value;
480
481
  };
481
482
 
483
+ /**
484
+ * Get the Markdown of a new instance of the given editor component, to insert it in the plain text
485
+ * mode. The component’s own `createMarkdown` function is used if any. Otherwise, a new node is
486
+ * created in a temporary editor and exported with the component’s transformer, just like the rich
487
+ * text editor does.
488
+ * @param {TextEditorComponent} component Editor component.
489
+ * @returns {string} Markdown. It can be empty, for example if the component’s output depends on a
490
+ * field value that is not set yet.
491
+ */
492
+ export const getComponentMarkdown = ({ node, createNode, transformer, createMarkdown }) => {
493
+ if (createMarkdown) {
494
+ return createMarkdown().trim();
495
+ }
496
+
497
+ const editor = createEditor({
498
+ namespace: 'component',
499
+ nodes: node ? [/** @type {any} */ (node)] : [],
500
+ /**
501
+ * Throw an error, so the export below fails as a whole.
502
+ * @param {Error} error Error.
503
+ * @throws {Error} Always.
504
+ */
505
+ onError: (error) => {
506
+ throw error;
507
+ },
508
+ });
509
+
510
+ let markdown = '';
511
+
512
+ try {
513
+ editor.update(
514
+ () => {
515
+ const newNode = createNode();
516
+
517
+ // An inline node needs a paragraph to live in
518
+ getRoot().append(newNode.isInline() ? createParagraphNode().append(newNode) : newNode);
519
+
520
+ markdown = onEditorUpdate(editor, [transformer]);
521
+ },
522
+ { discrete: true },
523
+ );
524
+ } catch {
525
+ // The component cannot be created or exported outside the rich text editor
526
+ }
527
+
528
+ return markdown.trim();
529
+ };
530
+
482
531
  /**
483
532
  * Initialize the Lexical editor.
484
533
  * @param {TextEditorConfig} config Editor configuration.
@@ -224,10 +224,10 @@
224
224
  .lexical-root[aria-invalid=true] {
225
225
  border-color: var(--sui-error-border-color);
226
226
  }
227
- .lexical-root :global([dir]:first-child) {
227
+ .lexical-root :global(:not(.sui):first-child) {
228
228
  margin-top: 0;
229
229
  }
230
- .lexical-root :global([dir]:last-child) {
230
+ .lexical-root :global(:not(.sui):last-child) {
231
231
  margin-bottom: 0;
232
232
  }
233
233
  .lexical-root :global(strong.italic) {
@@ -1,11 +1,9 @@
1
1
  /**
2
- * Markdown editing helpers for the plain text mode of the text editor, where the toolbar buttons
3
- * edit the Markdown source in the `<textarea>` instead of Lexical nodes. Apart from
4
- * {@link getRawTextState} and {@link applyRawTextEdit}, everything here is pure: a function takes
5
- * the current value and selection, and returns the edit to apply.
6
- */
7
- /**
8
- * @import { TextEditorBlockType, TextEditorFormatType } from '../../typedefs';
2
+ * @import {
3
+ * TextEditorBlockType,
4
+ * TextEditorComponent,
5
+ * TextEditorFormatType,
6
+ * } from '../../typedefs';
9
7
  */
10
8
  /**
11
9
  * Current value and selection of the `<textarea>`.
@@ -43,6 +41,7 @@ export function getRawTextState({ value, selectionStart, selectionEnd }: HTMLTex
43
41
  export function focusTextArea(textArea: HTMLTextAreaElement): Promise<boolean>;
44
42
  export function isRawTextEditable({ readOnly, disabled }: HTMLTextAreaElement): boolean;
45
43
  export function applyRawTextEdit(textArea: HTMLTextAreaElement, { start, end, text, selectionStart, selectionEnd }: RawTextEdit): Promise<void>;
44
+ export function insertComponent(textArea: HTMLTextAreaElement, component: TextEditorComponent): Promise<void>;
46
45
  /**
47
46
  * Current value and selection of the `<textarea>`.
48
47
  */
@@ -88,3 +87,4 @@ export type RawTextEdit = {
88
87
  };
89
88
  import type { TextEditorFormatType } from '../../typedefs';
90
89
  import type { TextEditorBlockType } from '../../typedefs';
90
+ import type { TextEditorComponent } from '../../typedefs';
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * Markdown editing helpers for the plain text mode of the text editor, where the toolbar buttons
3
3
  * edit the Markdown source in the `<textarea>` instead of Lexical nodes. Apart from
4
- * {@link getRawTextState} and {@link applyRawTextEdit}, everything here is pure: a function takes
5
- * the current value and selection, and returns the edit to apply.
4
+ * {@link getRawTextState}, {@link applyRawTextEdit} and {@link insertComponent}, everything here is
5
+ * pure: a function takes the current value and selection, and returns the edit to apply.
6
6
  */
7
7
 
8
+ import { getComponentMarkdown } from './core.js';
9
+
8
10
  /**
9
- * @import { TextEditorBlockType, TextEditorFormatType } from '../../typedefs';
11
+ * @import {
12
+ * TextEditorBlockType,
13
+ * TextEditorComponent,
14
+ * TextEditorFormatType,
15
+ * } from '../../typedefs';
10
16
  */
11
17
 
12
18
  /**
@@ -469,3 +475,26 @@ export const applyRawTextEdit = async (
469
475
 
470
476
  textArea.setSelectionRange(selectionStart, selectionEnd);
471
477
  };
478
+
479
+ /**
480
+ * Insert the Markdown of a new instance of the given editor component into the `<textarea>`. A
481
+ * component matched by a single-line pattern is inserted inline at the caret; anything else is
482
+ * inserted as a separate block. If the component has no Markdown to insert, the focus just moves
483
+ * back to the `<textarea>`.
484
+ * @param {HTMLTextAreaElement} textArea `<textarea>` element.
485
+ * @param {TextEditorComponent} component Editor component.
486
+ * @returns {Promise<void>} Nothing.
487
+ */
488
+ export const insertComponent = async (textArea, component) => {
489
+ const markdown = getComponentMarkdown(component);
490
+
491
+ if (!markdown) {
492
+ textArea.focus();
493
+
494
+ return;
495
+ }
496
+
497
+ const block = component.transformer?.type !== 'text-match';
498
+
499
+ await applyRawTextEdit(textArea, insertMarkdown(getRawTextState(textArea), markdown, { block }));
500
+ };
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
3
3
  */
4
- export const UI_VERSION: "0.78.2";
4
+ export const UI_VERSION: "0.78.3";
@@ -3,4 +3,4 @@
3
3
  /**
4
4
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
5
5
  */
6
- export const UI_VERSION = '0.78.2';
6
+ export const UI_VERSION = '0.78.3';
@@ -6,7 +6,7 @@
6
6
  import { getContext } from 'svelte';
7
7
  import Button from '../../button/button.svelte';
8
8
  import Icon from '../../icon/icon.svelte';
9
- import InsertComponentDialog from './insert-component-dialog.svelte';
9
+ import { insertComponent } from '../raw-markdown.js';
10
10
 
11
11
  /**
12
12
  * @import { TextEditorComponent, TextEditorStore } from '../../../typedefs';
@@ -28,8 +28,6 @@
28
28
  const editorStore = getContext('editorStore');
29
29
 
30
30
  const { label, icon, createNode } = $derived(component);
31
-
32
- let openDialog = $state(false);
33
31
  </script>
34
32
 
35
33
  <Button
@@ -39,9 +37,15 @@
39
37
  aria-label={label}
40
38
  aria-controls={editorStore.controlId}
41
39
  onclick={() => {
42
- // The plain text mode needs a dialog to fill in the component’s fields
43
- if (!editorStore.useRichText) {
44
- openDialog = true;
40
+ const { textArea, useRichText } = editorStore;
41
+
42
+ // Insert the component’s Markdown in the plain text mode
43
+ if (!useRichText) {
44
+ // The button is only enabled while the `<textarea>` is there
45
+ /* v8 ignore else */
46
+ if (textArea) {
47
+ insertComponent(textArea, component);
48
+ }
45
49
 
46
50
  return;
47
51
  }
@@ -58,5 +62,3 @@
58
62
  {/if}
59
63
  {/snippet}
60
64
  </Button>
61
-
62
- <InsertComponentDialog {component} bind:open={openDialog} />
@@ -9,7 +9,7 @@
9
9
  import MenuButton from '../../menu/menu-button.svelte';
10
10
  import MenuItem from '../../menu/menu-item.svelte';
11
11
  import Menu from '../../menu/menu.svelte';
12
- import InsertComponentDialog from './insert-component-dialog.svelte';
12
+ import { insertComponent } from '../raw-markdown.js';
13
13
 
14
14
  /**
15
15
  * @import { TextEditorComponent, TextEditorStore } from '../../../typedefs';
@@ -29,13 +29,6 @@
29
29
 
30
30
  /** @type {TextEditorStore} */
31
31
  const editorStore = getContext('editorStore');
32
-
33
- /**
34
- * Component to insert with the dialog in the plain text mode.
35
- * @type {TextEditorComponent | undefined}
36
- */
37
- let dialogComponent = $state();
38
- let openDialog = $state(false);
39
32
  </script>
40
33
 
41
34
  <MenuButton label={_('_sui.insert')}>
@@ -49,10 +42,15 @@
49
42
  <MenuItem
50
43
  {label}
51
44
  onclick={() => {
52
- // The plain text mode needs a dialog to fill in the component’s fields
53
- if (!editorStore.useRichText) {
54
- dialogComponent = component;
55
- openDialog = true;
45
+ const { textArea, useRichText } = editorStore;
46
+
47
+ // Insert the component’s Markdown in the plain text mode
48
+ if (!useRichText) {
49
+ // The item is only clickable while the `<textarea>` is there
50
+ /* v8 ignore else */
51
+ if (textArea) {
52
+ insertComponent(textArea, component);
53
+ }
56
54
 
57
55
  return;
58
56
  }
@@ -73,5 +71,3 @@
73
71
  </Menu>
74
72
  {/snippet}
75
73
  </MenuButton>
76
-
77
- <InsertComponentDialog component={dialogComponent} bind:open={openDialog} />
@@ -759,6 +759,13 @@ export type TextEditorComponent = {
759
759
  * Node transformer.
760
760
  */
761
761
  transformer: Transformer;
762
+ /**
763
+ * Function to create the Markdown inserted when the
764
+ * component is chosen in the plain text mode. By default, a new node created with `createNode` is
765
+ * exported with `transformer`, which may result in an empty string, for example if the output
766
+ * depends on a field value that is not set yet.
767
+ */
768
+ createMarkdown?: (() => string) | undefined;
762
769
  };
763
770
  export type TextEditorConfig = {
764
771
  /**
package/dist/typedefs.js CHANGED
@@ -318,6 +318,10 @@
318
318
  * @property {(props?: Record<string, any>) => LexicalNode} createNode Function to create a new node
319
319
  * instance.
320
320
  * @property {Transformer} transformer Node transformer.
321
+ * @property {() => string} [createMarkdown] Function to create the Markdown inserted when the
322
+ * component is chosen in the plain text mode. By default, a new node created with `createNode` is
323
+ * exported with `transformer`, which may result in an empty string, for example if the output
324
+ * depends on a field value that is not set yet.
321
325
  */
322
326
 
323
327
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sveltia/ui",
3
- "version": "0.78.2",
3
+ "version": "0.78.3",
4
4
  "description": "A collection of Svelte components and utilities for building user interfaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,96 +0,0 @@
1
- <!--
2
- @component
3
- A minimal rich text editor holding a single editor component, so it can be edited with its own
4
- field UI outside the main editor, and exported to Markdown. Used to insert a component in the
5
- plain text mode.
6
- -->
7
- <script>
8
- import {
9
- $createParagraphNode as createParagraphNode,
10
- $getRoot as getRoot,
11
- $insertNodes as insertNodes,
12
- } from 'lexical';
13
- import { getContext, setContext } from 'svelte';
14
- import { onEditorUpdate } from '../core.js';
15
- import LexicalRoot from '../lexical-root.svelte';
16
- import { createEditorStore } from '../store.svelte.js';
17
-
18
- /**
19
- * @import { TextEditorComponent, TextEditorStore } from '../../../typedefs';
20
- */
21
-
22
- /**
23
- * @typedef {object} Props
24
- * @property {TextEditorComponent} component Editor component to edit.
25
- * @property {string} [markdown] Markdown output of the component.
26
- */
27
-
28
- /** @type {Props} */
29
- let {
30
- /* eslint-disable prefer-const */
31
- component,
32
- markdown = $bindable(''),
33
- /* eslint-enable prefer-const */
34
- } = $props();
35
-
36
- /** @type {TextEditorStore} */
37
- const parentStore = getContext('editorStore');
38
- const editorStore = createEditorStore();
39
-
40
- // svelte-ignore state_referenced_locally
41
- editorStore.config = {
42
- ...parentStore.config,
43
- modes: ['rich-text'],
44
- components: [component],
45
- useMarkdownShortcuts: false,
46
- useEmojiAutocomplete: false,
47
- };
48
-
49
- setContext('editorStore', editorStore);
50
-
51
- let inserted = false;
52
-
53
- $effect(() => {
54
- const { editor, initialized } = editorStore;
55
-
56
- if (!editor || !initialized || inserted) {
57
- return;
58
- }
59
-
60
- inserted = true;
61
-
62
- editor.update(() => {
63
- const paragraph = createParagraphNode();
64
-
65
- getRoot().clear().append(paragraph);
66
- paragraph.select();
67
- insertNodes([component.createNode()]);
68
- });
69
- });
70
-
71
- $effect(() => {
72
- markdown = editorStore.inputValue.trim();
73
- });
74
-
75
- /**
76
- * Get the component’s Markdown right now. The bound `markdown` is only updated after a short
77
- * delay, so it may not reflect a field that has just been changed.
78
- * @returns {string} Markdown.
79
- */
80
- export const getMarkdown = () => {
81
- const { editor, enabledTransformers } = editorStore;
82
-
83
- // The dialog can only be submitted once the editor is there
84
- /* v8 ignore next 3 */
85
- if (!editor) {
86
- return markdown;
87
- }
88
-
89
- return editor
90
- .getEditorState()
91
- .read(() => onEditorUpdate(editor, enabledTransformers))
92
- .trim();
93
- };
94
- </script>
95
-
96
- <LexicalRoot aria-label={component.label} />
@@ -1,35 +0,0 @@
1
- export default ComponentEditor;
2
- type ComponentEditor = {
3
- $on?(type: string, callback: (e: any) => void): () => void;
4
- $set?(props: Partial<Props>): void;
5
- } & {
6
- getMarkdown: () => string;
7
- };
8
- /**
9
- * A minimal rich text editor holding a single editor component, so it can be edited with its own
10
- * field UI outside the main editor, and exported to Markdown. Used to insert a component in the
11
- * plain text mode.
12
- */
13
- declare const ComponentEditor: import("svelte").Component<{
14
- /**
15
- * Editor component to edit.
16
- */
17
- component: TextEditorComponent;
18
- /**
19
- * Markdown output of the component.
20
- */
21
- markdown?: string | undefined;
22
- }, {
23
- getMarkdown: () => string;
24
- }, "markdown">;
25
- type Props = {
26
- /**
27
- * Editor component to edit.
28
- */
29
- component: TextEditorComponent;
30
- /**
31
- * Markdown output of the component.
32
- */
33
- markdown?: string | undefined;
34
- };
35
- import type { TextEditorComponent } from '../../../typedefs';
@@ -1,92 +0,0 @@
1
- <!--
2
- @component
3
- A dialog to insert an editor component in the plain text mode. The component is edited with its
4
- own field UI in a minimal rich text editor, then inserted into the `<textarea>` as Markdown.
5
- -->
6
- <script>
7
- import { _ } from '@sveltia/i18n';
8
- import { getContext } from 'svelte';
9
- import Dialog from '../../dialog/dialog.svelte';
10
- import { applyRawTextEdit, getRawTextState, insertMarkdown } from '../raw-markdown.js';
11
- import ComponentEditor from './component-editor.svelte';
12
-
13
- /**
14
- * @import { TextEditorComponent, TextEditorStore } from '../../../typedefs';
15
- */
16
-
17
- /**
18
- * @typedef {object} Props
19
- * @property {TextEditorComponent | undefined} component Editor component to insert.
20
- * @property {boolean} [open] Whether to open the dialog.
21
- */
22
-
23
- /** @type {Props} */
24
- let {
25
- /* eslint-disable prefer-const */
26
- component,
27
- open = $bindable(false),
28
- /* eslint-enable prefer-const */
29
- } = $props();
30
-
31
- /** @type {TextEditorStore} */
32
- const editorStore = getContext('editorStore');
33
-
34
- let markdown = $state('');
35
- /** @type {ComponentEditor | undefined} */
36
- let componentEditor = $state();
37
- /**
38
- * Markdown taken from the editor as the dialog starts closing, while it’s still there.
39
- * @type {string}
40
- */
41
- let latestMarkdown = '';
42
-
43
- /**
44
- * Insert the component’s Markdown into the `<textarea>`, or just bring the focus back there.
45
- * @param {CustomEvent} event `close` event.
46
- */
47
- const onClose = (event) => {
48
- const { textArea } = editorStore;
49
-
50
- // The dialog can only be opened from a button, which needs the `<textarea>`
51
- /* v8 ignore next 3 */
52
- if (!textArea) {
53
- return;
54
- }
55
-
56
- if (event.detail.returnValue !== 'cancel' && latestMarkdown) {
57
- // A component matched by a single-line pattern is inline; anything else is a block
58
- const block = component?.transformer?.type !== 'text-match';
59
-
60
- applyRawTextEdit(
61
- textArea,
62
- insertMarkdown(getRawTextState(textArea), latestMarkdown, { block }),
63
- );
64
- } else {
65
- textArea.focus();
66
- }
67
-
68
- markdown = '';
69
- latestMarkdown = '';
70
- };
71
- </script>
72
-
73
- <Dialog
74
- title={component?.label ?? ''}
75
- size="large"
76
- bind:open
77
- okLabel={_('_sui.insert')}
78
- okDisabled={!markdown}
79
- restoreFocus={false}
80
- onClosing={() => {
81
- // Get the latest Markdown, as the bound value is updated with a delay
82
- latestMarkdown = componentEditor?.getMarkdown() ?? markdown;
83
- }}
84
- onClose={(event) => {
85
- onClose(event);
86
- }}
87
- >
88
- <!-- The dialog unmounts its content once closed, so each opening gets a fresh editor -->
89
- {#if component}
90
- <ComponentEditor bind:this={componentEditor} {component} bind:markdown />
91
- {/if}
92
- </Dialog>
@@ -1,30 +0,0 @@
1
- export default InsertComponentDialog;
2
- type InsertComponentDialog = {
3
- $on?(type: string, callback: (e: any) => void): () => void;
4
- $set?(props: Partial<Props>): void;
5
- };
6
- /**
7
- * A dialog to insert an editor component in the plain text mode. The component is edited with its
8
- * own field UI in a minimal rich text editor, then inserted into the `<textarea>` as Markdown.
9
- */
10
- declare const InsertComponentDialog: import("svelte").Component<{
11
- /**
12
- * Editor component to insert.
13
- */
14
- component: TextEditorComponent | undefined;
15
- /**
16
- * Whether to open the dialog.
17
- */
18
- open?: boolean | undefined;
19
- }, {}, "open">;
20
- type Props = {
21
- /**
22
- * Editor component to insert.
23
- */
24
- component: TextEditorComponent | undefined;
25
- /**
26
- * Whether to open the dialog.
27
- */
28
- open?: boolean | undefined;
29
- };
30
- import type { TextEditorComponent } from '../../../typedefs';