feat: polish bouquet edit flow (chat UI, pencil overlay, prompt tuning)

* feat: add dynamic artwork copy to upload page

* feat: add pencil overlay for partial bouquet edits

* feat: move edit step hints to description card

* feat: add chat-style edit flow and tighten edit image prompts
This commit is contained in:
Chaewon Lee
2026-06-14 15:14:18 +09:00
committed by GitHub
parent b50f57a6d6
commit 10881ded29
6 changed files with 396 additions and 243 deletions

View File

@@ -1,5 +1,5 @@
<script>
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import DescriptionCard from '$lib/components/ui/Artwork/DescriptionCard.svelte';
@@ -18,7 +18,7 @@
let loading = $state(true);
let error = $state('');
let prompt = $state('');
let mode = $state('whole');
let areaSelectionActive = $state(false);
let drawing = $state(false);
let selectionPoints = $state([]);
let initialImage = $state(null);
@@ -26,20 +26,50 @@
let recipe = $state(null);
let editing = $state(false);
let continuing = $state(false);
let editHistory = $state([]);
/** @type {Array<{ id: string, role: 'user' | 'assistant', prompt?: string, mode?: string, status?: 'pending' | 'done' | 'error', afterImage?: { mimeType: string, base64: string } | null, error?: string }>} */
let chatMessages = $state([]);
/** @type {HTMLDivElement | null} */
let chatScrollEl = $state(null);
const imageSrc = $derived(toDataUrl(generatedImage));
const hasAreaSelection = $derived(selectionPoints.length > 2);
const title = $derived(recipe?.concept ?? 'Generated bouquet');
const description = $derived(
recipe?.mainFlowers?.length
? `${recipe.mainFlowers.join(', ')} · ${recipe.wrapping ?? 'Custom wrap'}`
: 'Review and refine your bouquet before choosing a size.'
);
const description = $derived.by(() => {
if (hasAreaSelection) {
return 'Your prompt will apply to the marked area only.';
}
if (areaSelectionActive) {
return 'Use the pencil to draw a red outline, then describe that area.';
}
return 'Tap the pencil on the image to mark an area, or edit the whole bouquet.';
});
const selectionPolyline = $derived(
selectionPoints.map((point) => `${point.x},${point.y}`).join(' ')
);
const canSaveAreaPrompt = $derived(mode !== 'area' || selectionPoints.length > 2);
const latestEditId = $derived(editHistory[editHistory.length - 1]?.id ?? '');
const latestAssistantId = $derived.by(() => {
for (let index = chatMessages.length - 1; index >= 0; index -= 1) {
const message = chatMessages[index];
if (message.role === 'assistant' && message.status === 'done') {
return message.id;
}
}
return '';
});
async function scrollChatToBottom() {
await tick();
if (!chatScrollEl) return;
chatScrollEl.scrollTop = chatScrollEl.scrollHeight;
}
$effect(() => {
chatMessages.length;
editing;
scrollChatToBottom();
});
/**
* @param {PointerEvent} event
@@ -54,7 +84,7 @@
/** @param {PointerEvent} event */
function startDrawing(event) {
if (mode !== 'area') return;
if (!areaSelectionActive) return;
event.preventDefault();
/** @type {SVGElement} */ (event.currentTarget).setPointerCapture(event.pointerId);
drawing = true;
@@ -63,7 +93,7 @@
/** @param {PointerEvent} event */
function draw(event) {
if (!drawing || mode !== 'area') return;
if (!drawing || !areaSelectionActive) return;
event.preventDefault();
selectionPoints = [...selectionPoints, getPoint(event)];
}
@@ -72,30 +102,52 @@
drawing = false;
}
function clearSelection() {
function startAreaSelection() {
areaSelectionActive = true;
}
function cancelAreaSelection() {
areaSelectionActive = false;
drawing = false;
selectionPoints = [];
}
function toggleAreaTool() {
if (areaSelectionActive) {
cancelAreaSelection();
return;
}
startAreaSelection();
}
/** @param {string} text */
function addQuickPrompt(text) {
prompt = prompt ? `${prompt}, ${text.toLowerCase()}` : text;
}
/** @param {KeyboardEvent} event */
function handlePromptKeydown(event) {
if (event.key !== 'Enter' || event.shiftKey) return;
event.preventDefault();
applyEdit();
}
function getEditInstruction() {
if (!prompt.trim()) {
error = 'Tell us what to change first.';
return null;
}
if (!canSaveAreaPrompt) {
error = 'Draw the area you want to change first.';
if (selectionPoints.length > 0 && !hasAreaSelection) {
error = 'Finish drawing the area you want to change, or cancel with X.';
return null;
}
return {
mode,
mode: hasAreaSelection ? 'area' : 'whole',
prompt: prompt.trim(),
selection: mode === 'area' ? selectionPoints : []
selection: hasAreaSelection ? selectionPoints : []
};
}
@@ -103,30 +155,47 @@
const instruction = getEditInstruction();
if (!instruction || !jobId) return;
editing = true;
const assistantMessageId = `assistant-${Date.now()}`;
chatMessages = [
...chatMessages,
{
id: `user-${Date.now()}`,
role: 'user',
prompt: instruction.prompt,
mode: instruction.mode
},
{
id: assistantMessageId,
role: 'assistant',
status: 'pending'
}
];
prompt = '';
cancelAreaSelection();
error = '';
editing = true;
try {
const result = await editImages(jobId, instruction);
const afterImage = result.images?.primary ?? null;
generatedImage = afterImage;
editHistory = [
...editHistory,
{
id: `${Date.now()}-${editHistory.length}`,
instruction,
afterImage
}
];
prompt = '';
selectionPoints = [];
chatMessages = chatMessages.map((message) =>
message.id === assistantMessageId
? { ...message, status: 'done', afterImage }
: message
);
saveFlow({
editInstruction: instruction,
imagePrompt: result.imagePrompt,
mock: result.mock
});
} catch (err) {
error = err instanceof Error ? err.message : 'Edit failed';
const message = err instanceof Error ? err.message : 'Edit failed';
chatMessages = chatMessages.map((entry) =>
entry.id === assistantMessageId ? { ...entry, status: 'error', error: message } : entry
);
error = message;
} finally {
editing = false;
}
@@ -174,6 +243,89 @@
});
</script>
{#snippet editableImageFrame(image, editable = false)}
<div class="relative w-[42%] overflow-hidden bg-track ring-1 ring-black/5">
{#if image}
<img
src={toDataUrl(image)}
alt="Generated bouquet"
class={[
'aspect-[4/5] w-full object-contain',
editable && areaSelectionActive ? 'opacity-90' : ''
]}
draggable="false"
/>
{:else}
<div class="aspect-[4/5] w-full bg-placeholder"></div>
{/if}
{#if editable && image}
<button
type="button"
class="absolute top-2 right-2 z-20 flex size-8 items-center justify-center rounded-full bg-white/95 text-ink shadow-md ring-1 ring-black/10 transition-colors hover:bg-white"
aria-label={areaSelectionActive ? 'Cancel area selection' : 'Select area to edit'}
onclick={toggleAreaTool}
>
{#if areaSelectionActive}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
class="size-4"
aria-hidden="true"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
aria-hidden="true"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
{/if}
</button>
{#if areaSelectionActive}
<svg
class="absolute inset-0 z-10 h-full w-full touch-none"
viewBox="0 0 100 100"
preserveAspectRatio="none"
role="application"
aria-label="Draw an area to edit"
onpointerdown={startDrawing}
onpointermove={draw}
onpointerup={stopDrawing}
onpointercancel={stopDrawing}
onpointerleave={stopDrawing}
>
{#if selectionPoints.length > 1}
<polyline
points={selectionPolyline}
fill="rgba(239,68,68,0.12)"
stroke="#ef4444"
stroke-width="1.2"
vector-effect="non-scaling-stroke"
/>
{/if}
</svg>
{/if}
{/if}
</div>
{/snippet}
<div
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
>
@@ -199,156 +351,57 @@
</section>
<section class="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-4 px-6 py-5 lg:py-6">
<div
class="mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-4 px-6 py-5 pb-36 lg:py-6 lg:pb-6"
>
<div class="shrink-0">
<p class="text-xs tracking-[0.2em] text-muted uppercase">Edit bouquet</p>
<h2 class="mt-1 text-lg">Tell us how you want to refine it.</h2>
</div>
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
<div bind:this={chatScrollEl} class="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
<div class="space-y-2">
<p class="text-xs text-muted">Generated image</p>
<div class="relative w-[42%] overflow-hidden bg-track ring-1 ring-black/5">
{#if initialImage}
<img
src={toDataUrl(initialImage)}
alt="Generated bouquet"
class={[
'aspect-[4/5] w-full object-contain',
mode === 'area' && editHistory.length === 0 ? 'opacity-75' : ''
]}
draggable="false"
/>
{:else if imageSrc}
<img
src={imageSrc}
alt="Generated bouquet"
class={[
'aspect-[4/5] w-full object-contain',
mode === 'area' && editHistory.length === 0 ? 'opacity-75' : ''
]}
draggable="false"
/>
{:else}
<div class="aspect-[4/5] w-full bg-placeholder"></div>
{/if}
{#if mode === 'area' && editHistory.length === 0}
<svg
class="absolute inset-0 h-full w-full touch-none"
viewBox="0 0 100 100"
preserveAspectRatio="none"
role="application"
aria-label="Draw an area to edit"
onpointerdown={startDrawing}
onpointermove={draw}
onpointerup={stopDrawing}
onpointercancel={stopDrawing}
onpointerleave={stopDrawing}
>
{#if selectionPoints.length > 1}
<polyline
points={selectionPolyline}
fill="rgba(255,255,255,0.18)"
stroke="white"
stroke-width="0.8"
stroke-dasharray="1.4 1.2"
vector-effect="non-scaling-stroke"
/>
{#each selectionPoints.filter((_, index) => index % 8 === 0) as point, index (index)}
<circle cx={point.x} cy={point.y} r="0.8" fill="white" />
{/each}
{/if}
</svg>
{/if}
</div>
{@render editableImageFrame(initialImage ?? generatedImage, chatMessages.length === 0)}
</div>
{#each editHistory as edit (edit.id)}
<div class="space-y-4">
{#each chatMessages as message (message.id)}
{#if message.role === 'user'}
<div class="flex justify-end">
<div
class="max-w-[46%] rounded-3xl bg-pill px-4 py-3 text-sm leading-relaxed text-surface"
>
<p>{edit.instruction.prompt}</p>
{#if edit.instruction.mode === 'area'}
<p>{message.prompt}</p>
{#if message.mode === 'area'}
<p class="mt-2 text-xs opacity-70">Selected area only</p>
{/if}
</div>
</div>
<div class="space-y-2">
<div class="relative w-[42%] overflow-hidden bg-track ring-1 ring-black/5">
{#if edit.afterImage}
<img
src={toDataUrl(edit.afterImage)}
alt="Edited bouquet result"
class={[
'aspect-[4/5] w-full object-contain',
mode === 'area' && edit.id === latestEditId ? 'opacity-75' : ''
]}
draggable="false"
/>
{/if}
{#if mode === 'area' && edit.id === latestEditId}
<svg
class="absolute inset-0 h-full w-full touch-none"
viewBox="0 0 100 100"
preserveAspectRatio="none"
role="application"
aria-label="Draw an area to edit"
onpointerdown={startDrawing}
onpointermove={draw}
onpointerup={stopDrawing}
onpointercancel={stopDrawing}
onpointerleave={stopDrawing}
>
{#if selectionPoints.length > 1}
<polyline
points={selectionPolyline}
fill="rgba(255,255,255,0.18)"
stroke="white"
stroke-width="0.8"
stroke-dasharray="1.4 1.2"
vector-effect="non-scaling-stroke"
/>
{#each selectionPoints.filter((_, index) => index % 8 === 0) as point, index (index)}
<circle cx={point.x} cy={point.y} r="0.8" fill="white" />
{/each}
{/if}
</svg>
{/if}
{:else if message.status === 'pending'}
<div class="flex justify-start">
<div
class="max-w-[46%] rounded-3xl bg-track px-4 py-3 text-sm leading-relaxed text-muted ring-1 ring-black/5"
>
Editing bouquet image...
</div>
</div>
{:else if message.status === 'error'}
<div class="flex justify-start">
<div
class="max-w-[46%] rounded-3xl bg-surface px-4 py-3 text-sm leading-relaxed text-red-600 ring-1 ring-red-200"
>
{message.error}
</div>
</div>
{:else}
<div class="space-y-2">
{@render editableImageFrame(message.afterImage, message.id === latestAssistantId)}
<p class="text-xs text-muted">Result</p>
</div>
</div>
{/if}
{/each}
</div>
<div class="flex shrink-0 rounded-full bg-track p-1 ring-1 ring-black/5">
<button
type="button"
onclick={() => (mode = 'whole')}
class={[
'flex-1 rounded-full px-4 py-2 text-sm transition-colors',
mode === 'whole' ? 'bg-pill text-surface' : 'text-muted hover:text-ink'
]}
>
Whole image
</button>
<button
type="button"
onclick={() => (mode = 'area')}
class={[
'flex-1 rounded-full px-4 py-2 text-sm transition-colors',
mode === 'area' ? 'bg-pill text-surface' : 'text-muted hover:text-ink'
]}
>
Select area
</button>
</div>
<div class="flex shrink-0 flex-wrap gap-2">
{#each QUICK_PROMPTS as quickPrompt (quickPrompt)}
<button
@@ -360,63 +413,70 @@
</button>
{/each}
</div>
</div>
<div class="shrink-0 space-y-2">
<div
class="fixed right-0 bottom-0 left-0 z-20 space-y-2 border-t border-line/60 bg-surface/95 px-4 pt-3 pb-5 backdrop-blur-sm lg:static lg:mx-auto lg:w-full lg:max-w-2xl lg:border-t-0 lg:bg-transparent lg:px-6 lg:pt-0 lg:pb-6 lg:backdrop-blur-none"
>
{#if error}
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
{error}
</p>
{/if}
<div class="flex items-center gap-2 rounded-full border border-pill bg-surface py-1.5 pr-1.5 pl-5">
<textarea
bind:value={prompt}
rows="2"
placeholder={mode === 'area'
rows="1"
placeholder={hasAreaSelection
? 'Tell me how to change the selected area...'
: 'Tell me how you would like to change your bouquet...'}
class="w-full resize-none rounded-[2rem] border border-pill bg-surface px-6 py-3 text-sm outline-none placeholder:text-muted"
: areaSelectionActive
? 'Draw on the image, then describe the area...'
: 'Tell me how you would like to change your bouquet...'}
onkeydown={handlePromptKeydown}
class="max-h-24 min-h-6 flex-1 resize-none border-none bg-transparent py-1 text-sm leading-normal outline-none placeholder:text-muted"
></textarea>
<div class="flex flex-wrap items-center justify-between gap-3 text-xs text-muted">
<p>
{#if mode === 'area'}
Draw over the bouquet, then describe only that selected area.
{:else}
Prompt applies to the whole generated bouquet.
{/if}
</p>
{#if selectionPoints.length > 0}
<button type="button" class="underline hover:text-ink" onclick={clearSelection}>
Clear selection
</button>
<button
type="button"
aria-label={editing ? 'Applying edit' : 'Send edit'}
disabled={!prompt.trim() || editing || continuing}
onclick={applyEdit}
class="flex size-9 shrink-0 items-center justify-center rounded-full bg-pill text-surface transition-opacity disabled:opacity-40"
>
{#if editing}
<span
class="size-4 animate-spin rounded-full border-2 border-surface/30 border-t-surface"
aria-hidden="true"
></span>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
aria-hidden="true"
>
<path d="M12 19V5" />
<path d="m5 12 7-7 7 7" />
</svg>
{/if}
</div>
</button>
</div>
<div class="shrink-0 space-y-2">
{#if error}
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
{error}
</p>
{:else if editing}
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-muted ring-1 ring-black/5">
Editing bouquet image...
</p>
{/if}
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="border border-pill px-4 py-3 text-sm text-ink disabled:opacity-50"
disabled={!prompt.trim() || editing || continuing}
onclick={applyEdit}
>
{editing ? 'Applying...' : 'Apply edit'}
</button>
<button
type="button"
class="bg-pill px-4 py-3 text-sm text-surface"
disabled={editing || continuing}
onclick={continueToResult}
>
{continuing ? 'Preparing result...' : 'Continue to result'}
</button>
</div>
<div class="flex justify-end">
<button
type="button"
disabled={editing || continuing}
onclick={continueToResult}
class="px-2 py-2 text-sm whitespace-nowrap text-ink underline-offset-4 hover:underline disabled:opacity-50"
>
{continuing ? 'Preparing result...' : 'Continue to result ->'}
</button>
</div>
</div>
</section>