mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
ebe531ebaa
The addressing fix in the previous commit could not have worked. Tiptap's `options` is a getter returning a fresh spread on every access (@tiptap/core 3.22.5, dist/index.cjs:3452), so `ext.options.itemId = next` mutates a temporary and is discarded — an assignment that looks exactly like working code. So the address stops being two strings pushed in after the fact and becomes a reader the host supplies once and keeps honest: a closure over its own live props, called at emit time. That is correct for a host that is remounted per item (the body editor) and one that is reused across an item switch (the comment composer) without either knowing which it is. New $lib/attachments/hostAddress.ts states the contract; its test pins the dependency behaviour that forces it, so a future Tiptap bump that makes options writable fails a test instead of quietly inviting the mutation approach back. The event predicate now reuses isAddressable so 'both halves required' is stated once. Also completes the NodeView teardown fence (both MIME probe continuations and swapNodeUuid could run after destroy) and adds the stable-async-confirm and ordering cases the delete tests were missing — without them, a regression dropping every async-confirmed delete passed. The same dependency trap makes the editor's existing capabilities push a no-op; that is pre-existing and independent, filed as BUG-2426. All three from the orchestrator's second fresh-angle Codex pass.
2114 lines
78 KiB
Svelte
2114 lines
78 KiB
Svelte
<script lang="ts">
|
||
import { onMount, onDestroy, untrack } from 'svelte';
|
||
import { page } from '$app/state';
|
||
import { Editor, mergeAttributes } from '@tiptap/core';
|
||
import { Plugin } from '@tiptap/pm/state';
|
||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||
import StarterKit from '@tiptap/starter-kit';
|
||
import { Collaboration } from '@tiptap/extension-collaboration';
|
||
import { CollaborationCaret } from '@tiptap/extension-collaboration-caret';
|
||
import type { Awareness } from 'y-protocols/awareness';
|
||
import type * as Y from 'yjs';
|
||
import TaskList from '@tiptap/extension-task-list';
|
||
import TaskItem from '@tiptap/extension-task-item';
|
||
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table';
|
||
import {
|
||
CellSelection,
|
||
deleteRow,
|
||
deleteTable,
|
||
isInTable,
|
||
selectionCell,
|
||
findTable,
|
||
TableMap,
|
||
columnResizingPluginKey,
|
||
} from '@tiptap/pm/tables';
|
||
import Link from '@tiptap/extension-link';
|
||
import CodeBlock from '@tiptap/extension-code-block';
|
||
import Placeholder from '@tiptap/extension-placeholder';
|
||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||
|
||
// Serialized mermaid render queue — mermaid can't handle concurrent renders
|
||
let mermaidMod: typeof import('mermaid') | null = null;
|
||
let renderQueue: Promise<void> = Promise.resolve();
|
||
|
||
async function initMermaid() {
|
||
if (!mermaidMod) {
|
||
mermaidMod = await import('mermaid');
|
||
mermaidMod.default.initialize({
|
||
startOnLoad: false,
|
||
theme: 'dark',
|
||
securityLevel: 'strict',
|
||
fontFamily: 'inherit',
|
||
});
|
||
}
|
||
return mermaidMod;
|
||
}
|
||
|
||
function queueMermaidRender(source: string, target: HTMLElement) {
|
||
renderQueue = renderQueue.then(async () => {
|
||
try {
|
||
const m = await initMermaid();
|
||
const id = `mmd-${Math.random().toString(36).slice(2, 10)}`;
|
||
const { svg } = await m.default.render(id, source);
|
||
target.innerHTML = svg;
|
||
// A successful render means the source is now valid — drop any
|
||
// error styling left over from a prior failed render.
|
||
target.classList.remove('mermaid-error');
|
||
} catch {
|
||
target.textContent = '⚠ Invalid Mermaid syntax';
|
||
target.classList.add('mermaid-error');
|
||
}
|
||
});
|
||
}
|
||
|
||
// Chain a diagram-clear through the shared mermaid render queue so it
|
||
// executes AFTER any still-pending renders for the same target. Without
|
||
// this, an in-flight queueMermaidRender() could overwrite a synchronous
|
||
// clear with stale SVG.
|
||
function queueMermaidClear(target: HTMLElement) {
|
||
renderQueue = renderQueue.then(() => {
|
||
target.textContent = '';
|
||
target.classList.remove('mermaid-error');
|
||
});
|
||
}
|
||
|
||
// Build a hover-to-reveal "Copy" button for a code block.
|
||
// Reads the live code text from `codeEl` so it copies edits too.
|
||
function buildCopyButton(codeEl: HTMLElement): HTMLButtonElement {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'code-copy-btn';
|
||
btn.setAttribute('contenteditable', 'false');
|
||
btn.setAttribute('aria-label', 'Copy code');
|
||
btn.title = 'Copy';
|
||
btn.textContent = 'Copy';
|
||
// mousedown + preventDefault avoids stealing focus / clobbering the selection
|
||
btn.addEventListener('mousedown', (e) => e.preventDefault());
|
||
btn.addEventListener('click', async (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const text = codeEl.textContent ?? '';
|
||
const ok = await copyToClipboard(text);
|
||
const prev = btn.textContent;
|
||
btn.textContent = ok ? 'Copied' : 'Failed';
|
||
btn.classList.toggle('copied', ok);
|
||
setTimeout(() => {
|
||
btn.textContent = prev;
|
||
btn.classList.remove('copied');
|
||
}, 1200);
|
||
});
|
||
return btn;
|
||
}
|
||
|
||
// ProseMirror plugin: when the user copies/cuts a selection that lives
|
||
// entirely inside a single code_block node, write the raw code text to the
|
||
// clipboard instead of letting tiptap-markdown wrap it in ``` fences.
|
||
const codeBlockCopyPlugin = new Plugin({
|
||
props: {
|
||
handleDOMEvents: {
|
||
copy: (view, event) => writeCodeBlockClipboard(view, event as ClipboardEvent, false),
|
||
cut: (view, event) => writeCodeBlockClipboard(view, event as ClipboardEvent, true),
|
||
},
|
||
},
|
||
});
|
||
|
||
function writeCodeBlockClipboard(view: any, event: ClipboardEvent, isCut: boolean): boolean {
|
||
const { state } = view;
|
||
const { from, to, empty } = state.selection;
|
||
if (empty) return false;
|
||
|
||
// Find the nearest code_block ancestor of the selection's from position.
|
||
const resolvedFrom = state.doc.resolve(from);
|
||
let codeBlockDepth = -1;
|
||
for (let d = resolvedFrom.depth; d >= 0; d--) {
|
||
if (resolvedFrom.node(d).type.name === 'codeBlock') {
|
||
codeBlockDepth = d;
|
||
break;
|
||
}
|
||
}
|
||
if (codeBlockDepth < 0) return false;
|
||
|
||
// Selection must be entirely within that same code block.
|
||
const blockStart = resolvedFrom.start(codeBlockDepth);
|
||
const blockEnd = resolvedFrom.end(codeBlockDepth);
|
||
if (from < blockStart || to > blockEnd) return false;
|
||
|
||
const text = state.doc.textBetween(from, to, '\n');
|
||
if (!event.clipboardData) return false;
|
||
|
||
event.preventDefault();
|
||
event.clipboardData.setData('text/plain', text);
|
||
// Clearing HTML prevents tiptap-markdown from re-decorating the paste target.
|
||
event.clipboardData.setData('text/html', '');
|
||
|
||
// Master-freeze / R12 (TASK-2172): the clipboard WRITE (copy) is read-only
|
||
// and always allowed, but the CUT deletes content — a Yjs mutation. handleDOMEvents
|
||
// fire even when the view is read-only (a peeking master OR a view-only
|
||
// viewer), so gate the delete on `view.editable`. A read-only cut degrades
|
||
// to a copy (same viewer bug-fix as the attachment gates).
|
||
if (isCut && view.editable) {
|
||
const tr = state.tr.delete(from, to);
|
||
view.dispatch(tr);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ProseMirror plugin: when the user copies/cuts a selection that lives
|
||
// entirely inside a single table, write a plain-text representation of
|
||
// the cells (tab-separated, newline-separated rows) to the clipboard
|
||
// and clear text/html, so paste targets don't receive the wrapping
|
||
// <table> markup. Mirrors codeBlockCopyPlugin above.
|
||
const tableCopyPlugin = new Plugin({
|
||
props: {
|
||
handleDOMEvents: {
|
||
copy: (view, event) => writeTableClipboard(view, event as ClipboardEvent, false),
|
||
cut: (view, event) => writeTableClipboard(view, event as ClipboardEvent, true),
|
||
paste: (view, event) => readTablePasteClipboard(view, event as ClipboardEvent),
|
||
},
|
||
},
|
||
});
|
||
|
||
function writeTableClipboard(view: any, event: ClipboardEvent, isCut: boolean): boolean {
|
||
const { state } = view;
|
||
const selection = state.selection;
|
||
const { from, to, empty } = selection;
|
||
if (empty) return false;
|
||
if (!event.clipboardData) return false;
|
||
|
||
let text: string | null = null;
|
||
|
||
if (selection instanceof CellSelection) {
|
||
// Multi-cell selection — emit a RECTANGULAR TSV by walking the
|
||
// visual grid slot-by-slot using the TableMap.
|
||
//
|
||
// WHY NOT forEachCell: forEachCell (and cellsInRect) deduplicates
|
||
// physical cells, yielding one entry per merged cell. That makes
|
||
// the row field-count non-rectangular when merged cells are present
|
||
// (a colspan=2 cell in a 2-col selection would emit one field for
|
||
// that row, not two). The resulting TSV misaligns columns on paste.
|
||
//
|
||
// Instead we walk every visual (row, col) slot in the selection
|
||
// rect. For each slot we check whether it is the ORIGIN of its
|
||
// physical cell (cellRect.top === row && cellRect.left === col).
|
||
// Origin slots emit the cell's text; covered slots emit '' (empty
|
||
// field). This guarantees every TSV row has the same field count.
|
||
const selectionTable = selection.$anchorCell.node(-1);
|
||
const selectionMap = TableMap.get(selectionTable);
|
||
const selectionTableStart = selection.$anchorCell.start(-1);
|
||
const selectionRect = selectionMap.rectBetween(
|
||
selection.$anchorCell.pos - selectionTableStart,
|
||
selection.$headCell.pos - selectionTableStart,
|
||
);
|
||
const rows: string[][] = [];
|
||
for (let row = selectionRect.top; row < selectionRect.bottom; row++) {
|
||
const cols: string[] = [];
|
||
for (let col = selectionRect.left; col < selectionRect.right; col++) {
|
||
const slotIndex = row * selectionMap.width + col;
|
||
const cellOffset = selectionMap.map[slotIndex];
|
||
const cellRect = selectionMap.findCell(cellOffset);
|
||
const isOrigin = cellRect.top === row && cellRect.left === col;
|
||
if (isOrigin) {
|
||
const cellNode = selectionTable.nodeAt(cellOffset);
|
||
cols.push((cellNode?.textContent ?? '').replace(/\s+$/g, ''));
|
||
} else {
|
||
// Covered slot of a merged cell: emit empty field so
|
||
// every TSV row has an equal number of columns.
|
||
cols.push('');
|
||
}
|
||
}
|
||
rows.push(cols);
|
||
}
|
||
text = rows.map(r => r.join('\t')).join('\n');
|
||
} else {
|
||
// Plain text selection — must be entirely inside a single table.
|
||
const resolvedFrom = state.doc.resolve(from);
|
||
let tableDepth = -1;
|
||
for (let d = resolvedFrom.depth; d >= 0; d--) {
|
||
if (resolvedFrom.node(d).type.name === 'table') {
|
||
tableDepth = d;
|
||
break;
|
||
}
|
||
}
|
||
if (tableDepth < 0) return false;
|
||
const tableStart = resolvedFrom.start(tableDepth);
|
||
const tableEnd = resolvedFrom.end(tableDepth);
|
||
if (from < tableStart || to > tableEnd) return false;
|
||
text = state.doc.textBetween(from, to, '\n', ' ');
|
||
}
|
||
|
||
if (text === null) return false;
|
||
|
||
event.preventDefault();
|
||
event.clipboardData.setData('text/plain', text);
|
||
event.clipboardData.setData('text/html', '');
|
||
|
||
// Master-freeze / R12 (TASK-2172): copy is read-only; the cut deletes
|
||
// rows/cells (a Yjs mutation) — gate on `view.editable` (see
|
||
// writeCodeBlockClipboard). A read-only cut degrades to a copy.
|
||
if (isCut && view.editable) {
|
||
if (selection instanceof CellSelection && selection.isRowSelection()) {
|
||
// Whole-row selection: prefer structural row removal so undo restores
|
||
// the rows intact in one step.
|
||
//
|
||
// Header-row cut (selTop === 0, !allRows): deleteRow is safe here.
|
||
// The tiptap Table extension schema is `table: { content: "tableRow+" }`
|
||
// and `tableRow: { content: "(tableCell | tableHeader)*" }` — no leading
|
||
// header row is required by the schema (verified in
|
||
// node_modules/@tiptap/extension-table/src/table/table.ts:270 and
|
||
// src/row/table-row.ts:27). Row 0 is a "header row" only because its
|
||
// cells happen to be tableHeader (th) nodes; the schema does not enforce
|
||
// a mandatory first row, so removing it yields a valid table.
|
||
//
|
||
// Edge case: if the selection covers every row in the table, deleteRow
|
||
// refuses (prosemirror-tables intentionally prevents making a zero-row
|
||
// table). In that case we fall through to deleteTable so the user's
|
||
// mental model ("I cut everything → it's gone") is honoured.
|
||
const table = selection.$anchorCell.node(-1);
|
||
const map = TableMap.get(table);
|
||
// Compute the row extent of this selection via the map.
|
||
const anchorPos = selection.$anchorCell.pos - selection.$anchorCell.start(-1);
|
||
const headPos = selection.$headCell.pos - selection.$headCell.start(-1);
|
||
const anchorRect = map.findCell(anchorPos);
|
||
const headRect = map.findCell(headPos);
|
||
const selTop = Math.min(anchorRect.top, headRect.top);
|
||
const selBottom = Math.max(anchorRect.bottom, headRect.bottom);
|
||
const allRows = selTop === 0 && selBottom === map.height;
|
||
|
||
if (allRows) {
|
||
// Deleting all rows ≡ deleting the table.
|
||
deleteTable(state, view.dispatch);
|
||
} else {
|
||
// deleteRow reads the CellSelection from state.selection and
|
||
// removes only the rows that are fully covered.
|
||
deleteRow(state, view.dispatch);
|
||
}
|
||
} else {
|
||
// Partial cell selection or plain-text range within a table:
|
||
// clear cell content but keep the row/cell structure intact.
|
||
// This is the correct ProseMirror semantic for partial cuts.
|
||
const tr = state.tr.deleteSelection();
|
||
view.dispatch(tr);
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ProseMirror plugin handler: reconstruct table cells from tab-separated
|
||
// clipboard text when the paste target is inside a table cell.
|
||
//
|
||
// Returns true (and calls event.preventDefault) only when ALL of:
|
||
// 1. Anchor is inside a table cell.
|
||
// 2. text/plain contains \t (tab character — the TSV discriminator).
|
||
// 3. text/html does NOT contain a <table> element (spreadsheet pastes
|
||
// that carry <table> HTML are left for ProseMirror's HTML handler,
|
||
// which already reconstructs cells correctly).
|
||
//
|
||
// Falls through (returns false) on every non-matching path so default
|
||
// paste and tiptap-markdown's transform are unaffected.
|
||
//
|
||
// TSV discriminator rationale: we require \t, not (\t OR \n). Newline-
|
||
// only text (multi-line prose, code snippets, addresses) must NOT trigger
|
||
// this handler — it would silently overwrite cells downward. The tradeoff:
|
||
// our own editor's single-column cut writes tab-free TSV (one cell per
|
||
// line, no \t), so that cut output won't round-trip through this handler;
|
||
// it lands as multi-line text in the anchor cell via default paste.
|
||
// Spreadsheet single-column pastes are handled correctly via their
|
||
// text/html <table> path (guard 3). Do not change this to (\t OR \n)
|
||
// without also writing table HTML on the cut side.
|
||
//
|
||
// Known limitations:
|
||
// - RFC-4180 quoted cells ("a\nb" spanning multiple lines) are NOT
|
||
// parsed. The grid is split naively on \n then \t. A quoted
|
||
// multi-line cell will be split across rows. Full RFC-4180 is a
|
||
// follow-on task.
|
||
// - Paste data wider or taller than the remaining table is clamped
|
||
// and dropped (table is NOT grown).
|
||
// - Empty cells in the paste grid CLEAR the target cell (write an
|
||
// empty paragraph). This is intentional: it preserves cut→paste
|
||
// round-trip fidelity and matches spreadsheet convention.
|
||
function readTablePasteClipboard(view: any, event: ClipboardEvent): boolean {
|
||
if (!event.clipboardData) return false;
|
||
// Master-freeze / R12 (TASK-2172): a TSV paste REPLACES table cells — a Yjs
|
||
// mutation. handleDOMEvents fire even when the view is read-only (a peeking
|
||
// master OR a view-only viewer), so bail while read-only and let the default
|
||
// (contentEditable-blocked) paste path handle it — no cell mutation lands.
|
||
if (!view.editable) return false;
|
||
const { state } = view;
|
||
|
||
// Guard 1: anchor must be inside a table.
|
||
if (!isInTable(state)) return false;
|
||
|
||
// Guard 2: if the HTML clipboard carries a <table>, let ProseMirror's
|
||
// existing HTML paste handler take over (spreadsheet paste path).
|
||
const htmlData = event.clipboardData.getData('text/html');
|
||
if (/<table[\s>]/i.test(htmlData)) return false;
|
||
|
||
// Guard 3: plain text must contain a tab — the TSV discriminator.
|
||
// Newline-only text (prose, code) must fall through to default paste;
|
||
// see the comment block above for the full rationale.
|
||
const plainText = event.clipboardData.getData('text/plain');
|
||
if (!plainText.includes('\t')) return false;
|
||
|
||
// Parse the plain text into a 2D array of cell strings.
|
||
// Strip trailing \r from each line (Windows line endings from spreadsheets).
|
||
// Empty trailing rows (blank line at end of clipboard) are dropped.
|
||
const grid: string[][] = plainText
|
||
.split('\n')
|
||
.map((line) => line.replace(/\r$/, '').split('\t'))
|
||
.filter((row, i, arr) => !(i === arr.length - 1 && row.length === 1 && row[0] === ''));
|
||
|
||
if (grid.length === 0) return false;
|
||
|
||
// Find the anchor cell and the containing table.
|
||
// selectionCell is marked @internal in prosemirror-tables but is the
|
||
// canonical helper to resolve the anchor cell from any selection type;
|
||
// we're pinned to a specific version so a breakage here is visible.
|
||
// Note: ProseMirror names resolved positions with a $ prefix by convention;
|
||
// Svelte 5 runes mode reserves $ for reactive primitives, so we use
|
||
// `anchorCell` (no prefix) instead.
|
||
const anchorCell = selectionCell(state);
|
||
const tableInfo = findTable(anchorCell);
|
||
if (!tableInfo) return false;
|
||
const { node: table, start: tableStart } = tableInfo;
|
||
const map = TableMap.get(table);
|
||
|
||
// Determine the anchor cell's position in the map.
|
||
const anchorOffset = anchorCell.pos - tableStart;
|
||
const anchorCellRect = map.findCell(anchorOffset);
|
||
const startRow = anchorCellRect.top;
|
||
const startCol = anchorCellRect.left;
|
||
|
||
// Collect cells to fill in FORWARD (top-left → bottom-right) visual order,
|
||
// skipping covered slots by origin-check, then REVERSE for safe dispatch.
|
||
//
|
||
// WHY map.map[] NOT positionAt(): positionAt(row, col, table) SKIPS covered
|
||
// slots — for a visual slot covered by a rowspan from above it advances to
|
||
// the next physical cell in that row, causing column misalignment. Example:
|
||
// 2-col table, (0,0) has rowspan=2. positionAt(1,0) skips the covered slot
|
||
// and returns cell C (col 1), so grid[1][0] (a col-0 value) lands in a
|
||
// col-1 cell. map.map[row * width + col] returns the OWNING cell's offset
|
||
// for every visual slot — covered or not — matching the serializer.
|
||
//
|
||
// WHY ORIGIN-CHECK: map.map[] returns the owning cell's offset even when
|
||
// that cell's top-left origin is OUTSIDE the paste rectangle. Without the
|
||
// origin-check, a covered slot whose owner lives above or left of the
|
||
// anchor would silently overwrite a cell the user never selected. The fix:
|
||
// only write a cell when the current (targetRow, targetCol) is exactly the
|
||
// cell's origin (cellRect.top === targetRow && cellRect.left === targetCol).
|
||
// This skips covered slots structurally — intra-rect and partial-intersection
|
||
// alike. The dedup Set below is now a redundant-but-cheap invariant guard
|
||
// (an origin can recur only via colspan > table-width, which is malformed);
|
||
// it is kept to document intent and protect against future edits.
|
||
//
|
||
// WHY ALSO REVERSE: each tr.replaceWith shifts all doc positions AFTER the
|
||
// replaced range. Forward iteration would make later cellPos values stale.
|
||
// Reverse iteration means each write only displaces already-processed
|
||
// positions. DO NOT change map.map[] back to positionAt(), remove the
|
||
// origin-check, or remove the reverse — see verify-paste.cjs, verify-paste5.cjs.
|
||
const seenCellOffsets = new Set<number>();
|
||
const cellsToFill: Array<{ cellPos: number; value: string }> = [];
|
||
for (let r = 0; r < grid.length; r++) {
|
||
const targetRow = startRow + r;
|
||
if (targetRow >= map.height) break; // clamp at bottom edge
|
||
for (let c = 0; c < grid[r].length; c++) {
|
||
const targetCol = startCol + c;
|
||
if (targetCol >= map.width) break; // clamp at right edge
|
||
// map.map[] gives the owning cell's table-relative offset for every
|
||
// visual slot including covered ones. positionAt() would skip covered
|
||
// slots and misalign columns — see comment block above.
|
||
const cellOffset = map.map[targetRow * map.width + targetCol];
|
||
// Origin-check: only write a cell at its top-left origin slot.
|
||
// Covered slots (whether intra-rect or with origin outside the rect)
|
||
// resolve to the owning cell's offset; their origin row/col won't
|
||
// match targetRow/targetCol, so they are dropped here.
|
||
const cellRect = map.findCell(cellOffset);
|
||
if (cellRect.top !== targetRow || cellRect.left !== targetCol) continue;
|
||
if (seenCellOffsets.has(cellOffset)) continue; // invariant guard
|
||
seenCellOffsets.add(cellOffset);
|
||
cellsToFill.push({ cellPos: tableStart + cellOffset, value: grid[r][c] });
|
||
}
|
||
}
|
||
cellsToFill.reverse();
|
||
|
||
let tr = state.tr;
|
||
let changed = false;
|
||
|
||
for (const { cellPos, value } of cellsToFill) {
|
||
// Re-read the cell node from tr.doc at the (still-valid) cellPos.
|
||
// Because we iterate in reverse, positions of unprocessed cells have
|
||
// not been displaced yet, so cellPos is accurate for tr.doc.
|
||
const cellNode = tr.doc.nodeAt(cellPos);
|
||
if (!cellNode) continue;
|
||
|
||
const cellContentStart = cellPos + 1; // first position inside the cell
|
||
const cellContentEnd = cellPos + cellNode.nodeSize - 1; // last position inside
|
||
|
||
// Replace the entire cell content with a single paragraph.
|
||
// Empty paste cells write an empty paragraph — intentional: preserves
|
||
// cut→paste round-trip fidelity and matches spreadsheet convention
|
||
// (Excel pastes blanks as blanks).
|
||
const paraContent = value ? [state.schema.text(value)] : [];
|
||
const para = state.schema.nodes.paragraph.create({}, paraContent);
|
||
tr = tr.replaceWith(cellContentStart, cellContentEnd, para);
|
||
changed = true;
|
||
}
|
||
|
||
if (!changed) return false;
|
||
|
||
event.preventDefault();
|
||
view.dispatch(tr);
|
||
return true;
|
||
}
|
||
|
||
// CodeBlock with inline mermaid rendering via NodeView.
|
||
// Key: ignoreMutation prevents ProseMirror's MutationObserver from
|
||
// detecting our SVG insertion and triggering an infinite re-parse loop.
|
||
const MermaidCodeBlock = CodeBlock.extend({
|
||
addProseMirrorPlugins() {
|
||
return [codeBlockCopyPlugin];
|
||
},
|
||
addNodeView() {
|
||
return (({ node }: any) => {
|
||
const lang = node.attrs.language;
|
||
|
||
// Non-mermaid: default rendering + hover Copy button
|
||
if (lang !== 'mermaid') {
|
||
const pre = document.createElement('pre');
|
||
pre.classList.add('code-block');
|
||
const code = document.createElement('code');
|
||
if (lang) code.classList.add(`language-${lang}`);
|
||
pre.appendChild(code);
|
||
pre.appendChild(buildCopyButton(code));
|
||
return { dom: pre, contentDOM: code };
|
||
}
|
||
|
||
// Mermaid: diagram with hidden editable source
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'mermaid-wrapper';
|
||
|
||
const diagram = document.createElement('div');
|
||
diagram.className = 'mermaid-diagram';
|
||
diagram.setAttribute('contenteditable', 'false');
|
||
diagram.textContent = 'Rendering...';
|
||
|
||
const pre = document.createElement('pre');
|
||
pre.classList.add('code-block', 'mermaid-source');
|
||
pre.style.display = 'none';
|
||
const code = document.createElement('code');
|
||
code.classList.add('language-mermaid');
|
||
pre.appendChild(code);
|
||
|
||
const toggleBtn = document.createElement('button');
|
||
toggleBtn.className = 'mermaid-toggle';
|
||
toggleBtn.textContent = '< >';
|
||
toggleBtn.title = 'Toggle source code';
|
||
toggleBtn.setAttribute('contenteditable', 'false');
|
||
toggleBtn.addEventListener('mousedown', (e) => {
|
||
e.preventDefault();
|
||
const showingCode = pre.style.display !== 'none';
|
||
pre.style.display = showingCode ? 'none' : '';
|
||
diagram.style.display = showingCode ? '' : 'none';
|
||
toggleBtn.classList.toggle('active', !showingCode);
|
||
});
|
||
|
||
wrapper.append(toggleBtn, diagram, pre);
|
||
|
||
const source = node.textContent?.trim() ?? '';
|
||
if (source) {
|
||
queueMermaidRender(source, diagram);
|
||
}
|
||
|
||
// Closure state for update(): track last-rendered source + the
|
||
// language at NodeView creation time. ProseMirror only recreates
|
||
// the NodeView on node identity change (replace/retype) — text
|
||
// edits inside the same node hit update(), so we re-queue a
|
||
// mermaid render whenever the source text changes. See BUG-1246.
|
||
let lastSource = source;
|
||
const initialLang = lang;
|
||
|
||
return {
|
||
dom: wrapper,
|
||
contentDOM: code,
|
||
// Re-render the diagram when the node's text content changes.
|
||
// Typed via NodeView['update'] from prosemirror-view: the param
|
||
// is a ProseMirror Node (not the DOM Node global). Returning
|
||
// `true` accepts the in-place update; `false` forces ProseMirror
|
||
// to tear down + recreate this NodeView, which we want when
|
||
// the language attribute flips into/out of `mermaid` because
|
||
// the DOM shape (wrapper + diagram + toggle) is mermaid-only.
|
||
update(updatedNode: ProseMirrorNode) {
|
||
if (updatedNode.type.name !== 'codeBlock') return false;
|
||
if (updatedNode.attrs.language !== initialLang) return false;
|
||
|
||
const newSource = updatedNode.textContent?.trim() ?? '';
|
||
if (newSource !== lastSource) {
|
||
lastSource = newSource;
|
||
if (newSource) {
|
||
queueMermaidRender(newSource, diagram);
|
||
} else {
|
||
// Empty source: clear any stale SVG / error state.
|
||
// Routed through the render queue so a pending
|
||
// queueMermaidRender can't overwrite us afterward.
|
||
queueMermaidClear(diagram);
|
||
}
|
||
}
|
||
return true;
|
||
},
|
||
// Critical: tell ProseMirror to ignore DOM mutations outside
|
||
// the contentDOM (code element). Without this, inserting the
|
||
// mermaid SVG triggers ProseMirror's MutationObserver → re-parse
|
||
// → NodeView recreation → infinite loop.
|
||
ignoreMutation(mutation: MutationRecord) {
|
||
return !code.contains(mutation.target);
|
||
},
|
||
};
|
||
}) as any;
|
||
},
|
||
});
|
||
|
||
// Extend Link to render data-href instead of href in the editor DOM.
|
||
// This prevents mobile browsers from navigating when tapping links —
|
||
// no href attribute means nothing for the browser to follow.
|
||
// Mark attributes still store href, so markdown serialization and the
|
||
// link popover work unchanged.
|
||
const SafeLink = Link.extend({
|
||
renderHTML({ HTMLAttributes }) {
|
||
const merged = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes);
|
||
const { href, ...rest } = merged;
|
||
return ['a', { ...rest, 'data-href': href }, 0];
|
||
},
|
||
});
|
||
import { Markdown } from 'tiptap-markdown';
|
||
import { unescapeDocLinks } from '$lib/utils/markdown';
|
||
import { formatItemRef, itemUrlId, type Item } from '$lib/types';
|
||
import { collectionStore } from '$lib/stores/collections.svelte';
|
||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||
import { localIndex } from '$lib/stores/localIndex.svelte';
|
||
import { viewport } from '$lib/stores/breakpoint.svelte';
|
||
import { api } from '$lib/api/client';
|
||
import { notifyAttachmentUploaded, toUploadedAttachment } from '$lib/attachments/events';
|
||
import { BlockDragHandle } from './block-drag-handle';
|
||
import { HtmlBlock, captureHtmlBlockSnapshot, flipHtmlBlockToSource } from './extensions/htmlBlock';
|
||
import { SLASH_ITEMS } from './block-types';
|
||
import ImportFromUrlModal, { type InsertContext } from './ImportFromUrlModal.svelte';
|
||
import type { ImportURLResponse } from '$lib/api/client';
|
||
import {
|
||
AttachmentImage,
|
||
type AttachmentVariant,
|
||
notifyAttachmentImageCapabilitiesChanged,
|
||
} from './attachment-image';
|
||
import { AttachmentChip } from './attachment-chip';
|
||
import type { AttachmentHostAddress } from '$lib/attachments/hostAddress';
|
||
import { AttachmentUpload } from './attachment-upload';
|
||
|
||
let {
|
||
content = '',
|
||
editable = true,
|
||
itemId,
|
||
hostToken = '',
|
||
ydoc,
|
||
awareness,
|
||
collabUser,
|
||
onUpdate,
|
||
onEditor,
|
||
onImportInserted,
|
||
}: {
|
||
content?: string;
|
||
editable?: boolean;
|
||
/**
|
||
* UUID of the item being edited. Sent with attachment uploads so the
|
||
* server can authorize against the item's grant chain — a grant-based
|
||
* editor (share-link guest with no workspace editor role) can attach
|
||
* instead of hitting a 403 (BUG-1661). Optional; without it uploads
|
||
* fall back to the workspace editor-role gate.
|
||
*/
|
||
itemId?: string;
|
||
/**
|
||
* Identity of the `ItemDetail` mount that owns this editor
|
||
* (PLAN-2392 DR-8 / TASK-2421). Passed straight through to the
|
||
* attachment NodeViews so a chip / image can address the ONE host
|
||
* that owns it — `ItemDetail` runs as a master plus a peeked pane,
|
||
* and `itemId` alone would let both consume the same event. Empty
|
||
* (the default, for editors mounted outside an ItemDetail) disables
|
||
* panel addressing rather than broadcasting.
|
||
*/
|
||
hostToken?: string;
|
||
/**
|
||
* Optional Yjs document to bind this editor to via the Tiptap
|
||
* Collaboration extension (PLAN-1248). When set, the y-tiptap
|
||
* binding takes ownership of document state and undo/redo —
|
||
* StarterKit's history is disabled below so the two systems
|
||
* don't fight over keystrokes.
|
||
*
|
||
* When undefined (the default), the editor behaves exactly as
|
||
* it did pre-collab: a single ProseMirror Y-Doc-less Doc with
|
||
* StarterKit's built-in undoRedo. This keeps every existing
|
||
* call site backward-compatible.
|
||
*
|
||
* The WebSocket provider that syncs ydoc with the server is
|
||
* wired by the editor's host route (TASK-1260); this prop just
|
||
* accepts the constructed Y.Doc from there.
|
||
*/
|
||
ydoc?: Y.Doc;
|
||
/**
|
||
* Optional y-protocols `Awareness` instance from the same
|
||
* provider that owns `ydoc` (TASK-1263). When supplied
|
||
* alongside `collabUser`, the editor registers the
|
||
* CollaborationCaret extension to render remote peers'
|
||
* carets + selections. Without it (or without a `ydoc`),
|
||
* the cursor extension is omitted and the editor behaves
|
||
* identically to single-user mode.
|
||
*/
|
||
awareness?: Awareness;
|
||
/**
|
||
* Local user identity broadcast over awareness so peers can
|
||
* label our caret. `name` is what shows in the cursor label;
|
||
* `color` is the deterministic `#rrggbb` from `cursorColor.ts`
|
||
* (hex is required because y-tiptap's default selectionRender
|
||
* appends an alpha-hex byte and only validates hex). Both
|
||
* are required when `awareness` is set.
|
||
*/
|
||
collabUser?: { name: string; color: string };
|
||
onUpdate?: (markdown: string) => void;
|
||
onEditor?: (editor: Editor) => void;
|
||
/**
|
||
* Fired after the Insert-from-URL modal splices content into
|
||
* the editor. The host page uses this to stamp source_url +
|
||
* imported_at into the item's fields when the item has no
|
||
* source_url yet (TASK-1474). Optional — view-only / share-
|
||
* page mounts never trigger this since the modal is gated on
|
||
* a writable editor.
|
||
*/
|
||
onImportInserted?: (meta: ImportURLResponse, ctx: InsertContext) => void;
|
||
} = $props();
|
||
|
||
let element = $state<HTMLDivElement>();
|
||
let editor = $state<Editor | null>(null);
|
||
let editorFocused = $state(false);
|
||
let editorTick = $state(0);
|
||
let toolbarBottom = $state(0);
|
||
let keyboardVisible = $state(false);
|
||
let suppressUpdate = false;
|
||
let lastMarkdown = '';
|
||
// visualViewport 'resize'/'scroll' handler ref, so onDestroy can remove
|
||
// the exact listeners registered in onMount (TASK-2109).
|
||
let visualViewportHandler: (() => void) | null = null;
|
||
|
||
// Slash command state
|
||
let slashOpen = $state(false);
|
||
let slashQuery = $state('');
|
||
let slashX = $state(0);
|
||
let slashY = $state(0);
|
||
let slashIdx = $state(0);
|
||
let slashStartPos = -1;
|
||
|
||
// "Insert from URL" modal — opened by the importUrl slash command
|
||
// (and any future toolbar entry). The modal owns its own working
|
||
// state; we just track open/closed here so the slash handler can
|
||
// trigger it. See PLAN-1467 / TASK-1473.
|
||
let importUrlModalOpen = $state(false);
|
||
|
||
// [[ link picker state
|
||
let linkOpen = $state(false);
|
||
let linkQuery = $state('');
|
||
let linkX = $state(0);
|
||
let linkY = $state(0);
|
||
let linkIdx = $state(0);
|
||
let linkStartPos = -1;
|
||
let bracketCount = $state(0); // track consecutive [ chars
|
||
|
||
function getFilteredSlash() {
|
||
if (!slashQuery) return SLASH_ITEMS;
|
||
const q = slashQuery.toLowerCase();
|
||
return SLASH_ITEMS.filter((i) => {
|
||
const hay = [i.label, i.description, i.id, ...(i.keywords ?? [])]
|
||
.join(' ')
|
||
.toLowerCase();
|
||
return hay.includes(q);
|
||
});
|
||
}
|
||
|
||
function execSlash(id: string) {
|
||
// Reactive-freeze guard (PLAN-2179 DR-1 / TASK-2180): a slash menu left
|
||
// OPEN when the master froze must not insert into the read-only doc. The
|
||
// `{#if slashOpen && editable}` render gate hides it reactively, and this
|
||
// is the belt for a queued Enter/click racing the flip. (The old peeking
|
||
// `{#key}` remount reset this component state; the reactive freeze needs
|
||
// the explicit bail.)
|
||
if (!editor || !editor.isEditable) return;
|
||
if (slashStartPos >= 0) {
|
||
const to = editor.state.selection.from;
|
||
editor.chain().focus().deleteRange({ from: slashStartPos, to }).run();
|
||
}
|
||
const c = editor.chain().focus();
|
||
switch (id) {
|
||
case 'heading1': c.toggleHeading({ level: 1 }).run(); break;
|
||
case 'heading2': c.toggleHeading({ level: 2 }).run(); break;
|
||
case 'heading3': c.toggleHeading({ level: 3 }).run(); break;
|
||
case 'bulletList': c.toggleBulletList().run(); break;
|
||
case 'orderedList': c.toggleOrderedList().run(); break;
|
||
case 'taskList': c.toggleTaskList().run(); break;
|
||
case 'codeBlock': c.toggleCodeBlock().run(); break;
|
||
case 'htmlBlock': {
|
||
// Snapshot existing htmlBlock (pos, html) entries before
|
||
// insertion so flipHtmlBlockToSource can identify the new
|
||
// block by elimination — handles all cases including
|
||
// NodeSelection-replace (after.length === before.length
|
||
// but the replaced entry's html content differs).
|
||
if (!editor) break;
|
||
const before = captureHtmlBlockSnapshot(editor);
|
||
const insertionPoint = editor.state.selection.from;
|
||
c.setHtmlBlock({ html: '' }).run();
|
||
flipHtmlBlockToSource(editor, insertionPoint, before);
|
||
break;
|
||
}
|
||
case 'blockquote': c.toggleBlockquote().run(); break;
|
||
case 'horizontalRule': c.setHorizontalRule().run(); break;
|
||
case 'table': c.insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); break;
|
||
case 'importUrl':
|
||
// Defocus by completing the slash deletion above, then
|
||
// pop the modal. The modal's Insert runs insertContent
|
||
// against the editor reference we hold in this scope.
|
||
importUrlModalOpen = true;
|
||
break;
|
||
case 'attachFile':
|
||
// The slash text was already deleted above; open the
|
||
// native file picker. onAttachInputChange runs
|
||
// uploadAttachments, which inserts at the (preserved)
|
||
// selection where the slash was typed.
|
||
triggerAttachPicker();
|
||
break;
|
||
}
|
||
closeSlash();
|
||
}
|
||
|
||
function closeSlash() {
|
||
slashOpen = false;
|
||
slashQuery = '';
|
||
slashStartPos = -1;
|
||
slashIdx = 0;
|
||
}
|
||
|
||
function getFilteredLinks() {
|
||
// Pull link targets from the shared local-first read model (the
|
||
// same source the detail page resolves wiki-links from), NOT
|
||
// collectionStore.items — that slot is only populated by the
|
||
// legacy full-content loadItems() path, which the detail page no
|
||
// longer calls. localIndex carries every workspace item (skinny,
|
||
// no body) and is already hydrated on these routes.
|
||
const wsSlug = page.params.workspace ?? workspaceStore.current?.slug ?? '';
|
||
const items = localIndex.getAll(wsSlug);
|
||
if (!linkQuery) return items.slice(0, 10);
|
||
const q = linkQuery.toLowerCase();
|
||
return items
|
||
.filter(d => {
|
||
if (d.title.toLowerCase().includes(q)) return true;
|
||
// Match on the issue ref (e.g. DOC-535) and its numeric part
|
||
const ref = (formatItemRef(d) ?? '').toLowerCase();
|
||
if (ref && ref.includes(q)) return true;
|
||
return false;
|
||
})
|
||
.slice(0, 10);
|
||
}
|
||
|
||
function execLink(doc: Item) {
|
||
// Reactive-freeze guard (PLAN-2179 DR-1 / TASK-2180): see execSlash — a
|
||
// `[[` picker left open at freeze time must not mutate the read-only doc.
|
||
if (!editor || !editor.isEditable) return;
|
||
// Build the URL in the same shape wikiLinksToMarkdown produces so the
|
||
// save round-trip (markdownToWikiLinks) reliably converts it back to
|
||
// [[Title]]. We read from the live route params (not workspaceStore)
|
||
// because that's what the slug page uses when converting wiki-links —
|
||
// keeping the two in sync is what lets the round-trip work.
|
||
const routeUsername = page.params.username ?? '';
|
||
const routeWorkspace = page.params.workspace ?? workspaceStore.current?.slug ?? '';
|
||
const collSlug = doc.collection_slug ?? '';
|
||
const idSeg = itemUrlId(doc);
|
||
const prefix = routeUsername ? `/${routeUsername}/${routeWorkspace}` : `/${routeWorkspace}`;
|
||
const href = collSlug && idSeg && routeWorkspace ? `${prefix}/${collSlug}/${idSeg}` : '';
|
||
|
||
// Delete the [[ and any query text typed so far
|
||
const to = editor.state.selection.from;
|
||
const chain = editor.chain().focus().deleteRange({ from: linkStartPos, to });
|
||
|
||
if (href) {
|
||
// Insert the title as a real Tiptap link mark so it's clickable
|
||
// immediately (no reload needed). On save, tiptap-markdown emits
|
||
// [Title](href), which markdownToWikiLinks converts back to [[Title]].
|
||
chain.insertContent([
|
||
{
|
||
type: 'text',
|
||
text: doc.title,
|
||
marks: [{ type: 'link', attrs: { href } }],
|
||
},
|
||
// Trailing space drops the link mark so subsequent typing is plain text.
|
||
{ type: 'text', text: ' ' },
|
||
]).run();
|
||
} else {
|
||
// Fall back to [[Title]] text if we can't resolve a URL.
|
||
chain.insertContent(`[[${doc.title}]]`).run();
|
||
}
|
||
closeLink();
|
||
}
|
||
|
||
function closeLink() {
|
||
linkOpen = false;
|
||
linkQuery = '';
|
||
linkStartPos = -1;
|
||
linkIdx = 0;
|
||
bracketCount = 0;
|
||
}
|
||
|
||
onMount(() => {
|
||
if (!element) return;
|
||
|
||
// Resolve the workspace slug at mount time. The Editor lives inside
|
||
// a route that has page.params.workspace set; falling back to the
|
||
// workspace store covers code paths where the editor is rendered
|
||
// outside that route shape (e.g. component-driven previews). When
|
||
// neither is available, attachment images render the literal
|
||
// `pad-attachment:UUID` href and fail to load — clearly broken in
|
||
// the UI, which is the right signal for "no workspace context".
|
||
const wsSlug = page.params.workspace ?? workspaceStore.current?.slug ?? '';
|
||
const getAttachmentUrl = (uuid: string, variant?: AttachmentVariant) =>
|
||
wsSlug ? api.attachments.downloadUrl(wsSlug, uuid, variant) : `pad-attachment:${uuid}`;
|
||
|
||
// Reads the CURRENT host address at emit time (PLAN-2392 DR-8). This
|
||
// editor is remounted per item behind a {#key}, so a captured value
|
||
// would in fact be correct here — it is a reader anyway so both editor
|
||
// hosts publish one shape, and so nobody has to know which of them is
|
||
// remounted and which is reused (see hostAddress.ts).
|
||
const readHostAddress = (): AttachmentHostAddress => ({
|
||
itemId: itemId ?? '',
|
||
hostToken
|
||
});
|
||
|
||
// When a Y.Doc is supplied, the Collaboration extension owns
|
||
// undo/redo (Yjs maintains its own history that survives peer
|
||
// edits correctly) and StarterKit's undoRedo would fight it.
|
||
// In Tiptap v3 the option is `undoRedo: false`; v2's `history`
|
||
// key was renamed during the v3 migration.
|
||
const extensions = [
|
||
StarterKit.configure({
|
||
codeBlock: false,
|
||
link: false, // We use our own SafeLink extension below
|
||
...(ydoc ? { undoRedo: false } : {}),
|
||
}),
|
||
MermaidCodeBlock.configure({
|
||
HTMLAttributes: { class: 'code-block' },
|
||
}),
|
||
HtmlBlock,
|
||
TaskList,
|
||
TaskItem.configure({ nested: true }),
|
||
Table.extend({
|
||
addProseMirrorPlugins() {
|
||
return [...(this.parent?.() ?? []), tableCopyPlugin];
|
||
},
|
||
}).configure({ resizable: true, HTMLAttributes: { class: 'table-wrapper' } }),
|
||
TableRow,
|
||
TableCell,
|
||
TableHeader,
|
||
SafeLink.configure({
|
||
openOnClick: false,
|
||
autolink: true,
|
||
linkOnPaste: true,
|
||
HTMLAttributes: { class: 'editor-link', target: null, rel: null },
|
||
}),
|
||
Placeholder.configure({
|
||
placeholder: viewport.isMobile ? 'Start writing...' : 'Type / for commands...',
|
||
}),
|
||
Markdown.configure({
|
||
html: true,
|
||
transformPastedText: true,
|
||
transformCopiedText: true,
|
||
}),
|
||
// BlockDragHandle registers UNCONDITIONALLY (PLAN-2179 DR-1 /
|
||
// TASK-2180). It is now reactive-editable-aware: onMouseMove and
|
||
// the plugin update() choke on `editorView.editable` (hiding the
|
||
// handle synchronously the moment editable flips false), and every
|
||
// mutation dispatch site (drag-move, duplicate, delete, turn-into,
|
||
// attach) bails on `!editorView.editable`. That lets a read-only /
|
||
// peeking editor freeze the handle via `editable={!peeking}` WITHOUT
|
||
// remounting the editor — replacing the old construction-time gate
|
||
// (PLAN-1100 / TASK-1105) and, in ItemDetail, its peeking-driven
|
||
// `{#key}` remount. Pure superset: editable=true is byte-identical.
|
||
BlockDragHandle,
|
||
AttachmentImage.configure({
|
||
getDownloadUrl: getAttachmentUrl,
|
||
workspaceSlug: wsSlug,
|
||
// Panel / viewer addressing (PLAN-2392 DR-8). Read once at
|
||
// editor construction, which is correct: both are fixed for
|
||
// the life of a mount — ItemDetail remounts this editor per
|
||
// item ({#key item.id}) and mints one token per ItemDetail.
|
||
address: readHostAddress,
|
||
// Initial supportedFormats is empty — server capabilities
|
||
// are fetched async below. The toolbar starts disabled
|
||
// for all formats until capabilities resolve, then
|
||
// updates per-button via refreshToolbarState. The
|
||
// editor lifetime is long-lived so the one-call cost
|
||
// is amortized; no per-render fetch.
|
||
supportedFormats: [] as string[],
|
||
transform: async (uuid, payload) => {
|
||
if (!wsSlug) {
|
||
throw new Error('No workspace context — open the image inside a workspace to edit it.');
|
||
}
|
||
return api.attachments.transform(wsSlug, uuid, payload);
|
||
},
|
||
onError: (message) => {
|
||
console.error('[attachment image]', message);
|
||
if (typeof window !== 'undefined' && typeof window.alert === 'function') {
|
||
window.alert(`Couldn't transform image: ${message}`);
|
||
}
|
||
},
|
||
}),
|
||
AttachmentChip.configure({
|
||
getDownloadUrl: getAttachmentUrl,
|
||
workspaceSlug: wsSlug,
|
||
address: readHostAddress,
|
||
}),
|
||
// When a Y.Doc is provided, register the Collaboration
|
||
// extension so the y-tiptap binding takes over document
|
||
// state. Without ydoc this slot is empty and the editor
|
||
// behaves exactly as it did pre-collab. The `field` option
|
||
// defaults to "default" which matches what the WS provider
|
||
// (TASK-1260) and tests will use; explicit here so the
|
||
// shape is grep-able from a future RedisOpBus / multi-Doc
|
||
// path that might want a different field name per item.
|
||
...(ydoc ? [Collaboration.configure({ document: ydoc, field: 'default' })] : []),
|
||
// Presence cursors + remote selections (TASK-1263). Only
|
||
// register when both ydoc AND a usable awareness/user
|
||
// pair are present — `awareness` alone with no ydoc is
|
||
// nonsensical (the y-tiptap binding needs a Y.Doc) and
|
||
// missing user info would render an unlabelled stranger.
|
||
// CollaborationCaret v3 (the renamed
|
||
// extension-collaboration-cursor — see docs) takes a
|
||
// `provider`-shaped object exposing `awareness`. The full
|
||
// CollabProvider qualifies, but tiptap only reads
|
||
// `.awareness`, so we hand a minimal duck-typed object
|
||
// instead — keeps the editor decoupled from our
|
||
// CollabProvider class shape.
|
||
...(ydoc && awareness && collabUser
|
||
? [
|
||
CollaborationCaret.configure({
|
||
provider: { awareness },
|
||
user: collabUser,
|
||
}),
|
||
]
|
||
: []),
|
||
AttachmentUpload.configure({
|
||
upload: async (file) => {
|
||
if (!wsSlug) {
|
||
// Without a workspace context the upload endpoint
|
||
// has no path to hit. Fail the promise so the
|
||
// plugin's onError surfaces the limitation rather
|
||
// than leaving a silent stuck spinner.
|
||
throw new Error('No workspace context — drop a file from inside a workspace.');
|
||
}
|
||
// Capture the item id at upload START. The promise outlives an
|
||
// A→B switch even though the <Editor> itself is keyed on
|
||
// item.id, and AttachmentUploadResult carries no item_id, so
|
||
// this is the only point where the association is known
|
||
// (PLAN-2382 / TASK-2385).
|
||
const uploadItemId = itemId;
|
||
const result = await api.attachments.upload(wsSlug, file, uploadItemId);
|
||
// Only announce when the server actually persisted an
|
||
// association — a free-floating upload leaves item_id NULL
|
||
// and an optimistic tile for it would vanish on refresh.
|
||
notifyAttachmentUploaded(uploadItemId, toUploadedAttachment(result));
|
||
return result;
|
||
},
|
||
onError: (filename, message) => {
|
||
// Surface upload failures to the user. The editor's
|
||
// host route doesn't yet have a centralized toast
|
||
// system, so we log to console + window.alert as a
|
||
// minimal fallback. Replace with a real notification
|
||
// channel once the workspace ships one.
|
||
console.error(`[attachment upload] ${filename}: ${message}`);
|
||
if (typeof window !== 'undefined' && typeof window.alert === 'function') {
|
||
window.alert(`Couldn't upload ${filename}: ${message}`);
|
||
}
|
||
},
|
||
}),
|
||
];
|
||
|
||
editor = new Editor({
|
||
element,
|
||
editable,
|
||
extensions,
|
||
content,
|
||
onUpdate: ({ editor: e }) => {
|
||
if (suppressUpdate) return;
|
||
const md = unescapeDocLinks((e.storage as any).markdown.getMarkdown());
|
||
if (md === lastMarkdown) return;
|
||
lastMarkdown = md;
|
||
onUpdate?.(md);
|
||
if (slashOpen && slashStartPos >= 0) {
|
||
const curPos = e.state.selection.from;
|
||
if (curPos <= slashStartPos) { closeSlash(); }
|
||
else {
|
||
const text = e.state.doc.textBetween(slashStartPos, curPos, '');
|
||
if (text.startsWith('/')) {
|
||
slashQuery = text.slice(1);
|
||
slashIdx = 0;
|
||
// Auto-close if query has content but nothing matches
|
||
if (slashQuery && getFilteredSlash().length === 0) { closeSlash(); }
|
||
}
|
||
else { closeSlash(); }
|
||
}
|
||
}
|
||
if (linkOpen && linkStartPos >= 0) {
|
||
const curPos = e.state.selection.from;
|
||
if (curPos <= linkStartPos) { closeLink(); }
|
||
else {
|
||
const text = e.state.doc.textBetween(linkStartPos, curPos, '');
|
||
if (text.startsWith('[[')) { linkQuery = text.slice(2); linkIdx = 0; }
|
||
else { closeLink(); }
|
||
}
|
||
}
|
||
},
|
||
onTransaction: () => {
|
||
editor = editor;
|
||
editorTick++;
|
||
},
|
||
editorProps: {
|
||
handleKeyDown: (_view, event) => {
|
||
// --- [[ link picker ---
|
||
if (event.key === '[' && !linkOpen && !slashOpen) {
|
||
bracketCount++;
|
||
if (bracketCount === 2) {
|
||
// Second [ detected — open link picker
|
||
// linkStartPos points to the first [
|
||
linkStartPos = _view.state.selection.from - 1;
|
||
linkQuery = '';
|
||
linkIdx = 0;
|
||
setTimeout(() => {
|
||
const coords = _view.coordsAtPos(_view.state.selection.from);
|
||
linkX = coords.left;
|
||
linkY = coords.bottom + 4;
|
||
linkOpen = true;
|
||
}, 0);
|
||
bracketCount = 0;
|
||
return false;
|
||
}
|
||
// Reset after a short delay if second [ doesn't come
|
||
setTimeout(() => { if (bracketCount === 1) bracketCount = 0; }, 300);
|
||
return false;
|
||
}
|
||
if (event.key !== '[') bracketCount = 0;
|
||
|
||
if (linkOpen) {
|
||
const items = getFilteredLinks();
|
||
if (event.key === 'ArrowDown') { event.preventDefault(); linkIdx = (linkIdx + 1) % Math.max(items.length, 1); return true; }
|
||
if (event.key === 'ArrowUp') { event.preventDefault(); linkIdx = (linkIdx - 1 + items.length) % Math.max(items.length, 1); return true; }
|
||
if (event.key === 'Enter') { event.preventDefault(); if (items[linkIdx]) execLink(items[linkIdx]); return true; }
|
||
if (event.key === 'Escape') { event.preventDefault(); closeLink(); return true; }
|
||
return false;
|
||
}
|
||
|
||
// --- slash commands ---
|
||
if (event.key === '/' && !slashOpen) {
|
||
slashStartPos = _view.state.selection.from;
|
||
slashQuery = '';
|
||
slashIdx = 0;
|
||
setTimeout(() => {
|
||
const coords = _view.coordsAtPos(_view.state.selection.from);
|
||
slashX = coords.left;
|
||
slashY = coords.bottom + 4;
|
||
slashOpen = true;
|
||
}, 0);
|
||
return false;
|
||
}
|
||
if (!slashOpen) return false;
|
||
const items = getFilteredSlash();
|
||
if (items.length === 0) {
|
||
// No matches — close and let the keypress through
|
||
closeSlash();
|
||
return false;
|
||
}
|
||
if (event.key === 'ArrowDown') { event.preventDefault(); slashIdx = (slashIdx + 1) % items.length; return true; }
|
||
if (event.key === 'ArrowUp') { event.preventDefault(); slashIdx = (slashIdx - 1 + items.length) % items.length; return true; }
|
||
if (event.key === 'Enter') { event.preventDefault(); if (items[slashIdx]) execSlash(items[slashIdx].id); return true; }
|
||
if (event.key === 'Escape') { event.preventDefault(); closeSlash(); return true; }
|
||
return false;
|
||
},
|
||
},
|
||
});
|
||
|
||
lastMarkdown = unescapeDocLinks((editor.storage as any).markdown.getMarkdown());
|
||
onEditor?.(editor);
|
||
|
||
// Fetch the server's image-processor capabilities and push the
|
||
// supported-formats list into the AttachmentImage extension so
|
||
// its rotate toolbar can gate per-format. Async / fire-and-
|
||
// forget — the toolbar starts disabled (empty list) and
|
||
// snaps to the right state once this resolves. The endpoint
|
||
// is public, so the fetch works pre-login on shared-item
|
||
// preview surfaces too.
|
||
api.server.capabilities()
|
||
.then((caps) => {
|
||
const ext = editor?.extensionManager.extensions.find(
|
||
(e: { name: string }) => e.name === 'attachmentImage'
|
||
);
|
||
if (ext) ext.options.supportedFormats = caps.image.image_formats;
|
||
// Push the new list to any toolbars that were already
|
||
// open before this fetch resolved — without this, a
|
||
// user who selected an image during the in-flight
|
||
// capabilities request would see an indefinitely-
|
||
// disabled toolbar.
|
||
notifyAttachmentImageCapabilitiesChanged();
|
||
})
|
||
.catch(() => {
|
||
// Network blip / pre-login fetch failure → toolbar
|
||
// stays in its degraded "disabled with tooltip" state.
|
||
// We don't surface this to the user — the toolbar
|
||
// itself communicates the limitation.
|
||
});
|
||
|
||
editor.on('focus', () => { editorFocused = true; });
|
||
editor.on('blur', () => { editorFocused = false; });
|
||
|
||
// Prevent link navigation in edit mode — Tiptap's openOnClick:false
|
||
// doesn't fully prevent the browser default on all platforms (especially touch).
|
||
// Links are opened intentionally via the link popover's "Open" button.
|
||
editor.view.dom.addEventListener('click', (e) => {
|
||
const target = e.target as HTMLElement;
|
||
const link = target.closest('a');
|
||
if (link && editor?.view.dom.contains(link)) {
|
||
e.preventDefault();
|
||
}
|
||
});
|
||
|
||
// Prevent mobile keyboard from opening when tapping task list checkboxes
|
||
if (viewport.isMobile) {
|
||
editor.view.dom.addEventListener('touchend', (e) => {
|
||
const target = e.target as HTMLElement;
|
||
if (target.tagName === 'INPUT' && target.getAttribute('type') === 'checkbox' && target.closest('[data-type="taskItem"]')) {
|
||
// Let the checkbox toggle, but blur to prevent keyboard popup
|
||
requestAnimationFrame(() => {
|
||
editor?.view.dom.blur();
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// Track keyboard height via visualViewport for mobile toolbar positioning
|
||
if (window.visualViewport) {
|
||
const updateToolbarPos = () => {
|
||
const vv = window.visualViewport!;
|
||
const kbHeight = window.innerHeight - vv.height - vv.offsetTop;
|
||
toolbarBottom = kbHeight;
|
||
// Hide toolbar when keyboard is dismissed (viewport matches window)
|
||
keyboardVisible = kbHeight > 50;
|
||
};
|
||
visualViewportHandler = updateToolbarPos;
|
||
window.visualViewport.addEventListener('resize', updateToolbarPos);
|
||
window.visualViewport.addEventListener('scroll', updateToolbarPos);
|
||
}
|
||
});
|
||
|
||
onDestroy(() => {
|
||
editor?.destroy();
|
||
// onDestroy (unlike onMount) also runs during SSR, where `window` is
|
||
// undefined — guard with typeof before touching it (TASK-2109).
|
||
if (typeof window !== 'undefined' && window.visualViewport && visualViewportHandler) {
|
||
window.visualViewport.removeEventListener('resize', visualViewportHandler);
|
||
window.visualViewport.removeEventListener('scroll', visualViewportHandler);
|
||
}
|
||
});
|
||
|
||
$effect(() => {
|
||
// Only react to editable prop changes, not editor state changes
|
||
const shouldBeEditable = editable;
|
||
untrack(() => {
|
||
editor?.setEditable(shouldBeEditable);
|
||
});
|
||
});
|
||
|
||
// Master-freeze (PLAN-2179 DR-1 / TASK-2180): when the editor goes read-only
|
||
// (a peek), dismiss any open insertion UI — the slash / `[[` pickers and the
|
||
// Insert-from-URL modal — so none can linger over (or reappear on un-freeze
|
||
// above), or mutate, a frozen doc. Reactive counterpart to the old peeking
|
||
// `{#key}` remount, which reset this component state by destroying it. Split
|
||
// from the setEditable flip above per CONVE-606 (state reset vs sync).
|
||
$effect(() => {
|
||
if (editable) return;
|
||
untrack(() => {
|
||
slashOpen = false;
|
||
linkOpen = false;
|
||
importUrlModalOpen = false;
|
||
// Cancel an in-flight table column-resize: prosemirror-tables gates
|
||
// resize START on view.editable but its window mouseup completion does
|
||
// NOT recheck it, so a resize begun before the freeze would otherwise
|
||
// dispatch changed colwidths into the now-read-only doc (Codex round 3;
|
||
// direct Yjs repro confirmed). Clearing `dragging` (a meta-only tr — no
|
||
// doc step, no Yjs op, safe on a frozen view) makes that mouseup a
|
||
// no-op. The old peeking `{#key}` remount closed this by destroying the
|
||
// view mid-drag.
|
||
const view = editor?.view;
|
||
if (view && columnResizingPluginKey.getState(view.state)?.dragging) {
|
||
view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }));
|
||
}
|
||
});
|
||
});
|
||
|
||
// Sync content when prop changes (e.g. doc switch, external update).
|
||
//
|
||
// CRITICAL: when ydoc is set, this path MUST NOT run. Y.Doc is the
|
||
// authoritative state under collab; calling editor.commands.setContent
|
||
// would route through the y-tiptap binding as a LOCAL ProseMirror
|
||
// change and overwrite peers' state with stale REST markdown on
|
||
// every prop refresh / item switch. Markdown→Y.Doc seeding for the
|
||
// first-edit-on-empty case is TASK-1262's concern; it goes through
|
||
// Y.Doc's own primitives, not setContent.
|
||
const tracker: { prev: string | undefined } = { prev: undefined };
|
||
$effect(() => {
|
||
if (ydoc) {
|
||
// Capture the initial value so a future ydoc=undefined render
|
||
// (host route swapping back to non-collab mode) doesn't see
|
||
// `prev === undefined` and skip the first sync. In practice
|
||
// the host route doesn't switch ydoc on/off mid-editor today,
|
||
// but the cheap update here keeps the contract honest.
|
||
tracker.prev = content;
|
||
return;
|
||
}
|
||
if (tracker.prev === undefined) {
|
||
// First run: capture initial value without syncing
|
||
tracker.prev = content;
|
||
return;
|
||
}
|
||
if (editor && content !== tracker.prev) {
|
||
tracker.prev = content;
|
||
const currentEditorContent = unescapeDocLinks((editor.storage as any).markdown?.getMarkdown?.() ?? '');
|
||
if (currentEditorContent !== content) {
|
||
suppressUpdate = true;
|
||
editor.commands.setContent(content);
|
||
lastMarkdown = unescapeDocLinks((editor.storage as any).markdown?.getMarkdown?.() ?? '');
|
||
suppressUpdate = false;
|
||
}
|
||
}
|
||
});
|
||
|
||
|
||
export function getEditor(): Editor | null {
|
||
return editor;
|
||
}
|
||
|
||
function getTableToolbarPos(): { top: number; left: number } | null {
|
||
if (!editor || !element) return null;
|
||
const { selection } = editor.state;
|
||
const resolvedPos = selection.$from;
|
||
for (let d = resolvedPos.depth; d > 0; d--) {
|
||
if (resolvedPos.node(d).type.name === 'table') {
|
||
const tableStart = resolvedPos.before(d);
|
||
const dom = editor.view.nodeDOM(tableStart);
|
||
if (dom instanceof HTMLElement) {
|
||
const wrapperEl = element.parentElement;
|
||
if (!wrapperEl) return null;
|
||
const wrapperRect = wrapperEl.getBoundingClientRect();
|
||
const tableRect = dom.getBoundingClientRect();
|
||
return {
|
||
top: Math.max(0, tableRect.top - wrapperRect.top - 34),
|
||
left: tableRect.left - wrapperRect.left,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function openSlashFromToolbar() {
|
||
if (!editor) return;
|
||
editor.chain().focus().run();
|
||
slashStartPos = -1;
|
||
slashQuery = '';
|
||
slashIdx = 0;
|
||
slashX = 16;
|
||
slashY = 60;
|
||
slashOpen = true;
|
||
}
|
||
|
||
// Hidden file <input> that backs the attach-file button (mobile
|
||
// toolbar) and the /attach slash command. A real input is the only
|
||
// way to open a file picker on touch devices, where paste/drop —
|
||
// the editor's only other upload path — is unavailable (TASK-2067).
|
||
let attachInput = $state<HTMLInputElement>();
|
||
|
||
function triggerAttachPicker() {
|
||
attachInput?.click();
|
||
}
|
||
|
||
function onAttachInputChange(event: Event) {
|
||
const input = event.currentTarget as HTMLInputElement;
|
||
const files = input.files ? Array.from(input.files) : [];
|
||
if (files.length && editor) {
|
||
editor.commands.uploadAttachments(files);
|
||
}
|
||
// Reset so re-picking the same file fires 'change' again.
|
||
input.value = '';
|
||
}
|
||
|
||
type ListItemTypeName = 'listItem' | 'taskItem';
|
||
|
||
function getActiveListItemType(): ListItemTypeName | null {
|
||
if (!editor) return null;
|
||
const selectionAnchor = editor.state.selection.$from;
|
||
for (let depth = selectionAnchor.depth; depth > 0; depth--) {
|
||
const typeName = selectionAnchor.node(depth).type.name;
|
||
if (typeName === 'listItem' || typeName === 'taskItem') return typeName;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function canIndentListItem(type: ListItemTypeName | null): boolean {
|
||
if (!editor || !type) return false;
|
||
return editor.can().chain().focus().sinkListItem(type).run();
|
||
}
|
||
|
||
function canOutdentListItem(type: ListItemTypeName | null): boolean {
|
||
if (!editor || !type) return false;
|
||
return editor.can().chain().focus().liftListItem(type).run();
|
||
}
|
||
|
||
function indentCurrentListItem() {
|
||
const type = getActiveListItemType();
|
||
if (!editor || !type) return;
|
||
editor.chain().focus().sinkListItem(type).run();
|
||
}
|
||
|
||
function outdentCurrentListItem() {
|
||
const type = getActiveListItemType();
|
||
if (!editor || !type) return;
|
||
editor.chain().focus().liftListItem(type).run();
|
||
}
|
||
|
||
</script>
|
||
|
||
<!--
|
||
Gate on editorFocused, not just keyboardVisible (TASK-1124 follow-up).
|
||
`keyboardVisible` flips true whenever the on-screen keyboard appears
|
||
for ANY input — including the global CommandPalette's search input
|
||
and any other input on the page. Without the editorFocused gate this
|
||
mobile toolbar (z-index 100) would float over the search palette
|
||
(z-index 50) whenever the user typed in search on a page that has a
|
||
tiptap editor mounted. The component already tracks editorFocused via
|
||
the editor's own focus/blur events (declared at line 308, wired at
|
||
lines 658-659) — this just makes the toolbar visibility actually
|
||
consult it.
|
||
-->
|
||
{#if editable && viewport.isMobile && keyboardVisible && editor && editorFocused}
|
||
{@const _tick = editorTick}
|
||
{@const listItemType = getActiveListItemType()}
|
||
{@const canIndent = canIndentListItem(listItemType)}
|
||
{@const canOutdent = canOutdentListItem(listItemType)}
|
||
<div class="mobile-toolbar" role="toolbar" tabindex="0" style:bottom="{toolbarBottom}px" onmousedown={(e) => e.preventDefault()}>
|
||
<button class="mt-btn mt-btn-add" onclick={openSlashFromToolbar} title="Insert block">+</button>
|
||
<span class="mt-sep"></span>
|
||
<button class="mt-btn" class:active={_tick >= 0 && editor.isActive('bold')} onclick={() => editor?.chain().focus().toggleBold().run()}>B</button>
|
||
<button class="mt-btn" class:active={_tick >= 0 && editor.isActive('italic')} onclick={() => editor?.chain().focus().toggleItalic().run()}><em>I</em></button>
|
||
<button class="mt-btn" class:active={_tick >= 0 && editor.isActive('strike')} onclick={() => editor?.chain().focus().toggleStrike().run()}><s>S</s></button>
|
||
<span class="mt-sep"></span>
|
||
<button class="mt-btn" class:active={_tick >= 0 && editor.isActive('heading', { level: 2 })} onclick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
|
||
<button class="mt-btn" class:active={_tick >= 0 && editor.isActive('heading', { level: 3 })} onclick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}>H3</button>
|
||
<span class="mt-sep"></span>
|
||
<button class="mt-btn" class:active={_tick >= 0 && editor.isActive('bulletList')} onclick={() => editor?.chain().focus().toggleBulletList().run()}>•</button>
|
||
<button class="mt-btn" class:active={_tick >= 0 && editor.isActive('orderedList')} onclick={() => editor?.chain().focus().toggleOrderedList().run()}>1.</button>
|
||
<button class="mt-btn" class:active={_tick >= 0 && editor.isActive('taskList')} onclick={() => editor?.chain().focus().toggleTaskList().run()}>☐</button>
|
||
<button class="mt-btn" disabled={!canOutdent} onclick={outdentCurrentListItem} title="Outdent list item"><</button>
|
||
<button class="mt-btn" disabled={!canIndent} onclick={indentCurrentListItem} title="Indent list item">></button>
|
||
<span class="mt-sep"></span>
|
||
<button class="mt-btn" class:active={_tick >= 0 && editor.isActive('codeBlock')} onclick={() => editor?.chain().focus().toggleCodeBlock().run()}><></button>
|
||
<button class="mt-btn" class:active={_tick >= 0 && editor.isActive('blockquote')} onclick={() => editor?.chain().focus().toggleBlockquote().run()}>❝</button>
|
||
<span class="mt-sep"></span>
|
||
<button class="mt-btn" onclick={triggerAttachPicker} title="Attach file">📎</button>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="editor-wrapper">
|
||
<!--
|
||
Hidden file picker shared by the mobile toolbar attach button and
|
||
the /attach slash command. `multiple` mirrors paste/drop, which
|
||
can carry several files; no `accept` filter — images become inline
|
||
images and everything else becomes an attachment chip, same as
|
||
paste/drop (TASK-2067).
|
||
-->
|
||
<input
|
||
bind:this={attachInput}
|
||
type="file"
|
||
multiple
|
||
class="attach-file-input"
|
||
aria-hidden="true"
|
||
tabindex="-1"
|
||
onchange={onAttachInputChange}
|
||
/>
|
||
<div bind:this={element} class="editor-content prose"></div>
|
||
{#if editable && editor && editorTick >= 0 && editor.isActive('table')}
|
||
{@const tpos = getTableToolbarPos()}
|
||
{#if tpos}
|
||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||
<div class="table-toolbar" style:top="{tpos.top}px" style:left="{tpos.left}px" onmousedown={(e) => e.preventDefault()}>
|
||
<button class="tt-btn" onclick={() => editor?.chain().focus().addRowAfter().run()} title="Add row below">+ Row</button>
|
||
<button class="tt-btn" onclick={() => editor?.chain().focus().addColumnAfter().run()} title="Add column right">+ Col</button>
|
||
<span class="tt-sep"></span>
|
||
<button class="tt-btn" onclick={() => editor?.chain().focus().deleteRow().run()} title="Delete row">− Row</button>
|
||
<button class="tt-btn" onclick={() => editor?.chain().focus().deleteColumn().run()} title="Delete column">− Col</button>
|
||
<span class="tt-sep"></span>
|
||
<button class="tt-btn tt-btn-danger" onclick={() => editor?.chain().focus().deleteTable().run()} title="Delete table">✕</button>
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- `&& editable` (PLAN-2179 DR-1 / TASK-2180): reactively hide an open slash
|
||
menu the instant the master freezes, so a peeking editor can't insert via a
|
||
lingering picker. The old peeking `{#key}` remount dropped this state. -->
|
||
{#if slashOpen && editable}
|
||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||
<div role="none" style="position:fixed; inset:0; z-index:49;" onclick={closeSlash}></div>
|
||
<div class="slash-menu" style:left="{slashX}px" style:top="{slashY}px">
|
||
{#each getFilteredSlash() as item, i}
|
||
<button
|
||
class="slash-item"
|
||
class:selected={i === slashIdx}
|
||
onmouseenter={() => slashIdx = i}
|
||
onclick={() => execSlash(item.id)}
|
||
>
|
||
<span class="slash-icon">{item.icon}</span>
|
||
<span class="slash-title">{item.label}</span>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
{#if linkOpen && editable}
|
||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||
<div role="none" style="position:fixed; inset:0; z-index:49;" onclick={closeLink}></div>
|
||
<div class="slash-menu" style:left="{linkX}px" style:top="{linkY}px">
|
||
{#each getFilteredLinks() as doc, i (doc.id)}
|
||
<button
|
||
class="slash-item"
|
||
class:selected={i === linkIdx}
|
||
onmouseenter={() => linkIdx = i}
|
||
onclick={() => execLink(doc)}
|
||
>
|
||
<span class="slash-icon">{doc.collection_icon ?? '📄'}</span>
|
||
{#if formatItemRef(doc)}
|
||
<span class="slash-ref">{formatItemRef(doc)}</span>
|
||
{/if}
|
||
<span class="slash-title">{doc.title}</span>
|
||
</button>
|
||
{:else}
|
||
<div class="slash-item" style="color: var(--text-muted); cursor: default;">No matching documents</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
<ImportFromUrlModal bind:open={importUrlModalOpen} {editor} onInserted={onImportInserted} />
|
||
|
||
<style>
|
||
.editor-wrapper {
|
||
min-height: 200px;
|
||
position: relative;
|
||
}
|
||
.editor-content {
|
||
outline: none;
|
||
/* Pad left to make room for the drag handle */
|
||
padding-left: 24px;
|
||
}
|
||
|
||
/* Visually hidden but still focusable/clickable programmatically —
|
||
display:none inputs can't be .click()'d reliably on some browsers. */
|
||
.attach-file-input {
|
||
position: absolute;
|
||
width: 1px;
|
||
height: 1px;
|
||
padding: 0;
|
||
margin: -1px;
|
||
overflow: hidden;
|
||
clip: rect(0, 0, 0, 0);
|
||
border: 0;
|
||
}
|
||
|
||
/* Block drag handle */
|
||
.editor-wrapper :global(.block-drag-handle) {
|
||
position: absolute;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
color: var(--text-secondary);
|
||
cursor: grab;
|
||
font-size: 1.2em;
|
||
opacity: 0.5;
|
||
transition: opacity 0.15s, background 0.15s;
|
||
z-index: 10;
|
||
-webkit-user-select: none;
|
||
user-select: none;
|
||
-webkit-touch-callout: none;
|
||
touch-action: none;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
/* Larger touch target on mobile via invisible padding */
|
||
@media (max-width: 768px) {
|
||
.editor-wrapper :global(.block-drag-handle) {
|
||
width: 44px;
|
||
height: 44px;
|
||
font-size: 1.4em;
|
||
opacity: 0.7;
|
||
}
|
||
}
|
||
.editor-wrapper :global(.block-drag-handle:hover) {
|
||
opacity: 1;
|
||
background: var(--bg-hover);
|
||
color: var(--text-primary);
|
||
}
|
||
.editor-wrapper :global(.block-drag-handle.active) {
|
||
opacity: 1;
|
||
color: var(--accent-blue);
|
||
background: var(--bg-active);
|
||
cursor: grabbing;
|
||
}
|
||
.editor-wrapper :global(.block-drop-line) {
|
||
position: absolute;
|
||
left: 24px;
|
||
right: 0;
|
||
height: 3px;
|
||
background: var(--accent-blue);
|
||
border-radius: 2px;
|
||
z-index: 10;
|
||
pointer-events: none;
|
||
box-shadow: 0 0 4px var(--accent-blue);
|
||
}
|
||
/* Block context menu (appended to body, so use :global) */
|
||
:global(.block-context-menu) {
|
||
position: fixed;
|
||
z-index: 200;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
|
||
padding: 4px;
|
||
min-width: 180px;
|
||
max-height: 70vh;
|
||
overflow-y: auto;
|
||
}
|
||
:global(.block-menu-backdrop) {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 199;
|
||
}
|
||
:global(.block-menu-label) {
|
||
padding: 6px 10px 4px;
|
||
font-size: 0.7em;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--text-muted);
|
||
font-weight: 600;
|
||
}
|
||
:global(.block-menu-item) {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
padding: 7px 10px;
|
||
border: none;
|
||
background: none;
|
||
color: var(--text-primary);
|
||
font-size: 0.88em;
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
text-align: left;
|
||
font-family: inherit;
|
||
}
|
||
:global(.block-menu-item:hover),
|
||
:global(.block-menu-item.active) {
|
||
background: var(--bg-hover);
|
||
}
|
||
:global(.block-menu-item.active) {
|
||
color: var(--accent-blue);
|
||
}
|
||
:global(.block-menu-item-danger) {
|
||
color: var(--accent-red);
|
||
}
|
||
:global(.block-menu-item-danger:hover) {
|
||
background: rgba(239, 68, 68, 0.1);
|
||
}
|
||
:global(.block-menu-icon) {
|
||
width: 22px;
|
||
text-align: center;
|
||
font-weight: 600;
|
||
font-size: 0.9em;
|
||
flex-shrink: 0;
|
||
}
|
||
:global(.block-menu-divider) {
|
||
height: 1px;
|
||
background: var(--border);
|
||
margin: 4px 0;
|
||
}
|
||
.editor-content :global(.ProseMirror) {
|
||
outline: none;
|
||
min-height: 200px;
|
||
}
|
||
.editor-content :global(.ProseMirror p.is-editor-empty:first-child::before) {
|
||
content: attr(data-placeholder);
|
||
float: left;
|
||
color: var(--text-muted);
|
||
pointer-events: none;
|
||
height: 0;
|
||
}
|
||
|
||
/* Task list styles */
|
||
.editor-content :global(ul[data-type="taskList"]) {
|
||
list-style: none;
|
||
padding-left: 0;
|
||
}
|
||
.editor-content :global(ul[data-type="taskList"] li) {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
}
|
||
.editor-content :global(ul[data-type="taskList"] li label) {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
position: relative;
|
||
top: 1px;
|
||
}
|
||
.editor-content :global(ul[data-type="taskList"] li label input[type="checkbox"]) {
|
||
margin: 0;
|
||
cursor: pointer;
|
||
}
|
||
.editor-content :global(ul[data-type="taskList"] li > div) {
|
||
flex: 1;
|
||
}
|
||
|
||
/* Table styles */
|
||
.editor-content :global(table) {
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
margin: 0.8em 0;
|
||
}
|
||
.editor-content :global(th),
|
||
.editor-content :global(td) {
|
||
border: 1px solid var(--border);
|
||
padding: var(--space-2) var(--space-3);
|
||
text-align: left;
|
||
min-width: 80px;
|
||
}
|
||
.editor-content :global(th) {
|
||
background: var(--bg-secondary);
|
||
font-weight: 600;
|
||
}
|
||
.editor-content :global(.selectedCell) {
|
||
background: rgba(74, 158, 255, 0.1);
|
||
}
|
||
|
||
/* Code block */
|
||
.editor-content :global(pre) {
|
||
background: var(--bg-tertiary);
|
||
padding: var(--space-4);
|
||
border-radius: var(--radius);
|
||
overflow-x: auto;
|
||
margin: 0.8em 0;
|
||
font-family: var(--font-mono);
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
/* Mermaid diagrams (inline via NodeView) */
|
||
.editor-content :global(.mermaid-wrapper) {
|
||
position: relative;
|
||
margin: 0.8em 0;
|
||
background: var(--bg-tertiary);
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
}
|
||
.editor-content :global(.mermaid-diagram) {
|
||
padding: var(--space-4);
|
||
display: flex;
|
||
justify-content: center;
|
||
overflow-x: auto;
|
||
}
|
||
.editor-content :global(.mermaid-diagram svg) {
|
||
max-width: 100%;
|
||
height: auto;
|
||
}
|
||
.editor-content :global(.mermaid-error) {
|
||
color: var(--accent-orange);
|
||
font-size: 0.85em;
|
||
text-align: center;
|
||
}
|
||
.editor-content :global(.mermaid-toggle) {
|
||
position: absolute;
|
||
top: 4px;
|
||
right: 4px;
|
||
z-index: 5;
|
||
padding: 2px 8px;
|
||
font-size: 0.7em;
|
||
font-family: var(--font-mono);
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text-muted);
|
||
cursor: pointer;
|
||
opacity: 0;
|
||
transition: opacity 0.15s;
|
||
}
|
||
.editor-content :global(.mermaid-wrapper:hover .mermaid-toggle) {
|
||
opacity: 1;
|
||
}
|
||
.editor-content :global(.mermaid-toggle.active) {
|
||
opacity: 1;
|
||
color: var(--accent-blue);
|
||
border-color: var(--accent-blue);
|
||
}
|
||
.editor-content :global(.mermaid-source) {
|
||
margin: 0 !important;
|
||
border-radius: 0 !important;
|
||
}
|
||
|
||
/* HTML blocks (inline via NodeView — TASK-1325 / PLAN-1322) */
|
||
.editor-content :global(.html-block) {
|
||
position: relative;
|
||
margin: 0.8em 0;
|
||
background: var(--bg-tertiary);
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
transition: outline 0.12s;
|
||
}
|
||
.editor-content :global(.html-block--editing) {
|
||
outline: 1px solid var(--accent-blue);
|
||
outline-offset: 0;
|
||
}
|
||
.editor-content :global(.html-block-preview) {
|
||
padding: var(--space-3);
|
||
cursor: text;
|
||
}
|
||
.editor-content :global(.html-block--editing .html-block-preview) {
|
||
display: none;
|
||
}
|
||
.editor-content :global(.html-block-empty) {
|
||
color: var(--text-muted);
|
||
font-style: italic;
|
||
font-family: var(--font-mono);
|
||
font-size: 0.9em;
|
||
}
|
||
.editor-content :global(.html-block-source) {
|
||
display: none;
|
||
padding: var(--space-2);
|
||
}
|
||
.editor-content :global(.html-block--editing .html-block-source) {
|
||
display: block;
|
||
}
|
||
.editor-content :global(.html-block-source-input) {
|
||
width: 100%;
|
||
min-height: 120px;
|
||
max-height: 60vh;
|
||
padding: var(--space-2);
|
||
background: var(--bg-secondary);
|
||
color: var(--text-primary);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
font-family: var(--font-mono);
|
||
font-size: 0.9em;
|
||
line-height: 1.5;
|
||
resize: vertical;
|
||
box-sizing: border-box;
|
||
}
|
||
.editor-content :global(.html-block-source-input:focus) {
|
||
outline: none;
|
||
border-color: var(--accent-blue);
|
||
}
|
||
.editor-content :global(.html-block-actions) {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: var(--space-2);
|
||
}
|
||
.editor-content :global(.html-block-done-btn) {
|
||
padding: 4px 12px;
|
||
font-size: 0.85em;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
transition: color 0.12s, border-color 0.12s;
|
||
}
|
||
.editor-content :global(.html-block-done-btn:hover) {
|
||
color: var(--text-primary);
|
||
border-color: var(--accent-blue);
|
||
}
|
||
|
||
/* Hidden-content authoring warning (TASK-1327 / PLAN-1322) */
|
||
.editor-content :global(.html-block-warning) {
|
||
display: block;
|
||
width: 100%;
|
||
text-align: left;
|
||
padding: var(--space-2) var(--space-3);
|
||
background: var(--bg-warning, rgba(255, 180, 50, 0.12));
|
||
border: none;
|
||
border-bottom: 1px solid var(--border);
|
||
color: var(--accent-orange, #d68a3a);
|
||
font-size: 0.85em;
|
||
font-family: var(--font-mono);
|
||
cursor: pointer;
|
||
transition: background 0.12s;
|
||
}
|
||
.editor-content :global(.html-block-warning:hover) {
|
||
background: var(--bg-warning-hover, rgba(255, 180, 50, 0.22));
|
||
}
|
||
.editor-content :global(.html-block-inspector) {
|
||
padding: var(--space-3);
|
||
border-top: 1px solid var(--border);
|
||
background: var(--bg-secondary);
|
||
font-size: 0.9em;
|
||
}
|
||
.editor-content :global(.html-block-inspector-heading) {
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
margin-bottom: var(--space-2);
|
||
}
|
||
.editor-content :global(.html-block-inspector-list) {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0 0 var(--space-2) 0;
|
||
}
|
||
.editor-content :global(.html-block-inspector-item) {
|
||
padding: var(--space-2) 0;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.editor-content :global(.html-block-inspector-item:last-child) {
|
||
border-bottom: none;
|
||
}
|
||
.editor-content :global(.html-block-inspector-rule) {
|
||
color: var(--text-secondary);
|
||
}
|
||
.editor-content :global(.html-block-inspector-rule code) {
|
||
color: var(--accent-blue);
|
||
font-family: var(--font-mono);
|
||
font-size: 0.95em;
|
||
}
|
||
.editor-content :global(.html-block-inspector-snippet) {
|
||
margin-top: 4px;
|
||
padding: 4px var(--space-2);
|
||
color: var(--text-muted);
|
||
font-family: var(--font-mono);
|
||
font-size: 0.85em;
|
||
background: var(--bg-tertiary);
|
||
border-radius: var(--radius-sm);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.editor-content :global(.html-block-dismiss) {
|
||
padding: 4px 12px;
|
||
font-size: 0.85em;
|
||
background: var(--bg-tertiary);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
transition: color 0.12s, border-color 0.12s;
|
||
}
|
||
.editor-content :global(.html-block-dismiss:hover) {
|
||
color: var(--text-primary);
|
||
border-color: var(--accent-blue);
|
||
}
|
||
|
||
/* Mobile keyboard toolbar */
|
||
.mobile-toolbar {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 100;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
padding: var(--space-2) var(--space-3);
|
||
background: var(--bg-secondary);
|
||
border-top: 1px solid var(--border);
|
||
overflow-x: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
.mt-btn {
|
||
padding: var(--space-1) var(--space-2);
|
||
border-radius: var(--radius-sm);
|
||
font-size: 0.85em;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
min-width: 32px;
|
||
text-align: center;
|
||
font-family: var(--font-mono);
|
||
flex-shrink: 0;
|
||
}
|
||
@media (hover: hover) {
|
||
.mt-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||
}
|
||
.mt-btn:focus { outline: none; }
|
||
.mt-btn.active {
|
||
background: var(--bg-active);
|
||
color: var(--accent-blue);
|
||
}
|
||
.mt-btn:disabled {
|
||
opacity: 0.4;
|
||
cursor: default;
|
||
}
|
||
.mt-sep {
|
||
width: 1px;
|
||
height: 20px;
|
||
background: var(--border);
|
||
margin: 0 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
.slash-menu {
|
||
position: fixed; z-index: 50; background: var(--bg-secondary);
|
||
border: 1px solid var(--border); border-radius: var(--radius);
|
||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||
min-width: 200px; max-height: 320px; overflow-y: auto; padding: var(--space-1) 0;
|
||
}
|
||
.slash-item {
|
||
display: flex; align-items: center; gap: var(--space-3);
|
||
width: 100%; padding: var(--space-2) var(--space-3); text-align: left;
|
||
color: var(--text-primary); cursor: pointer; font-size: 0.9em;
|
||
}
|
||
.slash-item:hover, .slash-item.selected { background: var(--bg-hover); }
|
||
.slash-icon {
|
||
width: 24px; text-align: center; font-weight: 600; font-family: var(--font-mono);
|
||
font-size: 0.85em; color: var(--text-secondary);
|
||
}
|
||
.slash-title { font-weight: 500; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.slash-ref {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.75em;
|
||
color: var(--text-secondary);
|
||
background: var(--bg-hover);
|
||
padding: 1px 6px;
|
||
border-radius: 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Hover-to-reveal copy button on code blocks inside the editor */
|
||
.editor-wrapper :global(pre.code-block) {
|
||
position: relative;
|
||
}
|
||
.editor-wrapper :global(pre.code-block .code-copy-btn) {
|
||
position: absolute;
|
||
top: 6px;
|
||
right: 6px;
|
||
padding: 2px 8px;
|
||
font-size: 0.75em;
|
||
font-family: var(--font-sans, inherit);
|
||
color: var(--text-secondary);
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
opacity: 0;
|
||
transition: opacity 120ms ease, color 120ms ease, border-color 120ms ease;
|
||
user-select: none;
|
||
}
|
||
.editor-wrapper :global(pre.code-block:hover .code-copy-btn),
|
||
.editor-wrapper :global(pre.code-block .code-copy-btn:focus-visible) {
|
||
opacity: 1;
|
||
}
|
||
.editor-wrapper :global(pre.code-block .code-copy-btn:hover) {
|
||
color: var(--text-primary);
|
||
border-color: var(--text-secondary);
|
||
}
|
||
.editor-wrapper :global(pre.code-block .code-copy-btn.copied) {
|
||
color: var(--accent, #10b981);
|
||
border-color: var(--accent, #10b981);
|
||
opacity: 1;
|
||
}
|
||
|
||
/* Table toolbar */
|
||
.table-toolbar {
|
||
position: absolute;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
padding: 3px 4px;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||
z-index: 10;
|
||
white-space: nowrap;
|
||
}
|
||
.tt-btn {
|
||
padding: 3px 8px;
|
||
border-radius: var(--radius-sm);
|
||
font-size: 0.75em;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
font-family: inherit;
|
||
}
|
||
.tt-btn:hover {
|
||
background: var(--bg-hover);
|
||
color: var(--text-primary);
|
||
}
|
||
.tt-btn-danger:hover {
|
||
background: rgba(239, 68, 68, 0.1);
|
||
color: var(--accent-red);
|
||
}
|
||
.tt-sep {
|
||
width: 1px;
|
||
height: 16px;
|
||
background: var(--border);
|
||
margin: 0 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Mobile + button */
|
||
.mt-btn-add {
|
||
font-size: 1.1em;
|
||
color: var(--accent-blue);
|
||
}
|
||
|
||
/* CollaborationCaret presence (TASK-1263 / PLAN-1248). Styles
|
||
the remote-peer carets and selections rendered by the
|
||
@tiptap/extension-collaboration-caret default builders. The
|
||
user's color comes through inline via `border-color` /
|
||
`background-color` on the caret span and a light tint on the
|
||
selection wrapper. */
|
||
.editor-content :global(.collaboration-carets__caret) {
|
||
position: relative;
|
||
margin-left: -1px;
|
||
margin-right: -1px;
|
||
border-left: 1px solid;
|
||
border-right: 1px solid;
|
||
word-break: normal;
|
||
/* `pointer-events: none` would make the caret unhoverable
|
||
AND hide it from selection toolbars but that's fine — we
|
||
no longer rely on hover for the label (Codex review
|
||
round 1 [P2]: the caret is 1-2px wide so hover is
|
||
effectively unreachable anyway). The label is now always
|
||
visible while the peer is active, and the caret stays
|
||
click-through so it doesn't intercept text-selection or
|
||
click-to-place-cursor. */
|
||
pointer-events: none;
|
||
}
|
||
.editor-content :global(.collaboration-carets__label) {
|
||
position: absolute;
|
||
top: -1.4em;
|
||
left: -1px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
line-height: 1;
|
||
color: #fff;
|
||
padding: 0.2em 0.4em;
|
||
border-radius: 3px 3px 3px 0;
|
||
white-space: nowrap;
|
||
user-select: none;
|
||
pointer-events: none;
|
||
}
|
||
/* Remote-peer selection highlight. y-tiptap's default
|
||
selection builder applies `ProseMirror-yjs-selection` AND an
|
||
inline `background-color: <userColor>70` (where `70` is alpha
|
||
hex). We don't override the inline color here — that's
|
||
already coloured per-user — but a base rule keeps the
|
||
selection visible if the inline style fails to parse. */
|
||
.editor-content :global(.ProseMirror-yjs-selection) {
|
||
background-color: rgba(125, 125, 125, 0.18);
|
||
}
|
||
</style>
|