mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-26 09:23:50 +00:00
feat(document-mode): editable task titles, tight inline list layout
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.
This commit is contained in:
parent
73487e5c17
commit
cae32b6db7
2 changed files with 247 additions and 52 deletions
|
|
@ -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<string, unknown> }) {
|
||||
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<string, unknown>;
|
||||
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<void> => {
|
||||
try {
|
||||
const tasks = await PluginAPI.getTasks();
|
||||
|
|
@ -238,7 +312,7 @@ const setActiveContext = async (ctx: ActiveWorkContext | null): Promise<void> =>
|
|||
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<typeof editor.commands.setContent>[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<string, ReturnType<typeof setTimeout>>();
|
||||
const pendingTitleWrites = new Set<string>();
|
||||
|
||||
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<void> => {
|
|||
],
|
||||
content: { type: 'doc', content: [{ type: 'paragraph' }] },
|
||||
onUpdate: () => {
|
||||
reconcileTitlesFromDoc();
|
||||
scheduleSave();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue