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` + + `; + } + + render() { + const disabled = !this.view; + return html` + +
{ e.preventDefault(); e.stopPropagation(); }} + @click=${(e) => this._onToolbarClick(e)}> + + this._onBlockTypeChange(e)} + > + + + ${MARK_ITEMS.map((m) => this._renderMarkButton(m))} + + ${STRUCTURE_ITEMS.map((s) => this._renderStructureButton(s))} + + ${this._renderLinkButtons()} +
+
+ ${this._renderLinkDialog()} + `; + } +} + +customElements.define('nx-selection-toolbar', NxSelectionToolbar); + +export default NxSelectionToolbar; diff --git a/nx2/blocks/canvas/editor-utils/selection-toolbar.js b/nx2/blocks/canvas/editor-utils/selection-toolbar.js new file mode 100644 index 00000000..36f84794 --- /dev/null +++ b/nx2/blocks/canvas/editor-utils/selection-toolbar.js @@ -0,0 +1,57 @@ +/* eslint-disable import/no-unresolved -- importmap */ +import { Plugin } from 'da-y-wrapper'; + +export const TOOLBAR_PADDING_GAP = 64; + +let toolbar; +let componentLoaded; + +export function getSelectionToolbar() { + if (toolbar) return toolbar; + componentLoaded ??= import('./nx-selection-toolbar.js'); + toolbar = document.createElement('nx-selection-toolbar'); + document.body.append(toolbar); + return toolbar; +} + +export function hideSelectionToolbar() { + toolbar?.hide(); +} + +function syncToolbar(view) { + const tb = getSelectionToolbar(); + if (tb.linkDialogOpen) return; + if (view.state.selection.empty) { + hideSelectionToolbar(); + return; + } + const start = view.coordsAtPos(view.state.selection.from); + tb.view = view; + tb.show({ x: start.left, y: start.top - TOOLBAR_PADDING_GAP }); +} + +export function createSelectionToolbarPlugin() { + return new Plugin({ + view() { + let scrollEl; + const tb = getSelectionToolbar(); + const onScroll = () => syncToolbar(tb.view); + + return { + update(view) { + if (!scrollEl) { + scrollEl = view.dom.closest('.nx-editor-doc'); + scrollEl?.addEventListener('scroll', onScroll, { passive: true }); + } + const header = document.querySelector('nx-canvas-header'); + if (header?.editorView !== 'content') return; + syncToolbar(view); + }, + destroy() { + scrollEl?.removeEventListener('scroll', onScroll); + hideSelectionToolbar(); + }, + }; + }, + }); +} diff --git a/nx2/blocks/canvas/nx-editor-doc/nx-editor-doc.js b/nx2/blocks/canvas/nx-editor-doc/nx-editor-doc.js index 93bd375f..afddc02c 100644 --- a/nx2/blocks/canvas/nx-editor-doc/nx-editor-doc.js +++ b/nx2/blocks/canvas/nx-editor-doc/nx-editor-doc.js @@ -11,15 +11,15 @@ import { import { subscribeCollabUserList } from './utils/awareness-users.js'; import { prefetchWysiwygCookiesIfSignedIn, - createQuickEditGetToken, - buildQuickEditControllerCtx, wireQuickEditControllerPort, } from './utils/quick-edit-host.js'; +import { loadIms } from '../../../utils/ims.js'; import initProse from './prose.js'; import { createTrackingPlugin } from '../editor-utils/prose-diff.js'; import { resolveEditorDocSession } from './utils/load-editor-doc.js'; import { afterNextPaint, ensureProseMountedInShadow } from './utils/shadow-mount.js'; import { teardownEditorDocResources } from './utils/teardown.js'; +import { hideSelectionToolbar } from '../editor-utils/selection-toolbar.js'; const style = await loadStyle(import.meta.url); @@ -65,16 +65,17 @@ export class NxEditorDoc extends LitElement { prefetchWysiwygCookiesIfSignedIn(this.ctx); const { org, repo } = this.ctx ?? {}; - const getToken = createQuickEditGetToken(); - this._controllerCtx = buildQuickEditControllerCtx({ + this._controllerCtx = { view, wsProvider, port: this.quickEditPort, + iframe: this._wysiwygIframe, + suppressRerender: false, owner: org, repo, - pathname: controllerPathnameFromEditorCtx(this.ctx), - getToken, - }); + path: controllerPathnameFromEditorCtx(this.ctx), + getToken: async () => (await loadIms())?.accessToken?.token ?? null, + }; wireQuickEditControllerPort(this._controllerCtx); } @@ -159,11 +160,15 @@ export class NxEditorDoc extends LitElement { this._onCanvasEditorActive = (e) => { const view = e.detail?.view === 'content' ? 'content' : 'layout'; this.hidden = view !== 'content'; + hideSelectionToolbar(); }; this.parentElement?.addEventListener('nx-canvas-editor-active', this._onCanvasEditorActive); this._onWysiwygPortReady = (e) => { - const port = e.detail?.port; - if (port) this.quickEditPort = port; + const { port, iframe } = e.detail ?? {}; + if (port) { + this._wysiwygIframe = iframe; + this.quickEditPort = port; + } }; this.parentElement?.addEventListener('nx-wysiwyg-port-ready', this._onWysiwygPortReady); } diff --git a/nx2/blocks/canvas/nx-editor-doc/prose.js b/nx2/blocks/canvas/nx-editor-doc/prose.js index 1fbe2b17..d1a1a5b6 100644 --- a/nx2/blocks/canvas/nx-editor-doc/prose.js +++ b/nx2/blocks/canvas/nx-editor-doc/prose.js @@ -113,10 +113,12 @@ export default async function initProse({ }, { getHeadingKeymap }, { createSlashMenuPlugin }, + { createSelectionToolbarPlugin }, ] = await Promise.all([ import('https://da.live/blocks/edit/prose/plugins/keyHandlers.js'), import('https://da.live/blocks/edit/prose/plugins/menu/menu.js'), import('./slash-menu/slash-menu.js'), + import('../editor-utils/selection-toolbar.js'), ]); const dispatch = (tr) => { @@ -142,6 +144,7 @@ export default async function initProse({ plugins.push( createSlashMenuPlugin(), + createSelectionToolbarPlugin(), keymap(baseKeymap), getEnterInputRulesPlugin(dispatch), getURLInputRulesPlugin(), diff --git a/nx2/blocks/canvas/nx-editor-doc/slash-menu/slash-menu-handlers.js b/nx2/blocks/canvas/nx-editor-doc/slash-menu/slash-menu-handlers.js deleted file mode 100644 index 21982bf5..00000000 --- a/nx2/blocks/canvas/nx-editor-doc/slash-menu/slash-menu-handlers.js +++ /dev/null @@ -1,126 +0,0 @@ -/* eslint-disable import/no-unresolved -- importmap */ -import { - DOMParser, - Fragment, - setBlockType, - wrapIn, - wrapInList, -} from 'da-y-wrapper'; - -/** From da-live `blocks/edit/prose/table.js` — empty table with heading row + 2×2 body. */ -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))); -} - -function getTableBody(schema) { - const cell = schema.nodes.table_cell.createAndFill(); - return schema.nodes.table_row.create(null, Fragment.fromArray([cell, cell])); -} - -function getTrailingParagraph(schema) { - const fragment = document.createDocumentFragment(); - fragment.append(document.createElement('p')); - return DOMParser.fromSchema(schema).parse(fragment); -} - -export function insertEmptyTable(state, dispatch) { - const heading = getTableHeading(state.schema); - const content = getTableBody(state.schema); - const para = getTrailingParagraph(state.schema); - const node = state.schema.nodes.table.create(null, Fragment.fromArray([heading, content])); - - if (dispatch) { - const trx = state.tr.insert(state.selection.head, para); - trx.replaceSelectionWith(node).scrollIntoView(); - dispatch(trx); - } - return true; -} - -const setHeading = (state, dispatch, level) => { - const type = state.schema.nodes.heading; - return setBlockType(type, { level })(state, dispatch); -}; - -const wrapInBlockquote = (state, dispatch) => { - const { blockquote } = state.schema.nodes; - return wrapIn(blockquote)(state, dispatch); -}; - -const wrapInCodeBlock = (state, dispatch) => { - // eslint-disable-next-line camelcase - const { code_block } = state.schema.nodes; - return setBlockType(code_block)(state, dispatch); -}; - -/** Mirrors da-live `insertSectionBreak` from `blocks/edit/prose/plugins/menu/menu.js`. */ -export function insertSectionBreak(state, dispatch) { - const div = document.createElement('div'); - div.append(document.createElement('hr'), document.createElement('p')); - const newNodes = DOMParser.fromSchema(state.schema).parse(div); - dispatch(state.tr.replaceSelectionWith(newNodes)); -} - -function generateLoremIpsum(lines = 5) { - const loremSentences = [ - '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.', - ]; - - const result = []; - for (let i = 0; i < lines; i += 1) { - result.push(loremSentences[i % loremSentences.length]); - } - return result.join(' '); -} - -const MAX_LINES = 100; - -export function insertLoremIpsum(state, dispatch, lines = 5) { - const linesInt = Math.min(parseInt(lines, 10) || 5, MAX_LINES); - const { $cursor } = state.selection; - - if (!$cursor) return; - const from = $cursor.before(); - const to = $cursor.pos; - const loremText = generateLoremIpsum(linesInt); - const tr = state.tr.replaceWith(from, to, state.schema.text(loremText)); - dispatch(tr); -} - -function openLibraryPanel() { - document.querySelector('nx-canvas-header')?.dispatchEvent( - new CustomEvent('nx-canvas-open-panel', { - bubbles: true, - composed: true, - detail: { position: 'after' }, - }), - ); -} - -export const SLASH_MENU_HANDLERS = { - 'open-library': () => { - openLibraryPanel(); - }, - 'insert-block': insertEmptyTable, - 'heading-1': (state, dispatch) => setHeading(state, dispatch, 1), - 'heading-2': (state, dispatch) => setHeading(state, dispatch, 2), - 'heading-3': (state, dispatch) => setHeading(state, dispatch, 3), - blockquote: wrapInBlockquote, - 'code-block': wrapInCodeBlock, - 'bullet-list': (state, dispatch) => wrapInList(state.schema.nodes.bullet_list)(state, dispatch), - 'numbered-list': (state, dispatch) => wrapInList(state.schema.nodes.ordered_list)(state, dispatch), - 'section-break': insertSectionBreak, - 'lorem-ipsum': insertLoremIpsum, -}; diff --git a/nx2/blocks/canvas/nx-editor-doc/slash-menu/slash-menu-items.js b/nx2/blocks/canvas/nx-editor-doc/slash-menu/slash-menu-items.js deleted file mode 100644 index 26f7a402..00000000 --- a/nx2/blocks/canvas/nx-editor-doc/slash-menu/slash-menu-items.js +++ /dev/null @@ -1,41 +0,0 @@ -export const SLASH_MENU_GROUPS = [ - { - section: 'Blocks', - items: [ - { id: 'open-library', label: 'Open library', icon: 'CCLibrary' }, - { id: 'insert-block', label: 'Insert block', icon: 'TableAdd' }, - ], - }, - { - section: 'Text', - items: [ - { id: 'heading-1', label: 'Heading 1', icon: 'Heading1' }, - { id: 'heading-2', label: 'Heading 2', icon: 'Heading2' }, - { id: 'heading-3', label: 'Heading 3', icon: 'Heading3' }, - { id: 'blockquote', label: 'Blockquote', icon: 'BlockQuote' }, - { id: 'code-block', label: 'Code block', icon: 'BlockCode' }, - { id: 'bullet-list', label: 'Bullet list', icon: 'ListBulleted' }, - { id: 'numbered-list', label: 'Numbered list', icon: 'ListNumbered' }, - { id: 'section-break', label: 'Section break', icon: 'Separator' }, - { id: 'lorem-ipsum', label: 'Lorem ipsum', icon: 'Rail' }, - ], - }, -]; - -function flatten(groups) { - return groups.flatMap(({ section, items }) => [{ section }, ...items]); -} - -/** Flat list for `nx-menu`: `{ section }` rows plus `{ id, label, icon }` rows. */ -export function slashMenuItemsForQuery(query) { - const q = (query || '').toLowerCase(); - if (!q) return flatten(SLASH_MENU_GROUPS); - return flatten( - SLASH_MENU_GROUPS - .map(({ section, items }) => ({ - section, - items: items.filter((i) => i.label.toLowerCase().startsWith(q)), - })) - .filter((g) => g.items.length > 0), - ); -} diff --git a/nx2/blocks/canvas/nx-editor-doc/slash-menu/slash-menu.js b/nx2/blocks/canvas/nx-editor-doc/slash-menu/slash-menu.js index 5ad4fb94..f0674232 100644 --- a/nx2/blocks/canvas/nx-editor-doc/slash-menu/slash-menu.js +++ b/nx2/blocks/canvas/nx-editor-doc/slash-menu/slash-menu.js @@ -1,8 +1,7 @@ /* eslint-disable import/no-unresolved -- importmap */ import { Plugin } from 'da-y-wrapper'; import '../../../shared/menu/menu.js'; -import { slashMenuItemsForQuery } from './slash-menu-items.js'; -import { SLASH_MENU_HANDLERS } from './slash-menu-handlers.js'; +import { slashMenuItemsForQuery, COMMAND_BY_ID } from '../../editor-utils/command-defs.js'; function inTopLevelParagraph($from) { if ($from.parent.type.name !== 'paragraph') return false; @@ -48,7 +47,7 @@ function setup(container, view) { container.append(menu); menu.addEventListener('select', (e) => { - const run = SLASH_MENU_HANDLERS[e.detail.id]; + const run = COMMAND_BY_ID.get(e.detail.id)?.apply; const { state } = view; const slash = getSlashContext(state); if (slash && run) { @@ -56,7 +55,7 @@ function setup(container, view) { const head = state.selection.from; const tr = state.tr.delete(anchorPos, head); view.dispatch(tr); - run(view.state, view.dispatch.bind(view)); + run(view); } view.focus(); }); diff --git a/nx2/blocks/canvas/nx-editor-doc/utils/quick-edit-host.js b/nx2/blocks/canvas/nx-editor-doc/utils/quick-edit-host.js index dd6dabc3..484bd36e 100644 --- a/nx2/blocks/canvas/nx-editor-doc/utils/quick-edit-host.js +++ b/nx2/blocks/canvas/nx-editor-doc/utils/quick-edit-host.js @@ -14,34 +14,6 @@ export function prefetchWysiwygCookiesIfSignedIn(ctx) { })().catch(() => {}); } -export function createQuickEditGetToken() { - return async () => { - const { loadIms } = await import('../../../../utils/ims.js'); - return (await loadIms())?.accessToken?.token ?? null; - }; -} - -export function buildQuickEditControllerCtx({ - view, - wsProvider, - port, - owner, - repo, - pathname, - getToken, -}) { - return { - view, - wsProvider, - port, - suppressRerender: false, - owner, - repo, - path: pathname, - getToken, - }; -} - export function wireQuickEditControllerPort(controllerCtx) { controllerCtx.port.onmessage = createControllerOnMessage(controllerCtx); const sendInitialBodyAndCursors = () => { diff --git a/nx2/blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.js b/nx2/blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.js index 5df5a183..d24560b4 100644 --- a/nx2/blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.js +++ b/nx2/blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.js @@ -2,6 +2,7 @@ import { LitElement, html } from 'da-lit'; import { loadStyle } from '../../../utils/utils.js'; import { getPreviewOrigin, fetchWysiwygCookie } from '../editor-utils/preview.js'; import { loadIms } from '../../../utils/ims.js'; +import { hideSelectionToolbar } from '../editor-utils/selection-toolbar.js'; const style = await loadStyle(import.meta.url); @@ -70,7 +71,8 @@ export class NxEditorWysiwyg extends LitElement { const segments = path.split('/'); const pathWithoutOrgRepo = segments.slice(2).join('/'); const encodedPath = pathWithoutOrgRepo.split('/').map(encodeURIComponent).join('/'); - const base = `${getPreviewOrigin(org, repo)}/${encodedPath}?nx=ew&quick-edit=ew`; + const quickEdit = new URLSearchParams(window.location.search).get('quick-edit') || 'ew'; + const base = `${getPreviewOrigin(org, repo)}/${encodedPath}?nx=ew&quick-edit=${encodeURIComponent(quickEdit)}`; return `${base}&controller=parent`; } @@ -97,6 +99,7 @@ export class NxEditorWysiwyg extends LitElement { const view = this._canvasActiveView ?? 'layout'; const portReady = this.hasAttribute(WYSIWYG_PORT_READY_ATTR); this.hidden = view !== 'layout' || !portReady; + hideSelectionToolbar(); } _resetCookieStateForCtxChange() { @@ -129,10 +132,11 @@ export class NxEditorWysiwyg extends LitElement { this._clearQuickEditRetry(); this.setAttribute(WYSIWYG_PORT_READY_ATTR, ''); this._syncCanvasVisibility(); + const iframe = this.shadowRoot?.querySelector('iframe'); this.dispatchEvent(new CustomEvent('nx-wysiwyg-port-ready', { bubbles: true, composed: true, - detail: { port }, + detail: { port, iframe }, })); } @@ -188,6 +192,10 @@ export class NxEditorWysiwyg extends LitElement { this._scheduleQuickEditInitRetries(send); } + _onIframeBlur() { + hideSelectionToolbar(); + } + render() { const { org, repo, path } = this.ctx ?? {}; const hasPath = org && repo && path; @@ -207,6 +215,7 @@ export class NxEditorWysiwyg extends LitElement { allow="local-network-access" class="nx-editor-wysiwyg-iframe" @load=${this._onIframeLoad} + @blur=${this._onIframeBlur} > `; } diff --git a/nx2/blocks/canvas/nx-editor-wysiwyg/quick-edit-controller.js b/nx2/blocks/canvas/nx-editor-wysiwyg/quick-edit-controller.js index 148dd7f7..16b5e698 100644 --- a/nx2/blocks/canvas/nx-editor-wysiwyg/quick-edit-controller.js +++ b/nx2/blocks/canvas/nx-editor-wysiwyg/quick-edit-controller.js @@ -1,13 +1,17 @@ import { updateDocument } from '../editor-utils/document.js'; import { updateState, getEditor } from '../editor-utils/state.js'; +import { hideSelectionToolbar } from '../editor-utils/selection-toolbar.js'; import { handleImageReplace } from './utils/image.js'; import { - handleCursorMove, handleUndoRedo, + handleCursorMove, + handleUndoRedo, + handleIframeSelectionChange, } from './utils/handlers.js'; export function createControllerOnMessage(ctx) { return function onMessage(e) { if (e.data.type === 'cursor-move') { + hideSelectionToolbar(); handleCursorMove(e.data, ctx); } else if (e.data.type === 'reload') { updateDocument(ctx); @@ -19,6 +23,8 @@ export function createControllerOnMessage(ctx) { updateState(e.data, ctx); } else if (e.data.type === 'history') { handleUndoRedo(e.data, ctx); + } else if (e.data.type === 'selection-change') { + handleIframeSelectionChange(e.data, ctx); } }; } diff --git a/nx2/blocks/canvas/nx-editor-wysiwyg/utils/handlers.js b/nx2/blocks/canvas/nx-editor-wysiwyg/utils/handlers.js index 5aa15806..c0432d4f 100644 --- a/nx2/blocks/canvas/nx-editor-wysiwyg/utils/handlers.js +++ b/nx2/blocks/canvas/nx-editor-wysiwyg/utils/handlers.js @@ -1,4 +1,9 @@ import { TextSelection, yUndo, yRedo } from 'da-y-wrapper'; +import { + getSelectionToolbar, + hideSelectionToolbar, + TOOLBAR_PADDING_GAP, +} from '../../editor-utils/selection-toolbar.js'; import { getActiveBlockFlatIndex } from './blocks.js'; export function handleCursorMove({ cursorOffset, textCursorOffset }, ctx) { @@ -95,7 +100,7 @@ export function handleStoredMarks({ marks }, ctx) { export function handleSelectionChange({ anchor, head }, ctx) { const { view } = ctx; - if (!view) return; + if (!view) return false; const { state } = view; try { const a = Math.max(0, Math.min(anchor, state.doc.content.size)); @@ -105,8 +110,35 @@ export function handleSelectionChange({ anchor, head }, ctx) { ctx.suppressRerender = true; view.dispatch(tr); ctx.suppressRerender = false; + return true; } catch (e) { // eslint-disable-next-line no-console console.error('[quick-edit-controller] handleSelectionChange failed', e?.message); + return false; + } +} + +function positionSelectionToolbarFromIframe(data, ctx) { + const { view } = ctx; + const { anchorX, anchorY } = data; + const { iframe } = ctx; + if (!iframe) return; + + const iframeRect = iframe.getBoundingClientRect(); + const x = iframeRect.left + anchorX; + const y = iframeRect.top + anchorY - TOOLBAR_PADDING_GAP; + const tb = getSelectionToolbar(); + tb.view = view; + tb.show({ x, y }); +} + +/** PostMessage `selection-change` from wysiwyg iframe: sync PM selection and toolbar. */ +export function handleIframeSelectionChange(data, ctx) { + const { anchor, head } = data; + if (anchor === head) { + hideSelectionToolbar(); + return; } + if (!handleSelectionChange(data, ctx)) return; + positionSelectionToolbarFromIframe(data, ctx); } diff --git a/nx2/blocks/img/icons/S2_Icon_Code_20_N.svg b/nx2/blocks/img/icons/S2_Icon_Code_20_N.svg new file mode 100644 index 00000000..625b4d42 --- /dev/null +++ b/nx2/blocks/img/icons/S2_Icon_Code_20_N.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/nx2/blocks/img/icons/S2_Icon_Link_20_N.svg b/nx2/blocks/img/icons/S2_Icon_Link_20_N.svg new file mode 100644 index 00000000..2f54b8a4 --- /dev/null +++ b/nx2/blocks/img/icons/S2_Icon_Link_20_N.svg @@ -0,0 +1,3 @@ + + + diff --git a/nx2/blocks/img/icons/S2_Icon_TagBold_20_N.svg b/nx2/blocks/img/icons/S2_Icon_TagBold_20_N.svg new file mode 100644 index 00000000..ded1b999 --- /dev/null +++ b/nx2/blocks/img/icons/S2_Icon_TagBold_20_N.svg @@ -0,0 +1,3 @@ + + + diff --git a/nx2/blocks/img/icons/S2_Icon_TagItalic_20_N.svg b/nx2/blocks/img/icons/S2_Icon_TagItalic_20_N.svg new file mode 100644 index 00000000..436ab3f9 --- /dev/null +++ b/nx2/blocks/img/icons/S2_Icon_TagItalic_20_N.svg @@ -0,0 +1,3 @@ + + + diff --git a/nx2/blocks/img/icons/S2_Icon_TagStrikeThrough_20_N.svg b/nx2/blocks/img/icons/S2_Icon_TagStrikeThrough_20_N.svg new file mode 100644 index 00000000..e3ba30f9 --- /dev/null +++ b/nx2/blocks/img/icons/S2_Icon_TagStrikeThrough_20_N.svg @@ -0,0 +1,4 @@ + + + + diff --git a/nx2/blocks/img/icons/S2_Icon_TagUnderline_20_N.svg b/nx2/blocks/img/icons/S2_Icon_TagUnderline_20_N.svg new file mode 100644 index 00000000..9022c1ed --- /dev/null +++ b/nx2/blocks/img/icons/S2_Icon_TagUnderline_20_N.svg @@ -0,0 +1,4 @@ + + + + diff --git a/nx2/blocks/img/icons/S2_Icon_TextIndentDecrease_20_N.svg b/nx2/blocks/img/icons/S2_Icon_TextIndentDecrease_20_N.svg new file mode 100644 index 00000000..c49e2490 --- /dev/null +++ b/nx2/blocks/img/icons/S2_Icon_TextIndentDecrease_20_N.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/nx2/blocks/img/icons/S2_Icon_TextIndentIncrease_20_N.svg b/nx2/blocks/img/icons/S2_Icon_TextIndentIncrease_20_N.svg new file mode 100644 index 00000000..01b3da7e --- /dev/null +++ b/nx2/blocks/img/icons/S2_Icon_TextIndentIncrease_20_N.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/nx2/blocks/img/icons/S2_Icon_Unlink_20_N.svg b/nx2/blocks/img/icons/S2_Icon_Unlink_20_N.svg new file mode 100644 index 00000000..b63fea9f --- /dev/null +++ b/nx2/blocks/img/icons/S2_Icon_Unlink_20_N.svg @@ -0,0 +1,18 @@ + + + + + S Unlink 18 N + + + + + + + + + \ No newline at end of file diff --git a/nx2/blocks/shared/picker/picker.css b/nx2/blocks/shared/picker/picker.css index edc64084..c69c757b 100644 --- a/nx2/blocks/shared/picker/picker.css +++ b/nx2/blocks/shared/picker/picker.css @@ -10,6 +10,17 @@ } } +.picker-section { + padding: var(--s2-spacing-75) var(--s2-spacing-100) var(--s2-spacing-50); + margin: 0; + list-style: none; + font-size: var(--s2-body-size-xs); + font-weight: 600; + letter-spacing: 0.02em; + color: var(--s2-gray-700); + text-transform: none; +} + .picker-trigger { display: inline-flex; align-items: center; diff --git a/nx2/blocks/shared/picker/picker.js b/nx2/blocks/shared/picker/picker.js index 6015c000..65b28384 100644 --- a/nx2/blocks/shared/picker/picker.js +++ b/nx2/blocks/shared/picker/picker.js @@ -14,7 +14,13 @@ class NxPicker extends LitElement { static properties = { items: { attribute: false }, value: {}, + /** + * Non-empty string shown on the trigger instead of the label from `items` for `value`. + * Set to '' to use the normal label lookup again. + */ + labelOverride: { type: String }, _active: { state: true }, + ignoreFocus: { attribute: true }, }; get _popover() { return this.shadowRoot.querySelector('nx-popover'); } @@ -32,6 +38,12 @@ class NxPicker extends LitElement { return this.items?.find((i) => i.value === this.value)?.label ?? ''; } + get _triggerLabel() { + const o = this.labelOverride; + if (typeof o === 'string' && o.length > 0) return o; + return this._selectedLabel; + } + show() { this._popover?.show({ anchor: this._button, @@ -56,9 +68,13 @@ class NxPicker extends LitElement { _onPopoverToggle(e) { if (e.newState !== 'open') return; - this._active = this.value; + const selectable = this.items?.filter((i) => !i.divider && !i.section) ?? []; + const matched = selectable.some((i) => i.value === this.value); + this._active = matched ? this.value : (selectable[0]?.value ?? this.value); this.updateComplete.then(() => { - this.shadowRoot.querySelector(`[data-value="${this._active}"]`)?.focus(); + const key = String(this._active ?? ''); + const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(key) : key.replace(/"/g, '\\"'); + if (!this.ignoreFocus) this.shadowRoot.querySelector(`[data-value="${esc}"]`)?.focus(); }); } @@ -77,6 +93,7 @@ class NxPicker extends LitElement { setActive: (val) => { this._active = val; }, onSelect: (item) => this._select(item), onClose: () => this.close(), + focusActiveItem: !this.ignoreFocus, }); } @@ -86,6 +103,9 @@ class NxPicker extends LitElement { } _renderItem(item) { + if (item.section) { + return html``; + } if (item.divider) return html`

  • `; if (!item.label || item.value === undefined) return nothing; @@ -120,7 +140,7 @@ class NxPicker extends LitElement { aria-expanded="false" @click=${this._toggle} > - ${this._selectedLabel} + ${this._triggerLabel}