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/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 (
- +
+ + +
+ { + handleMouseUp() + setCursor('default') + }} + style={{ + cursor: isDragging || isResizing ? 'grabbing' : cursor, + width: `${CANVAS_WIDTH}px`, + height: `${CANVAS_HEIGHT}px`, + }} + /> +
+ + ) +} + diff --git a/site/app/studio/editor-manager.tsx b/site/app/studio/editor-manager.tsx new file mode 100644 index 0000000..e649390 --- /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..5ab8094 --- /dev/null +++ b/site/app/studio/page.tsx @@ -0,0 +1,263 @@ +'use client' + +import { useState, useRef, useEffect } from 'react' +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 [editorPanelWidth, setEditorPanelWidth] = useState(640) + const [isResizingPanel, setIsResizingPanel] = useState(false) + + const selectedEditor = editors.find((e) => e.id === selectedEditorId) || editors[0] + + useEffect(() => { + document.body.classList.add('studio-page') + return () => { + document.body.classList.remove('studio-page') + } + }, []) + + 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: defaultTitle, + code: '// ', + fontSize: 14, + fontFamily: 'Consolas, Monaco, monospace', + padding: '1rem', + lineNumbersWidth: '2.5rem', + lineNumbers: true, + } + 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) => { + 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: 4, + quality: 1, + }) + + 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 / 4, // Divide by 4 because pixelRatio is 4 + height: img.height / 4, + } + + // 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.map((editor) => ( +
setSelectedEditorId(editor.id)} + title={editor.title} + > +
+ {editors.length > 1 && ( + + )} +
+ ))} + +
+
+ +
+
+
+ updateEditor(selectedEditorId, updates)} + editorRef={(el) => { + editorRefs.current[selectedEditorId] = el + }} + onTakeScreenshot={takeScreenshot} + /> +
+
{ + setIsResizingPanel(true) + e.preventDefault() + }} + /> +
+ +
+
+
+ + {animatingScreenshot && ( +
+ Screenshot +
+ )} +
+ ) +} + diff --git a/site/app/studio/studio.css b/site/app/studio/studio.css new file mode 100644 index 0000000..30ee070 --- /dev/null +++ b/site/app/studio/studio.css @@ -0,0 +1,394 @@ +/* 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: 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 { + padding: 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.studio-sidebar-header h2 { + margin: 0; + font-size: 1.2rem; +} + +.studio-editor-list { + 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 { + position: relative; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + flex-shrink: 0; +} + +.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:hover .studio-editor-dot { + background-color: rgba(255, 255, 255, 0.5); + transform: scale(1.2); +} + +.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: rgba(255, 255, 255, 0.4); + cursor: pointer; + font-size: 16px; + line-height: 1; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s, color 0.2s; + z-index: 10; +} + +.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; + 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 { + 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 { + flex: 1; + min-width: 0; + 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: 60px; +} + +.editor-preview-editor [data-codice-content] code { + pointer-events: none; + min-height: 60px; +} + +.editor-preview-editor textarea { + z-index: 10; + pointer-events: auto !important; + min-height: 60px; +} + +/* 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: flex-end; + align-items: center; +} + +.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; +} + +.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"