diff --git a/WORKLOG.md b/WORKLOG.md index fd9b71ad..75c81f19 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -1,5 +1,11 @@ # Worklog +## 2026-04-21 + +### Canvas editor — selection toolbar + slash shared helpers +- **`selection-toolbar.js`**: exports `EDITOR_TEXT_FORMAT_ITEMS` and prose helpers (`applyHeadingLevel`, `wrapInBlockquote`, `setCodeBlock`, `setParagraph`, list wraps) for slash menu; block-type picker from `BLOCK_TYPE_PICKER_DEFS`; `STRUCTURE_COMMANDS` (`isActive` + `run`); `markIsActiveInSelection`; structure buttons from a toolbar subset of `EDITOR_TEXT_FORMAT_ITEMS`. +- **`slash-menu-items.js` / `slash-menu-handlers.js`**: import shared catalog/helpers from `selection-toolbar.js` (slash-only rows stay in items). + ## 2026-03-21 ### AGENTS.md creation @@ -88,6 +94,12 @@ Decided to wrap nav and sidenav in semantic HTML elements: ### nx2 canvas — quick-edit (controller=parent) WYSIWYG - **Superseded 2026-04-09** — structure was `nx-doc-editor` + `nx-wysiwyg-frame`; see next section. +## 2026-04-17 + +### nx2 canvas — selection toolbar block types + inline code +- **`selection-toolbar.js`**: “Change into” picker includes **Code block** (`setBlockType(code_block)`); new **Inline code** toggle uses the schema `code` mark (`toggleMarkOnSelection`). Toolbar order: block-type picker, then mark buttons, then structure actions (separators between groups). +- **`canvas.css`**: monospace styling for the inline-code toolbar button. + ## 2026-04-14 ### nx2 canvas — PR #351 review follow-up diff --git a/nx/public/plugins/quick-edit/src/prose.js b/nx/public/plugins/quick-edit/src/prose.js index f3d10681..f30f6b96 100644 --- a/nx/public/plugins/quick-edit/src/prose.js +++ b/nx/public/plugins/quick-edit/src/prose.js @@ -61,10 +61,13 @@ function handleTransaction(tr, ctx, editorView, editorParent) { if (oldSel.anchor !== newSel.anchor || oldSel.head !== newSel.head) { const base = currentCursorOffset - 1; if (newSel.anchor !== newSel.head) { + const coords = editorView.coordsAtPos(newSel.anchor); ctx.port.postMessage({ type: 'selection-change', anchor: base + newSel.anchor, head: base + newSel.head, + anchorX: coords.left, + anchorY: coords.top, }); } else { ctx.port.postMessage({ @@ -90,6 +93,39 @@ function handleTransaction(tr, ctx, editorView, editorParent) { positionToolbar(); } +let scrollRaf = null; +let scrollCtx = null; +let scrollBound = false; + +function initScrollListener(win, ctx) { + scrollCtx = ctx; + if (scrollBound) return; + scrollBound = true; + win.addEventListener('scroll', () => { + if (scrollRaf) return; + scrollRaf = requestAnimationFrame(() => { + scrollRaf = null; + const focused = document.querySelector('.prosemirror-editor .ProseMirror:focus'); + if (!focused) return; + const editorParent = focused.closest('.prosemirror-editor'); + const view = editorParent?.view; + if (!view) return; + const { selection } = view.state; + if (selection.anchor === selection.head) return; + const offset = parseInt(editorParent.getAttribute('data-prose-index'), 10); + const base = offset - 1; + const coords = view.coordsAtPos(selection.anchor); + scrollCtx.port.postMessage({ + type: 'selection-change', + anchor: base + selection.anchor, + head: base + selection.head, + anchorX: coords.left, + anchorY: coords.top, + }); + }); + }, { passive: true }); +} + let blurClearTimeout = null; function focus(view) { @@ -159,6 +195,7 @@ function createEditor(cursorOffset, state, ctx) { editorParent.view = editorView; setupImageDropListeners(ctx, editorParent); setRemoteCursors(); + initScrollListener(editorParent.ownerDocument.defaultView, ctx); if (blurClearTimeout !== null) { clearTimeout(blurClearTimeout); diff --git a/nx2/blocks/canvas/editor-utils/command-defs.js b/nx2/blocks/canvas/editor-utils/command-defs.js new file mode 100644 index 00000000..134d38d1 --- /dev/null +++ b/nx2/blocks/canvas/editor-utils/command-defs.js @@ -0,0 +1,265 @@ +/* eslint-disable import/no-unresolved -- importmap */ +import { DOMParser, Fragment } from 'da-y-wrapper'; +import { + blockType, + wrap, + list, + inlineMark, + sinkListLevel, + liftListLevel, + markIsActive, + inList, + canSinkList, + canLiftList, + getTableHeading, + getTableBody, + LOREM_SENTENCES, +} from './command-helpers.js'; + +export const COMMANDS = [ + // Toolbar: inline mark buttons + { + id: 'strong', + label: 'Bold', + schema: 'strong', + icon: 'TagBold', + showIn: ['toolbar-marks'], + active: (state) => markIsActive(state, 'strong'), + apply: inlineMark('strong'), + }, + { + id: 'em', + label: 'Italic', + schema: 'em', + icon: 'TagItalic', + showIn: ['toolbar-marks'], + active: (state) => markIsActive(state, 'em'), + apply: inlineMark('em'), + }, + { + id: 'code', + label: 'Inline code', + schema: 'code', + icon: 'Code', + showIn: ['toolbar-marks'], + active: (state) => markIsActive(state, 'code'), + apply: inlineMark('code'), + }, + { + id: 'underline', + label: 'Underline', + schema: 'u', + icon: 'TagUnderline', + showIn: ['toolbar-marks'], + active: (state) => markIsActive(state, 'u'), + apply: inlineMark('u'), + }, + { + id: 'strikethrough', + label: 'Strikethrough', + schema: 's', + icon: 'TagStrikeThrough', + showIn: ['toolbar-marks'], + active: (state) => markIsActive(state, 's'), + apply: inlineMark('s'), + }, + + // Toolbar: block-type picker + { + id: 'paragraph', + label: 'Paragraph', + schema: 'paragraph', + showIn: ['toolbar-picker'], + apply: blockType('paragraph'), + }, + { + id: 'heading-1', + label: 'Heading 1', + icon: 'Heading1', + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 1 }), + }, + { + id: 'heading-2', + label: 'Heading 2', + icon: 'Heading2', + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 2 }), + }, + { + id: 'heading-3', + label: 'Heading 3', + icon: 'Heading3', + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 3 }), + }, + { + id: 'heading-4', + label: 'Heading 4', + icon: 'Heading4', + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 4 }), + }, + { + id: 'heading-5', + label: 'Heading 5', + icon: 'Heading5', + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 5 }), + }, + { + id: 'heading-6', + label: 'Heading 6', + icon: 'Heading6', + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 6 }), + }, + { + id: 'code-block', + label: 'Code block', + icon: 'BlockCode', + schema: 'code_block', + showIn: ['toolbar-picker', 'slash-text'], + disabled: (state) => state.selection.$from.parent.type.name === 'code_block', + apply: blockType('code_block'), + }, + + // Toolbar: structure buttons + { + id: 'blockquote', + label: 'Blockquote', + icon: 'BlockQuote', + schema: 'blockquote', + showIn: ['toolbar-structure', 'slash-text'], + apply: wrap('blockquote'), + }, + { + id: 'bullet-list', + label: 'Bullet list', + icon: 'ListBulleted', + schema: 'bullet_list', + showIn: ['toolbar-structure', 'slash-text'], + visible: ({ selection: { $from } }) => !inList($from), + apply: list('bullet_list'), + }, + { + id: 'numbered-list', + label: 'Numbered list', + icon: 'ListNumbered', + schema: 'ordered_list', + showIn: ['toolbar-structure', 'slash-text'], + visible: ({ selection: { $from } }) => !inList($from), + apply: list('ordered_list'), + }, + { + id: 'list-indent', + label: 'Indent list', + icon: 'TextIndentIncrease', + showIn: ['toolbar-structure'], + visible: ({ selection: { $from } }) => inList($from), + disabled: (state) => !canSinkList(state), + apply: sinkListLevel, + }, + { + id: 'list-outdent', + label: 'Outdent list', + icon: 'TextIndentDecrease', + showIn: ['toolbar-structure'], + visible: ({ selection: { $from } }) => inList($from), + disabled: (state) => !canLiftList(state), + apply: liftListLevel, + }, + + // Slash menu: text section only + { + id: 'section-break', + label: 'Section break', + icon: 'Separator', + showIn: ['slash-text'], + apply: (view) => { + const div = document.createElement('div'); + div.append(document.createElement('hr'), document.createElement('p')); + const nodes = DOMParser.fromSchema(view.state.schema).parse(div); + view.dispatch(view.state.tr.replaceSelectionWith(nodes)); + }, + }, + { + id: 'lorem-ipsum', + label: 'Lorem ipsum', + icon: 'Rail', + showIn: ['slash-text'], + apply: (view) => { + const { $cursor } = view.state.selection; + if (!$cursor) return; + const text = Array.from( + { length: 5 }, + (_, i) => LOREM_SENTENCES[i % LOREM_SENTENCES.length], + ).join(' '); + view.dispatch( + view.state.tr.replaceWith($cursor.before(), $cursor.pos, view.state.schema.text(text)), + ); + }, + }, + + // Slash menu: blocks section + { + id: 'open-library', + label: 'Open library', + icon: 'CCLibrary', + showIn: ['slash-blocks'], + apply: () => { + const evt = new CustomEvent('nx-canvas-open-panel', { + bubbles: true, + composed: true, + detail: { position: 'after' }, + }); + document.querySelector('nx-canvas-header')?.dispatchEvent(evt); + }, + }, + { + id: 'insert-block', + label: 'Insert block', + icon: 'TableAdd', + showIn: ['slash-blocks'], + apply: (view) => { + const { state } = view; + const heading = getTableHeading(state.schema); + const body = getTableBody(state.schema); + const frag = document.createDocumentFragment(); + frag.append(document.createElement('p')); + const para = DOMParser.fromSchema(state.schema).parse(frag); + const node = state.schema.nodes.table.create(null, Fragment.fromArray([heading, body])); + const trx = state.tr.insert(state.selection.head, para); + trx.replaceSelectionWith(node).scrollIntoView(); + view.dispatch(trx); + }, + }, +]; + +export function commandsFor(showIn) { + return COMMANDS.filter((c) => c.showIn.includes(showIn)); +} + +export const COMMAND_BY_ID = new Map(COMMANDS.map((c) => [c.id, c])); + +const SLASH_GROUPS = [ + { section: 'Blocks', showIn: 'slash-blocks' }, + { section: 'Text', showIn: 'slash-text' }, +]; + +export function slashMenuItemsForQuery(query) { + const q = (query || '').toLowerCase(); + const groups = SLASH_GROUPS + .map(({ section, showIn }) => ({ + section, + items: commandsFor(showIn).filter((i) => !q || i.label.toLowerCase().startsWith(q)), + })) + .filter((g) => g.items.length > 0); + return groups.flatMap(({ section, items }) => [{ section }, ...items]); +} diff --git a/nx2/blocks/canvas/editor-utils/command-helpers.js b/nx2/blocks/canvas/editor-utils/command-helpers.js new file mode 100644 index 00000000..c890f81a --- /dev/null +++ b/nx2/blocks/canvas/editor-utils/command-helpers.js @@ -0,0 +1,214 @@ +/* eslint-disable import/no-unresolved -- importmap */ +import { + Fragment, + liftListItem, + setBlockType, + sinkListItem, + TextSelection, + toggleMark, + wrapIn, + wrapInList, +} from 'da-y-wrapper'; + +/* ---- Apply factories ---- */ + +export const blockType = (nodeKey, attrs) => (view) => { + const { state } = view; + setBlockType(state.schema.nodes[nodeKey], attrs)(state, view.dispatch.bind(view)); +}; + +export const wrap = (nodeKey) => (view) => { + const { state } = view; + wrapIn(state.schema.nodes[nodeKey])(state, view.dispatch.bind(view)); +}; + +export const list = (nodeKey) => (view) => { + const { state } = view; + wrapInList(state.schema.nodes[nodeKey])(state, view.dispatch.bind(view)); +}; + +export const inlineMark = (markKey) => (view) => { + const { state } = view; + toggleMark(state.schema.marks[markKey])(state, view.dispatch.bind(view)); +}; + +export const sinkListLevel = (view) => { + const { state } = view; + sinkListItem(state.schema.nodes.list_item)(state, view.dispatch.bind(view)); +}; + +export const liftListLevel = (view) => { + const { state } = view; + liftListItem(state.schema.nodes.list_item)(state, view.dispatch.bind(view)); +}; + +/* ---- Active queries ---- */ + +export function markIsActive(state, markName) { + const mark = state.schema.marks[markName]; + if (!mark) return false; + const { selection, storedMarks } = state; + if (selection.empty) { + return (storedMarks || selection.$from.marks()).some((m) => m.type === mark); + } + return state.doc.rangeHasMark(selection.from, selection.to, mark); +} + +export function inBlockquote($pos) { + for (let d = $pos.depth; d > 0; d -= 1) { + if ($pos.node(d).type.name === 'blockquote') return true; + } + return false; +} + +export function nearestListType($pos) { + for (let d = $pos.depth; d > 0; d -= 1) { + const { name } = $pos.node(d).type; + if (name === 'bullet_list' || name === 'ordered_list') return name; + } + return null; +} + +export function inList($pos) { + return nearestListType($pos) !== null; +} + +export function canSinkList(state) { + return sinkListItem(state.schema.nodes.list_item)(state); +} + +export function canLiftList(state) { + return liftListItem(state.schema.nodes.list_item)(state); +} + +/* ---- Slash-only action helpers ---- */ + +export function getTableHeading(schema) { + // eslint-disable-next-line camelcase + const { paragraph, table_row, table_cell } = schema.nodes; + const para = paragraph.create(null, schema.text('columns')); + // eslint-disable-next-line camelcase + return table_row.create(null, Fragment.from(table_cell.create({ colspan: 2 }, para))); +} + +export function getTableBody(schema) { + const cell = schema.nodes.table_cell.createAndFill(); + return schema.nodes.table_row.create(null, Fragment.fromArray([cell, cell])); +} + +export const LOREM_SENTENCES = [ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.', + 'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore.', + 'Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia.', + 'Nunc feugiat mi a tellus consequat imperdiet.', + 'Vestibulum sapien proin quam etiam ultrices suscipit gravida bibendum.', + 'Fusce pellentesque enim aliquam varius tincidunt aenean vulputate.', + 'Maecenas volutpat blandit aliquam etiam erat velit scelerisque in dictum.', +]; + +/* ---- Link queries ---- */ + +function findLinkInRange(state) { + const { from, to } = state.selection; + const linkType = state.schema.marks.link; + let found; + state.doc.nodesBetween(from, to, (node, pos) => { + if (found) return false; + const mark = linkType.isInSet(node.marks); + if (mark) { found = { node, mark, from: pos, to: pos + node.nodeSize }; } + return true; + }); + return found ?? null; +} + +export function selectionHasLink(state) { + return findLinkInRange(state) !== null; +} + +export function getLinkInfoInSelection(state) { + const result = findLinkInRange(state); + if (!result) return null; + return { + href: result.mark.attrs.href ?? '', + title: result.mark.attrs.title ?? '', + text: result.node.textContent, + from: result.from, + to: result.to, + }; +} + +/* ---- Link commands ---- */ + +export function applyLink(view, { href, text }) { + const { state } = view; + const { schema, selection } = state; + const linkType = schema.marks.link; + let { from, to } = selection; + let { tr } = state; + + const existingLink = findLinkInRange(state); + if (existingLink) { + ({ from, to } = existingLink); + tr = tr.removeMark(from, to, linkType); + } + + const displayText = text?.trim() || href; + const originalText = state.doc.textBetween(from, to); + + if (displayText !== originalText || from === to) { + const marks = from < state.doc.content.size + ? state.doc.resolve(from).marks().filter((m) => m.type !== linkType) + : []; + const textNode = schema.text(displayText, marks); + tr = tr.replaceWith(from, to, textNode); + to = from + displayText.length; + } + + tr = tr.addMark(from, to, linkType.create({ href: href.trim() })); + tr = tr.setSelection(TextSelection.create(tr.doc, to)); + view.dispatch(tr); +} + +export function removeLink(view) { + const { state } = view; + const linkType = state.schema.marks.link; + const found = findLinkInRange(state); + if (!found) return; + const { tr } = state; + tr.removeMark(found.from, found.to, linkType); + view.dispatch(tr); +} + +/* ---- Block-type picker value ---- */ + +const SCHEMA_NODE_TO_ID = new Map([ + ['paragraph', 'paragraph'], + ['code_block', 'code-block'], +]); + +function forEachTextblockInSelection({ doc, selection }, visit) { + doc.nodesBetween(selection.from, selection.to, (node) => { + if (node.isTextblock) { + visit(node); + return false; + } + return true; + }); +} + +export function getBlockTypePickerValue(state) { + const keys = []; + forEachTextblockInSelection(state, (node) => { + if (node.type.name === 'heading') { + keys.push(`heading-${node.attrs.level}`); + } else { + keys.push(SCHEMA_NODE_TO_ID.get(node.type.name) ?? node.type.name); + } + }); + const uniq = [...new Set(keys)]; + if (uniq.length === 0) return 'paragraph'; + if (uniq.length > 1) return 'mixed'; + return uniq[0]; +} diff --git a/nx2/blocks/canvas/editor-utils/nx-selection-toolbar.css b/nx2/blocks/canvas/editor-utils/nx-selection-toolbar.css new file mode 100644 index 00000000..412f0d1d --- /dev/null +++ b/nx2/blocks/canvas/editor-utils/nx-selection-toolbar.css @@ -0,0 +1,169 @@ +:host { + display: contents; +} + +.toolbar-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + max-width: min(92vw, 500px); +} + +.toolbar-actions[data-disabled] { + pointer-events: none; + opacity: 0.45; +} + +.toolbar-btn { + box-sizing: border-box; + min-width: 24px; + height: 24px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: var(--s2-corner-radius-100); + background: none; + color: var(--s2-gray-800); + font: inherit; + cursor: pointer; +} + +.toolbar-btn:hover { + background: var(--s2-gray-200); +} + +.toolbar-btn[aria-pressed="true"] { + background: var(--s2-blue-200); + color: var(--s2-blue-900); +} + +.toolbar-btn[aria-pressed="true"]:hover { + background: var(--s2-blue-300); +} + +.toolbar-btn[data-mark="strong"] { + font-weight: 700; +} + +.toolbar-btn[data-mark="em"] { + font-style: italic; +} + +.toolbar-btn[data-mark="code"] { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.7rem; + letter-spacing: -0.02em; + padding: 0; +} + +.toolbar-btn[hidden] { + display: none; +} + +.toolbar-btn svg { + width: 16px; + height: 16px; + display: block; +} + +.toolbar-sep { + width: 1px; + height: 16px; + background: var(--s2-gray-300); + margin: 0 2px; +} + +.toolbar-block-type-wrap { + display: inline-flex; + align-items: center; +} + +/* Link dialog */ + +.link-dialog { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + background: rgb(0 0 0 / 40%); +} + +.link-form { + display: flex; + flex-direction: column; + gap: var(--s2-spacing-100); + padding: var(--s2-spacing-200); + min-width: 340px; + max-width: min(90vw, 420px); + background: var(--s2-gray-25); + border: 1px solid var(--s2-gray-200); + border-radius: var(--s2-corner-radius-500); + box-shadow: 0 8px 32px rgb(0 0 0 / 18%); +} + +.link-form-field { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 0.75rem; + color: var(--s2-gray-700); +} + +.link-form-field input { + box-sizing: border-box; + height: 32px; + padding: 0 var(--s2-spacing-100); + border: 1px solid var(--s2-gray-300); + border-radius: var(--s2-corner-radius-100); + background: var(--s2-gray-50); + color: var(--s2-gray-900); + font: inherit; + font-size: 0.875rem; +} + +.link-form-field input:focus { + outline: 2px solid var(--s2-blue-600); + outline-offset: -1px; + border-color: transparent; +} + +.link-form-actions { + display: flex; + gap: var(--s2-spacing-75); + justify-content: flex-end; + margin-top: var(--s2-spacing-50); +} + +.link-form-actions button { + height: 32px; + padding: 0 16px; + border: none; + border-radius: var(--s2-corner-radius-800); + font: inherit; + font-size: var(--s2-body-size-s, 0.875rem); + font-weight: 600; + cursor: pointer; +} + +.link-form-cancel { + background: var(--s2-gray-200); + color: var(--s2-gray-800); +} + +.link-form-cancel:hover { + background: var(--s2-gray-300); +} + +.link-form-save { + background: var(--s2-blue-900); + color: light-dark(var(--s2-gray-25), #fff); +} + +.link-form-save:hover { + background: var(--s2-blue-1000); +} diff --git a/nx2/blocks/canvas/editor-utils/nx-selection-toolbar.js b/nx2/blocks/canvas/editor-utils/nx-selection-toolbar.js new file mode 100644 index 00000000..41924509 --- /dev/null +++ b/nx2/blocks/canvas/editor-utils/nx-selection-toolbar.js @@ -0,0 +1,311 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { loadStyle } from '../../../utils/utils.js'; +import '../../shared/popover/popover.js'; +import '../../shared/picker/picker.js'; +import { loadHrefSvg } from '../../../utils/svg.js'; +import { commandsFor, COMMAND_BY_ID } from './command-defs.js'; +import { + getBlockTypePickerValue, + selectionHasLink, + getLinkInfoInSelection, + applyLink, + removeLink, +} from './command-helpers.js'; + +const styles = await loadStyle(import.meta.url); + +const ICONS_BASE = new URL('../../img/icons/', import.meta.url).href; + +const MARK_ITEMS = commandsFor('toolbar-marks'); +const STRUCTURE_ITEMS = commandsFor('toolbar-structure'); +const PICKER_DEFS = commandsFor('toolbar-picker'); + +const BLOCK_TYPE_LABELS = new Map(PICKER_DEFS.map(({ id, label }) => [id, label])); + +const BLOCK_TYPE_PICKER_ITEMS = [ + { section: 'Change into' }, + ...PICKER_DEFS.map(({ id, label }) => ({ value: id, label })), +]; + +function blockTypeLabelForRaw(raw) { + if (raw === 'mixed') return 'Mixed'; + return BLOCK_TYPE_LABELS.get(raw) + ?? raw.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +async function loadSvgIcon(name) { + return loadHrefSvg(`${ICONS_BASE}S2_Icon_${name}_20_N.svg`); +} + +class NxSelectionToolbar extends LitElement { + static properties = { + view: { attribute: false }, + _icons: { state: true }, + _linkDialogOpen: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [styles]; + this._loadIcons(); + } + + get _popover() { return this.shadowRoot?.querySelector('nx-popover'); } + + get _picker() { return this.shadowRoot?.querySelector('nx-picker'); } + + show({ x, y }) { + this._popover?.show({ x, y, placement: 'above' }); + this.requestUpdate(); + } + + hide() { + this._popover?.close(); + } + + get open() { + return this._popover?.open ?? false; + } + + async _loadIcons() { + const names = [...MARK_ITEMS.map((i) => i.icon), ...STRUCTURE_ITEMS.map((i) => i.icon), 'Link', 'Unlink']; + const svgs = await Promise.all(names.map(loadSvgIcon)); + this._icons = Object.fromEntries(names.map((n, i) => [n, svgs[i]])); + } + + _icon(name) { + const svg = this._icons?.[name]; + return svg ? html`${svg.cloneNode(true)}` : nothing; + } + + /* ---- Block-type picker ---- */ + + _syncBlockTypePicker() { + const picker = this._picker; + if (!picker || !this.view) return; + const raw = getBlockTypePickerValue(this.view.state); + if (BLOCK_TYPE_LABELS.has(raw)) { + picker.value = raw; + picker.labelOverride = ''; + } else { + picker.value = ''; + picker.labelOverride = blockTypeLabelForRaw(raw); + } + } + + _onBlockTypeChange(e) { + if (!this.view) return; + const cmd = COMMAND_BY_ID.get(e.detail.value); + if (cmd) { + cmd.apply(this.view); + this.requestUpdate(); + this.view.focus(); + } + } + + /* ---- Mark / structure buttons ---- */ + + _onToolbarClick(e) { + e.preventDefault(); + if (!this.view) return; + const btn = e.target instanceof Element ? e.target.closest('button') : null; + if (!btn || btn.disabled) return; + + const { id, link } = btn.dataset; + if (link === 'create' || link === 'edit') { + this._showLinkDialog(); + return; + } + if (link === 'remove') { + removeLink(this.view); + this.requestUpdate(); + this.view.focus(); + return; + } + if (id) { + COMMAND_BY_ID.get(id)?.apply(this.view); + this.requestUpdate(); + this.view.focus(); + } + } + + _isCommandActive(id) { + if (!this.view) return false; + return COMMAND_BY_ID.get(id)?.active?.(this.view.state) ?? false; + } + + _isCommandVisible(id) { + if (!this.view) return true; + const cmd = COMMAND_BY_ID.get(id); + return cmd?.visible ? cmd.visible(this.view.state) : true; + } + + _isCommandDisabled(id) { + if (!this.view) return false; + const cmd = COMMAND_BY_ID.get(id); + return cmd?.disabled ? cmd.disabled(this.view.state) : false; + } + + _hasLink() { + if (!this.view) return false; + return selectionHasLink(this.view.state); + } + + /* ---- Link dialog ---- */ + + _showLinkDialog() { + if (!this.view) return; + this.hide(); + this._linkDialogOpen = true; + } + + _closeLinkDialog() { + this._linkDialogOpen = false; + this.view?.focus(); + } + + _onLinkDialogSubmit(e) { + e.preventDefault(); + if (!this.view) return; + const form = e.target; + const href = form.elements['link-href'].value.trim(); + if (!href) return; + const text = form.elements['link-text'].value; + this._closeLinkDialog(); + applyLink(this.view, { href, text }); + this.view.focus(); + } + + _onLinkBackdropMousedown(e) { + if (e.target === e.currentTarget) this._closeLinkDialog(); + } + + _onLinkBackdropKeydown(e) { + if (e.key === 'Escape') { + e.stopPropagation(); + this._closeLinkDialog(); + } + } + + get linkDialogOpen() { return this._linkDialogOpen ?? false; } + + /* ---- Rendering ---- */ + + updated() { + this._syncBlockTypePicker(); + } + + _renderMarkButton({ id, label, icon }) { + const pressed = this._isCommandActive(id); + return html` + + `; + } + + _renderStructureButton({ id, label, icon }) { + const hidden = !this._isCommandVisible(id); + const disabled = this._isCommandDisabled(id); + return html` + + `; + } + + _renderLinkButtons() { + const hasLink = this._hasLink(); + return html` + + + + `; + } + + _renderLinkDialog() { + if (!this._linkDialogOpen) return nothing; + const info = this.view ? getLinkInfoInSelection(this.view.state) : null; + + let hrefVal = ''; + let textVal = ''; + if (info) { + hrefVal = info.href; + textVal = info.text; + } else if (this.view) { + const { from, to } = this.view.state.selection; + textVal = from !== to ? this.view.state.doc.textBetween(from, to) : ''; + } + + return html` +