From 53c60669990660258ac33893e946861cbd827e25 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Mon, 29 Dec 2025 23:20:08 -0300 Subject: [PATCH 1/2] studio --- .gitignore | 3 +- package.json | 1 - pnpm-lock.yaml | 6 +- site/app/page.tsx | 11 +- site/app/studio/canvas-view.tsx | 336 +++++++++++++++++++++++++++++ site/app/studio/editor-manager.tsx | 52 +++++ site/app/studio/page.tsx | 213 ++++++++++++++++++ site/app/studio/studio.css | 266 +++++++++++++++++++++++ site/next-env.d.ts | 6 - site/package.json | 1 + 10 files changed, 881 insertions(+), 14 deletions(-) create mode 100644 site/app/studio/canvas-view.tsx create mode 100644 site/app/studio/editor-manager.tsx create mode 100644 site/app/studio/page.tsx create mode 100644 site/app/studio/studio.css delete mode 100644 site/next-env.d.ts diff --git a/.gitignore b/.gitignore index e1e2198..89c3b76 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ node_modules dist .next .DS_Store -next-env.d.ts +*/**/next-env.d.ts + diff --git a/package.json b/package.json index 347c4ba..456f830 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "@types/node": "^22.10.7", "@types/react": "^19.0.7", "bunchee": "^6.5.2", - "html-to-image": "^1.11.13", "next": "^16.0.7", "postcss": "^8.5.4", "prettier": "^3.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32af8bd..09b11e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,9 +28,6 @@ importers: bunchee: specifier: ^6.5.2 version: 6.5.2(typescript@5.7.3) - html-to-image: - specifier: ^1.11.13 - version: 1.11.13 next: specifier: ^16.0.7 version: 16.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -61,6 +58,9 @@ importers: codice: specifier: workspace:* version: link:.. + html-to-image: + specifier: ^1.11.13 + version: 1.11.13 prettier: specifier: ^3.6.2 version: 3.6.2 diff --git a/site/app/page.tsx b/site/app/page.tsx index 7eff87f..fb72068 100644 --- a/site/app/page.tsx +++ b/site/app/page.tsx @@ -11,9 +11,14 @@ export default async function Page(props: { searchParams: Promise return (
- - Source Code ↗ - +
+ + Studio → + + + Source Code ↗ + +

