feat(comments): lean Tiptap CommentEditor — inline image thumbnails (TASK-1664) (#666)

Replaces the plain-textarea comment composer and reply box with a small
WYSIWYG editor so pasted/dropped images render as inline thumbnails
instead of `![](pad-attachment:…)` markdown text.

- web/src/lib/components/CommentEditor.svelte: a purpose-built Tiptap
  instance (NOT the heavy Editor.svelte) — StarterKit basics + Link +
  Placeholder + tiptap-markdown + the shared attachment pipeline
  (AttachmentUpload plugin + AttachmentImage/AttachmentChip nodes). No
  tables/slash/collab/URL-modal. Emits markdown via the markdown storage
  (round-trips through the nodes' addStorage serializers), so comment.body
  stays markdown — display, lightbox, search, and orphan-GC are untouched.
  Wraps the upload fn to track in-flight uploads and gate submit (the
  plugin doesn't expose its placeholder count). Ctrl/Cmd+Enter submits,
  Esc cancels (reply mode).
- ItemTimeline composer + TimelineCommentCard reply box now render
  CommentEditor; submitComment/submitReply take the markdown string and
  throw on failure so the editor preserves the draft. Removed the
  textarea + commentAttachments paste/drop wiring and now-dead CSS.

Inline thumbnails in the editor are capped to match the rendered-comment
display. Parent: PLAN-1662. Unblocks TASK-1665 (edit mode reuses this).
This commit is contained in:
xarmian
2026-05-30 12:49:05 -04:00
committed by GitHub
parent 076fb9b2e7
commit 6ed16ef930
3 changed files with 330 additions and 304 deletions
+299
View File
@@ -0,0 +1,299 @@
<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 { unescapeDocLinks } from '$lib/utils/markdown';
import { AttachmentImage } from './editor/attachment-image';
import { AttachmentChip } from './editor/attachment-chip';
import { AttachmentUpload } from './editor/attachment-upload';
interface Props {
/** Initial markdown body. Parsed as markdown on mount. */
content?: string;
placeholder?: string;
/** Workspace slug — required for attachment upload + image URLs. */
wsSlug: 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,
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;
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.
editor.commands.clearContent();
} catch {
// Keep the draft so the user can retry.
} finally {
saving = false;
}
}
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,
// 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 }),
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 {
return await api.attachments.upload(wsSlug, file);
} 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>
@@ -8,16 +8,11 @@
import TimelineCommentCard from './TimelineCommentCard.svelte';
import TimelineActivityCard from './TimelineActivityCard.svelte';
import TimelineVersionCard from './TimelineVersionCard.svelte';
import {
filesFromPaste,
filesFromDrop,
isFileDrag,
uploadIntoTextarea,
attachmentRefsIn
} from '$lib/utils/commentAttachments';
import { attachmentRefsIn } from '$lib/utils/commentAttachments';
import { fetchAttachmentMetadata } from '$lib/components/editor/attachment-metadata';
import { attachmentDownloadUrl, type AttachmentMeta } from '$lib/markdown/attachments';
import Lightbox, { type LightboxImage } from '$lib/components/common/Lightbox.svelte';
import CommentEditor from '$lib/components/CommentEditor.svelte';
interface Props {
wsSlug: string;
@@ -51,13 +46,6 @@
let loading: boolean = $state(false);
let loadingMore: boolean = $state(false);
let error: string = $state('');
let newBody: string = $state('');
// Comment composer attachment state (IDEA-1650). pendingUploads gates
// submit while a paste/drop upload is in flight; composeTextarea is the
// caret anchor the upload helper splices markdown into.
let pendingUploads: number = $state(0);
let composeTextarea: HTMLTextAreaElement | undefined = $state();
// Resolver for `pad-attachment:UUID` references in comment bodies.
// Metadata (MIME + size) is fetched lazily per UUID via a HEAD probe and
@@ -98,29 +86,6 @@
}
});
function startComposeUploads(files: File[]) {
if (!composeTextarea) return;
uploadIntoTextarea(files, composeTextarea, wsSlug, {
getValue: () => newBody,
setValue: (v) => {
newBody = v;
},
onPendingDelta: (d) => {
pendingUploads += d;
},
onError: (msg) => {
error = msg;
}
});
}
function handleComposePaste(e: ClipboardEvent) {
const files = filesFromPaste(e);
if (files.length === 0) return;
e.preventDefault();
startComposeUploads(files);
}
// Lightbox state (IDEA-1660). Set when a thumbnail is activated; cleared
// on close. Null = closed, so the host remounts fresh on each open.
let lightbox: { images: LightboxImage[]; index: number } | null = $state(null);
@@ -194,20 +159,6 @@
});
});
function handleComposeDragOver(e: DragEvent) {
// Cancel only file drags so the browser delivers the drop here
// instead of navigating; text drag-drop within the textarea is left
// to default handling.
if (isFileDrag(e)) e.preventDefault();
}
function handleComposeDrop(e: DragEvent) {
const files = filesFromDrop(e);
if (files.length === 0) return;
e.preventDefault();
startComposeUploads(files);
}
// Current user ID for reaction toggle — read from the global auth store.
let currentUserId = $derived(authStore.userId);
@@ -311,31 +262,26 @@
let submitting: boolean = $state(false);
async function submitComment() {
if (!newBody.trim() || submitting || pendingUploads > 0) return;
// Posts a new comment. Throws on failure so CommentEditor preserves the
// draft; clears itself on success.
async function submitComment(body: string) {
submitting = true;
error = '';
try {
await api.comments.create(wsSlug, itemSlug, {
body: newBody.trim(),
body,
created_by: 'user',
source: 'web'
});
newBody = '';
await loadTimeline();
} catch (err: any) {
error = err?.message ?? 'Failed to post comment';
throw err;
} finally {
submitting = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
submitComment();
}
}
async function handleReply(commentId: string, body: string) {
try {
await api.comments.reply(wsSlug, commentId, {
@@ -397,32 +343,13 @@
thread but cannot post; the composer is hidden entirely. -->
{#if canEdit}
<div class="compose">
<textarea
class="compose-input"
bind:this={composeTextarea}
placeholder="Write a comment... (paste or drop an image to attach)"
bind:value={newBody}
onkeydown={handleKeydown}
onpaste={handleComposePaste}
ondragover={handleComposeDragOver}
ondrop={handleComposeDrop}
disabled={submitting}
></textarea>
<div class="compose-actions">
<span class="shortcut-hint">
{pendingUploads > 0
? `Uploading ${pendingUploads} file${pendingUploads === 1 ? '' : 's'}…`
: 'Ctrl+Enter to submit'}
</span>
<button
class="submit-btn"
type="button"
disabled={!newBody.trim() || submitting || pendingUploads > 0}
onclick={submitComment}
>
{submitting ? 'Posting...' : 'Comment'}
</button>
</div>
<CommentEditor
{wsSlug}
placeholder="Write a comment… (paste or drop an image to attach)"
submitLabel="Comment"
{submitting}
onSubmit={submitComment}
/>
</div>
{/if}
@@ -538,65 +465,6 @@
gap: var(--space-2);
}
.compose-input {
width: 100%;
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;
font-family: inherit;
line-height: 1.5;
resize: vertical;
min-height: 60px;
}
.compose-input::placeholder {
color: var(--text-muted);
}
.compose-input:focus {
outline: none;
border-color: var(--accent-blue);
}
.compose-input:disabled {
opacity: 0.6;
}
.compose-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-3);
}
.shortcut-hint {
font-size: 0.75em;
color: var(--text-muted);
}
.submit-btn {
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;
}
.submit-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Loading / Error ──────────────────────────────────────────────────── */
.loading {
@@ -2,7 +2,7 @@
import type { Comment, Item, Reaction } from '$lib/types';
import { relativeTime, renderMarkdown } from '$lib/utils/markdown';
import type { AttachmentResolver } from '$lib/markdown/attachments';
import { filesFromPaste, filesFromDrop, isFileDrag, uploadIntoTextarea } from '$lib/utils/commentAttachments';
import CommentEditor from '$lib/components/CommentEditor.svelte';
import ReactionPicker from './ReactionPicker.svelte';
interface Props {
@@ -36,75 +36,22 @@
let { comment, wsSlug, username = '', items, currentUserId = '', canEdit = true, attachmentResolver, onDelete, onReply, onReaction, onRemoveReaction }: Props = $props();
let showReplyForm = $state(false);
let replyBody = $state('');
let submittingReply = $state(false);
// Reply-box attachment upload (IDEA-1650). Mirrors the top-level
// composer in ItemTimeline; replyPending gates submit while a
// paste/drop upload is in flight.
let replyTextarea: HTMLTextAreaElement | undefined = $state();
let replyPending = $state(0);
function startReplyUploads(files: File[]) {
if (!replyTextarea) return;
uploadIntoTextarea(files, replyTextarea, wsSlug, {
getValue: () => replyBody,
setValue: (v) => {
replyBody = v;
},
onPendingDelta: (d) => {
replyPending += d;
},
onError: (msg) => {
if (typeof window !== 'undefined') window.alert(`Couldn't upload: ${msg}`);
}
});
}
function handleReplyPaste(e: ClipboardEvent) {
const files = filesFromPaste(e);
if (files.length === 0) return;
e.preventDefault();
startReplyUploads(files);
}
function handleReplyDragOver(e: DragEvent) {
if (isFileDrag(e)) e.preventDefault();
}
function handleReplyDrop(e: DragEvent) {
const files = filesFromDrop(e);
if (files.length === 0) return;
e.preventDefault();
startReplyUploads(files);
}
async function submitReply() {
const body = replyBody.trim();
if (!body || submittingReply || replyPending > 0) return;
// Posts a reply via the host callback. Throws on failure so CommentEditor
// keeps the draft; closes the form on success.
async function submitReply(body: string) {
submittingReply = true;
try {
await onReply(comment.id, body);
replyBody = '';
showReplyForm = false;
} catch {
// Keep draft on failure so the user can retry.
} catch (err) {
throw err;
} finally {
submittingReply = false;
}
}
function handleReplyKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
submitReply();
}
if (e.key === 'Escape') {
showReplyForm = false;
replyBody = '';
}
}
interface ReactionGroup {
emoji: string;
count: number;
@@ -245,30 +192,15 @@
{#if showReplyForm && canEdit}
<div class="reply-compose">
<textarea
class="reply-input"
bind:this={replyTextarea}
placeholder="Write a reply... (paste or drop an image to attach)"
bind:value={replyBody}
onkeydown={handleReplyKeydown}
onpaste={handleReplyPaste}
ondragover={handleReplyDragOver}
ondrop={handleReplyDrop}
disabled={submittingReply}
></textarea>
<div class="reply-actions">
<span class="reply-hint">
{replyPending > 0
? `Uploading ${replyPending} file${replyPending === 1 ? '' : 's'}…`
: 'Ctrl+Enter to submit · Esc to cancel'}
</span>
<div class="reply-buttons">
<button class="reply-cancel" type="button" onclick={() => { showReplyForm = false; replyBody = ''; }}>Cancel</button>
<button class="reply-submit" type="button" disabled={!replyBody.trim() || submittingReply || replyPending > 0} onclick={submitReply}>
{submittingReply ? 'Posting...' : 'Reply'}
</button>
</div>
</div>
<CommentEditor
{wsSlug}
placeholder="Write a reply… (paste or drop an image to attach)"
submitLabel="Reply"
autofocus
submitting={submittingReply}
onSubmit={submitReply}
onCancel={() => { showReplyForm = false; }}
/>
</div>
{/if}
@@ -549,80 +481,7 @@
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.reply-input {
width: 100%;
padding: var(--space-2) var(--space-3);
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.85em;
font-family: inherit;
line-height: 1.5;
resize: vertical;
min-height: 52px;
}
.reply-input::placeholder {
color: var(--text-muted);
}
.reply-input:focus {
outline: none;
border-color: var(--accent-blue);
}
.reply-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.reply-hint {
font-size: 0.7em;
color: var(--text-muted);
}
.reply-buttons {
display: flex;
gap: var(--space-2);
}
.reply-cancel {
padding: var(--space-1) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
color: var(--text-muted);
font-size: 0.8em;
cursor: pointer;
}
.reply-cancel:hover {
color: var(--text-primary);
border-color: var(--text-muted);
}
.reply-submit {
padding: var(--space-1) var(--space-3);
background: var(--accent-blue);
border: none;
border-radius: var(--radius-sm);
color: #fff;
font-size: 0.8em;
font-weight: 500;
cursor: pointer;
}
.reply-submit:hover:not(:disabled) {
filter: brightness(1.1);
}
.reply-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
margin-top: var(--space-2);
}
.replies {