mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
feat(ui,ai): enhance chat panel and design generation feedback
- Introduce a "Thinking..." indicator in the AI chat panel to improve user experience during processing. - Update chat message styles for better spacing and alignment. - Implement batch undo functionality for number input adjustments to streamline user interactions. - Refactor canvas event handling to ensure accurate history tracking during drag operations. - Add keyboard shortcuts for agent settings and improve canvas selection handling during history state restoration.
This commit is contained in:
parent
4a51f4742d
commit
afdec6c2d6
8 changed files with 96 additions and 29 deletions
|
|
@ -87,7 +87,8 @@ React Components (Toolbar, LayerPanel, PropertyPanel)
|
|||
- `ai-chat-panel.tsx` / `chat-message.tsx` — AI chat with markdown, design block collapse, apply design
|
||||
- `code-panel.tsx` — Code generation output (React/Tailwind and HTML/CSS)
|
||||
- **`src/components/shared/`** — Reusable UI (8 files): ColorPicker, NumberInput, DropdownSelect, SectionHeader, ExportDialog, SaveDialog, AgentSettingsDialog, IconPickerDialog
|
||||
- **`src/components/ui/`** — shadcn/ui primitives: Button, Select, Separator, Slider, Toggle, Tooltip
|
||||
- **`src/components/icons/`** — Provider logos: ClaudeLogo, OpenAILogo
|
||||
- **`src/components/ui/`** — shadcn/ui primitives: Button, Select, Separator, Slider, Switch, Toggle, Tooltip
|
||||
- **`src/services/ai/`** — AI chat service, design prompts, design-to-node generation, AI types
|
||||
- **`src/services/codegen/`** — React+Tailwind and HTML+CSS code generators
|
||||
- **`src/hooks/`** — `use-keyboard-shortcuts` (global keyboard event handling: tools, clipboard, undo/redo, save, select all, delete, arrow nudge, z-order)
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -76,6 +76,12 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative
|
|||
- Dual provider: Anthropic API or local Claude Code (OAuth)
|
||||
- Multi-provider settings: Claude Code, Codex CLI
|
||||
|
||||
### Editor UI
|
||||
|
||||
- Dark / light theme toggle (persisted to localStorage)
|
||||
- Fullscreen mode
|
||||
- Draggable, snap-to-corner AI chat panel
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|
|
@ -86,6 +92,7 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative
|
|||
| L | Line |
|
||||
| T | Text |
|
||||
| F | Frame |
|
||||
| P | Path (pen tool) |
|
||||
| H | Hand (pan) |
|
||||
| Cmd+A | Select all |
|
||||
| Cmd+Z | Undo |
|
||||
|
|
@ -97,6 +104,7 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative
|
|||
| Cmd+Shift+E | Export |
|
||||
| Cmd+Shift+C | Code panel |
|
||||
| Cmd+J | AI chat |
|
||||
| Cmd+, | Agent settings |
|
||||
| Delete/Backspace | Delete selected |
|
||||
| Arrow keys | Nudge (1px, +Shift = 10px) |
|
||||
| [ / ] | Reorder layers |
|
||||
|
|
@ -165,8 +173,9 @@ src/
|
|||
components/
|
||||
editor/ # Editor layout, toolbar, tool buttons, status bar
|
||||
panels/ # Layer panel, property panel (15 files), AI chat, code panel
|
||||
shared/ # ColorPicker, NumberInput, ExportDialog, IconPickerDialog, etc.
|
||||
ui/ # shadcn/ui primitives (Button, Select, Slider, etc.)
|
||||
shared/ # ColorPicker, NumberInput, ExportDialog, AgentSettingsDialog, etc.
|
||||
icons/ # Provider logos (Claude, OpenAI)
|
||||
ui/ # shadcn/ui primitives (Button, Select, Slider, Switch, etc.)
|
||||
hooks/ # Keyboard shortcuts
|
||||
lib/ # Utility functions (cn class merging)
|
||||
services/
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import * as fabric from 'fabric'
|
|||
import { useCanvasStore } from '@/stores/canvas-store'
|
||||
import { useDocumentStore, generateId } from '@/stores/document-store'
|
||||
import { useHistoryStore } from '@/stores/history-store'
|
||||
import type { PenNode } from '@/types/pen'
|
||||
import type { PenDocument, PenNode } from '@/types/pen'
|
||||
import type { ToolType } from '@/types/canvas'
|
||||
import {
|
||||
DEFAULT_FILL,
|
||||
|
|
@ -409,16 +409,24 @@ export function useCanvasEvents() {
|
|||
upperEl.addEventListener('pointerup', onPointerUp)
|
||||
upperEl.addEventListener('dblclick', onDoubleClick)
|
||||
|
||||
// --- History batching for drag/resize/rotate ---
|
||||
// --- Drag session setup (layout reorder + parent-child propagation) ---
|
||||
// We capture the document snapshot here (before any modification) so that
|
||||
// `object:modified` can use it as the undo base state. History batching
|
||||
// lives in `object:modified` — NOT here — so that click-to-select without
|
||||
// modification never creates a no-op undo entry.
|
||||
let preModificationDoc: PenDocument | null = null
|
||||
|
||||
canvas.on('mouse:down', (opt) => {
|
||||
clipPathsCleared = false
|
||||
preModificationDoc = null
|
||||
const tool = useCanvasStore.getState().activeTool
|
||||
if (tool !== 'select') return
|
||||
const target = opt.target as FabricObjectWithPenId | null
|
||||
if (!target?.penNodeId) return
|
||||
useHistoryStore
|
||||
.getState()
|
||||
.startBatch(useDocumentStore.getState().document)
|
||||
|
||||
// Snapshot the document BEFORE any drag/resize/rotate begins.
|
||||
// structuredClone ensures we have a deep copy unaffected by later mutations.
|
||||
preModificationDoc = structuredClone(useDocumentStore.getState().document)
|
||||
|
||||
// Try to start layout reorder drag first
|
||||
beginLayoutDrag(target.penNodeId)
|
||||
|
|
@ -433,7 +441,6 @@ export function useCanvasEvents() {
|
|||
// commit and cleanup. In Fabric.js v7 mouse:up can fire before
|
||||
// object:modified, which would clear the session prematurely.
|
||||
endParentDrag()
|
||||
useHistoryStore.getState().endBatch()
|
||||
})
|
||||
|
||||
// --- Object modifications (drag, resize, rotate) via Fabric events ---
|
||||
|
|
@ -528,17 +535,6 @@ export function useCanvasEvents() {
|
|||
setFabricSyncLock(false)
|
||||
}
|
||||
|
||||
// History batching: group all intermediate drag/resize/rotate updates
|
||||
// into a single undo entry instead of one per mouse-move event.
|
||||
canvas.on('mouse:down', () => {
|
||||
useHistoryStore
|
||||
.getState()
|
||||
.startBatch(useDocumentStore.getState().document)
|
||||
})
|
||||
canvas.on('mouse:up', () => {
|
||||
useHistoryStore.getState().endBatch()
|
||||
})
|
||||
|
||||
// Real-time sync during drag / resize / rotate (locked to prevent circular sync)
|
||||
let clipPathsCleared = false
|
||||
|
||||
|
|
@ -591,11 +587,28 @@ export function useCanvasEvents() {
|
|||
}
|
||||
})
|
||||
|
||||
// Final sync: reset scale to 1 and bake into width/height
|
||||
// Final sync: reset scale to 1 and bake into width/height.
|
||||
// History batching lives here (not in mouse:down/mouse:up) so that
|
||||
// click-to-select without modification never creates a no-op undo
|
||||
// entry. We use the pre-modification snapshot captured in mouse:down
|
||||
// as the batch base to guarantee a correct undo point.
|
||||
canvas.on('object:modified', (opt) => {
|
||||
clearGuides()
|
||||
const target = opt.target
|
||||
|
||||
// Use the snapshot from mouse:down if available; otherwise fall back
|
||||
// to the current document (e.g. programmatic modifications).
|
||||
const baseDoc = preModificationDoc ?? useDocumentStore.getState().document
|
||||
preModificationDoc = null
|
||||
|
||||
// Open a history batch for this modification when no outer batch
|
||||
// (e.g. AI generation) is active.
|
||||
const needsBatch = useHistoryStore.getState().batchDepth === 0
|
||||
if (needsBatch) {
|
||||
useHistoryStore.getState().startBatch(baseDoc)
|
||||
}
|
||||
|
||||
try {
|
||||
// Single object -- bake scale and sync
|
||||
const asPen = target as FabricObjectWithPenId
|
||||
if (asPen.penNodeId) {
|
||||
|
|
@ -686,6 +699,12 @@ export function useCanvasEvents() {
|
|||
// committed (e.g. cursor left the container on the final move frame).
|
||||
cancelDragInto()
|
||||
|
||||
} finally {
|
||||
if (needsBatch) {
|
||||
useHistoryStore.getState().endBatch()
|
||||
}
|
||||
}
|
||||
|
||||
// Force re-sync so clip paths (which use absolute coordinates) are
|
||||
// recomputed from the new node positions. Without this, children of
|
||||
// a dragged frame stay clipped to the old parent frame bounds.
|
||||
|
|
|
|||
|
|
@ -206,8 +206,16 @@ function useChatHandlers() {
|
|||
} else {
|
||||
// --- CHAT MODE ---
|
||||
chatHistory.push({ role: 'user', content: fullUserMessage })
|
||||
let chatThinking = false
|
||||
for await (const chunk of streamChat(CHAT_SYSTEM_PROMPT, chatHistory, model)) {
|
||||
if (chunk.type === 'text') {
|
||||
if (chunk.type === 'thinking') {
|
||||
// Show a brief thinking indicator so the UI isn't stuck on empty
|
||||
if (!chatThinking && !accumulated) {
|
||||
chatThinking = true
|
||||
updateLastMessage('*Thinking...*')
|
||||
}
|
||||
} else if (chunk.type === 'text') {
|
||||
chatThinking = false
|
||||
accumulated += chunk.content
|
||||
updateLastMessage(accumulated)
|
||||
} else if (chunk.type === 'error') {
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ function ActionSteps({ steps }: { steps: string[] }) {
|
|||
if (steps.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 my-1 w-full">
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
{steps.map((step, i) => {
|
||||
const title = parseStepTitle(step)
|
||||
const content = parseStepContent(step)
|
||||
|
|
@ -110,7 +110,7 @@ function ActionStepItem({
|
|||
|
||||
// Pencil-like style: Rounded pill/card with subtle border
|
||||
return (
|
||||
<div className="group mb-1.5 last:mb-0">
|
||||
<div className="group">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
|
|
@ -460,7 +460,7 @@ function DesignJsonBlock({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="group my-1.5 first:mt-0 last:mb-0 w-full">
|
||||
<div className="group mt-0.5 w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
|
|
@ -563,7 +563,7 @@ export default function ChatMessage({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start mt-2')}>
|
||||
{isUser ? (
|
||||
<div className="max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap bg-primary text-primary-foreground rounded-br-sm">
|
||||
{content}
|
||||
|
|
@ -581,7 +581,7 @@ export default function ChatMessage({
|
|||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap mb-2">
|
||||
<div className="whitespace-pre-wrap">
|
||||
{parseMarkdown(displayContent, onApplyDesign, isApplied, isStreaming && !isEmpty)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useHistoryStore } from '@/stores/history-store'
|
||||
import { useDocumentStore } from '@/stores/document-store'
|
||||
|
||||
interface NumberInputProps {
|
||||
value: number
|
||||
|
|
@ -72,6 +74,9 @@ export default function NumberInput({
|
|||
dragStartY.current = e.clientY
|
||||
dragStartValue.current = value
|
||||
|
||||
// Batch all scrub-drag onChange calls into a single undo entry
|
||||
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
|
||||
|
||||
const handleMouseMove = (ev: MouseEvent) => {
|
||||
const delta = dragStartY.current - ev.clientY
|
||||
const newValue = clamp(dragStartValue.current + delta * step)
|
||||
|
|
@ -80,6 +85,7 @@ export default function NumberInput({
|
|||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
useHistoryStore.getState().endBatch()
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,13 @@ export function useKeyboardShortcuts() {
|
|||
if (prev) {
|
||||
useDocumentStore.getState().applyHistoryState(prev)
|
||||
}
|
||||
// Deselect so Fabric re-renders objects at their restored dimensions
|
||||
useCanvasStore.getState().clearSelection()
|
||||
const canvas = useCanvasStore.getState().fabricCanvas
|
||||
if (canvas) {
|
||||
canvas.discardActiveObject()
|
||||
canvas.requestRenderAll()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -72,6 +79,12 @@ export function useKeyboardShortcuts() {
|
|||
if (next) {
|
||||
useDocumentStore.getState().applyHistoryState(next)
|
||||
}
|
||||
useCanvasStore.getState().clearSelection()
|
||||
const canvas = useCanvasStore.getState().fabricCanvas
|
||||
if (canvas) {
|
||||
canvas.discardActiveObject()
|
||||
canvas.requestRenderAll()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -171,11 +171,20 @@ export async function generateDesign(
|
|||
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
|
||||
}
|
||||
|
||||
let isThinking = false
|
||||
|
||||
try {
|
||||
for await (const chunk of streamChat(DESIGN_GENERATOR_PROMPT, [
|
||||
{ role: 'user', content: userMessage },
|
||||
], undefined, DESIGN_STREAM_TIMEOUTS)) {
|
||||
if (chunk.type === 'text') {
|
||||
if (chunk.type === 'thinking') {
|
||||
// Show a "Thinking" step so the UI isn't stuck on the empty indicator
|
||||
if (!isThinking && !fullResponse) {
|
||||
isThinking = true
|
||||
callbacks?.onTextUpdate?.('<step title="Thinking">Analyzing your design request...</step>')
|
||||
}
|
||||
} else if (chunk.type === 'text') {
|
||||
isThinking = false
|
||||
fullResponse += chunk.content
|
||||
if (callbacks?.onTextUpdate) {
|
||||
callbacks.onTextUpdate(fullResponse)
|
||||
|
|
@ -265,7 +274,9 @@ export async function generateDesignModification(
|
|||
for await (const chunk of streamChat(DESIGN_MODIFIER_PROMPT, [
|
||||
{ role: 'user', content: userMessage },
|
||||
], undefined, DESIGN_STREAM_TIMEOUTS)) {
|
||||
if (chunk.type === 'text') {
|
||||
if (chunk.type === 'thinking') {
|
||||
// Ignore thinking chunks for modification — caller already shows progress
|
||||
} else if (chunk.type === 'text') {
|
||||
fullResponse += chunk.content
|
||||
} else if (chunk.type === 'error') {
|
||||
streamError = chunk.content
|
||||
|
|
|
|||
Loading…
Reference in a new issue