Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions WORKLOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions nx/public/plugins/quick-edit/src/prose.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
265 changes: 265 additions & 0 deletions nx2/blocks/canvas/editor-utils/command-defs.js
Original file line number Diff line number Diff line change
@@ -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]);
}
Loading
Loading