Codice_ diff --git a/site/app/studio/canvas-view.tsx b/site/app/studio/canvas-view.tsx new file mode 100644 index 0000000..e99b567 --- /dev/null +++ b/site/app/studio/canvas-view.tsx @@ -0,0 +1,336 @@ +'use client' + +import { useState, useRef, useEffect } from 'react' +import './studio.css' + +interface Screenshot { + id: string + dataUrl: string + x: number + y: number + width: number + height: number +} + +interface CanvasViewProps { + screenshots: Screenshot[] + onUpdateScreenshots: (screenshots: Screenshot[]) => void +} + +export function CanvasView({ screenshots, onUpdateScreenshots }: CanvasViewProps) { + const canvasRef = useRef(null) + const [selectedScreenshot, setSelectedScreenshot] = useState(null) + const [isDragging, setIsDragging] = useState(false) + const [isResizing, setIsResizing] = useState(false) + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }) + const [resizeHandle, setResizeHandle] = useState<'se' | 'sw' | 'ne' | 'nw' | null>(null) + const [loadedImages, setLoadedImages] = useState>({}) + const [cursor, setCursor] = useState('default') + + const CANVAS_WIDTH = 800 + const CANVAS_HEIGHT = 600 + + // Load images when screenshots change + useEffect(() => { + screenshots.forEach((screenshot) => { + if (!loadedImages[screenshot.id]) { + const img = new Image() + img.src = screenshot.dataUrl + img.onload = () => { + setLoadedImages((prev) => ({ ...prev, [screenshot.id]: img })) + } + } + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [screenshots]) + + useEffect(() => { + drawCanvas() + }, [screenshots, selectedScreenshot, loadedImages]) + + const drawCanvas = () => { + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext('2d') + if (!ctx) return + + // Clear canvas + ctx.fillStyle = '#1a1a1a' + ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT) + + // Draw grid + ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)' + ctx.lineWidth = 1 + for (let x = 0; x <= CANVAS_WIDTH; x += 20) { + ctx.beginPath() + ctx.moveTo(x, 0) + ctx.lineTo(x, CANVAS_HEIGHT) + ctx.stroke() + } + for (let y = 0; y <= CANVAS_HEIGHT; y += 20) { + ctx.beginPath() + ctx.moveTo(0, y) + ctx.lineTo(CANVAS_WIDTH, y) + ctx.stroke() + } + + // Draw screenshots + screenshots.forEach((screenshot) => { + const img = loadedImages[screenshot.id] + if (img) { + ctx.drawImage(img, screenshot.x, screenshot.y, screenshot.width, screenshot.height) + + // Draw delete button on top right corner (centered with corner) + const deleteBtnSize = 16 + const deleteBtnX = screenshot.x + screenshot.width + const deleteBtnY = screenshot.y + + // Background circle - more subtle + ctx.fillStyle = 'rgba(255, 255, 255, 0.15)' + ctx.beginPath() + ctx.arc(deleteBtnX, deleteBtnY, deleteBtnSize / 2, 0, Math.PI * 2) + ctx.fill() + + // X mark - subtle gray + ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)' + ctx.lineWidth = 1.5 + ctx.beginPath() + ctx.moveTo(deleteBtnX - 5, deleteBtnY - 5) + ctx.lineTo(deleteBtnX + 5, deleteBtnY + 5) + ctx.moveTo(deleteBtnX + 5, deleteBtnY - 5) + ctx.lineTo(deleteBtnX - 5, deleteBtnY + 5) + ctx.stroke() + + // Draw selection border if selected + if (selectedScreenshot === screenshot.id) { + ctx.strokeStyle = '#f47067' + ctx.lineWidth = 2 + ctx.setLineDash([5, 5]) + ctx.strokeRect(screenshot.x, screenshot.y, screenshot.width, screenshot.height) + ctx.setLineDash([]) + + // Draw resize handles + const handleSize = 8 + const handles = [ + { x: screenshot.x + screenshot.width, y: screenshot.y + screenshot.height, type: 'se' }, + { x: screenshot.x, y: screenshot.y + screenshot.height, type: 'sw' }, + { x: screenshot.x + screenshot.width, y: screenshot.y, type: 'ne' }, + { x: screenshot.x, y: screenshot.y, type: 'nw' }, + ] + + handles.forEach((handle) => { + ctx.fillStyle = '#f47067' + ctx.fillRect(handle.x - handleSize / 2, handle.y - handleSize / 2, handleSize, handleSize) + }) + } + } + }) + } + + const getMousePos = (e: React.MouseEvent) => { + const canvas = canvasRef.current + if (!canvas) return { x: 0, y: 0 } + const rect = canvas.getBoundingClientRect() + return { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + } + } + + const getScreenshotAt = (x: number, y: number): Screenshot | null => { + for (let i = screenshots.length - 1; i >= 0; i--) { + const s = screenshots[i] + if (x >= s.x && x <= s.x + s.width && y >= s.y && y <= s.y + s.height) { + return s + } + } + return null + } + + const getResizeHandle = (screenshot: Screenshot, x: number, y: number): 'se' | 'sw' | 'ne' | 'nw' | null => { + const handleSize = 8 + const handles = [ + { x: screenshot.x + screenshot.width, y: screenshot.y + screenshot.height, type: 'se' as const }, + { x: screenshot.x, y: screenshot.y + screenshot.height, type: 'sw' as const }, + { x: screenshot.x + screenshot.width, y: screenshot.y, type: 'ne' as const }, + { x: screenshot.x, y: screenshot.y, type: 'nw' as const }, + ] + + for (const handle of handles) { + if ( + x >= handle.x - handleSize / 2 && + x <= handle.x + handleSize / 2 && + y >= handle.y - handleSize / 2 && + y <= handle.y + handleSize / 2 + ) { + return handle.type + } + } + return null + } + + const handleMouseDown = (e: React.MouseEvent) => { + const pos = getMousePos(e) + + // Check if clicking on delete button + const screenshot = getScreenshotAt(pos.x, pos.y) + if (screenshot) { + const deleteBtnSize = 16 + const deleteBtnX = screenshot.x + screenshot.width + const deleteBtnY = screenshot.y + const distance = Math.sqrt(Math.pow(pos.x - deleteBtnX, 2) + Math.pow(pos.y - deleteBtnY, 2)) + + if (distance <= deleteBtnSize / 2) { + onUpdateScreenshots(screenshots.filter((s) => s.id !== screenshot.id)) + setSelectedScreenshot(null) + return + } + } + + if (screenshot) { + const handle = getResizeHandle(screenshot, pos.x, pos.y) + if (handle) { + setIsResizing(true) + setResizeHandle(handle) + setSelectedScreenshot(screenshot.id) + } else { + setIsDragging(true) + setSelectedScreenshot(screenshot.id) + } + setDragStart({ x: pos.x - screenshot.x, y: pos.y - screenshot.y }) + } else { + setSelectedScreenshot(null) + } + } + + const handleMouseMove = (e: React.MouseEvent) => { + const pos = getMousePos(e) + + // Update cursor if not dragging/resizing + if (!isDragging && !isResizing) { + const screenshot = getScreenshotAt(pos.x, pos.y) + if (screenshot) { + const handle = getResizeHandle(screenshot, pos.x, pos.y) + if (handle) { + const cursors = { se: 'se-resize', sw: 'sw-resize', ne: 'ne-resize', nw: 'nw-resize' } + setCursor(cursors[handle]) + } else { + setCursor('grab') + } + } else { + setCursor('default') + } + } + + if (isDragging && selectedScreenshot) { + const screenshot = screenshots.find((s) => s.id === selectedScreenshot) + if (screenshot) { + const newX = Math.max(0, Math.min(CANVAS_WIDTH - screenshot.width, pos.x - dragStart.x)) + const newY = Math.max(0, Math.min(CANVAS_HEIGHT - screenshot.height, pos.y - dragStart.y)) + onUpdateScreenshots( + screenshots.map((s) => (s.id === selectedScreenshot ? { ...s, x: newX, y: newY } : s)) + ) + drawCanvas() + } + } else if (isResizing && selectedScreenshot && resizeHandle) { + const screenshot = screenshots.find((s) => s.id === selectedScreenshot) + if (screenshot) { + let newX = screenshot.x + let newY = screenshot.y + let newWidth = screenshot.width + let newHeight = screenshot.height + + if (resizeHandle === 'se') { + newWidth = Math.max(50, Math.min(CANVAS_WIDTH - screenshot.x, pos.x - screenshot.x)) + newHeight = Math.max(50, Math.min(CANVAS_HEIGHT - screenshot.y, pos.y - screenshot.y)) + } else if (resizeHandle === 'sw') { + const deltaX = screenshot.x - pos.x + newX = Math.max(0, pos.x) + newWidth = Math.max(50, screenshot.width + deltaX) + newHeight = Math.max(50, Math.min(CANVAS_HEIGHT - screenshot.y, pos.y - screenshot.y)) + } else if (resizeHandle === 'ne') { + newWidth = Math.max(50, Math.min(CANVAS_WIDTH - screenshot.x, pos.x - screenshot.x)) + const deltaY = screenshot.y - pos.y + newY = Math.max(0, pos.y) + newHeight = Math.max(50, screenshot.height + deltaY) + } else if (resizeHandle === 'nw') { + const deltaX = screenshot.x - pos.x + const deltaY = screenshot.y - pos.y + newX = Math.max(0, pos.x) + newY = Math.max(0, pos.y) + newWidth = Math.max(50, screenshot.width + deltaX) + newHeight = Math.max(50, screenshot.height + deltaY) + } + + onUpdateScreenshots( + screenshots.map((s) => + s.id === selectedScreenshot ? { ...s, x: newX, y: newY, width: newWidth, height: newHeight } : s + ) + ) + drawCanvas() + } + } + } + + const handleMouseUp = () => { + setIsDragging(false) + setIsResizing(false) + setResizeHandle(null) + } + + const copyCanvasAsImage = async () => { + const canvas = canvasRef.current + if (!canvas) return + + try { + const dataUrl = canvas.toDataURL('image/png') + await navigator.clipboard.write([ + new ClipboardItem({ + 'image/png': new Blob([await fetch(dataUrl).then((r) => r.blob())], { type: 'image/png' }), + }), + ]) + alert('Canvas copied to clipboard!') + } catch (error) { + console.error('Failed to copy canvas:', error) + // Fallback: download the image + const link = document.createElement('a') + link.download = 'canvas.png' + link.href = canvas.toDataURL('image/png') + link.click() + } + } + + + return ( +
+
+

