Add multi-file tabs
Open multiple documents in tabs within a single window, like Figma. - Tab bar appears when 2+ documents are open, with close (×) and new (+) buttons - ⌘N / ⌘T create a new empty tab - ⌘W closes the active tab (last tab closes and reopens as Untitled) - ⌘O opens .fig files in a new tab (reuses current tab if Untitled with no changes) - Middle-click closes a tab - Each tab has its own SceneGraph, UndoManager, viewport, and selection state - Switching tabs remounts the canvas to rebind the Skia renderer to the active store - Keyboard shortcuts, menu actions, and AI tools all operate on the active tab
This commit is contained in:
parent
7d98201548
commit
1b4dc08c54
|
|
@ -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() },
|
||||
|
|
@ -159,7 +164,8 @@ const topMenus = [
|
|||
v-else
|
||||
class="min-w-0 flex-1 cursor-default truncate rounded px-1 py-0.5 text-xs text-surface hover:bg-hover"
|
||||
@dblclick="startRename"
|
||||
>{{ store.state.documentName }}</span>
|
||||
>{{ store.state.documentName }}</span
|
||||
>
|
||||
<button
|
||||
class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded text-muted transition-colors hover:bg-hover hover:text-surface"
|
||||
title="Toggle UI (⌘\)"
|
||||
|
|
@ -177,58 +183,60 @@ const topMenus = [
|
|||
{{ menu.label }}
|
||||
</MenubarTrigger>
|
||||
|
||||
<MenubarPortal>
|
||||
<MenubarContent
|
||||
:side-offset="4"
|
||||
align="start"
|
||||
class="min-w-52 rounded-lg border border-border bg-panel p-1 shadow-lg"
|
||||
>
|
||||
<template v-for="(item, i) in menu.items" :key="i">
|
||||
<MenubarSeparator v-if="item.separator" class="mx-1 my-1 h-px bg-border" />
|
||||
<MenubarSub v-else-if="item.sub">
|
||||
<MenubarSubTrigger
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs text-surface outline-none select-none hover:bg-hover"
|
||||
<MenubarPortal>
|
||||
<MenubarContent
|
||||
:side-offset="4"
|
||||
align="start"
|
||||
class="min-w-52 rounded-lg border border-border bg-panel p-1 shadow-lg"
|
||||
>
|
||||
<template v-for="(item, i) in menu.items" :key="i">
|
||||
<MenubarSeparator v-if="item.separator" class="mx-1 my-1 h-px bg-border" />
|
||||
<MenubarSub v-else-if="item.sub">
|
||||
<MenubarSubTrigger
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs text-surface outline-none select-none hover:bg-hover"
|
||||
>
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
<IconChevronRight class="size-3 text-muted" />
|
||||
</MenubarSubTrigger>
|
||||
<MenubarPortal>
|
||||
<MenubarSubContent
|
||||
:side-offset="4"
|
||||
class="min-w-44 rounded-lg border border-border bg-panel p-1 shadow-lg"
|
||||
>
|
||||
<template v-for="(sub, j) in item.sub" :key="j">
|
||||
<MenubarSeparator v-if="sub.separator" class="mx-1 my-1 h-px bg-border" />
|
||||
<MenubarItem
|
||||
v-else
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none select-none"
|
||||
:class="sub.disabled ? 'text-muted/50' : 'text-surface hover:bg-hover'"
|
||||
:disabled="sub.disabled"
|
||||
@select="sub.action?.()"
|
||||
>
|
||||
<span class="flex-1">{{ sub.label }}</span>
|
||||
<span v-if="sub.shortcut" class="text-[11px] text-muted">{{
|
||||
sub.shortcut
|
||||
}}</span>
|
||||
</MenubarItem>
|
||||
</template>
|
||||
</MenubarSubContent>
|
||||
</MenubarPortal>
|
||||
</MenubarSub>
|
||||
<MenubarItem
|
||||
v-else
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none select-none"
|
||||
:class="item.disabled ? 'text-muted/50' : 'text-surface hover:bg-hover'"
|
||||
:disabled="item.disabled"
|
||||
@select="item.action?.()"
|
||||
>
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
<IconChevronRight class="size-3 text-muted" />
|
||||
</MenubarSubTrigger>
|
||||
<MenubarPortal>
|
||||
<MenubarSubContent
|
||||
:side-offset="4"
|
||||
class="min-w-44 rounded-lg border border-border bg-panel p-1 shadow-lg"
|
||||
>
|
||||
<template v-for="(sub, j) in item.sub" :key="j">
|
||||
<MenubarSeparator v-if="sub.separator" class="mx-1 my-1 h-px bg-border" />
|
||||
<MenubarItem
|
||||
v-else
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none select-none"
|
||||
:class="sub.disabled ? 'text-muted/50' : 'text-surface hover:bg-hover'"
|
||||
:disabled="sub.disabled"
|
||||
@select="sub.action?.()"
|
||||
>
|
||||
<span class="flex-1">{{ sub.label }}</span>
|
||||
<span v-if="sub.shortcut" class="text-[11px] text-muted">{{
|
||||
sub.shortcut
|
||||
}}</span>
|
||||
</MenubarItem>
|
||||
</template>
|
||||
</MenubarSubContent>
|
||||
</MenubarPortal>
|
||||
</MenubarSub>
|
||||
<MenubarItem
|
||||
v-else
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none select-none"
|
||||
:class="item.disabled ? 'text-muted/50' : 'text-surface hover:bg-hover'"
|
||||
:disabled="item.disabled"
|
||||
@select="item.action?.()"
|
||||
>
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
<span v-if="item.shortcut" class="text-[11px] text-muted">{{ item.shortcut }}</span>
|
||||
</MenubarItem>
|
||||
</template>
|
||||
</MenubarContent>
|
||||
</MenubarPortal>
|
||||
</MenubarMenu>
|
||||
<span v-if="item.shortcut" class="text-[11px] text-muted">{{
|
||||
item.shortcut
|
||||
}}</span>
|
||||
</MenubarItem>
|
||||
</template>
|
||||
</MenubarContent>
|
||||
</MenubarPortal>
|
||||
</MenubarMenu>
|
||||
</MenubarRoot>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
55
src/components/TabBar.vue
Normal file
55
src/components/TabBar.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<script setup lang="ts">
|
||||
import { useTabsStore, createTab } from '@/stores/tabs'
|
||||
|
||||
const { tabs, switchTab, closeTab } = useTabsStore()
|
||||
|
||||
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>
|
||||
<div
|
||||
v-if="tabs.length > 1"
|
||||
class="flex h-9 shrink-0 items-end gap-0 overflow-x-auto border-b border-border bg-panel scrollbar-none"
|
||||
>
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="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"
|
||||
:class="
|
||||
tab.isActive
|
||||
? 'bg-surface text-surface'
|
||||
: 'bg-panel text-muted hover:bg-hover hover:text-surface'
|
||||
"
|
||||
@click="switchTab(tab.id)"
|
||||
@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"
|
||||
:class="tab.isActive ? 'opacity-100' : ''"
|
||||
title="Close tab"
|
||||
@click="onClose($event, tab.id)"
|
||||
>
|
||||
<icon-lucide-x class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="flex size-9 shrink-0 cursor-pointer items-center justify-center text-muted transition-colors hover:bg-hover hover:text-surface"
|
||||
title="New tab"
|
||||
@click="createTab()"
|
||||
>
|
||||
<icon-lucide-plus class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -6,6 +6,7 @@ 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'
|
||||
|
|
@ -65,7 +66,13 @@ function createTransport() {
|
|||
}
|
||||
})
|
||||
|
||||
const tools = editorStore ? createAITools(editorStore) : {}
|
||||
let currentStore: EditorStore | null = editorStore
|
||||
try {
|
||||
currentStore = useEditorStore()
|
||||
} catch {
|
||||
// store not yet available
|
||||
}
|
||||
const tools = currentStore ? createAITools(currentStore) : {}
|
||||
|
||||
const agent = new ToolLoopAgent({
|
||||
model: openrouter(modelId.value),
|
||||
|
|
|
|||
|
|
@ -1,56 +1,61 @@
|
|||
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()
|
||||
|
||||
function store() {
|
||||
return useEditorStore()
|
||||
}
|
||||
|
||||
useEventListener(window, 'copy', (e: ClipboardEvent) => {
|
||||
if (isEditing(e)) return
|
||||
e.preventDefault()
|
||||
if (e.clipboardData) store.writeCopyData(e.clipboardData)
|
||||
if (e.clipboardData) store().writeCopyData(e.clipboardData)
|
||||
})
|
||||
|
||||
useEventListener(window, 'cut', (e: ClipboardEvent) => {
|
||||
if (isEditing(e)) return
|
||||
e.preventDefault()
|
||||
if (e.clipboardData) store.writeCopyData(e.clipboardData)
|
||||
store.deleteSelected()
|
||||
if (e.clipboardData) store().writeCopyData(e.clipboardData)
|
||||
store().deleteSelected()
|
||||
})
|
||||
|
||||
useEventListener(window, 'paste', (e: ClipboardEvent) => {
|
||||
if (isEditing(e)) return
|
||||
e.preventDefault()
|
||||
const html = e.clipboardData?.getData('text/html') ?? ''
|
||||
if (html) store.pasteFromHTML(html)
|
||||
if (html) store().pasteFromHTML(html)
|
||||
})
|
||||
|
||||
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
if (isEditing(e)) return
|
||||
const s = store()
|
||||
|
||||
const tool = TOOL_SHORTCUTS[e.key.toLowerCase()]
|
||||
if (tool) {
|
||||
store.setTool(tool)
|
||||
s.setTool(tool)
|
||||
return
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.altKey) {
|
||||
if (e.code === 'KeyK') {
|
||||
e.preventDefault()
|
||||
store.createComponentFromSelection()
|
||||
s.createComponentFromSelection()
|
||||
return
|
||||
}
|
||||
if (e.code === 'KeyB') {
|
||||
e.preventDefault()
|
||||
store.detachInstance()
|
||||
s.detachInstance()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -58,23 +63,23 @@ export function useKeyboard(store: EditorStore) {
|
|||
if ((e.metaKey || e.ctrlKey) && e.shiftKey) {
|
||||
if (e.code === 'KeyK') {
|
||||
e.preventDefault()
|
||||
store.createComponentSetFromComponents()
|
||||
s.createComponentSetFromComponents()
|
||||
return
|
||||
}
|
||||
if (e.code === 'KeyH') {
|
||||
e.preventDefault()
|
||||
store.toggleVisibility()
|
||||
s.toggleVisibility()
|
||||
return
|
||||
}
|
||||
if (e.code === 'KeyL') {
|
||||
e.preventDefault()
|
||||
store.toggleLock()
|
||||
s.toggleLock()
|
||||
return
|
||||
}
|
||||
if (e.code === 'KeyE') {
|
||||
e.preventDefault()
|
||||
if (store.state.selectedIds.size > 0) {
|
||||
store.exportSelection(1, 'PNG')
|
||||
if (s.state.selectedIds.size > 0) {
|
||||
s.exportSelection(1, 'PNG')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -83,7 +88,7 @@ export function useKeyboard(store: EditorStore) {
|
|||
if (e.metaKey || e.ctrlKey) {
|
||||
if (e.code === 'Backslash') {
|
||||
e.preventDefault()
|
||||
store.state.showUI = !store.state.showUI
|
||||
s.state.showUI = !s.state.showUI
|
||||
return
|
||||
}
|
||||
if (e.code === 'KeyJ') {
|
||||
|
|
@ -91,78 +96,88 @@ 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()
|
||||
s.undoAction()
|
||||
} else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
|
||||
e.preventDefault()
|
||||
store.redoAction()
|
||||
s.redoAction()
|
||||
} else if (e.key === '0') {
|
||||
e.preventDefault()
|
||||
store.zoomToFit()
|
||||
s.zoomToFit()
|
||||
} else if (e.key === 'd') {
|
||||
e.preventDefault()
|
||||
store.duplicateSelected()
|
||||
s.duplicateSelected()
|
||||
} else if (e.key === 'a') {
|
||||
e.preventDefault()
|
||||
store.selectAll()
|
||||
s.selectAll()
|
||||
} else if (e.key === 's' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
store.saveFigFileAs()
|
||||
s.saveFigFileAs()
|
||||
} else if (e.key === 's') {
|
||||
e.preventDefault()
|
||||
store.saveFigFile()
|
||||
s.saveFigFile()
|
||||
} else if (e.key === 'o') {
|
||||
e.preventDefault()
|
||||
openFileDialog(store)
|
||||
openFileDialog()
|
||||
} else if (e.key === 'g' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
store.groupSelected()
|
||||
s.groupSelected()
|
||||
} else if (e.key === 'g' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
store.ungroupSelected()
|
||||
s.ungroupSelected()
|
||||
}
|
||||
}
|
||||
|
||||
if (e.shiftKey && e.key === 'A') {
|
||||
e.preventDefault()
|
||||
const node = store.selectedNode.value
|
||||
if (node && node.type === 'FRAME' && store.selectedNodes.value.length === 1) {
|
||||
store.setLayoutMode(node.id, node.layoutMode === 'NONE' ? 'VERTICAL' : 'NONE')
|
||||
} else if (store.selectedNodes.value.length > 0) {
|
||||
store.wrapInAutoLayout()
|
||||
const node = s.selectedNode.value
|
||||
if (node && node.type === 'FRAME' && s.selectedNodes.value.length === 1) {
|
||||
s.setLayoutMode(node.id, node.layoutMode === 'NONE' ? 'VERTICAL' : 'NONE')
|
||||
} else if (s.selectedNodes.value.length > 0) {
|
||||
s.wrapInAutoLayout()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === ']') {
|
||||
e.preventDefault()
|
||||
store.bringToFront()
|
||||
s.bringToFront()
|
||||
return
|
||||
}
|
||||
if (e.key === '[') {
|
||||
e.preventDefault()
|
||||
store.sendToBack()
|
||||
s.sendToBack()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
store.deleteSelected()
|
||||
s.deleteSelected()
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && store.state.penState) {
|
||||
if (e.key === 'Enter' && s.state.penState) {
|
||||
e.preventDefault()
|
||||
store.penCommit(false)
|
||||
s.penCommit(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (store.state.penState) {
|
||||
store.penCancel()
|
||||
if (s.state.penState) {
|
||||
s.penCancel()
|
||||
return
|
||||
}
|
||||
store.clearSelection()
|
||||
store.setTool('SELECT')
|
||||
s.clearSelection()
|
||||
s.setTool('SELECT')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,38 @@ 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) => {
|
||||
if (store.state.selectedIds.size > 0) store.exportSelection(1, 'PNG')
|
||||
function getStore() {
|
||||
return useEditorStore()
|
||||
}
|
||||
|
||||
const MENU_ACTIONS: Record<string, () => void> = {
|
||||
open: () => openFileDialog(),
|
||||
save: () => getStore().saveFigFile(),
|
||||
'save-as': () => getStore().saveFigFileAs(),
|
||||
duplicate: () => getStore().duplicateSelected(),
|
||||
delete: () => getStore().deleteSelected(),
|
||||
group: () => getStore().groupSelected(),
|
||||
ungroup: () => getStore().ungroupSelected(),
|
||||
'create-component': () => getStore().createComponentFromSelection(),
|
||||
'create-component-set': () => getStore().createComponentSetFromComponents(),
|
||||
'detach-instance': () => getStore().detachInstance(),
|
||||
'zoom-fit': () => getStore().zoomToFit(),
|
||||
export: () => {
|
||||
const s = getStore()
|
||||
if (s.state.selectedIds.size > 0) s.exportSelection(1, 'PNG')
|
||||
},
|
||||
'new-tab': () => createTab(),
|
||||
'close-tab': () => {
|
||||
if (activeTab.value) closeTab(activeTab.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
export function useMenu(store: EditorStore) {
|
||||
export function useMenu() {
|
||||
if (!IS_TAURI) return
|
||||
|
||||
let unlisten: (() => void) | undefined
|
||||
|
|
@ -72,7 +81,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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -618,7 +618,11 @@ export function createEditorStore() {
|
|||
if (!path) return
|
||||
filePath = path
|
||||
fileHandle = null
|
||||
state.documentName = path.split('/').pop()?.replace(/\.fig$/i, '') ?? 'Untitled'
|
||||
state.documentName =
|
||||
path
|
||||
.split('/')
|
||||
.pop()
|
||||
?.replace(/\.fig$/i, '') ?? 'Untitled'
|
||||
await writeFile(data)
|
||||
return
|
||||
}
|
||||
|
|
@ -1595,8 +1599,8 @@ export function createEditorStore() {
|
|||
parseFigmaClipboard(html).then((figma) => {
|
||||
if (figma) {
|
||||
const bounds = figmaNodesBounds(figma.nodes)
|
||||
const viewCenterX = (-state.panX + (window.innerWidth / 2)) / state.zoom
|
||||
const viewCenterY = (-state.panY + (window.innerHeight / 2)) / state.zoom
|
||||
const viewCenterX = (-state.panX + window.innerWidth / 2) / state.zoom
|
||||
const viewCenterY = (-state.panY + window.innerHeight / 2) / state.zoom
|
||||
const offsetX = bounds ? viewCenterX - (bounds.x + bounds.w / 2) : 0
|
||||
const offsetY = bounds ? viewCenterY - (bounds.y + bounds.h / 2) : 0
|
||||
|
||||
|
|
@ -1965,6 +1969,10 @@ export type EditorStore = ReturnType<typeof createEditorStore>
|
|||
|
||||
const storeRef = shallowRef<EditorStore>()
|
||||
|
||||
export function setActiveEditorStore(store: EditorStore) {
|
||||
storeRef.value = store
|
||||
}
|
||||
|
||||
export function provideEditorStore(): EditorStore {
|
||||
const store = createEditorStore()
|
||||
storeRef.value = store
|
||||
|
|
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { provide } from 'vue'
|
||||
import { computed, provide } from 'vue'
|
||||
import { useEventListener, useUrlSearchParams } from '@vueuse/core'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
|
||||
|
|
@ -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 = computed(() => useEditorStore())
|
||||
useKeyboard()
|
||||
useMenu()
|
||||
const collab = useCollab(firstTab.store)
|
||||
provide(COLLAB_KEY, collab)
|
||||
window.__OPEN_PENCIL_STORE__ = store
|
||||
|
||||
useEventListener(
|
||||
document,
|
||||
|
|
@ -37,12 +39,10 @@ useEventListener(
|
|||
{ passive: false }
|
||||
)
|
||||
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -68,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"
|
||||
|
|
@ -108,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 />
|
||||
|
|
@ -127,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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue