diff --git a/CHANGELOG.md b/CHANGELOG.md index 478119172..0e4f46174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,13 @@ ### Features +- Multi-file tabs — open multiple documents in tabs within a single window +- Tab bar with close buttons, middle-click to close, and new tab (+) button +- Keyboard shortcuts: ⌘N/⌘T new tab, ⌘W close tab, ⌘O opens in new tab +- Native Tauri menu: File → New and File → Close Tab wired to tab actions +- Render text from SkPicture cache when fonts are missing — pixel-perfect display without the font installed +- Missing font indicator (⚠) next to font picker in the sidebar - Right-click context menu on layers panel — same actions as the canvas context menu -- Extract shared `NodeContextMenuContent` component to avoid menu duplication - 40+ new AI/MCP tools ported from figma-use: - Granular set tools: `set_rotation`, `set_opacity`, `set_radius`, `set_minmax`, `set_text`, `set_font`, `set_font_range`, `set_text_resize`, `set_visible`, `set_blend`, `set_locked`, `set_stroke_align` - Node operations: `node_bounds`, `node_move`, `node_resize`, `node_ancestors`, `node_children`, `node_tree`, `node_bindings`, `node_replace_with` @@ -17,6 +22,12 @@ - Viewport: `viewport_get`, `viewport_set`, `viewport_zoom_to_fit`, `page_bounds` - Misc: `flatten_nodes`, `list_fonts` +### Fixes + +- Fix clipboard "Outside int range" error — `pasteID` used unsigned int exceeding Kiwi's signed 32-bit field +- Error toasts are now sticky (don't auto-dismiss), with selectable text, copy button, and close button +- Truncate long node names in export button + ### Build - Auto-populate GitHub Release notes from CHANGELOG.md via `ffurrer2/extract-release-notes@v2` @@ -25,6 +36,11 @@ ### Internal - Extract shared color constants (`BLACK`, `TRANSPARENT`, `DEFAULT_SHADOW_COLOR`) — replaces 8 inline literals across core +- Extract shared `NodeContextMenuContent` component to avoid menu duplication + +### Tests + +- Clipboard roundtrip tests: encode to Figma Kiwi binary → decode → verify ## [0.4.2] (2026-03-02) diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs index df67437d4..3e2984c4b 100644 --- a/desktop/src/lib.rs +++ b/desktop/src/lib.rs @@ -243,7 +243,7 @@ pub fn run() { #[allow(unused_mut)] let mut file_menu_builder = SubmenuBuilder::new(app, "File") .item( - &MenuItemBuilder::new("New File") + &MenuItemBuilder::new("New") .id("new") .accelerator("CmdOrCtrl+N") .build(app)?, @@ -276,7 +276,7 @@ pub fn run() { ) .separator() .item( - &MenuItemBuilder::new("Close Window") + &MenuItemBuilder::new("Close Tab") .id("close") .accelerator("CmdOrCtrl+W") .build(app)?, diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index 9d9f69a02..6509392ee 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -434,7 +434,7 @@ export function buildFigmaClipboardHTML(nodes: SceneNode[], graph: SceneGraph): type: 'NODE_CHANGES', sessionID: 0, ackID: 0, - pasteID: crypto.getRandomValues(new Uint32Array(1))[0], + pasteID: crypto.getRandomValues(new Int32Array(1))[0], pasteFileKey: 'openpencil', nodeChanges } @@ -472,6 +472,7 @@ export function parseOpenPencilClipboard( try { const decoded = JSON.parse(atob(match[1])) if (decoded.format === 'openpencil/v1' && Array.isArray(decoded.nodes)) { + restoreTextPictures(decoded.nodes) return decoded.nodes } } catch { @@ -480,23 +481,50 @@ export function parseOpenPencilClipboard( return null } -export function buildOpenPencilClipboardHTML(nodes: SceneNode[], graph: SceneGraph): string { +function restoreTextPictures(nodes: Array>): void { + for (const node of nodes) { + if (typeof node.textPicture === 'string') { + node.textPicture = base64ToBinary(node.textPicture) + } + if (Array.isArray(node.children)) { + restoreTextPictures(node.children) + } + } +} + +export type TextPictureBuilder = (node: SceneNode) => Uint8Array | null + +export function buildOpenPencilClipboardHTML( + nodes: SceneNode[], + graph: SceneGraph, + textPictureBuilder?: TextPictureBuilder +): string { const data = { format: 'openpencil/v1', - nodes: collectNodeTree(nodes, graph) + nodes: collectNodeTree(nodes, graph, textPictureBuilder) } return `` } function collectNodeTree( nodes: SceneNode[], - graph: SceneGraph -): Array { + graph: SceneGraph, + textPictureBuilder?: TextPictureBuilder +): Array> { return nodes.map((node) => { const children = graph.getChildren(node.id) - return { - ...node, - children: children.length > 0 ? collectNodeTree(children, graph) : undefined + const serialized: Record = { ...node } + + if (node.type === 'TEXT' && node.text && textPictureBuilder) { + const pic = node.textPicture ?? textPictureBuilder(node) + if (pic) serialized.textPicture = binaryToBase64(pic) + } else { + delete serialized.textPicture } + + if (children.length > 0) { + serialized.children = collectNodeTree(children, graph, textPictureBuilder) + } + return serialized }) } diff --git a/packages/core/src/fonts.ts b/packages/core/src/fonts.ts index 647a6687e..bc4594849 100644 --- a/packages/core/src/fonts.ts +++ b/packages/core/src/fonts.ts @@ -133,6 +133,10 @@ export async function ensureNodeFont(family: string, weight: number): Promise k.startsWith(`${family}|`)) +} + export function weightToStyle(weight: number, italic = false): string { let label = 'Regular' if (weight <= 100) label = 'Thin' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 63b6d4939..952fb53f3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -55,6 +55,7 @@ export { listFamilies, initFontService, getFontProvider, + isFontLoaded, ensureNodeFont, styleToWeight, weightToStyle @@ -127,7 +128,8 @@ export { parseOpenPencilClipboard, buildFigmaClipboardHTML, buildOpenPencilClipboardHTML, - prefetchFigmaSchema + prefetchFigmaSchema, + type TextPictureBuilder } from './clipboard' export { readFigFile, parseFigFile } from './kiwi/fig-file' diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 082a403a2..f20b7a81a 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -58,6 +58,7 @@ import { TEXT_CARET_WIDTH } from './constants' import { vectorNetworkToPath } from './vector' +import { isFontLoaded } from './fonts' import type { SceneNode, SceneGraph, Fill, Stroke } from './scene-graph' import type { SnapGuide } from './snap' @@ -1676,14 +1677,54 @@ export class SkiaRenderer { if (!text) return if (this.fontsLoaded && this.fontProvider) { - const paragraph = this.buildParagraph(node, this.fillPaint.getColor()) - canvas.drawParagraph(paragraph, 0, 0) - paragraph.delete() + if (this.isNodeFontLoaded(node)) { + const paragraph = this.buildParagraph(node, this.fillPaint.getColor()) + canvas.drawParagraph(paragraph, 0, 0) + paragraph.delete() + } else if (node.textPicture) { + const pic = this.ck.MakePicture(node.textPicture) + if (pic) { + canvas.drawPicture(pic) + pic.delete() + } + } else if (this.textFont) { + canvas.drawText(text, 0, node.fontSize || DEFAULT_FONT_SIZE, this.fillPaint, this.textFont) + } } else if (this.textFont) { canvas.drawText(text, 0, node.fontSize || DEFAULT_FONT_SIZE, this.fillPaint, this.textFont) } } + isNodeFontLoaded(node: SceneNode): boolean { + const families = new Set() + families.add(node.fontFamily || 'Inter') + for (const run of node.styleRuns) { + if (run.style.fontFamily) families.add(run.style.fontFamily) + } + return [...families].every((f) => isFontLoaded(f)) + } + + buildTextPicture(node: SceneNode): Uint8Array | null { + if (!this.fontsLoaded || !this.fontProvider || !this.isNodeFontLoaded(node)) return null + if (node.type !== 'TEXT' || !node.text) return null + + const ck = this.ck + const recorder = new ck.PictureRecorder() + const bounds = ck.LTRBRect(0, 0, node.width || 1e6, node.height || 1e6) + const recCanvas = recorder.beginRecording(bounds) + + const paragraph = this.buildParagraph(node) + recCanvas.drawParagraph(paragraph, 0, 0) + paragraph.delete() + + const picture = recorder.finishRecordingAsPicture() + recorder.delete() + + const bytes = picture.serialize() + picture.delete() + return bytes ?? null + } + buildParagraph(node: SceneNode, color?: Float32Array): import('canvaskit-wasm').Paragraph { const ck = this.ck const baseColor = color ?? ck.BLACK diff --git a/packages/core/src/scene-graph.ts b/packages/core/src/scene-graph.ts index 426bd1d97..553426315 100644 --- a/packages/core/src/scene-graph.ts +++ b/packages/core/src/scene-graph.ts @@ -265,6 +265,8 @@ export interface SceneNode { overrides: Record boundVariables: Record + + textPicture: Uint8Array | null } export type VariableType = 'COLOR' | 'FLOAT' | 'STRING' | 'BOOLEAN' @@ -385,6 +387,7 @@ function createDefaultNode(type: NodeType, overrides: Partial = {}): componentId: null, overrides: {}, boundVariables: {}, + textPicture: null, ...overrides } } diff --git a/src/components/AppMenu.vue b/src/components/AppMenu.vue index 16fa3ce62..89ac2307f 100644 --- a/src/components/AppMenu.vue +++ b/src/components/AppMenu.vue @@ -54,7 +54,12 @@ interface MenuItem { } const fileMenu: MenuItem[] = [ - { label: 'Open…', shortcut: `${mod}O`, action: () => openFileDialog(store) }, + { + label: 'New', + shortcut: `${mod}N`, + action: () => import('@/stores/tabs').then((m) => m.createTab()) + }, + { label: 'Open…', shortcut: `${mod}O`, action: () => openFileDialog() }, { separator: true }, { label: 'Save', shortcut: `${mod}S`, action: () => store.saveFigFile() }, { label: 'Save as…', shortcut: `${mod}⇧S`, action: () => store.saveFigFileAs() }, diff --git a/src/components/AppToast.vue b/src/components/AppToast.vue index 407e07a6f..87275f69e 100644 --- a/src/components/AppToast.vue +++ b/src/components/AppToast.vue @@ -1,15 +1,20 @@