diff --git a/.changeset/add-editor-toolbar.md b/.changeset/add-editor-toolbar.md
new file mode 100644
index 0000000000..864cf7dde0
--- /dev/null
+++ b/.changeset/add-editor-toolbar.md
@@ -0,0 +1,19 @@
+---
+'@sl-design-system/editor': minor
+---
+
+Add rich text editor toolbar with common formatting actions.
+
+A new `` component is now rendered inside `` by default. It provides buttons for:
+
+- **Text marks**: Bold, Italic, Underline, Strikethrough, Inline code
+- **Block formats**: Blockquote, Heading 1–3
+- **Lists**: Bullet list, Ordered list
+- **History**: Undo, Redo
+
+The toolbar uses `` and `` from the design system, supports overflow and keyboard navigation, and reflects the current editor selection state via `aria-pressed`.
+
+### New API
+
+- `Editor.toolbar` (`boolean`, default `true`) — set to `false` to hide the toolbar
+- `EditorToolbar` — exported class for the toolbar element (`sl-editor-toolbar`)
diff --git a/packages/angular/tsconfig.json b/packages/angular/tsconfig.json
index d3cf7e3704..d28f656284 100644
--- a/packages/angular/tsconfig.json
+++ b/packages/angular/tsconfig.json
@@ -5,7 +5,6 @@
"emitDeclarationOnly": false,
"module": "ES2022",
"moduleResolution": "bundler",
- "noPropertyAccessFromIndexSignature": true,
"outDir": "./out-tsc",
"paths": {
"@sl-design-system/angular": ["./dist"],
diff --git a/packages/components/editor/index.ts b/packages/components/editor/index.ts
index aefd97173e..ecc0099f4f 100644
--- a/packages/components/editor/index.ts
+++ b/packages/components/editor/index.ts
@@ -1 +1,2 @@
export * from './src/editor.js';
+export * from './src/editor-toolbar.js';
diff --git a/packages/components/editor/package.json b/packages/components/editor/package.json
index 33da909ca5..9940a49a25 100644
--- a/packages/components/editor/package.json
+++ b/packages/components/editor/package.json
@@ -38,10 +38,15 @@
"test": "echo \"Error: run tests from monorepo root.\" && exit 1"
},
"dependencies": {
+ "@sl-design-system/button": "^1.3.4",
"@sl-design-system/form": "^1.3.5",
- "@sl-design-system/shared": "^0.11.0"
+ "@sl-design-system/icon": "^1.4.2",
+ "@sl-design-system/shared": "^0.11.0",
+ "@sl-design-system/tool-bar": "^0.2.2",
+ "@sl-design-system/tooltip": "^1.3.2"
},
"devDependencies": {
+ "@open-wc/scoped-elements": "^3.0.6",
"prosemirror-commands": "^1.5.2",
"prosemirror-history": "^1.4.0",
"prosemirror-inputrules": "^1.4.0",
@@ -52,6 +57,7 @@
"prosemirror-view": "^1.33.8"
},
"peerDependencies": {
+ "@open-wc/scoped-elements": "^3.0.6",
"prosemirror-commands": "^1.5.2",
"prosemirror-history": "^1.4.0",
"prosemirror-inputrules": "^1.4.0",
diff --git a/packages/components/editor/register.ts b/packages/components/editor/register.ts
index 89e609581a..5746a08c24 100644
--- a/packages/components/editor/register.ts
+++ b/packages/components/editor/register.ts
@@ -1,3 +1,5 @@
import { Editor } from './src/editor.js';
+import { EditorToolbar } from './src/editor-toolbar.js';
customElements.define('sl-editor', Editor);
+customElements.define('sl-editor-toolbar', EditorToolbar);
diff --git a/packages/components/editor/src/commands.ts b/packages/components/editor/src/commands.ts
index e7eb206f3e..2d7b09685e 100644
--- a/packages/components/editor/src/commands.ts
+++ b/packages/components/editor/src/commands.ts
@@ -1,8 +1,13 @@
import { AllSelection, type Command, type EditorState, type Transaction } from 'prosemirror-state';
import { createContentNode } from './utils.js';
+/** Shorthand for a ProseMirror transaction dispatch function. */
export type DispatchFn = (tr: Transaction) => void;
+/**
+ * Returns a ProseMirror command that replaces the entire document content
+ * with the given HTML string.
+ */
export const setHTML =
(content: string): Command =>
(state: EditorState, dispatch?: DispatchFn): boolean => {
diff --git a/packages/components/editor/src/editor-toolbar.scss b/packages/components/editor/src/editor-toolbar.scss
new file mode 100644
index 0000000000..7ec38c5ecf
--- /dev/null
+++ b/packages/components/editor/src/editor-toolbar.scss
@@ -0,0 +1,11 @@
+:host {
+ background: var(--sl-elevation-surface-raised-default);
+ border-radius: var(--sl-size-borderRadius-default);
+ display: block;
+}
+
+sl-tool-bar {
+ border-block-end: var(--sl-size-borderWidth-default) solid var(--sl-color-border-plain);
+ padding-block: var(--sl-size-050);
+ padding-inline: var(--sl-size-050);
+}
diff --git a/packages/components/editor/src/editor-toolbar.spec.ts b/packages/components/editor/src/editor-toolbar.spec.ts
new file mode 100644
index 0000000000..1cb5f692f6
--- /dev/null
+++ b/packages/components/editor/src/editor-toolbar.spec.ts
@@ -0,0 +1,235 @@
+import { fixture } from '@sl-design-system/vitest-browser-lit';
+import { html } from 'lit';
+import { TextSelection } from 'prosemirror-state';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import '../register.js';
+import { EditorToolbar } from './editor-toolbar.js';
+import { Editor } from './editor.js';
+
+describe('sl-editor-toolbar', () => {
+ let editor: Editor;
+ let toolbar: EditorToolbar;
+
+ const getButtonByIconName = (iconName: string): HTMLElement | null => {
+ return toolbar.renderRoot.querySelector(`sl-icon[name="${iconName}"]`)?.closest('sl-button') ?? null;
+ };
+
+ const getNativeButtonByIconName = (iconName: string): HTMLButtonElement | null => {
+ return getButtonByIconName(iconName)?.shadowRoot?.querySelector('button') ?? null;
+ };
+
+ describe('defaults', () => {
+ beforeEach(async () => {
+ editor = await fixture(html``);
+ toolbar = editor.renderRoot.querySelector('sl-editor-toolbar')!;
+
+ // Wait for toolbar to update with view
+ await toolbar.updateComplete;
+ });
+
+ it('should render a toolbar inside the editor', () => {
+ expect(toolbar).to.exist;
+ });
+
+ it('should have an sl-tool-bar element', () => {
+ const toolBar = toolbar.renderRoot.querySelector('sl-tool-bar');
+ expect(toolBar).to.exist;
+ });
+
+ it('should render a Bold button', () => {
+ const button = getButtonByIconName('far-bold');
+ expect(button).to.exist;
+ });
+
+ it('should render an Italic button', () => {
+ const button = getButtonByIconName('far-italic');
+ expect(button).to.exist;
+ });
+
+ it('should render an Underline button', () => {
+ const button = getButtonByIconName('far-underline');
+ expect(button).to.exist;
+ });
+
+ it('should render a Strikethrough button', () => {
+ const button = getButtonByIconName('far-strikethrough');
+ expect(button).to.exist;
+ });
+
+ it('should render an Inline code button', () => {
+ const button = getButtonByIconName('far-code');
+ expect(button).to.exist;
+ });
+
+ it('should render a Blockquote button', () => {
+ const button = getButtonByIconName('far-quote-left');
+ expect(button).to.exist;
+ });
+
+ it('should render heading buttons H1–H3', () => {
+ expect(getButtonByIconName('far-h1')).to.exist;
+ expect(getButtonByIconName('far-h2')).to.exist;
+ expect(getButtonByIconName('far-h3')).to.exist;
+ });
+
+ it('should render list buttons', () => {
+ expect(getButtonByIconName('far-list')).to.exist;
+ expect(getButtonByIconName('far-list-ol')).to.exist;
+ });
+
+ it('should render Undo and Redo buttons', () => {
+ expect(getButtonByIconName('far-arrow-rotate-left')).to.exist;
+ expect(getButtonByIconName('far-arrow-rotate-right')).to.exist;
+ });
+
+ it('should not be disabled by default', () => {
+ expect(toolbar).not.to.have.attribute('disabled');
+ });
+
+ it('should have Undo disabled when there is no history', () => {
+ const undo = getNativeButtonByIconName('far-arrow-rotate-left');
+ expect(undo).to.have.attribute('aria-disabled', 'true');
+ });
+
+ it('should have Redo disabled when there is nothing to redo', () => {
+ const redo = getNativeButtonByIconName('far-arrow-rotate-right');
+ expect(redo).to.have.attribute('aria-disabled', 'true');
+ });
+
+ it('should not show any format button as active (no selection)', () => {
+ const activeButtons = toolbar.renderRoot.querySelectorAll('sl-button[aria-pressed="true"]');
+ expect(activeButtons.length).to.equal(0);
+ });
+ });
+
+ describe('with content', () => {
+ beforeEach(async () => {
+ editor = await fixture(html`bold text
'}>`);
+ toolbar = editor.renderRoot.querySelector('sl-editor-toolbar')!;
+ await toolbar.updateComplete;
+ });
+
+ it('should keep Undo disabled with initial content and no history', () => {
+ const undo = getButtonByIconName('far-arrow-rotate-left');
+ const nativeUndo = getNativeButtonByIconName('far-arrow-rotate-left');
+ expect(undo).to.exist;
+ expect(nativeUndo).to.have.attribute('aria-disabled', 'true');
+ });
+ });
+
+ describe('active state', () => {
+ beforeEach(async () => {
+ editor = await fixture(html`bold'}>`);
+ toolbar = editor.renderRoot.querySelector('sl-editor-toolbar')!;
+ await toolbar.updateComplete;
+ });
+
+ it('should show Bold button as active when cursor is in bold text', async () => {
+ const view = editor.view!;
+
+ // Select the "bold" text by dispatching a selection transaction
+ const tr = view.state.tr.setSelection(
+ // Select all content inside the paragraph
+ TextSelection.near(view.state.doc.resolve(2))
+ );
+ view.dispatch(tr);
+ await toolbar.updateComplete;
+
+ const boldButton = getButtonByIconName('far-bold');
+ expect(boldButton).to.have.attribute('fill', 'outline');
+ });
+ });
+
+ describe('toolbar disabled', () => {
+ beforeEach(async () => {
+ editor = await fixture(html``);
+ toolbar = editor.renderRoot.querySelector('sl-editor-toolbar')!;
+ await toolbar.updateComplete;
+ });
+
+ it('should be disabled when the editor is disabled', () => {
+ expect(toolbar).to.have.attribute('disabled');
+ });
+
+ it('should pass disabled to the sl-tool-bar', () => {
+ const toolBar = toolbar.renderRoot.querySelector('sl-tool-bar');
+ expect(toolBar).to.have.attribute('disabled');
+ });
+ });
+
+ describe('toolbar hidden', () => {
+ beforeEach(async () => {
+ editor = await fixture(html``);
+ await editor.updateComplete;
+ });
+
+ it('should not render the toolbar when toolbar=false', () => {
+ const t = editor.renderRoot.querySelector('sl-editor-toolbar');
+ expect(t).not.to.exist;
+ });
+ });
+
+ describe('formatting actions', () => {
+ beforeEach(async () => {
+ editor = await fixture(html`hello world'}>`);
+ toolbar = editor.renderRoot.querySelector('sl-editor-toolbar')!;
+ await toolbar.updateComplete;
+
+ // Select all text via ProseMirror
+ const { state, dispatch } = editor.view!;
+ const { AllSelection } = await import('prosemirror-state');
+ dispatch(state.tr.setSelection(new AllSelection(state.doc)));
+ await toolbar.updateComplete;
+ });
+
+ afterEach(() => {
+ editor.remove();
+ });
+
+ it('should apply bold when Bold button is clicked', async () => {
+ const boldButton = getButtonByIconName('far-bold')!;
+ boldButton.click();
+ await toolbar.updateComplete;
+
+ expect(getButtonByIconName('far-bold')).to.have.attribute('fill', 'outline');
+ });
+
+ it('should apply italic when Italic button is clicked', async () => {
+ const italicButton = getButtonByIconName('far-italic')!;
+ italicButton.click();
+ await toolbar.updateComplete;
+
+ expect(getButtonByIconName('far-italic')).to.have.attribute('fill', 'outline');
+ });
+
+ it('should toggle heading when H1 button is clicked', async () => {
+ const h1Button = getButtonByIconName('far-h1')!;
+ h1Button.click();
+ await toolbar.updateComplete;
+
+ expect(getButtonByIconName('far-h1')).to.have.attribute('fill', 'outline');
+ });
+
+ it('should deactivate heading when clicked again', async () => {
+ const h1Button = getButtonByIconName('far-h1')!;
+ h1Button.click();
+ await toolbar.updateComplete;
+ h1Button.click();
+ await toolbar.updateComplete;
+
+ expect(getButtonByIconName('far-h1')).not.to.have.attribute('fill');
+ });
+
+ it('should keep paragraph block when Blockquote action is not applicable', async () => {
+ const view = editor.view!;
+ view.dispatch(view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(2))));
+ await toolbar.updateComplete;
+
+ const bqButton = getButtonByIconName('far-quote-left')!;
+ bqButton.click();
+ await toolbar.updateComplete;
+
+ expect(editor.view?.state.doc.firstChild?.type.name).to.equal('paragraph');
+ });
+ });
+});
diff --git a/packages/components/editor/src/editor-toolbar.ts b/packages/components/editor/src/editor-toolbar.ts
new file mode 100644
index 0000000000..0639288e6e
--- /dev/null
+++ b/packages/components/editor/src/editor-toolbar.ts
@@ -0,0 +1,295 @@
+import {
+ faArrowRotateLeft,
+ faArrowRotateRight,
+ faBold,
+ faCode,
+ faH1,
+ faH2,
+ faH3,
+ faItalic,
+ faList,
+ faListOl,
+ faQuoteLeft,
+ faStrikethrough,
+ faUnderline
+} from '@fortawesome/pro-regular-svg-icons';
+import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js';
+import { Button } from '@sl-design-system/button';
+import { Icon } from '@sl-design-system/icon';
+import { ToolBar, ToolBarDivider } from '@sl-design-system/tool-bar';
+import { Tooltip, tooltip } from '@sl-design-system/tooltip';
+import { type CSSResultGroup, LitElement, type TemplateResult, html, nothing } from 'lit';
+import { property, state } from 'lit/decorators.js';
+import { lift, setBlockType, toggleMark, wrapIn } from 'prosemirror-commands';
+import { redo, undo } from 'prosemirror-history';
+import { type MarkType, type NodeType, type Node as PMNode } from 'prosemirror-model';
+import { liftListItem, wrapInList } from 'prosemirror-schema-list';
+import { type EditorState } from 'prosemirror-state';
+import { type EditorView } from 'prosemirror-view';
+import styles from './editor-toolbar.scss.js';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'sl-editor-toolbar': EditorToolbar;
+ }
+}
+
+Icon.register(
+ faArrowRotateLeft,
+ faArrowRotateRight,
+ faBold,
+ faCode,
+ faH1,
+ faH2,
+ faH3,
+ faItalic,
+ faList,
+ faListOl,
+ faQuoteLeft,
+ faStrikethrough,
+ faUnderline
+);
+
+/**
+ * A toolbar for the rich text editor, providing common rich text formatting actions.
+ * Rendered inside the `` component.
+ *
+ * @slot - Not intended for direct use; content is generated from the editor schema.
+ */
+export class EditorToolbar extends ScopedElementsMixin(LitElement) {
+ /** @internal */
+ static get scopedElements(): ScopedElementsMap {
+ return {
+ 'sl-button': Button,
+ 'sl-icon': Icon,
+ 'sl-tool-bar': ToolBar,
+ 'sl-tool-bar-divider': ToolBarDivider,
+ 'sl-tooltip': Tooltip
+ };
+ }
+
+ /** @internal */
+ static override styles: CSSResultGroup = styles;
+
+ /** Whether the toolbar and all its buttons are disabled. */
+ @property({ type: Boolean, reflect: true }) disabled?: boolean;
+
+ /** The ProseMirror editor view this toolbar controls. */
+ @property({ attribute: false }) view?: EditorView;
+
+ /**
+ * @internal The current editor state, updated by the editor on every transaction
+ * to trigger re-renders so button active states stay in sync.
+ */
+ @state() editorState?: EditorState;
+
+ override render(): TemplateResult {
+ const schema = this.view?.state.schema;
+
+ return html`
+
+ ${schema?.marks.strong ? this.#renderIconButton('Bold', 'far-bold', 'bold') : nothing}
+ ${schema?.marks.em ? this.#renderIconButton('Italic', 'far-italic', 'italic') : nothing}
+ ${schema?.marks.underline ? this.#renderIconButton('Underline', 'far-underline', 'underline') : nothing}
+ ${schema?.marks.strikethrough
+ ? this.#renderIconButton('Strikethrough', 'far-strikethrough', 'strikethrough')
+ : nothing}
+ ${schema?.marks.code || schema?.nodes.blockquote ? html`` : nothing}
+ ${schema?.marks.code ? this.#renderIconButton('Inline code', 'far-code', 'code') : nothing}
+ ${schema?.nodes.blockquote ? this.#renderIconButton('Blockquote', 'far-quote-left', 'blockquote') : nothing}
+ ${schema?.nodes.heading
+ ? html`
+
+ ${this.#renderIconButton('Heading 1', 'far-h1', 'heading-1')}
+ ${this.#renderIconButton('Heading 2', 'far-h2', 'heading-2')}
+ ${this.#renderIconButton('Heading 3', 'far-h3', 'heading-3')}
+ `
+ : nothing}
+ ${schema?.nodes.bulletList || schema?.nodes.orderedList
+ ? html``
+ : nothing}
+ ${schema?.nodes.bulletList && schema.nodes.listItem
+ ? this.#renderIconButton('Bullet list', 'far-list', 'bullet-list')
+ : nothing}
+ ${schema?.nodes.orderedList && schema.nodes.listItem
+ ? this.#renderIconButton('Ordered list', 'far-list-ol', 'ordered-list')
+ : nothing}
+
+
+
+ this.#onUndo()}
+ >
+ this.#onRedo()}
+ >
+
+ `;
+ }
+
+ #renderIconButton(label: string, iconName: string, format: string): TemplateResult {
+ const active = this.#isFormatActive(format);
+
+ return html`
+ this.#execFormat(format)}
+ >
+ `;
+ }
+
+ #isFormatActive(format: string): boolean {
+ const state = this.view?.state;
+ if (!state) return false;
+ const schema = state.schema;
+
+ switch (format) {
+ case 'bold':
+ return this.#isMarkActive(schema.marks.strong);
+ case 'italic':
+ return this.#isMarkActive(schema.marks.em);
+ case 'underline':
+ return this.#isMarkActive(schema.marks.underline);
+ case 'strikethrough':
+ return this.#isMarkActive(schema.marks.strikethrough);
+ case 'code':
+ return this.#isMarkActive(schema.marks.code);
+ case 'blockquote':
+ return this.#isNodeActive(schema.nodes.blockquote);
+ case 'heading-1':
+ return this.#isNodeActive(schema.nodes.heading, { level: 1 });
+ case 'heading-2':
+ return this.#isNodeActive(schema.nodes.heading, { level: 2 });
+ case 'heading-3':
+ return this.#isNodeActive(schema.nodes.heading, { level: 3 });
+ case 'bullet-list':
+ return this.#isNodeActive(schema.nodes.bulletList);
+ case 'ordered-list':
+ return this.#isNodeActive(schema.nodes.orderedList);
+ default:
+ return false;
+ }
+ }
+
+ #isMarkActive(markType: MarkType): boolean {
+ const state = this.view?.state;
+ if (!state || !markType) return false;
+
+ const { from, $from, to, empty } = state.selection;
+
+ if (empty) {
+ return !!markType.isInSet(state.storedMarks ?? $from.marks());
+ }
+
+ return state.doc.rangeHasMark(from, to, markType);
+ }
+
+ #isNodeActive(nodeType: NodeType, attrs?: Record): boolean {
+ const state = this.view?.state;
+ if (!state || !nodeType) return false;
+
+ const { from, to } = state.selection;
+ let found = false;
+
+ state.doc.nodesBetween(from, to, (node: PMNode) => {
+ if (node.type === nodeType) {
+ if (!attrs || Object.entries(attrs).every(([key, val]) => node.attrs[key] === val)) {
+ found = true;
+ }
+ }
+ });
+
+ return found;
+ }
+
+ #canUndo(): boolean {
+ if (!this.view) return false;
+ return undo(this.view.state);
+ }
+
+ #canRedo(): boolean {
+ if (!this.view) return false;
+ return redo(this.view.state);
+ }
+
+ #execFormat(format: string): void {
+ const view = this.view;
+ if (!view) return;
+
+ const { state } = view;
+ const schema = state.schema;
+
+ switch (format) {
+ case 'bold':
+ toggleMark(schema.marks.strong)(state, view.dispatch);
+ break;
+ case 'italic':
+ toggleMark(schema.marks.em)(state, view.dispatch);
+ break;
+ case 'underline':
+ toggleMark(schema.marks.underline)(state, view.dispatch);
+ break;
+ case 'strikethrough':
+ toggleMark(schema.marks.strikethrough)(state, view.dispatch);
+ break;
+ case 'code':
+ toggleMark(schema.marks.code)(state, view.dispatch);
+ break;
+ case 'blockquote':
+ if (this.#isNodeActive(schema.nodes.blockquote)) {
+ lift(state, view.dispatch);
+ } else {
+ wrapIn(schema.nodes.blockquote)(state, view.dispatch);
+ }
+ break;
+ case 'heading-1':
+ case 'heading-2':
+ case 'heading-3': {
+ const level = parseInt(format.slice(-1));
+ if (this.#isNodeActive(schema.nodes.heading, { level })) {
+ setBlockType(schema.nodes.paragraph)(state, view.dispatch);
+ } else {
+ setBlockType(schema.nodes.heading, { level })(state, view.dispatch);
+ }
+ break;
+ }
+ case 'bullet-list':
+ if (this.#isNodeActive(schema.nodes.bulletList)) {
+ liftListItem(schema.nodes.listItem)(state, view.dispatch);
+ } else {
+ wrapInList(schema.nodes.bulletList)(state, view.dispatch);
+ }
+ break;
+ case 'ordered-list':
+ if (this.#isNodeActive(schema.nodes.orderedList)) {
+ liftListItem(schema.nodes.listItem)(state, view.dispatch);
+ } else {
+ wrapInList(schema.nodes.orderedList)(state, view.dispatch);
+ }
+ break;
+ }
+
+ view.focus();
+ }
+
+ #onUndo(): void {
+ if (!this.view) return;
+ undo(this.view.state, this.view.dispatch);
+ this.view.focus();
+ }
+
+ #onRedo(): void {
+ if (!this.view) return;
+ redo(this.view.state, this.view.dispatch);
+ this.view.focus();
+ }
+}
diff --git a/packages/components/editor/src/editor.scss b/packages/components/editor/src/editor.scss
index b4e70ae69f..4e900313e9 100644
--- a/packages/components/editor/src/editor.scss
+++ b/packages/components/editor/src/editor.scss
@@ -37,7 +37,13 @@
}
}
+.container {
+ display: flex;
+ flex-direction: column;
+}
+
.mount {
+ flex: 1;
outline: none;
padding: var(--sl-size-100);
}
diff --git a/packages/components/editor/src/editor.stories.ts b/packages/components/editor/src/editor.stories.ts
index a3f19af6e7..77fd476b0a 100644
--- a/packages/components/editor/src/editor.stories.ts
+++ b/packages/components/editor/src/editor.stories.ts
@@ -6,21 +6,25 @@ import { html } from 'lit';
import '../register.js';
import { type Editor } from './editor.js';
-type Props = Pick & { hint?: string; label?: string };
+type Props = Pick & { hint?: string; label?: string };
type Story = StoryObj;
export default {
title: 'Form/Editor',
- tags: ['!dev'],
+ tags: [/*'!dev',*/ 'draft'],
args: {
disabled: false,
- label: 'Label'
+ label: 'Label',
+ toolbar: true
+ },
+ argTypes: {
+ toolbar: { control: 'boolean' }
},
parameters: {
// Disables Chromatic's snapshotting on a story level
chromatic: { disableSnapshot: true }
},
- render: ({ disabled, hint, label, value }) => {
+ render: ({ disabled, hint, label, toolbar, value }) => {
const onClick = (event: Event & { target: HTMLElement }): void => {
event.target.closest('sl-form')?.reportValidity();
};
@@ -28,7 +32,7 @@ export default {
return html`
-
+
Report validity
@@ -38,21 +42,22 @@ export default {
}
} satisfies Meta;
+const richContent = `
+ Rich Text Editor
+ This component is a rich text editor based on the ProseMirror library.
+ It has support for the following editor actions:
+
+ Typography: bold, italic, underline and strikethrough
+ Format: paragraph, quotation and headings
+ Lists: ordered or unordered
+ Inline code: const x = 1;
+ History: undo and redo
+
+`;
+
export const Basic: Story = {
args: {
- value: `
- Rich Text Editor
- This component is a rich text editor based on the ProseMirror library.
- It has support for the following editor actions:
-
- Typography: bold, italic, underline and strikethrough
- Format: paragraph, quotation and headings
- Alignment: left, right or center
- Lists: ordered or unordered
- Indentation: indent and outdent
- Insert links
-
- `
+ value: richContent
}
};
@@ -62,3 +67,16 @@ export const Disabled: Story = {
disabled: true
}
};
+
+export const NoToolbar: Story = {
+ args: {
+ ...Basic.args,
+ toolbar: false
+ }
+};
+
+export const Empty: Story = {
+ args: {
+ toolbar: true
+ }
+};
diff --git a/packages/components/editor/src/editor.ts b/packages/components/editor/src/editor.ts
index 982c35744d..9f9c510dc1 100644
--- a/packages/components/editor/src/editor.ts
+++ b/packages/components/editor/src/editor.ts
@@ -1,7 +1,8 @@
+import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js';
import { FormControlMixin } from '@sl-design-system/form';
import { EventsController } from '@sl-design-system/shared';
import { type CSSResultGroup, LitElement, type PropertyValues, type TemplateResult, html } from 'lit';
-import { property } from 'lit/decorators.js';
+import { property, query } from 'lit/decorators.js';
import { baseKeymap } from 'prosemirror-commands';
import { history } from 'prosemirror-history';
import { keymap } from 'prosemirror-keymap';
@@ -9,6 +10,7 @@ import { Schema } from 'prosemirror-model';
import { EditorState, type Plugin } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { setHTML } from './commands.js';
+import { EditorToolbar } from './editor-toolbar.js';
import styles from './editor.scss.js';
import { buildKeymap, buildListKeymap } from './keymap.js';
import { type EditorMarks, type EditorNodes, marks, nodes } from './schema.js';
@@ -20,10 +22,17 @@ declare global {
}
}
-export class Editor extends FormControlMixin(LitElement) {
+export class Editor extends ScopedElementsMixin(FormControlMixin(LitElement)) {
/** @internal */
static formAssociated = true;
+ /** @internal */
+ static get scopedElements(): ScopedElementsMap {
+ return {
+ 'sl-editor-toolbar': EditorToolbar
+ };
+ }
+
/** @internal */
static override styles: CSSResultGroup = styles;
@@ -39,6 +48,9 @@ export class Editor extends FormControlMixin(LitElement) {
/** Additional plugins. */
@property({ attribute: false }) plugins?: Plugin[];
+ /** Whether to show the formatting toolbar. Defaults to true. */
+ @property({ type: Boolean }) toolbar = true;
+
override get value(): string {
return this.#value;
}
@@ -55,6 +67,9 @@ export class Editor extends FormControlMixin(LitElement) {
/** The ProseMirror editor view instance. */
view?: EditorView;
+ /** @internal Reference to the toolbar element. */
+ @query('sl-editor-toolbar') toolbarElement?: EditorToolbar;
+
override connectedCallback(): void {
super.connectedCallback();
@@ -66,6 +81,10 @@ export class Editor extends FormControlMixin(LitElement) {
override firstUpdated(): void {
this.view ??= this.createEditor();
+
+ if (this.toolbarElement) {
+ this.toolbarElement.view = this.view;
+ }
}
override updated(changes: PropertyValues): void {
@@ -80,9 +99,12 @@ export class Editor extends FormControlMixin(LitElement) {
}
override render(): TemplateResult {
+ const isDisabled = this.disabled || this.hasAttribute('disabled');
+
return html`
+ ${this.toolbar ? html`
` : ''}
`;
@@ -96,9 +118,13 @@ export class Editor extends FormControlMixin(LitElement) {
{ mount },
{
state,
- dispatchTransaction: function (tr) {
- // `this` is bound to the view instance.
- (this as unknown as EditorView).updateState(this.state.apply(tr));
+ dispatchTransaction: tr => {
+ editor.updateState(editor.state.apply(tr));
+
+ // Notify toolbar so it can sync active-state of buttons
+ if (this.toolbarElement) {
+ this.toolbarElement.editorState = editor.state;
+ }
}
}
);
diff --git a/packages/components/editor/src/keymap.ts b/packages/components/editor/src/keymap.ts
index ef45ad385f..502337f998 100644
--- a/packages/components/editor/src/keymap.ts
+++ b/packages/components/editor/src/keymap.ts
@@ -18,9 +18,13 @@ import { type DispatchFn } from './commands.js';
import { splitListItemKeepMarks } from './list-utils.js';
import { type EditorMarks, type EditorNodes } from './schema.js';
-// https://github.com/ProseMirror/prosemirror-example-setup/blob/master/src/keymap.js
-const mac = typeof navigator !== 'undefined' ? /Mac/.test(navigator.platform) : false;
+// Detect macOS to register platform-specific key bindings.
+const mac = typeof navigator !== 'undefined' ? /Mac|iPhone|iPad|iPod/.test(navigator.userAgent) : false;
+/**
+ * Build the core key bindings for the editor: undo/redo, backspace, enter,
+ * bold/italic/underline toggles, and hard-break insertion.
+ */
export const buildKeymap = (schema: Schema): { [key: string]: Command } => {
const keys: { [key: string]: Command } = {};
@@ -64,6 +68,10 @@ export const buildKeymap = (schema: Schema): { [key: s
return keys;
};
+/**
+ * Build list-specific key bindings. This is registered as a separate keymap
+ * so list Enter handling takes priority over the generic Enter behavior.
+ */
export const buildListKeymap = (schema: Schema): { [key: string]: Command } => {
const keys: { [key: string]: Command } = {};
diff --git a/packages/components/editor/src/list-utils.ts b/packages/components/editor/src/list-utils.ts
index 2499869f73..4bd98cec58 100644
--- a/packages/components/editor/src/list-utils.ts
+++ b/packages/components/editor/src/list-utils.ts
@@ -1,315 +1,21 @@
-import { autoJoin } from 'prosemirror-commands';
-import { Fragment, type Node, NodeRange, type NodeType, type ResolvedPos, type Schema, Slice } from 'prosemirror-model';
-import { wrapInList as pslWrapInList, splitListItem } from 'prosemirror-schema-list';
-import {
- type Command,
- type EditorState,
- NodeSelection,
- type Selection,
- TextSelection,
- type Transaction
-} from 'prosemirror-state';
-import { ReplaceAroundStep, liftTarget } from 'prosemirror-transform';
-import { type EditorView } from 'prosemirror-view';
+import { type NodeType } from 'prosemirror-model';
+import { splitListItem } from 'prosemirror-schema-list';
+import { type EditorState } from 'prosemirror-state';
import { type DispatchFn } from './commands.js';
-export const rootListDepth = (pos: ResolvedPos, nodes: { [key: string]: NodeType }): number | undefined => {
- // Get the depth of the nearest ancestor list
- const { bulletList, orderedList, listItem } = nodes;
-
- let depth;
- for (let i = pos.depth - 1; i > 0; i--) {
- const node = pos.node(i);
-
- if (node.type === bulletList || node.type === orderedList) {
- depth = i;
- }
-
- if (node.type !== bulletList && node.type !== orderedList && node.type !== listItem) {
- break;
- }
- }
-
- return depth;
-};
-
-export const getListLiftTarget = (schema: Schema, resPos: ResolvedPos): number => {
- // This will return (depth - 1) for root list parent of a list.
- const { bulletList, orderedList, listItem } = schema.nodes;
-
- let target = resPos.depth;
- for (let i = resPos.depth; i > 0; i--) {
- const node = resPos.node(i);
-
- if (node.type === bulletList || node.type === orderedList) {
- target = i;
- }
-
- if (node.type !== bulletList && node.type !== orderedList && node.type !== listItem) {
- break;
- }
- }
-
- return target - 1;
-};
-
-export function liftSelectionList(state: EditorState, tr: Transaction): Transaction {
- // The function will list paragraphs in selection out to level 1 below root list.
- const { from, to } = state.selection,
- { paragraph } = state.schema.nodes,
- listCol: Array<{ node: Node; pos: number }> = [];
-
- tr.doc.nodesBetween(from, to, (node, pos) => {
- if (node.type === paragraph) {
- listCol.push({ node, pos });
- }
- });
-
- for (let i = listCol.length - 1; i >= 0; i--) {
- const paragr = listCol[i],
- start = tr.doc.resolve(tr.mapping.map(paragr.pos));
-
- if (start.depth > 0) {
- let end;
-
- if (paragr.node.textContent && paragr.node.textContent.length > 0) {
- end = tr.doc.resolve(tr.mapping.map(paragr.pos + paragr.node.textContent.length));
- } else {
- end = tr.doc.resolve(tr.mapping.map(paragr.pos + 1));
- }
-
- const range = start.blockRange(end);
- if (range) {
- tr.lift(range, getListLiftTarget(state.schema, start));
- }
- }
- }
-
- return tr;
-}
-
-export const toggleList = (state: EditorState, dispatch: DispatchFn, view: EditorView, listType: string): boolean => {
- const { selection } = state,
- fromNode = selection.$from.node(selection.$from.depth - 2),
- endNode = selection.$to.node(selection.$to.depth - 2);
-
- if (!fromNode || fromNode.type.name !== listType || !endNode || endNode.type.name !== listType) {
- return toggleListCommand(listType)(state, dispatch, view);
- } else {
- const depth = rootListDepth(selection.$to, state.schema.nodes) || 0;
-
- let tr = liftFollowingList(state, selection.$to.pos, selection.$to.end(depth), depth, state.tr);
- tr = liftSelectionList(state, tr);
- dispatch(tr);
-
- return true;
- }
-};
-
-export function toggleListCommand(listType: string): Command {
- return function (state: EditorState, dispatch?: DispatchFn, view?: EditorView) {
- if (!view) {
- return false;
- }
-
- state = view.state;
-
- const { $from, $to } = state.selection,
- parent = $from.node(-2),
- grandgrandParent = $from.node(-3),
- isRangeOfSingleType = isRangeOfType(state.doc, $from, $to, state.schema.nodes[listType]);
-
- if (
- ((parent && parent.type === state.schema.nodes[listType]) ||
- (grandgrandParent && grandgrandParent.type === state.schema.nodes[listType])) &&
- isRangeOfSingleType
- ) {
- // Untoggles list
- return liftListItems()(state, dispatch);
- } else {
- // Wraps selection in list and converts list type e.g. bullet_list -> ordered_list if needed
- if (!isRangeOfSingleType) {
- liftListItems()(state, dispatch);
- state = view.state;
- }
-
- return wrapInList(state.schema.nodes[listType])(state, dispatch);
- }
- };
-}
-
-function liftListItem(state: EditorState, selection: Selection, tr: Transaction): Transaction {
- const { $from, $to } = selection,
- nodeType: NodeType = state.schema.nodes['listItem'];
-
- let range = $from.blockRange($to, node => !!node.childCount && node.firstChild?.type === nodeType);
- if (!range || range.depth < 2 || $from.node(range.depth - 1).type !== nodeType) {
- return tr;
- }
-
- const end = range.end,
- endOfList = $to.end(range.depth);
-
- if (end < endOfList) {
- tr.step(
- new ReplaceAroundStep(
- end - 1,
- endOfList,
- end,
- endOfList,
- new Slice(Fragment.from(nodeType.create(undefined, range.parent.copy())), 1, 0),
- 1,
- true
- )
- );
-
- range = new NodeRange(tr.doc.resolve($from.pos), tr.doc.resolve(endOfList), range.depth);
- }
-
- return tr.lift(range, liftTarget(range) || 0).scrollIntoView();
-}
-
-export function liftFollowingList(
- state: EditorState,
- from: number,
- to: number,
- rootListDepthNum: number,
- tr: Transaction
-): Transaction {
- // Function will lift list item following selection to level-1.
- const { listItem } = state.schema.nodes;
-
- let lifted = false;
- tr.doc.nodesBetween(from, to, (node, pos) => {
- if (!lifted && node.type === listItem && pos > from) {
- lifted = true;
-
- let listDepth = rootListDepthNum + 3;
- while (listDepth > rootListDepthNum + 2) {
- const start = tr.doc.resolve(tr.mapping.map(pos));
- listDepth = start.depth;
- const end = tr.doc.resolve(tr.mapping.map(pos + node.textContent.length));
- const sel = new TextSelection(start, end);
- tr = liftListItem(state, sel, tr);
- }
- }
- });
-
- return tr;
-}
-
-export function isRangeOfType(doc: Node, $from: ResolvedPos, $to: ResolvedPos, nodeType: NodeType): boolean {
- // Step through block-nodes between $from and $to and returns false if a node is
- // found that isn't of the specified type
- return getAncestorNodesBetween(doc, $from, $to).filter(node => node.type !== nodeType).length === 0;
-}
-
-export function getAncestorNodesBetween(doc: Node, $from: ResolvedPos, $to: ResolvedPos): Node[] {
- // Returns all top-level ancestor-nodes between $from and $to
- const nodes: Node[] = [],
- maxDepth = findAncestorPosition(doc, $from).depth;
-
- let current = doc.resolve($from.start(maxDepth));
- while (current.pos <= $to.start($to.depth)) {
- const depth = Math.min(current.depth, maxDepth),
- node = current.node(depth);
-
- if (node) {
- nodes.push(node);
- }
-
- if (depth === 0) {
- break;
- }
-
- let next = doc.resolve(current.after(depth));
- if (next.start(depth) >= doc.nodeSize - 2) {
- break;
- }
-
- if (next.depth !== current.depth) {
- next = doc.resolve(next.pos + 2);
- }
-
- if (next.depth) {
- current = doc.resolve(next.start(next.depth));
- } else {
- current = doc.resolve(next.end(next.depth));
- }
- }
-
- return nodes;
-}
-
-export function findAncestorPosition(doc: Node, pos: ResolvedPos): ResolvedPos {
- // Traverse the document until an "ancestor" is found. Any nestable block can be an ancestor.
- const nestableBlocks = ['blockquote', 'bulletList', 'orderedList'];
-
- if (pos.depth === 1) {
- return pos;
- }
-
- let node = pos.node(pos.depth),
- newPos = pos;
- while (pos.depth >= 1) {
- pos = doc.resolve(pos.before(pos.depth));
- node = pos.node(pos.depth);
-
- if (node && nestableBlocks.indexOf(node.type.name) !== -1) {
- newPos = pos;
- }
- }
-
- return newPos;
-}
-
-export function liftListItems(): Command {
- return function (state: EditorState, dispatch?: DispatchFn): true {
- const { tr } = state,
- { $from, $to } = state.selection;
-
- tr.doc.nodesBetween($from.pos, $to.pos, (node: Node, pos: number): boolean | void => {
- // Following condition will ensure that block types paragraph, heading, codeBlock, blockquote, panel are lifted.
- // isTextblock is true for paragraph, heading, codeBlock.
- if (node.isTextblock || node.type.name === 'blockquote' || node.type.name === 'panel') {
- const sel = new NodeSelection(tr.doc.resolve(tr.mapping.map(pos))),
- range = sel.$from.blockRange(sel.$to);
-
- if (!range || sel.$from.parent.type !== state.schema.nodes['listItem']) {
- return false;
- }
-
- const target = range && liftTarget(range);
- if (target === undefined || target === null) {
- return false;
- }
-
- tr.lift(range, target);
- }
- });
-
- dispatch?.(tr);
-
- return true;
- };
-}
-
-export function wrapInList(nodeType: NodeType): Command {
- return autoJoin(pslWrapInList(nodeType), (before, after) => before.type === after.type && before.type === nodeType);
-}
-
-export function toggleUnorderedList(state: EditorState, dispatch: DispatchFn, view: EditorView): boolean {
- return toggleList(state, dispatch, view, 'bulletList');
-}
-
-export function toggleOrderedList(state: EditorState, dispatch: DispatchFn, view: EditorView): boolean {
- return toggleList(state, dispatch, view, 'orderedList');
-}
-
+/**
+ * A variant of ProseMirror's `splitListItem` that preserves active marks
+ * (bold, italic, etc.) when splitting a list item with Enter.
+ *
+ * Without this, pressing Enter inside a bold list item would reset the
+ * marks on the new item. This reads the stored marks (or the marks at the
+ * current cursor position) and re-applies them after the split.
+ *
+ * @see https://github.com/ProseMirror/prosemirror-commands/blob/master/src/commands.js#L321-L327
+ */
export const splitListItemKeepMarks =
(itemType: NodeType) =>
(state: EditorState, dispatch?: DispatchFn): boolean => {
- // see https://github.com/ProseMirror/prosemirror-commands/blob/master/src/commands.js#L321-L327
return splitListItem(itemType)(state, tr => {
const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks());
@@ -317,8 +23,6 @@ export const splitListItemKeepMarks =
tr.ensureMarks(marks);
}
- if (dispatch) {
- dispatch(tr);
- }
+ dispatch?.(tr);
});
};
diff --git a/packages/components/editor/src/schema.ts b/packages/components/editor/src/schema.ts
index e8be655541..3c222559cb 100644
--- a/packages/components/editor/src/schema.ts
+++ b/packages/components/editor/src/schema.ts
@@ -31,35 +31,31 @@ export declare type EditorNodes =
| 'orderedList'
| 'bulletList';
-const SLOT = 0; // https://prosemirror.net/docs/guide/#schema.serialization_and_parsing
+/**
+ * The "hole" constant used in ProseMirror DOM output specs to indicate where child content goes.
+ * @see https://prosemirror.net/docs/guide/#schema.serialization_and_parsing
+ */
+const SLOT = 0;
-export const isEmpty = (obj: Record): boolean => Object.keys(obj).length === 0;
+/** Returns true if the given object has no own keys. */
+const isEmpty = (obj: Record): boolean => Object.keys(obj).length === 0;
-export const removeEntries = (
- obj: Record,
- predicate: (key: string) => boolean
-): Record => {
- return (
- Object.keys(obj)
- .filter(key => predicate(key))
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
- .reduce((acc, curr: any) => Object.assign(acc, { [curr]: obj[curr] }), {})
- );
+/** Returns a new object containing only the entries where the predicate returns true. */
+const removeEntries = (obj: Record, predicate: (key: string) => boolean): Record => {
+ return Object.fromEntries(Object.entries(obj).filter(([key]) => predicate(key))) as Record;
};
-export const removeEmptyEntries = (obj: Record): Record => {
- const predicate = (key: string): boolean => obj[key] !== null && obj[key] !== undefined && obj[key] !== '';
-
- return removeEntries(obj, predicate);
+/** Returns a new object with all null, undefined, and empty-string values removed. */
+const removeEmptyEntries = (obj: Record): Record => {
+ return removeEntries(obj, key => obj[key] !== null && obj[key] !== undefined && obj[key] !== '');
};
-export const commonAttributes = (): Attrs => {
- return {
- class: { default: null },
- id: { default: null },
- style: { default: null }
- };
-};
+/** Common HTML attributes (class, id, style) shared across multiple node specs. */
+const commonAttributes = (): Attrs => ({
+ class: { default: null },
+ id: { default: null },
+ style: { default: null }
+});
export const marks: Record = {
/**
@@ -91,8 +87,8 @@ export const marks: Record = {
],
toDOM: (mark: Mark): DOMOutputSpec => [
'a',
- // Add default value for href. Otherwise the link is not rendered properly
- Object.assign({}, { href: '' }, removeEmptyEntries(mark.attrs)),
+ // Ensure href always has a value, otherwise the link is not rendered properly
+ { href: '', ...removeEmptyEntries(mark.attrs) },
SLOT
]
},
@@ -220,7 +216,7 @@ export const nodes: Record = {
* A plain textblock paragraph. Represented as a `` element in the DOM.
*/
paragraph: {
- attrs: Object.assign({}, commonAttributes()),
+ attrs: { ...commonAttributes() },
content: 'inline*',
group: 'block',
parseDOM: [{ tag: 'p', getAttrs: getAttributes }],
@@ -231,7 +227,7 @@ export const nodes: Record = {
* A blockquote (``) which wraps one or more blocks.
*/
blockquote: {
- attrs: Object.assign({}, commonAttributes()),
+ attrs: { ...commonAttributes() },
content: 'inline*',
defining: true,
group: 'block',
@@ -251,7 +247,7 @@ export const nodes: Record = {
* Parsed and serialized as an `` to an `` element.
*/
heading: {
- attrs: Object.assign({ level: { default: 1 } }, commonAttributes()),
+ attrs: { level: { default: 1 }, ...commonAttributes() },
content: 'inline*',
defining: true,
group: 'block',
@@ -297,12 +293,13 @@ export const nodes: Record = {
* The last two default to an empty string.
*/
image: {
- attrs: Object.assign({}, commonAttributes(), {
+ attrs: {
+ ...commonAttributes(),
alt: { default: null },
height: { default: null },
src: {},
width: { default: null }
- }),
+ },
draggable: true,
group: 'inline',
inline: true,
@@ -323,7 +320,7 @@ export const nodes: Record = {
* A list item. Represented as a `` element.
*/
listItem: {
- attrs: Object.assign({}, commonAttributes()),
+ attrs: { ...commonAttributes() },
content: 'paragraph block*',
marks: '_',
parseDOM: [{ tag: 'li', getAttrs: getAttributes }],
diff --git a/packages/components/editor/src/utils.ts b/packages/components/editor/src/utils.ts
index 17775d8596..e5df709378 100644
--- a/packages/components/editor/src/utils.ts
+++ b/packages/components/editor/src/utils.ts
@@ -1,6 +1,10 @@
import { DOMParser, DOMSerializer, type Node, type Schema } from 'prosemirror-model';
import { type EditorState } from 'prosemirror-state';
+/**
+ * Parse an HTML string into a ProseMirror document node using the given schema.
+ * Returns an empty document when value is empty or undefined.
+ */
export const createContentNode = (schema: Schema, value = ''): Node => {
const element = document.createElement('div');
@@ -9,6 +13,9 @@ export const createContentNode = (schema: Schema, value = ''): Node => {
return DOMParser.fromSchema(schema).parse(element);
};
+/**
+ * Serialize the current editor state's document back to an HTML string.
+ */
export const getHTML = (state: EditorState): string => {
const fragment = DOMSerializer.fromSchema(state.schema).serializeFragment(state.doc.content),
element = document.createElement('div');
diff --git a/yarn.lock b/yarn.lock
index 4a718a2aba..cd6696dcce 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5463,8 +5463,13 @@ __metadata:
version: 0.0.0-use.local
resolution: "@sl-design-system/editor@workspace:packages/components/editor"
dependencies:
+ "@open-wc/scoped-elements": "npm:^3.0.6"
+ "@sl-design-system/button": "npm:^1.3.4"
"@sl-design-system/form": "npm:^1.3.5"
+ "@sl-design-system/icon": "npm:^1.4.2"
"@sl-design-system/shared": "npm:^0.11.0"
+ "@sl-design-system/tool-bar": "npm:^0.2.2"
+ "@sl-design-system/tooltip": "npm:^1.3.2"
prosemirror-commands: "npm:^1.5.2"
prosemirror-history: "npm:^1.4.0"
prosemirror-inputrules: "npm:^1.4.0"
@@ -5474,6 +5479,7 @@ __metadata:
prosemirror-state: "npm:^1.4.3"
prosemirror-view: "npm:^1.33.8"
peerDependencies:
+ "@open-wc/scoped-elements": ^3.0.6
prosemirror-commands: ^1.5.2
prosemirror-history: ^1.4.0
prosemirror-inputrules: ^1.4.0