Merge branch 'multi-file-tabs'

This commit is contained in:
Danila Poyarkov 2026-03-02 19:52:03 +03:00
commit da28a0ed93
21 changed files with 543 additions and 75 deletions

View file

@ -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)

View file

@ -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)?,

View file

@ -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<Record<string, unknown>>): 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 `<!--(openpencil)${btoa(JSON.stringify(data))}(/openpencil)-->`
}
function collectNodeTree(
nodes: SceneNode[],
graph: SceneGraph
): Array<SceneNode & { children?: SceneNode[] }> {
graph: SceneGraph,
textPictureBuilder?: TextPictureBuilder
): Array<Record<string, unknown>> {
return nodes.map((node) => {
const children = graph.getChildren(node.id)
return {
...node,
children: children.length > 0 ? collectNodeTree(children, graph) : undefined
const serialized: Record<string, unknown> = { ...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
})
}

View file

@ -133,6 +133,10 @@ export async function ensureNodeFont(family: string, weight: number): Promise<vo
await loadFont(family, style)
}
export function isFontLoaded(family: string): boolean {
return [...loadedFamilies.keys()].some((k) => k.startsWith(`${family}|`))
}
export function weightToStyle(weight: number, italic = false): string {
let label = 'Regular'
if (weight <= 100) label = 'Thin'

View file

@ -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'

View file

@ -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<string>()
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

View file

@ -265,6 +265,8 @@ export interface SceneNode {
overrides: Record<string, unknown>
boundVariables: Record<string, string>
textPicture: Uint8Array | null
}
export type VariableType = 'COLOR' | 'FLOAT' | 'STRING' | 'BOOLEAN'
@ -385,6 +387,7 @@ function createDefaultNode(type: NodeType, overrides: Partial<SceneNode> = {}):
componentId: null,
overrides: {},
boundVariables: {},
textPicture: null,
...overrides
}
}

View file

@ -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() },

View file

@ -1,15 +1,20 @@
<script setup lang="ts">
import { ToastProvider, ToastRoot, ToastDescription, ToastViewport } from 'reka-ui'
import { ToastProvider, ToastRoot, ToastDescription, ToastViewport, ToastClose } from 'reka-ui'
import { useClipboard } from '@vueuse/core'
import { toast } from '@/composables/use-toast'
const { copy, copied } = useClipboard({ copiedDuring: 1500 })
</script>
<template>
<ToastProvider :duration="toast.TOAST_DURATION" swipe-direction="up">
<ToastProvider swipe-direction="up">
<ToastRoot
v-for="t in toast.toasts.value"
:key="t.id"
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-white shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in data-[state=open]:slide-in-from-top-1 data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-1 data-[swipe=move]:translate-y-[var(--reka-toast-swipe-move-y)] data-[swipe=cancel]:translate-y-0 data-[swipe=cancel]:transition-transform"
:duration="t.variant === 'error' ? 0 : toast.TOAST_DURATION"
class="flex max-w-sm items-start gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-white shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in data-[state=open]:slide-in-from-top-1 data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-1 data-[swipe=move]:translate-y-[var(--reka-toast-swipe-move-y)] data-[swipe=cancel]:translate-y-0 data-[swipe=cancel]:transition-transform"
:class="t.variant === 'error' ? 'bg-red-600' : 'bg-blue-600'"
@update:open="
(open) => {
@ -17,9 +22,24 @@ import { toast } from '@/composables/use-toast'
}
"
>
<icon-lucide-check v-if="t.variant === 'default'" class="size-3 shrink-0" />
<icon-lucide-alert-triangle v-else class="size-3 shrink-0" />
<ToastDescription>{{ t.message }}</ToastDescription>
<icon-lucide-check v-if="t.variant === 'default'" class="mt-0.5 size-3 shrink-0" />
<icon-lucide-alert-triangle v-else class="mt-0.5 size-3 shrink-0" />
<ToastDescription class="min-w-0 flex-1 select-text">{{ t.message }}</ToastDescription>
<button
v-if="t.variant === 'error'"
class="mt-0.5 shrink-0 cursor-pointer rounded p-0.5 opacity-70 hover:opacity-100"
:title="copied ? 'Copied!' : 'Copy error'"
@click="copy(t.message)"
>
<icon-lucide-check v-if="copied" class="size-3" />
<icon-lucide-copy v-else class="size-3" />
</button>
<ToastClose
v-if="t.variant === 'error'"
class="mt-0.5 shrink-0 cursor-pointer rounded p-0.5 opacity-70 hover:opacity-100"
>
<icon-lucide-x class="size-3" />
</ToastClose>
</ToastRoot>
<ToastViewport