Canvas

+
+ +
+
+ +
+ { + handleMouseUp() + setCursor('default') + }} + style={{ + cursor: isDragging || isResizing ? 'grabbing' : cursor, + }} + /> +
+
+ ) +} + diff --git a/site/app/studio/editor-manager.tsx b/site/app/studio/editor-manager.tsx new file mode 100644 index 0000000..833df6a --- /dev/null +++ b/site/app/studio/editor-manager.tsx @@ -0,0 +1,52 @@ +'use client' + +import { Editor } from 'codice' +import { EditorConfig } from './page' +import './studio.css' + +interface EditorManagerProps { + editor: EditorConfig + onUpdate: (updates: Partial) => void + editorRef: (el: HTMLDivElement | null) => void + onTakeScreenshot: () => void +} + +export function EditorManager({ editor, onUpdate, editorRef, onTakeScreenshot }: EditorManagerProps) { + return ( +
+
+
+
+ +
+ +
+
+ onUpdate({ code })} + onChangeTitle={(title) => onUpdate({ title })} + className="editor-preview-editor" + /> +
+
+
+ ) +} + diff --git a/site/app/studio/page.tsx b/site/app/studio/page.tsx new file mode 100644 index 0000000..f976c4d --- /dev/null +++ b/site/app/studio/page.tsx @@ -0,0 +1,213 @@ +'use client' + +import { useState, useRef, useEffect } from 'react' +import { Editor } from 'codice' +import { EditorManager } from './editor-manager' +import { CanvasView } from './canvas-view' +import './studio.css' + +export interface EditorConfig { + id: string + title: string + code: string + fontSize?: string | number + fontFamily?: string + padding?: string + lineNumbersWidth?: string + lineNumbers?: boolean +} + +export default function StudioPage() { + const [editors, setEditors] = useState([ + { + id: '1', + title: 'example.js', + code: `function hello() { + console.log('Hello, World!') +}`, + fontSize: 14, + fontFamily: 'Consolas, Monaco, monospace', + padding: '1rem', + lineNumbersWidth: '2.5rem', + lineNumbers: true, + }, + ]) + const [selectedEditorId, setSelectedEditorId] = useState('1') + const [screenshots, setScreenshots] = useState>([]) + const editorRefs = useRef>({}) + const [animatingScreenshot, setAnimatingScreenshot] = useState<{ + dataUrl: string + startX: number + startY: number + endX: number + endY: number + width: number + height: number + } | null>(null) + + const selectedEditor = editors.find((e) => e.id === selectedEditorId) || editors[0] + + useEffect(() => { + document.body.classList.add('studio-page') + return () => { + document.body.classList.remove('studio-page') + } + }, []) + + const updateEditor = (id: string, updates: Partial) => { + setEditors((prev) => prev.map((e) => (e.id === id ? { ...e, ...updates } : e))) + } + + const addEditor = () => { + const newId = String(Date.now()) + const newEditor: EditorConfig = { + id: newId, + title: `file-${editors.length + 1}.js`, + code: '', + fontSize: 14, + fontFamily: 'Consolas, Monaco, monospace', + padding: '1rem', + lineNumbersWidth: '2.5rem', + lineNumbers: true, + } + setEditors((prev) => [...prev, newEditor]) + setSelectedEditorId(newId) + } + + const deleteEditor = (id: string) => { + setEditors((prev) => prev.filter((e) => e.id !== id)) + if (selectedEditorId === id && editors.length > 1) { + const remaining = editors.filter((e) => e.id !== id) + setSelectedEditorId(remaining[0]?.id || '') + } + } + + const takeScreenshot = async () => { + const editorElement = editorRefs.current[selectedEditorId] + if (!editorElement) return + + try { + // Get editor position for animation + const rect = editorElement.getBoundingClientRect() + const canvasPanel = document.querySelector('.studio-canvas-panel') + const canvasRect = canvasPanel?.getBoundingClientRect() + + // Dynamic import to avoid SSR issues + const { toPng } = await import('html-to-image') + const dataUrl = await toPng(editorElement, { + backgroundColor: 'transparent', + pixelRatio: 2, + }) + + const img = new Image() + img.src = dataUrl + await new Promise((resolve) => { + img.onload = resolve + }) + + const screenshotId = `screenshot-${Date.now()}` + const newScreenshot = { + id: screenshotId, + dataUrl, + x: 100 + screenshots.length * 20, + y: 100 + screenshots.length * 20, + width: img.width / 2, // Divide by 2 because pixelRatio is 2 + height: img.height / 2, + } + + // Trigger animation + if (canvasRect) { + setAnimatingScreenshot({ + dataUrl, + startX: rect.left + rect.width / 2, + startY: rect.top + rect.height / 2, + endX: canvasRect.left + newScreenshot.x + newScreenshot.width / 2, + endY: canvasRect.top + newScreenshot.y + newScreenshot.height / 2, + width: newScreenshot.width, + height: newScreenshot.height, + }) + + // Add screenshot after animation completes + setTimeout(() => { + setScreenshots((prev) => [...prev, newScreenshot]) + setAnimatingScreenshot(null) + }, 600) // Match animation duration + } else { + setScreenshots((prev) => [...prev, newScreenshot]) + } + } catch (error) { + console.error('Failed to take screenshot:', error) + } + } + + return ( +
+
+
+

Editors

+ +
+
+ {editors.map((editor) => ( +
setSelectedEditorId(editor.id)} + > + {editor.title} + {editors.length > 1 && ( + + )} +
+ ))} +
+
+ +
+
+
+ updateEditor(selectedEditorId, updates)} + editorRef={(el) => { + editorRefs.current[selectedEditorId] = el + }} + onTakeScreenshot={takeScreenshot} + /> +
+ +
+ +
+
+
+ + {animatingScreenshot && ( +
+ Screenshot +
+ )} +
+ ) +} + diff --git a/site/app/studio/studio.css b/site/app/studio/studio.css new file mode 100644 index 0000000..f5dc986 --- /dev/null +++ b/site/app/studio/studio.css @@ -0,0 +1,266 @@ +/* Studio page global overrides */ +body.studio-page { + max-width: 100% !important; + margin: 0 !important; + padding: 0 !important; +} + +/* Studio container */ +.studio-container { + display: flex; + height: 100vh; + width: 100vw; + overflow: hidden; + background-color: #343434; + color: #ffeceb; + position: fixed; + top: 0; + left: 0; +} + +/* Sidebar */ +.studio-sidebar { + width: 250px; + background-color: #242424; + border-right: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.studio-sidebar-header { + padding: 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.studio-sidebar-header h2 { + margin: 0 0 1rem 0; + font-size: 1.2rem; +} + +.studio-editor-list { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +.studio-editor-item { + padding: 0.25rem; + margin-bottom: 0.25rem; + background-color: rgba(124, 124, 124, 0.05); + border-radius: 4px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + transition: background-color 0.2s; +} + +.studio-editor-item:hover { + background-color: rgba(146, 146, 146, 0.1); +} + +.studio-editor-item.active { + background-color: rgba(127, 127, 127, 0.2); +} + +.studio-editor-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.studio-btn-close { + background: none; + border: none; + color: #ffeceb; + cursor: pointer; + font-size: 1.5rem; + line-height: 1; + padding: 0 0.5rem; + opacity: 0.6; + transition: opacity 0.2s; +} + +.studio-btn-close:hover { + opacity: 1; +} + +/* Main content */ +.studio-main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.studio-toolbar { + padding: 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + justify-content: space-between; + align-items: center; +} + +.studio-content { + flex: 1; + display: flex; + overflow: hidden; +} + +.studio-editor-panel { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +.studio-canvas-panel { + width: 600px; + border-left: 1px solid rgba(255, 255, 255, 0.1); + background-color: #2a2a2a; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* Screenshot animation */ +.screenshot-animation { + position: fixed; + top: var(--start-y); + left: var(--start-x); + width: var(--width); + height: var(--height); + transform: translate(-50%, -50%); + pointer-events: none; + z-index: 10000; + animation: float-to-canvas 0.6s ease-out forwards; +} + +.screenshot-animation img { + width: 100%; + height: 100%; + object-fit: contain; + border-radius: 4px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); +} + +@keyframes float-to-canvas { + 0% { + top: var(--start-y); + left: var(--start-x); + transform: translate(-50%, -50%) scale(1) rotate(0deg); + opacity: 1; + } + 50% { + transform: translate(-50%, -50%) scale(0.9) rotate(5deg); + opacity: 0.9; + } + 100% { + top: var(--end-y); + left: var(--end-x); + transform: translate(-50%, -50%) scale(0.8) rotate(-5deg); + opacity: 0; + } +} + +/* Editor manager */ +.editor-manager { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.editor-wrapper { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.editor-controls { + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: flex-end; +} + +.editor-controls .control-button { + height: 26px; + display: inline-flex; + align-items: center; +} + +.editor-setting-toggle label { + cursor: pointer; +} + +.editor-preview { + position: relative; +} + +.editor-preview-editor { + background-color: var(--app-editor-bg-color); + border-radius: 8px; + overflow: hidden; +} + +/* Make textarea easier to interact with */ +.editor-preview-editor [data-codice-content] { + position: relative; + min-height: 200px; +} + +.editor-preview-editor [data-codice-content] code { + pointer-events: none; + min-height: 200px; +} + +.editor-preview-editor textarea { + z-index: 10; + pointer-events: auto !important; + min-height: 200px; +} + +/* Set title font size */ +.editor-preview-editor [data-codice-title] { + font-size: 14px; +} + +/* Canvas view */ +.canvas-view { + display: flex; + flex-direction: column; + height: 100%; +} + +.canvas-toolbar { + padding: 0.5rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.canvas-toolbar h3 { + margin: 0; + font-size: 1.1rem; +} + +.canvas-actions { + display: flex; + gap: 0.5rem; +} + +.canvas-container { + flex: 1; + overflow: auto; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.canvas-container canvas { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + background-color: #1a1a1a; +} + diff --git a/site/next-env.d.ts b/site/next-env.d.ts deleted file mode 100644 index 9edff1c..0000000 --- a/site/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/site/package.json b/site/package.json index 6152d15..edabb9f 100644 --- a/site/package.json +++ b/site/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "codice": "workspace:*", + "html-to-image": "^1.11.13", "prettier": "^3.6.2", "react": "^19.2.1", "react-dom": "^19.2.1" From 4a9aed5bea3437bd59668736926bc3a61280b3c0 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Tue, 30 Dec 2025 00:37:53 -0300 Subject: [PATCH 2/2] resize --- site/app/live-editor.tsx | 30 ++--- site/app/studio/canvas-view.tsx | 117 +++++++++++++++-- site/app/studio/editor-manager.tsx | 2 +- site/app/studio/page.tsx | 80 +++++++++--- site/app/studio/studio.css | 194 ++++++++++++++++++++++++----- 5 files changed, 352 insertions(+), 71 deletions(-) diff --git a/site/app/live-editor.tsx b/site/app/live-editor.tsx index 2e1f9d3..03b8e1b 100644 --- a/site/app/live-editor.tsx +++ b/site/app/live-editor.tsx @@ -110,10 +110,10 @@ export async function copyImageDataUrl(dataUrl: string) { } const blob = await (await fetch(dataUrl)).blob() - + // Safari compatibility: Check if the format is supported const mimeType = 'image/png' - + // Use ClipboardItem.supports() if available (modern browsers) if (typeof ClipboardItem.supports === 'function') { if (!ClipboardItem.supports(mimeType)) { @@ -122,19 +122,19 @@ export async function copyImageDataUrl(dataUrl: string) { } // Safari-specific: Create ClipboardItem with promise-based blob for better compatibility - const clipboardItem = new ClipboardItem({ + const clipboardItem = new ClipboardItem({ [mimeType]: Promise.resolve(blob) }) // Safari requires user gesture - this should be called within a user interaction await navigator.clipboard.write([clipboardItem]) return Promise.resolve(dataUrl) - + } catch (error) { if (process.env.NODE_ENV === 'development') { console.error('Clipboard error:', error) } - + // Safari-specific error handling with better messages if (error instanceof Error) { if (error.name === 'NotAllowedError') { @@ -148,7 +148,7 @@ export async function copyImageDataUrl(dataUrl: string) { return Promise.reject('Security policy prevented clipboard access.') } } - + // Generic error fallback return Promise.reject('Failed to copy image to clipboard. Please try again.') } @@ -204,7 +204,7 @@ function ScreenshotButton({ editorElementRef }: { editorElementRef: React.RefObj if (!editorElementRef.current) { return Promise.resolve(null) } - + try { // Safari fix: Create clipboard promise immediately to preserve user gesture if (!navigator.clipboard || !window.ClipboardItem) { @@ -222,7 +222,7 @@ function ScreenshotButton({ editorElementRef }: { editorElementRef: React.RefObj // Start clipboard write immediately (synchronously from user event) await navigator.clipboard.write([clipboardItem]) - + // Generate dataUrl for return (can be done after clipboard operation) const dataUrl = await toPng(editorElementRef.current) return dataUrl @@ -235,7 +235,7 @@ function ScreenshotButton({ editorElementRef }: { editorElementRef: React.RefObj } const [actionState, dispatch, isPending] = useActionState< - { state: 'idle' | 'succeed' | 'error'; dataUrl?: string }, + { state: 'idle' | 'succeed' | 'error'; dataUrl?: string }, { type: 'reset' } | { type: 'copy'; dataUrl: string | null } >( (state, action) => { @@ -243,7 +243,7 @@ function ScreenshotButton({ editorElementRef }: { editorElementRef: React.RefObj return { state: 'idle' } } else if (action.type === 'copy') { const imageDataUrl = action.dataUrl - + if (imageDataUrl) { const id = Date.now().toString() @@ -453,7 +453,7 @@ function DropdownMenu({ if (Date.now() - openTimeRef.current < 100) { return } - + const dropdown = nodeRef.current if (dropdown && !dropdown.contains(event.target as Node)) { setIsOpen(false) @@ -481,7 +481,7 @@ function DropdownMenu({ const arrowRect = arrowElement.getBoundingClientRect() const arrowLeft = arrowRect.left const clientX = e.clientX - + // Check if pointer is on the arrow (right side) - use a wider touch target const touchPadding = e.pointerType === 'touch' ? 12 : 8 // Extra padding for touch if (clientX >= arrowLeft - touchPadding) { @@ -502,9 +502,9 @@ function DropdownMenu({ return (
- @@ -316,8 +419,6 @@ export function CanvasView({ screenshots, onUpdateScreenshots }: CanvasViewProps
diff --git a/site/app/studio/editor-manager.tsx b/site/app/studio/editor-manager.tsx index 833df6a..e649390 100644 --- a/site/app/studio/editor-manager.tsx +++ b/site/app/studio/editor-manager.tsx @@ -28,7 +28,7 @@ export function EditorManager({ editor, onUpdate, editorRef, onTakeScreenshot }:

diff --git a/site/app/studio/page.tsx b/site/app/studio/page.tsx index f976c4d..5ab8094 100644 --- a/site/app/studio/page.tsx +++ b/site/app/studio/page.tsx @@ -1,7 +1,6 @@ 'use client' import { useState, useRef, useEffect } from 'react' -import { Editor } from 'codice' import { EditorManager } from './editor-manager' import { CanvasView } from './canvas-view' import './studio.css' @@ -44,6 +43,8 @@ export default function StudioPage() { width: number height: number } | null>(null) + const [editorPanelWidth, setEditorPanelWidth] = useState(640) + const [isResizingPanel, setIsResizingPanel] = useState(false) const selectedEditor = editors.find((e) => e.id === selectedEditorId) || editors[0] @@ -54,16 +55,39 @@ export default function StudioPage() { } }, []) + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (isResizingPanel) { + const newWidth = Math.min(640, Math.max(300, e.clientX - 60)) // 60px is sidebar width + setEditorPanelWidth(newWidth) + } + } + + const handleMouseUp = () => { + setIsResizingPanel(false) + } + + if (isResizingPanel) { + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + } + }, [isResizingPanel]) + const updateEditor = (id: string, updates: Partial) => { setEditors((prev) => prev.map((e) => (e.id === id ? { ...e, ...updates } : e))) } const addEditor = () => { const newId = String(Date.now()) + const defaultTitle = `file-${editors.length + 1}.js` const newEditor: EditorConfig = { id: newId, - title: `file-${editors.length + 1}.js`, - code: '', + title: defaultTitle, + code: '// ', fontSize: 14, fontFamily: 'Consolas, Monaco, monospace', padding: '1rem', @@ -72,6 +96,23 @@ export default function StudioPage() { } setEditors((prev) => [...prev, newEditor]) setSelectedEditorId(newId) + + // Focus the title input after a short delay to ensure it's rendered + setTimeout(() => { + const editorElement = editorRefs.current[newId] + if (editorElement) { + const titleInput = editorElement.querySelector(`[data-codice-title]`) as HTMLInputElement + if (titleInput && titleInput instanceof HTMLInputElement) { + titleInput.focus() + if (typeof titleInput.select === 'function') { + titleInput.select() + } else { + // Fallback for browsers that don't support select() + titleInput.setSelectionRange(0, titleInput.value.length) + } + } + } + }, 100) } const deleteEditor = (id: string) => { @@ -96,7 +137,8 @@ export default function StudioPage() { const { toPng } = await import('html-to-image') const dataUrl = await toPng(editorElement, { backgroundColor: 'transparent', - pixelRatio: 2, + pixelRatio: 4, + quality: 1, }) const img = new Image() @@ -111,8 +153,8 @@ export default function StudioPage() { dataUrl, x: 100 + screenshots.length * 20, y: 100 + screenshots.length * 20, - width: img.width / 2, // Divide by 2 because pixelRatio is 2 - height: img.height / 2, + width: img.width / 4, // Divide by 4 because pixelRatio is 4 + height: img.height / 4, } // Trigger animation @@ -143,20 +185,16 @@ export default function StudioPage() { return (
-
-

Editors

- -
+
{editors.map((editor) => (
setSelectedEditorId(editor.id)} + title={editor.title} > - {editor.title} +
{editors.length > 1 && (
))} +
-
+
updateEditor(selectedEditorId, updates)} @@ -185,7 +229,13 @@ export default function StudioPage() { onTakeScreenshot={takeScreenshot} />
- +
{ + setIsResizingPanel(true) + e.preventDefault() + }} + />
diff --git a/site/app/studio/studio.css b/site/app/studio/studio.css index f5dc986..30ee070 100644 --- a/site/app/studio/studio.css +++ b/site/app/studio/studio.css @@ -20,12 +20,13 @@ body.studio-page { /* Sidebar */ .studio-sidebar { - width: 250px; + width: 60px; background-color: #242424; border-right: 1px solid rgba(255, 255, 255, 0.1); display: flex; flex-direction: column; overflow: hidden; + height: 100%; } .studio-sidebar-header { @@ -34,7 +35,7 @@ body.studio-page { } .studio-sidebar-header h2 { - margin: 0 0 1rem 0; + margin: 0; font-size: 1.2rem; } @@ -42,51 +43,99 @@ body.studio-page { flex: 1; overflow-y: auto; padding: 0.5rem; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; } .studio-editor-item { - padding: 0.25rem; - margin-bottom: 0.25rem; - background-color: rgba(124, 124, 124, 0.05); - border-radius: 4px; - cursor: pointer; + position: relative; + width: 32px; + height: 32px; display: flex; - justify-content: space-between; align-items: center; - transition: background-color 0.2s; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + flex-shrink: 0; } -.studio-editor-item:hover { - background-color: rgba(146, 146, 146, 0.1); +.studio-editor-dot { + width: 12px; + height: 12px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.3); + transition: all 0.2s; } -.studio-editor-item.active { - background-color: rgba(127, 127, 127, 0.2); +.studio-editor-item:hover .studio-editor-dot { + background-color: rgba(255, 255, 255, 0.5); + transform: scale(1.2); } -.studio-editor-title { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.studio-editor-item.active .studio-editor-dot { + background-color: #f47067; + transform: scale(1.3); + box-shadow: 0 0 8px rgba(244, 112, 103, 0.5); } .studio-btn-close { + position: absolute; + top: -4px; + right: -4px; + width: 18px; + height: 18px; background: none; border: none; - color: #ffeceb; + color: rgba(255, 255, 255, 0.4); cursor: pointer; - font-size: 1.5rem; + font-size: 16px; line-height: 1; - padding: 0 0.5rem; - opacity: 0.6; - transition: opacity 0.2s; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s, color 0.2s; + z-index: 10; } -.studio-btn-close:hover { +.studio-editor-item:hover .studio-btn-close { opacity: 1; } +.studio-btn-close:hover { + color: rgba(255, 255, 255, 0.7); +} + +.studio-add-button { + width: 32px; + height: 32px; + padding: 0; + background-color: var(--control-bg-color); + border: none; + border-radius: 50%; + color: var(--control-color); + font-size: 18px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease-in-out; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.studio-add-button:hover { + background-color: var(--control-hover-color); +} + +.studio-add-button:active { + opacity: 0.7; +} + /* Main content */ .studio-main { flex: 1; @@ -110,13 +159,33 @@ body.studio-page { } .studio-editor-panel { - flex: 1; overflow-y: auto; padding: 0.5rem; + width: 640px; + max-width: 640px; + min-width: 300px; + flex-shrink: 0; +} + +.studio-resize-handle { + width: 4px; + background-color: rgba(255, 255, 255, 0.1); + cursor: col-resize; + flex-shrink: 0; + transition: background-color 0.2s; +} + +.studio-resize-handle:hover { + background-color: rgba(255, 255, 255, 0.2); +} + +.studio-resize-handle:active { + background-color: rgba(244, 112, 103, 0.5); } .studio-canvas-panel { - width: 600px; + flex: 1; + min-width: 0; border-left: 1px solid rgba(255, 255, 255, 0.1); background-color: #2a2a2a; overflow: hidden; @@ -207,18 +276,18 @@ body.studio-page { /* Make textarea easier to interact with */ .editor-preview-editor [data-codice-content] { position: relative; - min-height: 200px; + min-height: 60px; } .editor-preview-editor [data-codice-content] code { pointer-events: none; - min-height: 200px; + min-height: 60px; } .editor-preview-editor textarea { z-index: 10; pointer-events: auto !important; - min-height: 200px; + min-height: 60px; } /* Set title font size */ @@ -236,17 +305,76 @@ body.studio-page { .canvas-toolbar { padding: 0.5rem; display: flex; - justify-content: space-between; + justify-content: flex-end; align-items: center; } -.canvas-toolbar h3 { - margin: 0; - font-size: 1.1rem; +.canvas-bg-indicator { + width: 16px; + height: 16px; + border-radius: 50%; + background-color: #1a1a1a; + border: 1px solid rgba(255, 255, 255, 0.2); + flex-shrink: 0; + cursor: pointer; + transition: transform 0.2s; +} + +.canvas-bg-indicator:hover { + transform: scale(1.1); +} + +.canvas-color-picker-wrapper { + position: relative; +} + +.canvas-color-picker { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + background-color: var(--control-bg-color); + border-radius: 8px; + padding: 0.75rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 1000; + display: flex; + flex-direction: column; + gap: 0.75rem; + min-width: 200px; +} + +.canvas-color-input { + width: 100%; + height: 40px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + cursor: pointer; + background: none; + padding: 0; +} + +.canvas-color-presets { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; +} + +.canvas-color-preset { + width: 100%; + aspect-ratio: 1; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.2); + cursor: pointer; + transition: transform 0.2s; +} + +.canvas-color-preset:hover { + transform: scale(1.1); } .canvas-actions { display: flex; + align-items: center; gap: 0.5rem; }