mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 03:16:43 +00:00
b253a2be6f
Final review round 3, on the test suite as a deliverable. The panel had no producer-to-host test: the strip's tests mock the event bus, the panel host's tests emit on it directly, and between them a broken hostToken thread through ItemDetail would have passed everything. Verified by breaking that thread — the new browser test fails, the whole unit suite stays green. It is also the only place DR-12's "activates exactly once per key press" can be demonstrated at all: jsdom does not synthesise a button's activation click, so the unit test could only ever prove the narrower "no hand-rolled handler races the UA click". That test is renamed to claim exactly that, with a pointer to where the real one lives. Also folds the workspace into the host-address reader. It was captured once in the Tiptap options while the URL builder stayed live, so a mounted chip surviving a pane workspace switch would probe under the PREVIOUS workspace's key — a cross-workspace answer, cached under the wrong key. Same staleness class as the item id, one axis over. This incidentally makes the image extension's `address` option load-bearing rather than the dead plumbing the review flagged: its probes read the live workspace through it now. isAddressable deliberately takes only the two ROUTING fields — the workspace rides along for cache keying and says nothing about whether an event can find its host.
363 lines
11 KiB
Svelte
363 lines
11 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* Lean WYSIWYG comment editor (TASK-1664 / PLAN-1662). A small, purpose-
|
|
* built Tiptap instance — NOT the heavy block editor (Editor.svelte). It
|
|
* reuses the shared attachment pipeline (AttachmentUpload plugin +
|
|
* AttachmentImage/AttachmentChip nodes) and tiptap-markdown so pasted/
|
|
* dropped images show as inline thumbnails while composing, yet the value
|
|
* round-trips to plain markdown — comment.body stays markdown, so display,
|
|
* search, and the orphan-GC are untouched.
|
|
*
|
|
* Deliberately excludes tables, task lists, slash commands, the block drag
|
|
* handle, the import-from-URL modal, and collaboration — comments don't
|
|
* need a document editor.
|
|
*
|
|
* Used for the new-comment composer, the reply box, and (TASK-1665) inline
|
|
* edit mode.
|
|
*/
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { Editor } from '@tiptap/core';
|
|
import StarterKit from '@tiptap/starter-kit';
|
|
import Link from '@tiptap/extension-link';
|
|
import Placeholder from '@tiptap/extension-placeholder';
|
|
import { Markdown } from 'tiptap-markdown';
|
|
import { api } from '$lib/api/client';
|
|
import { notifyAttachmentUploaded, toUploadedAttachment } from '$lib/attachments/events';
|
|
import { unescapeDocLinks } from '$lib/utils/markdown';
|
|
import { AttachmentImage } from './editor/attachment-image';
|
|
import { AttachmentChip } from './editor/attachment-chip';
|
|
import { AttachmentUpload } from './editor/attachment-upload';
|
|
import type { AttachmentHostAddress } from '$lib/attachments/hostAddress';
|
|
|
|
interface Props {
|
|
/** Initial markdown body. Parsed as markdown on mount. */
|
|
content?: string;
|
|
placeholder?: string;
|
|
/** Workspace slug — required for attachment upload + image URLs. */
|
|
wsSlug: string;
|
|
/**
|
|
* UUID of the item this comment belongs to. Sent with attachment
|
|
* uploads so the server can authorize against the item's grant chain
|
|
* — a grant-based editor (share-link guest, 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 composer
|
|
* (PLAN-2392 DR-8 / TASK-2421). Threaded down from ItemDetail through
|
|
* ItemTimeline (and TimelineCommentCard for edits/replies) so an
|
|
* attachment chip in a comment body can address the ONE host that
|
|
* owns it — a master and a peeked pane are both mounted, and `itemId`
|
|
* alone would let both consume the same event. Empty (the default,
|
|
* for composers mounted outside an ItemDetail) disables addressing
|
|
* rather than broadcasting.
|
|
*/
|
|
hostToken?: string;
|
|
/** Label for the submit button (e.g. "Comment", "Reply", "Save"). */
|
|
submitLabel?: string;
|
|
/** External busy flag (network in flight in the host). */
|
|
submitting?: boolean;
|
|
autofocus?: boolean;
|
|
/** Show a Cancel button + enable Esc-to-cancel (reply / edit mode). */
|
|
onCancel?: () => void;
|
|
/**
|
|
* Called with the current markdown when the user submits. May return a
|
|
* promise; on resolution the editor clears (composer behaviour). If it
|
|
* throws, the draft is kept so the user can retry.
|
|
*/
|
|
onSubmit: (markdown: string) => void | Promise<void>;
|
|
}
|
|
|
|
let {
|
|
content = '',
|
|
placeholder = 'Write a comment…',
|
|
wsSlug,
|
|
itemId,
|
|
hostToken = '',
|
|
submitLabel = 'Comment',
|
|
submitting = false,
|
|
autofocus = false,
|
|
onCancel,
|
|
onSubmit
|
|
}: Props = $props();
|
|
|
|
let element: HTMLDivElement | undefined = $state();
|
|
let editor: Editor | undefined;
|
|
let pendingUploads = $state(0);
|
|
let empty = $state(true);
|
|
let saving = $state(false);
|
|
|
|
let busy = $derived(submitting || saving || pendingUploads > 0);
|
|
|
|
function currentMarkdown(): string {
|
|
if (!editor) return '';
|
|
return unescapeDocLinks((editor.storage as any).markdown?.getMarkdown?.() ?? '').trim();
|
|
}
|
|
|
|
async function doSubmit() {
|
|
if (busy || !editor) return;
|
|
const md = currentMarkdown();
|
|
if (md === '') return;
|
|
// Capture the composer's item identity BEFORE the await. This composer
|
|
// is REUSED across a no-{#key} item switch in the timeline (its `itemId`
|
|
// prop just changes), so if the user switches A→B while A's submit is in
|
|
// flight, clearing on completion would ERASE B's freshly-typed draft
|
|
// (PLAN-2105 / TASK-2112; Codex). Only clear when the composer is still
|
|
// on the same item and its editor is still alive.
|
|
const reqWs = wsSlug;
|
|
const reqItem = itemId;
|
|
saving = true;
|
|
try {
|
|
await onSubmit(md);
|
|
// Composer behaviour: clear on success. In edit/reply mode the host
|
|
// unmounts this component, so the clear is harmless there.
|
|
if (editor && !editor.isDestroyed && reqWs === wsSlug && reqItem === itemId) {
|
|
editor.commands.clearContent();
|
|
}
|
|
} catch {
|
|
// Keep the draft so the user can retry.
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reads the CURRENT host address at emit time (PLAN-2392 DR-8).
|
|
*
|
|
* This composer is reused across a no-{#key} item switch — the same reason
|
|
* `doSubmit` above captures its item before awaiting — so a value baked
|
|
* into the extension config at mount would address the PREVIOUS item after
|
|
* a switch, and the host would correctly ignore the event. Tiptap's
|
|
* `options` getter returns a fresh spread per access, so there is no
|
|
* writing the new value in afterwards either (see hostAddress.ts).
|
|
*/
|
|
const readHostAddress = (): AttachmentHostAddress => ({
|
|
workspaceSlug: wsSlug,
|
|
itemId: itemId ?? '',
|
|
hostToken
|
|
});
|
|
|
|
const attachmentUrl = (uuid: string, variant?: 'thumb-sm' | 'thumb-md' | 'original') =>
|
|
wsSlug ? api.attachments.downloadUrl(wsSlug, uuid, variant) : `pad-attachment:${uuid}`;
|
|
|
|
onMount(() => {
|
|
if (!element) return;
|
|
|
|
editor = new Editor({
|
|
element,
|
|
content,
|
|
autofocus: autofocus ? 'end' : false,
|
|
extensions: [
|
|
StarterKit.configure({ link: false }),
|
|
Link.configure({ openOnClick: false, autolink: true, linkOnPaste: true }),
|
|
Placeholder.configure({ placeholder }),
|
|
Markdown.configure({ html: true, transformPastedText: true, transformCopiedText: true }),
|
|
AttachmentImage.configure({
|
|
getDownloadUrl: attachmentUrl,
|
|
workspaceSlug: wsSlug,
|
|
// Panel / viewer addressing (PLAN-2392 DR-8).
|
|
address: readHostAddress,
|
|
// Rotate/crop stays disabled in comments — keep it lean.
|
|
supportedFormats: [] as string[],
|
|
transform: async () => {
|
|
throw new Error('Image transforms are not available in comments.');
|
|
}
|
|
}),
|
|
AttachmentChip.configure({
|
|
getDownloadUrl: attachmentUrl,
|
|
workspaceSlug: wsSlug,
|
|
address: readHostAddress
|
|
}),
|
|
AttachmentUpload.configure({
|
|
// Wrap upload so the host can track in-flight uploads and gate
|
|
// submit — the plugin doesn't expose its placeholder count.
|
|
upload: async (file) => {
|
|
if (!wsSlug) {
|
|
throw new Error('No workspace context — open a comment inside a workspace to attach files.');
|
|
}
|
|
pendingUploads += 1;
|
|
try {
|
|
const uploadItemId = itemId;
|
|
const result = await api.attachments.upload(wsSlug, file, uploadItemId);
|
|
// A comment upload carries item context too, so the
|
|
// server associates it and it belongs in that item's
|
|
// attachment strip — announce it like the body editor
|
|
// does, or the strip stays stale until reload
|
|
// (PLAN-2382 / TASK-2385).
|
|
notifyAttachmentUploaded(uploadItemId, toUploadedAttachment(result));
|
|
return result;
|
|
} finally {
|
|
pendingUploads -= 1;
|
|
}
|
|
},
|
|
onError: (filename, message) => {
|
|
console.error(`[comment attachment] ${filename}: ${message}`);
|
|
if (typeof window !== 'undefined' && typeof window.alert === 'function') {
|
|
window.alert(`Couldn't upload ${filename}: ${message}`);
|
|
}
|
|
}
|
|
})
|
|
],
|
|
editorProps: {
|
|
handleKeyDown: (_view, event) => {
|
|
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
event.preventDefault();
|
|
doSubmit();
|
|
return true;
|
|
}
|
|
if (event.key === 'Escape' && onCancel) {
|
|
event.preventDefault();
|
|
onCancel();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
},
|
|
onUpdate: ({ editor: e }) => {
|
|
empty = e.isEmpty;
|
|
}
|
|
});
|
|
empty = editor.isEmpty;
|
|
});
|
|
|
|
onDestroy(() => {
|
|
editor?.destroy();
|
|
});
|
|
</script>
|
|
|
|
<div class="comment-editor" class:busy>
|
|
<div class="ce-surface prose" bind:this={element}></div>
|
|
<div class="ce-actions">
|
|
<span class="ce-hint">
|
|
{#if pendingUploads > 0}
|
|
Uploading {pendingUploads} file{pendingUploads === 1 ? '' : 's'}…
|
|
{:else}
|
|
{onCancel ? 'Ctrl+Enter to submit · Esc to cancel' : 'Ctrl+Enter to submit · paste or drop an image'}
|
|
{/if}
|
|
</span>
|
|
<div class="ce-buttons">
|
|
{#if onCancel}
|
|
<button class="ce-cancel" type="button" onclick={onCancel} disabled={saving}>Cancel</button>
|
|
{/if}
|
|
<button class="ce-submit" type="button" onclick={doSubmit} disabled={busy || empty}>
|
|
{saving || submitting ? 'Posting…' : submitLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.comment-editor {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.ce-surface {
|
|
width: 100%;
|
|
min-height: 60px;
|
|
max-height: 360px;
|
|
overflow-y: auto;
|
|
padding: var(--space-2) var(--space-3);
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
color: var(--text-primary);
|
|
font-size: 0.9em;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.ce-surface :global(.ProseMirror) {
|
|
outline: none;
|
|
min-height: 44px;
|
|
}
|
|
|
|
/* Placeholder (Placeholder extension renders a data-attr on the empty doc). */
|
|
.ce-surface :global(.ProseMirror p.is-editor-empty:first-child::before) {
|
|
content: attr(data-placeholder);
|
|
color: var(--text-muted);
|
|
float: left;
|
|
height: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.ce-surface :global(p:first-child) {
|
|
margin-top: 0;
|
|
}
|
|
.ce-surface :global(p:last-child) {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
/* Inline image previews render as compact thumbnails while composing,
|
|
matching the rendered-comment display. */
|
|
.ce-surface :global(img[data-attachment-id]) {
|
|
max-width: 280px;
|
|
max-height: 180px;
|
|
width: auto;
|
|
height: auto;
|
|
object-fit: contain;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
|
|
.ce-surface:focus-within {
|
|
border-color: var(--accent-blue);
|
|
}
|
|
|
|
.comment-editor.busy .ce-surface {
|
|
opacity: 0.85;
|
|
}
|
|
|
|
.ce-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.ce-hint {
|
|
font-size: 0.75em;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.ce-buttons {
|
|
display: flex;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.ce-submit {
|
|
padding: var(--space-1) var(--space-4);
|
|
background: var(--accent-blue);
|
|
border: none;
|
|
border-radius: var(--radius);
|
|
color: #fff;
|
|
font-size: 0.85em;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.ce-submit:hover:not(:disabled) {
|
|
filter: brightness(1.1);
|
|
}
|
|
|
|
.ce-submit:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.ce-cancel {
|
|
padding: var(--space-1) var(--space-3);
|
|
background: transparent;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
color: var(--text-secondary);
|
|
font-size: 0.85em;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.ce-cancel:hover:not(:disabled) {
|
|
background: var(--bg-tertiary);
|
|
}
|
|
</style>
|