View file

@ -9,7 +9,7 @@ import CodePanel from './CodePanel.vue'
import DesignPanel from './DesignPanel.vue'
const store = useEditorStore()
const { activeTab } = useAIChat(store)
const { activeTab } = useAIChat()
</script>
<template>

65
src/components/TabBar.vue Normal file
View file

@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue'
import { TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { useTabsStore, createTab } from '@/stores/tabs'
const { tabs, activeTabId, switchTab, closeTab } = useTabsStore()
const modelValue = computed({
get: () => activeTabId.value,
set: (id: string) => switchTab(id)
})
function onMiddleClick(e: MouseEvent, tabId: string) {
if (e.button === 1) {
e.preventDefault()
closeTab(tabId)
}
}
function onClose(e: MouseEvent, tabId: string) {
e.stopPropagation()
closeTab(tabId)
}
</script>
<template>
<TabsRoot
v-if="tabs.length > 1"
v-model="modelValue"
activation-mode="automatic"
class="flex h-9 shrink-0 items-end overflow-x-auto border-b border-border bg-[#1e1e1e] scrollbar-none"
>
<TabsList class="flex h-full items-end">
<TabsTrigger
v-for="tab in tabs"
:key="tab.id"
:value="tab.id"
class="group/tab flex h-full max-w-48 min-w-0 cursor-pointer items-center gap-1.5 border-r border-border px-3 text-xs transition-colors select-none outline-none focus-visible:ring-1 focus-visible:ring-accent data-[state=active]:bg-panel data-[state=active]:text-surface data-[state=inactive]:text-muted data-[state=inactive]:hover:text-surface"
@mousedown="onMiddleClick($event, tab.id)"
>
<icon-lucide-file class="size-3 shrink-0 opacity-50" />
<span class="min-w-0 flex-1 truncate">{{ tab.name }}</span>
<button
class="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded opacity-0 transition-opacity hover:bg-hover group-hover/tab:opacity-100 data-[state=active]:opacity-100"
:class="tab.isActive ? 'opacity-100' : ''"
:title="`Close ${tab.name}`"
:aria-label="`Close ${tab.name}`"
tabindex="-1"
@click="onClose($event, tab.id)"
>
<icon-lucide-x class="size-3" />
</button>
</TabsTrigger>
</TabsList>
<button
class="flex size-9 shrink-0 cursor-pointer items-center justify-center text-muted transition-colors hover:text-surface"
title="New tab"
aria-label="New tab"
@click="createTab()"
>
<icon-lucide-plus class="size-3.5" />
</button>
</TabsRoot>
</template>

View file

@ -138,7 +138,7 @@ function formatScale(scale: number): string {
<button
v-if="settings.length > 0"
class="mt-1.5 w-full cursor-pointer rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:cursor-default disabled:opacity-50"
class="mt-1.5 w-full cursor-pointer truncate rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:cursor-default disabled:opacity-50"
:disabled="exporting"
@click="doExport"
>

View file

@ -3,10 +3,12 @@ import { computed, onMounted } from 'vue'
import FontPicker from '@/components/FontPicker.vue'
import ScrubInput from '@/components/ScrubInput.vue'
import { useNodeFontStatus } from '@/composables/use-font-status'
import { useNodeProps } from '@/composables/use-node-props'
import { loadFont } from '@/engine/fonts'
const { store, node, updateProp, commitProp } = useNodeProps()
const { missingFonts, hasMissingFonts } = useNodeFontStatus(() => node.value)
const WEIGHTS = [
{ value: 100, label: 'Thin' },
@ -73,8 +75,13 @@ onMounted(async () => {
<div v-if="node" class="border-b border-border px-3 py-2">
<label class="mb-1.5 block text-[11px] text-muted">Typography</label>
<div class="mb-1.5">
<FontPicker :model-value="node.fontFamily" @select="selectFamily" />
<div class="mb-1.5 flex items-center gap-1.5">
<FontPicker class="min-w-0 flex-1" :model-value="node.fontFamily" @select="selectFamily" />
<icon-lucide-alert-triangle
v-if="hasMissingFonts"
class="size-3.5 shrink-0 text-amber-400"
:title="'Missing font' + (missingFonts.length > 1 ? 's' : '') + ': ' + missingFonts.join(', ')"
/>
</div>
<!-- Weight + Size -->

View file

@ -6,8 +6,8 @@ import dedent from 'dedent'
import { computed, ref, watch } from 'vue'
import { createAITools } from '@/ai/tools'
import { useEditorStore } from '@/stores/editor'
import type { EditorStore } from '@/stores/editor'
import type { UIMessage } from 'ai'
export { AI_MODELS as MODELS } from '@open-pencil/core'
@ -33,8 +33,6 @@ const apiKey = ref(localStorage.getItem(API_KEY_STORAGE) ?? '')
const modelId = ref(localStorage.getItem(MODEL_STORAGE) ?? DEFAULT_AI_MODEL)
const activeTab = ref<'design' | 'ai'>('design')
let editorStore: EditorStore | null = null
watch(apiKey, (key) => {
if (key) {
localStorage.setItem(API_KEY_STORAGE, key)
@ -65,7 +63,7 @@ function createTransport() {
}
})
const tools = editorStore ? createAITools(editorStore) : {}
const tools = createAITools(useEditorStore())
const agent = new ToolLoopAgent({
model: openrouter(modelId.value),
@ -96,10 +94,7 @@ if (typeof window !== 'undefined') {
}
}
export function useAIChat(store?: EditorStore) {
if (store) {
editorStore = store
}
export function useAIChat() {
return {
apiKey,
modelId,

View file

@ -0,0 +1,23 @@
import { isFontLoaded } from '@open-pencil/core'
import { computed } from 'vue'
import type { SceneNode } from '@open-pencil/core'
export function useNodeFontStatus(node: () => SceneNode) {
const missingFonts = computed(() => {
const n = node()
if (n.type !== 'TEXT') return []
const families = new Set<string>()
families.add(n.fontFamily || 'Inter')
for (const run of n.styleRuns) {
if (run.style.fontFamily) families.add(run.style.fontFamily)
}
return [...families].filter((f) => !isFontLoaded(f))
})
const hasMissingFonts = computed(() => missingFonts.value.length > 0)
return { missingFonts, hasMissingFonts }
}

View file

@ -1,18 +1,19 @@
import { useEventListener } from '@vueuse/core'
import { useAIChat } from '@/composables/use-chat'
import { TOOL_SHORTCUTS } from '@/stores/editor'
import { TOOL_SHORTCUTS, useEditorStore } from '@/stores/editor'
import { closeTab, createTab, activeTab as activeTabRef } from '@/stores/tabs'
import { openFileDialog } from './use-menu'
import type { EditorStore } from '@/stores/editor'
function isEditing(e: Event) {
return e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement
}
export function useKeyboard(store: EditorStore) {
export function useKeyboard() {
const { activeTab } = useAIChat()
const store = useEditorStore()
useEventListener(window, 'copy', (e: ClipboardEvent) => {
if (isEditing(e)) return
e.preventDefault()
@ -91,6 +92,16 @@ export function useKeyboard(store: EditorStore) {
activeTab.value = activeTab.value === 'ai' ? 'design' : 'ai'
return
}
if (e.key === 'w') {
e.preventDefault()
if (activeTabRef.value) closeTab(activeTabRef.value.id)
return
}
if (e.key === 'n' || e.key === 't') {
e.preventDefault()
createTab()
return
}
if (e.key === 'z' && !e.shiftKey) {
e.preventDefault()
store.undoAction()
@ -114,7 +125,7 @@ export function useKeyboard(store: EditorStore) {
store.saveFigFile()
} else if (e.key === 'o') {
e.preventDefault()
openFileDialog(store)
openFileDialog()
} else if (e.key === 'g' && !e.shiftKey) {
e.preventDefault()
store.groupSelected()

View file

@ -1,10 +1,10 @@
import { onUnmounted } from 'vue'
import { IS_TAURI } from '@/constants'
import { useEditorStore } from '@/stores/editor'
import { openFileInNewTab, createTab, closeTab, activeTab } from '@/stores/tabs'
import type { EditorStore } from '@/stores/editor'
export async function openFileDialog(store: EditorStore) {
export async function openFileDialog() {
if (IS_TAURI) {
const { open } = await import('@tauri-apps/plugin-dialog')
const { readFile } = await import('@tauri-apps/plugin-fs')
@ -15,7 +15,7 @@ export async function openFileDialog(store: EditorStore) {
if (!path) return
const bytes = await readFile(path as string)
const file = new File([bytes], (path as string).split('/').pop() ?? 'file.fig')
await store.openFigFile(file, undefined, path as string)
await openFileInNewTab(file, undefined, path as string)
return
}
@ -30,7 +30,7 @@ export async function openFileDialog(store: EditorStore) {
]
})
const file = await handle.getFile()
await store.openFigFile(file, handle)
await openFileInNewTab(file, handle)
return
} catch (e) {
if ((e as Error).name === 'AbortError') return
@ -42,29 +42,35 @@ export async function openFileDialog(store: EditorStore) {
input.accept = '.fig'
input.addEventListener('change', () => {
const file = input.files?.[0]
if (file) store.openFigFile(file)
if (file) openFileInNewTab(file)
})
input.click()
}
const MENU_ACTIONS: Record<string, (store: EditorStore) => void> = {
open: (store) => openFileDialog(store),
save: (store) => store.saveFigFile(),
'save-as': (store) => store.saveFigFileAs(),
duplicate: (store) => store.duplicateSelected(),
delete: (store) => store.deleteSelected(),
group: (store) => store.groupSelected(),
ungroup: (store) => store.ungroupSelected(),
'create-component': (store) => store.createComponentFromSelection(),
'create-component-set': (store) => store.createComponentSetFromComponents(),
'detach-instance': (store) => store.detachInstance(),
'zoom-fit': (store) => store.zoomToFit(),
export: (store) => {
const store = useEditorStore()
const MENU_ACTIONS: Record<string, () => void> = {
new: () => createTab(),
open: () => openFileDialog(),
close: () => {
if (activeTab.value) closeTab(activeTab.value.id)
},
save: () => store.saveFigFile(),
'save-as': () => store.saveFigFileAs(),
duplicate: () => store.duplicateSelected(),
delete: () => store.deleteSelected(),
group: () => store.groupSelected(),
ungroup: () => store.ungroupSelected(),
'create-component': () => store.createComponentFromSelection(),
'create-component-set': () => store.createComponentSetFromComponents(),
'detach-instance': () => store.detachInstance(),
'zoom-fit': () => store.zoomToFit(),
export: () => {
if (store.state.selectedIds.size > 0) store.exportSelection(1, 'PNG')
}
}
export function useMenu(store: EditorStore) {
export function useMenu() {
if (!IS_TAURI) return
let unlisten: (() => void) | undefined
@ -72,7 +78,7 @@ export function useMenu(store: EditorStore) {
import('@tauri-apps/api/event').then(({ listen }) => {
listen<string>('menu-event', (event) => {
const action = MENU_ACTIONS[event.payload]
if (action) action(store)
if (action) action()
}).then((fn) => {
unlisten = fn
})

View file

@ -1569,7 +1569,11 @@ export function createEditorStore() {
if (nodes.length === 0) return
const names = nodes.map((n) => n.name).join('\n')
const internalHtml = buildOpenPencilClipboardHTML(nodes, graph)
const renderer = _renderer
const textPicBuilder = renderer
? (node: SceneNode) => renderer.buildTextPicture(node)
: undefined
const internalHtml = buildOpenPencilClipboardHTML(nodes, graph, textPicBuilder)
const figmaHtml = buildFigmaClipboardHTML(nodes, graph)
const html = figmaHtml ? figmaHtml + internalHtml : internalHtml
@ -1969,13 +1973,21 @@ export type EditorStore = ReturnType<typeof createEditorStore>
const storeRef = shallowRef<EditorStore>()
export function provideEditorStore(): EditorStore {
const store = createEditorStore()
export function setActiveEditorStore(store: EditorStore) {
storeRef.value = store
return store
}
export function useEditorStore(): EditorStore {
export function getActiveEditorStore(): EditorStore {
if (!storeRef.value) throw new Error('Editor store not provided')
return storeRef.value
}
const storeProxy = new Proxy({} as EditorStore, {
get(_, prop) {
return Reflect.get(getActiveEditorStore(), prop)
}
})
export function useEditorStore(): EditorStore {
return storeProxy
}

108
src/stores/tabs.ts Normal file
View file

@ -0,0 +1,108 @@
import { shallowRef, computed } from 'vue'
import { createEditorStore, setActiveEditorStore } from './editor'
import type { EditorStore } from './editor'
export interface Tab {
id: string
store: EditorStore
}
let nextTabId = 1
function generateTabId(): string {
return `tab-${nextTabId++}`
}
const tabsRef = shallowRef<Tab[]>([])
const activeTabId = shallowRef('')
export const activeTab = computed(() => tabsRef.value.find((t) => t.id === activeTabId.value))
export const allTabs = computed(() =>
tabsRef.value.map((t) => ({
id: t.id,
name: t.store.state.documentName,
isActive: t.id === activeTabId.value
}))
)
export function getActiveStore(): EditorStore {
const tab = tabsRef.value.find((t) => t.id === activeTabId.value)
if (!tab) throw new Error('No active tab')
return tab.store
}
export function createTab(store?: EditorStore): Tab {
const s = store ?? createEditorStore()
const tab: Tab = { id: generateTabId(), store: s }
tabsRef.value = [...tabsRef.value, tab]
activateTab(tab)
return tab
}
function activateTab(tab: Tab) {
activeTabId.value = tab.id
setActiveEditorStore(tab.store)
window.__OPEN_PENCIL_STORE__ = tab.store
}
export function switchTab(tabId: string) {
const tab = tabsRef.value.find((t) => t.id === tabId)
if (!tab) return
activateTab(tab)
}
export function closeTab(tabId: string) {
const idx = tabsRef.value.findIndex((t) => t.id === tabId)
if (idx < 0) return
const wasActive = activeTabId.value === tabId
tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId)
if (tabsRef.value.length === 0) {
createTab()
return
}
if (wasActive) {
const newIdx = Math.min(idx, tabsRef.value.length - 1)
activateTab(tabsRef.value[newIdx])
}
}
export async function openFileInNewTab(
file: File,
handle?: FileSystemFileHandle,
path?: string
): Promise<void> {
const current = activeTab.value
const isUntouched =
current && current.store.state.documentName === 'Untitled' && !current.store.undo.canUndo
if (isUntouched) {
await current.store.openFigFile(file, handle, path)
} else {
const store = createEditorStore()
createTab(store)
await store.openFigFile(file, handle, path)
}
}
export function tabCount(): number {
return tabsRef.value.length
}
export function useTabsStore() {
return {
tabs: allTabs,
activeTabId,
createTab,
switchTab,
closeTab,
openFileInNewTab,
getActiveStore,
tabCount
}
}

View file

@ -9,24 +9,26 @@ import { useMenu } from '@/composables/use-menu'
import { useCollab, COLLAB_KEY } from '@/composables/use-collab'
import { toast } from '@/composables/use-toast'
import { createDemoShapes } from '@/demo'
import { provideEditorStore } from '@/stores/editor'
import { useEditorStore } from '@/stores/editor'
import { createTab, activeTab } from '@/stores/tabs'
import CollabPanel from '@/components/CollabPanel.vue'
import EditorCanvas from '@/components/EditorCanvas.vue'
import LayersPanel from '@/components/LayersPanel.vue'
import PropertiesPanel from '@/components/PropertiesPanel.vue'
import SafariBanner from '@/components/SafariBanner.vue'
import TabBar from '@/components/TabBar.vue'
import Toolbar from '@/components/Toolbar.vue'
const route = useRoute()
const router = useRouter()
const store = provideEditorStore()
useKeyboard(store)
useMenu(store)
const collab = useCollab(store)
const firstTab = createTab()
const store = useEditorStore()
useKeyboard()
useMenu()
const collab = useCollab(firstTab.store)
provide(COLLAB_KEY, collab)
window.__OPEN_PENCIL_STORE__ = store
useEventListener(
document,
@ -40,7 +42,7 @@ useEventListener(
const params = useUrlSearchParams('history')
const showChrome = !('no-chrome' in params)
if (!('test' in params)) {
createDemoShapes(store)
createDemoShapes(firstTab.store)
}
const pendingRoomId = (route.params.roomId as string) || null
@ -66,8 +68,10 @@ function onDisconnect() {
<template>
<div class="flex h-screen w-screen flex-col">
<SafariBanner />
<TabBar />
<SplitterGroup
v-if="showChrome && store.state.showUI"
:key="activeTab?.id"
direction="horizontal"
class="flex-1 overflow-hidden"
auto-save-id="editor-layout"
@ -106,7 +110,11 @@ function onDisconnect() {
<PropertiesPanel />
</SplitterPanel>
</SplitterGroup>
<div v-else-if="showChrome" class="flex flex-1 overflow-hidden">
<div
v-else-if="showChrome"
:key="'collapsed-' + activeTab?.id"
class="flex flex-1 overflow-hidden"
>
<div class="relative flex min-w-0 flex-1">
<EditorCanvas />
<Toolbar />
@ -125,7 +133,7 @@ function onDisconnect() {
</div>
</div>
</div>
<div v-else class="flex flex-1 overflow-hidden">
<div v-else :key="'bare-' + activeTab?.id" class="flex flex-1 overflow-hidden">
<div class="relative flex min-w-0 flex-1">
<EditorCanvas />
</div>

View file

@ -1,10 +1,12 @@
import { describe, expect, it } from 'bun:test'
import { beforeAll, describe, expect, it } from 'bun:test'
import {
parseFigmaClipboard,
importClipboardNodes,
figmaNodesBounds,
buildFigmaClipboardHTML,
} from '../../packages/core/src/clipboard'
import { initCodec } from '../../packages/core/src/kiwi/codec'
import { SceneGraph, type SceneNode } from '../../packages/core/src/scene-graph'
function makeClipboardHtml(nodeChanges: unknown[], meta = { fileKey: 'test', pasteID: 1, dataType: 'scene' }) {
@ -334,3 +336,115 @@ describe('figmaNodesBounds', () => {
expect(figmaNodesBounds(nodes)).toBeNull()
})
})
describe('buildFigmaClipboardHTML', () => {
beforeAll(async () => {
await initCodec()
})
it('encodes a simple frame without throwing', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const frame = graph.createNode('FRAME', page.id, {
name: 'Card',
x: 0, y: 0, width: 300, height: 200,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
})
const html = buildFigmaClipboardHTML([frame], graph)
expect(html).toContain('figmeta')
expect(html).toContain('figma')
})
it('encodes text nodes with style runs', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const text = graph.createNode('TEXT', page.id, {
name: 'Styled',
x: 0, y: 0, width: 200, height: 24,
text: 'Hello World',
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 16,
styleRuns: [
{ start: 0, length: 5, style: { fontWeight: 700 } },
{ start: 6, length: 5, style: { fontWeight: 400, italic: true } },
],
})
const html = buildFigmaClipboardHTML([text], graph)
expect(html).toContain('figmeta')
})
it('encodes auto-layout frames', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const frame = graph.createNode('FRAME', page.id, {
name: 'Row',
x: 0, y: 0, width: 400, height: 100,
layoutMode: 'HORIZONTAL',
itemSpacing: 16,
paddingTop: 12, paddingRight: 12, paddingBottom: 12, paddingLeft: 12,
primaryAxisSizing: 'HUG',
counterAxisSizing: 'FIXED',
})
graph.createNode('RECTANGLE', frame.id, {
name: 'Child',
x: 0, y: 0, width: 50, height: 50,
})
const html = buildFigmaClipboardHTML([frame], graph)
expect(html).toContain('figmeta')
})
it('roundtrips: encode then decode back', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const frame = graph.createNode('FRAME', page.id, {
name: 'Analytics Overview',
x: 0, y: 0, width: 300, height: 200,
layoutMode: 'VERTICAL',
itemSpacing: 8,
paddingTop: 20, paddingRight: 20, paddingBottom: 20, paddingLeft: 20,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
cornerRadius: 12,
})
graph.createNode('TEXT', frame.id, {
name: 'Title',
x: 0, y: 0, width: 260, height: 24,
text: 'Analytics Overview',
fontFamily: 'Inter',
fontWeight: 600,
fontSize: 18,
})
graph.createNode('TEXT', frame.id, {
name: 'Subtitle',
x: 0, y: 0, width: 260, height: 40,
text: 'Track your key metrics and performance indicators in real time.',
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 14,
})
const html = buildFigmaClipboardHTML([frame], graph)
expect(html).not.toBeNull()
const parsed = await parseFigmaClipboard(html!)
expect(parsed).not.toBeNull()
expect(parsed!.nodes.length).toBeGreaterThan(0)
const graph2 = new SceneGraph()
const page2 = graph2.getPages()[0]
const created = importClipboardNodes(parsed!.nodes, graph2, page2.id)
expect(created).toHaveLength(1)
const imported = graph2.getNode(created[0])!
expect(imported.name).toBe('Analytics Overview')
expect(imported.cornerRadius).toBe(12)
const children = graph2.getChildren(imported.id)
expect(children).toHaveLength(2)
expect(children[0].text).toBe('Analytics Overview')
expect(children[1].text).toContain('Track your key metrics')
})
})