mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 19:32:10 +00:00
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 `` 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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user