Merge branch 'multi-file-tabs'
This commit is contained in:
commit
da28a0ed93
18
CHANGELOG.md
18
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)?,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
65
src/components/TabBar.vue
Normal 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>
|
||||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
23
src/composables/use-font-status.ts
Normal file
23
src/composables/use-font-status.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
108
src/stores/tabs.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue