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