From cae32b6db7b70eaad76bbae84d3cb481bc2187f9 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 21 May 2026 17:40:53 +0200 Subject: [PATCH] feat(document-mode): editable task titles, tight inline list layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task chips were atom nodes that just displayed the title and floated as cards, with extra block spacing between them. Now they're content-bearing nodes whose inline content IS the task title — typing inside one edits the linked task. - taskRef switches from atom to content: 'inline*', exposes contentDOM on the .title span so ProseMirror manages the editable content. - isDone moves to an attr (instead of read-from-cache) so undo/redo carries it; the checkbox dispatches a setNodeAttribute transaction. - buildSeedDoc populates each taskRef with the task title from cache. - migrateStoredDoc walks older atom-style stored docs and backfills content from the cache so they load under the new schema. - Title write-back: walking the doc on each onUpdate, per-task debounced updateTask. pendingTitleWrites + isTaskRefFocused guard the reconciler from clobbering an active edit with the echo of our own write (or an unrelated ANY_TASK_UPDATE). - ANY_TASK_UPDATE now refreshes only the affected node via a precise replaceWith on the taskRef's inline range — preserves cursor on surrounding blocks. - CSS: zero margin between consecutive taskRefs, baseline-aligned checkbox, no card background — the chip reads as a list item. --- .../plugin-dev/document-mode/src/ui/editor.ts | 253 +++++++++++++++--- .../document-mode/src/ui/index.html | 46 ++-- 2 files changed, 247 insertions(+), 52 deletions(-) diff --git a/packages/plugin-dev/document-mode/src/ui/editor.ts b/packages/plugin-dev/document-mode/src/ui/editor.ts index 1e1c4bb84a..1136804012 100644 --- a/packages/plugin-dev/document-mode/src/ui/editor.ts +++ b/packages/plugin-dev/document-mode/src/ui/editor.ts @@ -43,71 +43,85 @@ let isLoadingDoc = false; /* taskRef node */ /* -------------------------------------------------------------------------- */ +/** + * taskRef is a content-bearing block node — its inline content IS the task + * title, so typing inside it edits the linked task. We debounce write-back + * to PluginAPI.updateTask and reconcile against ANY_TASK_UPDATE events + * from the host without clobbering an active edit. + */ const TaskRefNode = Node.create({ name: 'taskRef', group: 'block', - atom: true, + content: 'inline*', selectable: true, draggable: true, addAttributes() { return { taskId: { default: '' }, + isDone: { + default: false, + parseHTML: (el: HTMLElement) => el.getAttribute('data-done') === 'true', + renderHTML: (attrs) => ({ 'data-done': attrs.isDone ? 'true' : 'false' }), + }, }; }, parseHTML() { return [ { tag: 'div[data-task-ref]', - getAttrs: (el: HTMLElement | string): { taskId: string } | false => { + getAttrs: (el: HTMLElement | string) => { if (typeof el === 'string') return false; - const taskId = el.getAttribute('data-task-id') || ''; - return { taskId }; + return { + taskId: el.getAttribute('data-task-id') || '', + isDone: el.getAttribute('data-done') === 'true', + }; }, }, ]; }, - renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + renderHTML({ HTMLAttributes }) { return [ 'div', mergeAttributes(HTMLAttributes, { 'data-task-ref': '', - 'data-task-id': HTMLAttributes.taskId as string, + 'data-task-id': HTMLAttributes.taskId, class: 'task-ref', }), + 0, ]; }, addNodeView() { - return ({ node, getPos, editor: viewEditor }: NodeViewRendererProps) => { + return ({ node, editor: viewEditor, getPos }: NodeViewRendererProps) => { const dom = document.createElement('div'); dom.className = 'task-ref'; dom.dataset.taskRef = ''; dom.dataset.taskId = node.attrs.taskId; - dom.contentEditable = 'false'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; + checkbox.contentEditable = 'false'; const title = document.createElement('span'); title.className = 'title'; - const render = (): void => { - const taskId = node.attrs.taskId as string; + const applyState = (n: ProseMirrorNode): void => { + const taskId = n.attrs.taskId as string; const task = taskCache.get(taskId); if (!task) { dom.classList.add('is-missing'); dom.classList.remove('is-done'); checkbox.checked = false; checkbox.disabled = true; - title.textContent = '(task not found)'; } else { dom.classList.remove('is-missing'); - dom.classList.toggle('is-done', !!task.isDone); - checkbox.checked = !!task.isDone; + const done = !!(n.attrs.isDone || task.isDone); + dom.classList.toggle('is-done', done); + checkbox.checked = done; checkbox.disabled = false; - title.textContent = task.title || '(untitled)'; } }; - checkbox.addEventListener('click', (ev) => { + checkbox.addEventListener('mousedown', (ev) => { + ev.preventDefault(); ev.stopPropagation(); const taskId = node.attrs.taskId as string; const task = taskCache.get(taskId); @@ -117,26 +131,27 @@ const TaskRefNode = Node.create({ PluginAPI.log.err('updateTask failed', err); }); taskCache.set(taskId, { ...task, isDone: next }); - render(); - }); - - dom.addEventListener('click', (ev) => { - if (ev.target === checkbox) return; + // Reflect on attr so undo stack carries it. const pos = typeof getPos === 'function' ? getPos() : null; - if (pos === null || pos === undefined) return; - viewEditor.commands.setNodeSelection(pos); + if (pos !== null && pos !== undefined) { + const tr = viewEditor.state.tr.setNodeAttribute(pos, 'isDone', next); + viewEditor.view.dispatch(tr); + } else { + applyState(node); + } }); dom.appendChild(checkbox); dom.appendChild(title); - render(); + applyState(node); return { dom, + contentDOM: title, update: (updatedNode: ProseMirrorNode): boolean => { if (updatedNode.type.name !== 'taskRef') return false; if (updatedNode.attrs.taskId !== node.attrs.taskId) return false; - render(); + applyState(updatedNode); return true; }, }; @@ -201,11 +216,22 @@ const scheduleSave = (): void => { /* Seed + task sync */ /* -------------------------------------------------------------------------- */ -const buildSeedDoc = (ctx: ActiveWorkContext): unknown => { - const taskNodes = ctx.taskIds.map((taskId) => ({ +/** + * Build a fresh doc from the work-context's task list. Task titles are + * pulled from the cache (populated by refreshTaskCache before this is + * called) so the taskRef nodes have content, not just IDs. + */ +const taskRefNodeJSON = (taskId: string): unknown => { + const task = taskCache.get(taskId); + const title = task?.title || ''; + return { type: 'taskRef', - attrs: { taskId }, - })); + attrs: { taskId, isDone: !!task?.isDone }, + content: title ? [{ type: 'text', text: title }] : [], + }; +}; + +const buildSeedDoc = (ctx: ActiveWorkContext): unknown => { return { type: 'doc', content: [ @@ -214,12 +240,60 @@ const buildSeedDoc = (ctx: ActiveWorkContext): unknown => { attrs: { level: 1 }, content: [{ type: 'text', text: ctx.title }], }, - ...taskNodes, + ...ctx.taskIds.map(taskRefNodeJSON), { type: 'paragraph' }, ], }; }; +type PMText = { type: 'text'; text: string }; +type PMNode = { + type?: string; + attrs?: Record; + content?: (PMNode | PMText)[]; + text?: string; +}; + +/** + * Older docs stored taskRef as an atom node (no `content` array). Walk the + * stored JSON and populate content from the task cache so the new + * content-bearing schema can load them. Idempotent — nodes that already + * have content are left alone. + */ +const migrateStoredDoc = (raw: unknown): unknown => { + const visit = (node: PMNode | PMText | undefined): PMNode | PMText | undefined => { + if (!node || typeof node !== 'object') return node; + if ('text' in node) return node; + if (node.type === 'taskRef') { + const taskId = (node.attrs?.taskId as string) || ''; + const task = taskCache.get(taskId); + const hasContent = Array.isArray(node.content) && node.content.length > 0; + return { + ...node, + attrs: { + taskId, + isDone: (node.attrs?.isDone as boolean) ?? !!task?.isDone, + }, + content: hasContent + ? node.content + : task?.title + ? [{ type: 'text', text: task.title }] + : [], + }; + } + if (Array.isArray(node.content)) { + return { + ...node, + content: node.content + .map(visit) + .filter((n): n is PMNode | PMText => n !== undefined), + }; + } + return node; + }; + return visit(raw as PMNode); +}; + const refreshTaskCache = async (): Promise => { try { const tasks = await PluginAPI.getTasks(); @@ -238,7 +312,7 @@ const setActiveContext = async (ctx: ActiveWorkContext | null): Promise => await refreshTaskCache(); const stored = storedState.docs[ctx.id]; - const docJson = stored ?? buildSeedDoc(ctx); + const docJson = stored ? migrateStoredDoc(stored) : buildSeedDoc(ctx); try { editor.commands.setContent( docJson as Parameters[0], @@ -277,12 +351,124 @@ const appendMissingTask = (taskId: string): void => { .run(); }; +/** + * Per-task debouncers for writing edited titles back to the host. Pending + * writes prevent ANY_TASK_UPDATE echoes from clobbering the user's typing. + */ +const titleWriteTimers = new Map>(); +const pendingTitleWrites = new Set(); + +const writeTitleBack = (taskId: string, newTitle: string): void => { + const existing = titleWriteTimers.get(taskId); + if (existing) clearTimeout(existing); + pendingTitleWrites.add(taskId); + titleWriteTimers.set( + taskId, + setTimeout(() => { + titleWriteTimers.delete(taskId); + PluginAPI.updateTask(taskId, { title: newTitle }) + .then(() => { + const cached = taskCache.get(taskId); + if (cached) taskCache.set(taskId, { ...cached, title: newTitle }); + }) + .catch((err) => { + PluginAPI.log.err('updateTask (title) failed', err); + }) + .finally(() => { + // Keep the "pending" marker briefly to absorb the echo from our + // own write that will arrive via ANY_TASK_UPDATE. + setTimeout(() => pendingTitleWrites.delete(taskId), 500); + }); + }, 600), + ); +}; + +/** + * Walk all taskRef nodes in the current doc and emit write-backs for any + * whose inline content drifted from the task cache. + */ +const reconcileTitlesFromDoc = (): void => { + if (!editor || isLoadingDoc) return; + editor.state.doc.descendants((node) => { + if (node.type.name !== 'taskRef') return; + const taskId = node.attrs.taskId as string; + if (!taskId) return; + const docTitle = node.textContent; + const cached = taskCache.get(taskId); + if (!cached) return; + if (docTitle !== cached.title) { + writeTitleBack(taskId, docTitle); + } + }); +}; + +const isTaskRefFocused = (taskId: string): boolean => { + if (!editor) return false; + const { from, to } = editor.state.selection; + let focused = false; + editor.state.doc.nodesBetween(from, to, (node) => { + if (node.type.name === 'taskRef' && node.attrs.taskId === taskId) { + focused = true; + return false; + } + return undefined; + }); + return focused; +}; + +/** + * Refresh inline content + isDone attr for one taskRef from the cache. + * Skips nodes that the user is currently editing or that have a pending + * write-back (so we don't undo their typing). + */ +const refreshTaskRef = (taskId: string): void => { + if (!editor) return; + if (pendingTitleWrites.has(taskId)) return; + if (isTaskRefFocused(taskId)) return; + const task = taskCache.get(taskId); + if (!task) return; + + const updates: { pos: number; nodeSize: number; node: ProseMirrorNode }[] = []; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'taskRef' && node.attrs.taskId === taskId) { + updates.push({ pos, nodeSize: node.nodeSize, node }); + return false; + } + return undefined; + }); + if (updates.length === 0) return; + + const tr = editor.state.tr; + for (const { pos, nodeSize, node } of updates) { + if (node.attrs.isDone !== !!task.isDone) { + tr.setNodeAttribute(pos, 'isDone', !!task.isDone); + } + if (node.textContent !== task.title) { + const schema = editor.schema; + const titleText = task.title || ''; + const newContent = titleText ? schema.text(titleText) : null; + // Replace inline content of the node: positions are [pos+1, pos+nodeSize-1]. + const from = pos + 1; + const to = pos + nodeSize - 1; + if (newContent) { + tr.replaceWith(from, to, newContent); + } else { + tr.delete(from, to); + } + } + } + if (tr.docChanged) { + isLoadingDoc = true; + editor.view.dispatch(tr); + isLoadingDoc = false; + } +}; + const onAnyTaskUpdate = (payload: AnyTaskUpdatePayload): void => { if (!currentCtx || !editor) return; void refreshTaskCache().then(() => { - if (editor) { - const tr = editor.state.tr.setMeta('taskRefRefresh', true); - editor.view.dispatch(tr); + if (payload.taskId) { + refreshTaskRef(payload.taskId); } }); if (payload.task && payload.taskId) { @@ -646,6 +832,7 @@ const mount = async (): Promise => { ], content: { type: 'doc', content: [{ type: 'paragraph' }] }, onUpdate: () => { + reconcileTitlesFromDoc(); scheduleSave(); }, }); diff --git a/packages/plugin-dev/document-mode/src/ui/index.html b/packages/plugin-dev/document-mode/src/ui/index.html index 22fc58d4ce..5b706e6e0f 100644 --- a/packages/plugin-dev/document-mode/src/ui/index.html +++ b/packages/plugin-dev/document-mode/src/ui/index.html @@ -36,6 +36,12 @@ .ProseMirror > * { margin: 4px 0; } + .ProseMirror > [data-task-ref] { + margin: 0; + } + .ProseMirror > [data-task-ref] + [data-task-ref] { + margin-top: 0; + } .ProseMirror > h1 { font-size: 1.8em; font-weight: 700; @@ -100,46 +106,48 @@ pointer-events: none; } - /* taskRef chip */ + /* taskRef — editable list-item, not a card */ .task-ref { display: flex; - align-items: center; - gap: 10px; - padding: 4px 8px; - margin: 1px 0; - border-radius: 4px; - cursor: pointer; - user-select: none; - line-height: 1.4; - } - .task-ref:hover { - background: var(--task-c-bg, rgba(0, 0, 0, 0.04)); + align-items: baseline; + gap: 8px; + padding: 1px 2px; + margin: 0; + line-height: 1.5; + background: transparent; + border-radius: 3px; } .ProseMirror-selectednode.task-ref { - background: var(--task-c-selected-bg, rgba(63, 81, 181, 0.12)); - outline: 2px solid var(--c-primary, #3f51b5); + background: var(--task-c-selected-bg, rgba(63, 81, 181, 0.08)); + outline: 1px solid var(--c-primary, #3f51b5); outline-offset: -1px; } .task-ref input[type='checkbox'] { flex-shrink: 0; accent-color: var(--c-primary, #3f51b5); cursor: pointer; + margin: 0; + position: relative; + top: 2px; } .task-ref .title { flex: 1; min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + outline: none; + word-break: break-word; + white-space: pre-wrap; } .task-ref.is-done .title { text-decoration: line-through; - opacity: var(--task-is-done-dim-opacity, 0.45); + opacity: var(--task-is-done-dim-opacity, 0.5); } - .task-ref.is-missing { + .task-ref.is-missing .title { opacity: 0.4; font-style: italic; } + .task-ref.is-missing .title:empty::before { + content: '(task not found)'; + } /* Block hover gutter */ .block-gutter {