diff --git a/components/wysiwyg/Canvas.resize.test.tsx b/components/wysiwyg/Canvas.resize.test.tsx new file mode 100644 index 0000000..5a89236 --- /dev/null +++ b/components/wysiwyg/Canvas.resize.test.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +function makeFrame(): Frame { + return { + id: 'f1', + width: 400, + height: 300, + background: '#ffffff', + layers: [ + { + id: 'l1', + name: 'L1', + visible: true, + locked: false, + objects: [ + { id: 'o1', type: 'image', x: 100, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } }, + ], + }, + ], + } +} + +describe('WYSIWYG Canvas resize with 8px snap', () => { + it('dragging bottom-right handle resizes and snaps to nearest 8px', () => { + const frame = makeFrame() + const { getByTestId } = render() + const handle = getByTestId('resize-handle-o1') + const canvas = getByTestId('wysiwyg-canvas') + + // start resize + fireEvent.mouseDown(handle, { clientX: 150, clientY: 140 }) + // move by +7,+9 → width 50-> 57 -> snap8 => 56, height 40->49 -> snap8 => 48 + fireEvent.mouseMove(canvas, { clientX: 157, clientY: 149 }) + fireEvent.mouseUp(canvas) + + const obj = getByTestId('obj-o1') + expect(obj).toHaveStyle({ width: '56px', height: '48px' }) + }) +}) diff --git a/components/wysiwyg/Canvas.rotate.test.tsx b/components/wysiwyg/Canvas.rotate.test.tsx new file mode 100644 index 0000000..3acd4bc --- /dev/null +++ b/components/wysiwyg/Canvas.rotate.test.tsx @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { Canvas } from '@/components/wysiwyg/Canvas' +import type { Frame } from '@/lib/wysiwyg/schema' + +function makeFrame(): Frame { + return { + id: 'f1', + width: 400, + height: 300, + background: '#ffffff', + layers: [ + { + id: 'l1', + name: 'L1', + visible: true, + locked: false, + objects: [ + { id: 'o1', type: 'image', x: 100, y: 100, width: 50, height: 40, rotate: 0, zIndex: 0, props: { src: 'x', alt: '' } }, + ], + }, + ], + } +} + +describe('WYSIWYG Canvas rotate with snap', () => { + it('rotates with 15deg snap by default', () => { + const frame = makeFrame() + const { getByTestId } = render() + const handle = getByTestId('rotate-handle-o1') + const canvas = getByTestId('wysiwyg-canvas') + + // start rotate at 0deg + fireEvent.mouseDown(handle, { clientX: 100, clientY: 100 }) + // move +16px on x → +16deg → snap15 => 15deg + fireEvent.mouseMove(canvas, { clientX: 116, clientY: 100 }) + fireEvent.mouseUp(canvas) + + const obj = getByTestId('obj-o1') + expect(obj).toHaveStyle({ transform: 'rotate(15deg)' }) + }) + + it('rotates with 1deg step while holding Shift', () => { + const frame = makeFrame() + const { getByTestId } = render() + const handle = getByTestId('rotate-handle-o1') + const canvas = getByTestId('wysiwyg-canvas') + + fireEvent.mouseDown(handle, { clientX: 100, clientY: 100 }) + // move +7px with Shift → +7deg, no snap + fireEvent.mouseMove(canvas, { clientX: 107, clientY: 100, shiftKey: true }) + fireEvent.mouseUp(canvas) + + const obj = getByTestId('obj-o1') + expect(obj).toHaveStyle({ transform: 'rotate(7deg)' }) + }) +}) diff --git a/components/wysiwyg/Canvas.tsx b/components/wysiwyg/Canvas.tsx index 51c5f6f..4a7741b 100644 --- a/components/wysiwyg/Canvas.tsx +++ b/components/wysiwyg/Canvas.tsx @@ -14,6 +14,8 @@ export function Canvas({ frame, onChange }: CanvasProps) { const [model, setModel] = useState(frame) const [selectedId, setSelectedId] = useState(model.layers[0]?.objects[0]?.id ?? null) const draggingRef = useRef<{ id: string; startX: number; startY: number; origX: number; origY: number } | null>(null) + const resizingRef = useRef<{ id: string; startX: number; startY: number; origW: number; origH: number } | null>(null) + const rotatingRef = useRef<{ id: string; startX: number; startY: number; origDeg: number } | null>(null) const selected = useMemo(() => { for (const layer of model.layers) { @@ -53,17 +55,49 @@ export function Canvas({ frame, onChange }: CanvasProps) { }, []) const onMouseMoveCanvas = useCallback((e: React.MouseEvent) => { + const rot = rotatingRef.current + if (rot) { + const dx = e.clientX - rot.startX + let next = rot.origDeg + dx + if (!e.shiftKey) { + next = Math.round(next / 15) * 15 + } + updateObject(rot.id, (o) => ({ ...o, rotate: next })) + return + } + const r = resizingRef.current + if (r) { + const dx = e.clientX - r.startX + const dy = e.clientY - r.startY + const nw = Math.max(8, snap8(r.origW + dx)) + const nh = Math.max(8, snap8(r.origH + dy)) + updateObject(r.id, (o) => ({ ...o, width: nw, height: nh })) + return + } const d = draggingRef.current - if (!d) return - const dx = e.clientX - d.startX - const dy = e.clientY - d.startY - const nx = snap8(d.origX + dx) - const ny = snap8(d.origY + dy) - updateObject(d.id, (o) => ({ ...o, x: nx, y: ny })) + if (d) { + const dx = e.clientX - d.startX + const dy = e.clientY - d.startY + const nx = snap8(d.origX + dx) + const ny = snap8(d.origY + dy) + updateObject(d.id, (o) => ({ ...o, x: nx, y: ny })) + } }, [updateObject]) const onMouseUpCanvas = useCallback(() => { draggingRef.current = null + resizingRef.current = null + rotatingRef.current = null + }, []) + + const onMouseDownResize = useCallback((e: React.MouseEvent, o: WObject) => { + e.stopPropagation() + resizingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origW: o.width, origH: o.height } + }, []) + + const onMouseDownRotate = useCallback((e: React.MouseEvent, o: WObject) => { + e.stopPropagation() + rotatingRef.current = { id: o.id, startX: e.clientX, startY: e.clientY, origDeg: o.rotate } }, []) return ( @@ -84,7 +118,22 @@ export function Canvas({ frame, onChange }: CanvasProps) { aria-label={`object ${o.id}`} onMouseDown={(e) => onMouseDownObj(e, o)} style={{ position: 'absolute', left: o.x, top: o.y, width: o.width, height: o.height, transform: `rotate(${o.rotate}deg)`, outline: selectedId===o.id? '2px solid #0ea5e9':'none' }} - /> + > + {selectedId === o.id && ( +
onMouseDownResize(e, o)} + style={{ position: 'absolute', right: 0, bottom: 0, width: 10, height: 10, background: '#0ea5e9', cursor: 'nwse-resize' }} + /> + )} + {selectedId === o.id && ( +
onMouseDownRotate(e, o)} + style={{ position: 'absolute', left: '50%', top: -16, width: 10, height: 10, marginLeft: -5, background: '#0ea5e9', borderRadius: 5, cursor: 'grab' }} + /> + )} +
)))}
) diff --git a/components/wysiwyg/LayersPanel.test.tsx b/components/wysiwyg/LayersPanel.test.tsx new file mode 100644 index 0000000..84d6964 --- /dev/null +++ b/components/wysiwyg/LayersPanel.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { LayersPanel } from '@/components/wysiwyg/LayersPanel' +import type { Frame } from '@/lib/wysiwyg/schema' + +function makeFrame(): Frame { + return { + id: 'f1', width: 400, height: 300, background: '#ffffff', + layers: [ + { id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }, + { id: 'l2', name: 'Text', visible: true, locked: false, objects: [] }, + ], + } +} + +describe('LayersPanel', () => { + it('reorders layers with up/down buttons', () => { + const frame = makeFrame() + const onChange = vi.fn() + const { getByTestId, getAllByTestId } = render() + const items = getAllByTestId('layer-item') + expect(items[0]).toHaveTextContent('Base') + expect(items[1]).toHaveTextContent('Text') + + fireEvent.click(getByTestId('layer-down-l1')) + expect(onChange).toHaveBeenCalled() + const next = (onChange.mock.lastCall?.[0]) as Frame + expect(next.layers[0].id).toBe('l2') + expect(next.layers[1].id).toBe('l1') + }) + + it('toggles lock and visibility', () => { + const frame = makeFrame() + const { getByTestId } = render( {}} />) + fireEvent.click(getByTestId('layer-lock-l2')) + fireEvent.click(getByTestId('layer-hide-l2')) + // no crash; states toggled reflected in aria-pressed + expect(getByTestId('layer-lock-l2')).toHaveAttribute('aria-pressed', 'true') + expect(getByTestId('layer-hide-l2')).toHaveAttribute('aria-pressed', 'true') + }) +}) diff --git a/components/wysiwyg/LayersPanel.tsx b/components/wysiwyg/LayersPanel.tsx new file mode 100644 index 0000000..e0122b6 --- /dev/null +++ b/components/wysiwyg/LayersPanel.tsx @@ -0,0 +1,65 @@ +import React, { useCallback, useState } from 'react' +import type { Frame } from '@/lib/wysiwyg/schema' + +export type LayersPanelProps = { + frame: Frame + onChange?: (next: Frame) => void +} + +export function LayersPanel({ frame, onChange }: LayersPanelProps) { + const [model, setModel] = useState(frame) + + const move = useCallback((id: string, dir: 1 | -1) => { + const idx = model.layers.findIndex((l) => l.id === id) + if (idx < 0) return + const j = idx + dir + if (j < 0 || j >= model.layers.length) return + const nextLayers = model.layers.slice() + const [it] = nextLayers.splice(idx, 1) + nextLayers.splice(j, 0, it) + const next = { ...model, layers: nextLayers } + setModel(next) + onChange?.(next) + }, [model, onChange]) + + const toggle = useCallback((id: string, key: 'locked' | 'visible') => { + const nextLayers = model.layers.map((l) => l.id === id ? { ...l, [key]: key === 'visible' ? !l.visible : !l.locked } : l) + const next = { ...model, layers: nextLayers } + setModel(next) + onChange?.(next) + }, [model, onChange]) + + return ( +
+ {model.layers.map((l) => ( +
+ {l.name} + + + + +
+ ))} +
+ ) +} diff --git a/components/wysiwyg/PropsPanel.test.tsx b/components/wysiwyg/PropsPanel.test.tsx new file mode 100644 index 0000000..3336734 --- /dev/null +++ b/components/wysiwyg/PropsPanel.test.tsx @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { PropsPanel } from '@/components/wysiwyg/PropsPanel' +import type { WObject } from '@/lib/wysiwyg/schema' + +describe('PropsPanel', () => { + it('edits position/size/rotate numeric fields', () => { + const obj: WObject = { id: 'o1', type: 'image', x: 10, y: 20, width: 100, height: 80, rotate: 0, zIndex: 0, props: { src: 'a', alt: '' } } + const calls: WObject[] = [] + const { getByLabelText } = render( calls.push(n)} />) + fireEvent.change(getByLabelText('X'), { target: { value: '12' } }) + fireEvent.change(getByLabelText('Y'), { target: { value: '30' } }) + fireEvent.change(getByLabelText('Width'), { target: { value: '120' } }) + fireEvent.change(getByLabelText('Height'), { target: { value: '88' } }) + fireEvent.change(getByLabelText('Rotate'), { target: { value: '15' } }) + const last = calls.at(-1)! + expect(last.x).toBe(12) + expect(last.y).toBe(30) + expect(last.width).toBe(120) + expect(last.height).toBe(88) + expect(last.rotate).toBe(15) + }) + + it('edits image src/alt', () => { + const obj: WObject = { id: 'o2', type: 'image', x: 0, y: 0, width: 10, height: 10, rotate: 0, zIndex: 0, props: { src: 'x', alt: 'a' } } + const calls: WObject[] = [] + const { getByLabelText } = render( calls.push(n)} />) + fireEvent.change(getByLabelText('Src'), { target: { value: 'https://example.com/img.jpg' } }) + fireEvent.change(getByLabelText('Alt'), { target: { value: 'Example' } }) + expect(calls.at(-1)?.props).toEqual({ src: 'https://example.com/img.jpg', alt: 'Example' }) + }) +}) diff --git a/components/wysiwyg/PropsPanel.tsx b/components/wysiwyg/PropsPanel.tsx new file mode 100644 index 0000000..8581080 --- /dev/null +++ b/components/wysiwyg/PropsPanel.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react' +import type { WObject } from '@/lib/wysiwyg/schema' + +export type PropsPanelProps = { + object: WObject + onChange?: (next: WObject) => void +} + +function num(v: string, fallback: number) { + const n = Number(v) + return Number.isFinite(n) ? n : fallback +} + +function isImage(o: WObject): o is Extract { + return o.type === 'image' +} + +export function PropsPanel({ object, onChange }: PropsPanelProps) { + const [model, setModel] = useState(object) + + const emit = (next: WObject) => { + setModel(next) + onChange?.(next) + } + + const update = (patch: Partial>): void => { + const next: WObject = { ...model, ...patch } + emit(next) + } + const updateImageProps = (patch: Partial<{ src: string; alt?: string }>): void => { + if (!isImage(model)) return + const next: WObject = { ...model, props: { ...model.props, ...patch } } + emit(next) + } + + return ( +
+
+ + + + + +
+ + {isImage(model) && ( +
+ + +
+ )} +
+ ) +} diff --git a/lib/wysiwyg/export.test.ts b/lib/wysiwyg/export.test.ts new file mode 100644 index 0000000..fb0e5f7 --- /dev/null +++ b/lib/wysiwyg/export.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { exportFrame } from '@/lib/wysiwyg/export' +import type { Frame } from '@/lib/wysiwyg/schema' + +function makeFrame(): Frame { + return { + id: 'f1', width: 320, height: 200, background: '#ffffff', + layers: [ + { id: 'l1', name: 'Base', visible: true, locked: false, objects: [ + { id: 'o1', type: 'image', x: 16, y: 24, width: 80, height: 60, rotate: 15, zIndex: 0, props: { src: 'https://example.com/a.jpg', alt: 'A' } }, + ]}, + { id: 'l2', name: 'Text', visible: true, locked: false, objects: [ + { id: 't1', type: 'text', x: 24, y: 120, width: 200, height: 32, rotate: 0, zIndex: 1, props: { text: 'Hello', fontSize: 18, color: '#111111' } }, + ]}, + ], + } +} + +describe('wysiwyg export (absolute positioned HTML/CSS + scale wrapper)', () => { + it('renders wrapper with fixed width/height and absolutely positioned children', () => { + const frame = makeFrame() + const out = exportFrame(frame) + expect(out.html).toMatch(/
]*src=\"https:\/\/example.com\/a.jpg\"/) + expect(out.css).toMatch(/#o1\s*{[^}]*left:16px;[^}]*top:24px;[^}]*width:80px;[^}]*height:60px;[^}]*transform:rotate\(15deg\)/) + // text object + expect(out.html).toMatch(/

{ + const frame = makeFrame() + const out = exportFrame(frame) + expect(out.css).toMatch(/\.frame-scale\s*{[^}]*transform-origin:0 0/) + expect(out.html).toMatch(/class=\"frame-scale\"/) + }) +}) diff --git a/lib/wysiwyg/export.ts b/lib/wysiwyg/export.ts new file mode 100644 index 0000000..c6d0deb --- /dev/null +++ b/lib/wysiwyg/export.ts @@ -0,0 +1,48 @@ +import type { Frame, WObject } from '@/lib/wysiwyg/schema' + +export type Exported = { html: string; css: string; js: string } + +function esc(s: string) { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +function isImage(o: WObject): o is Extract { return o.type === 'image' } +function isText(o: WObject): o is Extract { return o.type === 'text' } +function isButton(o: WObject): o is Extract { return o.type === 'button' } + +function renderObject(o: WObject): { html: string; css: string } { + const baseCss = `#${o.id}{position:absolute;left:${o.x}px;top:${o.y}px;width:${o.width}px;height:${o.height}px;transform:rotate(${o.rotate}deg)}` + if (isImage(o)) { + const html = `${esc(o.props.alt || '')}` + return { html, css: baseCss } + } + if (isText(o)) { + const html = `

${esc(o.props.text)}

` + return { html, css: baseCss + `;font-size:${o.props.fontSize}px;color:${o.props.color}` } + } + // button minimal + const label = isButton(o) ? o.props.label : 'Button' + const href = isButton(o) ? (o.props.href || '#') : '#' + const html = `${esc(label)}` + return { html, css: baseCss } +} + +export function exportFrame(frame: Frame): Exported { + const piecesHtml: string[] = [] + const piecesCss: string[] = [] + for (const layer of frame.layers) { + if (!layer.visible) continue + for (const o of layer.objects) { + const r = renderObject(o) + piecesHtml.push(r.html) + piecesCss.push(r.css) + } + } + const html = `
${piecesHtml.join('')}
` + const css = [ + `.frame{position:relative;width:${frame.width}px;height:${frame.height}px;background:${frame.background}}`, + `.frame-scale{transform-origin:0 0}`, + ...piecesCss, + ].join('\n') + return { html, css, js: '' } +}