Once a document is opened from the storage workspace there is no way back to it: the only in-app route is Settings -> Cloud storage -> Open workspace, and the desktop shell has no browser back at all. Add an explicit button in the shared header row (rendered on both web and Tauri, unlike the File/Edit menubar below it) that routes to /storage. It is a fixed destination rather than history navigation, so it behaves the same on both platforms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
202 lines
8 KiB
Vue
202 lines
8 KiB
Vue
<script setup lang="ts">
|
|
import { watch } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { templateRef } from '@vueuse/core'
|
|
|
|
import {
|
|
MenubarCheckboxItem,
|
|
MenubarContent,
|
|
MenubarItem,
|
|
MenubarItemIndicator,
|
|
MenubarMenu,
|
|
MenubarPortal,
|
|
MenubarRoot,
|
|
MenubarSeparator,
|
|
MenubarSub,
|
|
MenubarSubContent,
|
|
MenubarSubTrigger,
|
|
MenubarTrigger
|
|
} from 'reka-ui'
|
|
|
|
import IconArrowLeft from '~icons/lucide/arrow-left'
|
|
import IconChevronRight from '~icons/lucide/chevron-right'
|
|
|
|
import { vTestId, useI18n } from '@open-pencil/vue'
|
|
import AppShortcutText from '@/components/ui/AppShortcutText.vue'
|
|
import { useMenuUI } from '@/components/ui/menu'
|
|
import { IS_TAURI } from '@/constants'
|
|
import { useAppMenu } from '@/app/shell/menu/app-menu'
|
|
import { useDocumentNameRename } from '@/app/shell/menu/document-name'
|
|
import { appMenuShortcutLabel } from '@/app/shell/menu/shortcut'
|
|
import {
|
|
hasMenuSubItems,
|
|
isMenuCheckbox,
|
|
isMenuSeparator,
|
|
menuChecked,
|
|
menuDisabled,
|
|
menuLabel,
|
|
menuShortcut,
|
|
menuSubItems,
|
|
runMenuAction,
|
|
updateMenuChecked
|
|
} from '@/app/shell/menu/entry'
|
|
import { useEditorStore } from '@/app/editor/active-store'
|
|
import { openSettingsDialog } from '@/app/settings/dialog'
|
|
|
|
const store = useEditorStore()
|
|
const router = useRouter()
|
|
|
|
// The workspace is the app's top level. Browser back is history-dependent (and
|
|
// absent in the desktop shell), so the way back has to be an explicit
|
|
// destination, present on both platforms. Shown unconditionally: StorageView
|
|
// prompts for setup itself when storage isn't configured yet.
|
|
async function backToWorkspace(): Promise<void> {
|
|
await router.push('/storage')
|
|
}
|
|
|
|
const { rename, editingName, startRename, commitRename } = useDocumentNameRename(store)
|
|
const nameInput = templateRef<HTMLInputElement>('nameInput')
|
|
|
|
watch(nameInput, (input) => {
|
|
if (input) void rename.focusInput(input)
|
|
})
|
|
|
|
const { dialogs, menu: t } = useI18n()
|
|
|
|
const { topMenus } = useAppMenu()
|
|
const menuCls = useMenuUI()
|
|
const mainMenuCls = useMenuUI({ content: 'min-w-52' })
|
|
const subMenuCls = useMenuUI({ content: 'min-w-44' })
|
|
</script>
|
|
|
|
<template>
|
|
<div class="shrink-0 border-b border-border">
|
|
<button
|
|
type="button"
|
|
data-test-id="app-back-to-workspace"
|
|
class="flex w-full cursor-pointer items-center gap-1.5 border-b border-border px-2 py-1.5 text-xs text-muted transition-colors hover:bg-hover hover:text-surface"
|
|
@click="backToWorkspace"
|
|
>
|
|
<IconArrowLeft class="size-3.5 shrink-0" />
|
|
<span class="truncate">{{ dialogs.backToStorageWorkspace }}</span>
|
|
</button>
|
|
<div class="flex items-center gap-2 px-2 py-1.5">
|
|
<img data-test-id="app-logo" src="/favicon-32.png" class="size-4" alt="OpenPencil" />
|
|
<input
|
|
v-if="editingName"
|
|
ref="nameInput"
|
|
data-test-id="app-document-name-input"
|
|
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0.5 text-xs text-surface outline-none"
|
|
:value="store.state.documentName"
|
|
@blur="commitRename($event)"
|
|
@keydown="rename.onKeydown"
|
|
/>
|
|
<span
|
|
v-else
|
|
data-test-id="app-document-name"
|
|
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
|
|
>
|
|
<Tip :label="dialogs.settings">
|
|
<button
|
|
type="button"
|
|
data-test-id="app-settings-trigger"
|
|
:aria-label="dialogs.settings"
|
|
class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded text-muted transition-colors hover:bg-hover hover:text-surface"
|
|
@click="openSettingsDialog()"
|
|
>
|
|
<icon-lucide-settings class="size-3.5" />
|
|
</button>
|
|
</Tip>
|
|
<Tip :label="`${t.toggleUI} (${appMenuShortcutLabel('toggle-ui')})`">
|
|
<button
|
|
data-test-id="app-toggle-ui"
|
|
:aria-label="t.toggleUI"
|
|
class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded text-muted transition-colors hover:bg-hover hover:text-surface"
|
|
@click="store.state.showUI = !store.state.showUI"
|
|
>
|
|
<icon-lucide-sidebar class="size-3.5" />
|
|
</button>
|
|
</Tip>
|
|
</div>
|
|
<div v-if="!IS_TAURI" class="flex items-center px-1 pb-1">
|
|
<MenubarRoot class="scrollbar-none flex items-center gap-0.5 overflow-x-auto">
|
|
<MenubarMenu v-for="menu in topMenus" :key="menu.label">
|
|
<MenubarTrigger
|
|
v-test-id="`menubar-${menu.label.toLowerCase()}`"
|
|
class="flex cursor-pointer items-center rounded px-2 py-1 text-[11px] text-surface/80 transition-colors select-none hover:bg-hover hover:text-surface data-[state=open]:bg-hover data-[state=open]:text-surface"
|
|
>
|
|
{{ menu.label }}
|
|
</MenubarTrigger>
|
|
|
|
<MenubarPortal>
|
|
<MenubarContent :side-offset="4" align="start" :class="mainMenuCls.content">
|
|
<template v-for="(item, i) in menu.items" :key="i">
|
|
<MenubarSeparator v-if="isMenuSeparator(item)" :class="menuCls.separator" />
|
|
<MenubarSub v-else-if="hasMenuSubItems(item)">
|
|
<MenubarSubTrigger :class="menuCls.item" :disabled="menuDisabled(item)">
|
|
<span class="flex-1">{{ menuLabel(item) }}</span>
|
|
<IconChevronRight class="size-3 text-muted" />
|
|
</MenubarSubTrigger>
|
|
<MenubarPortal>
|
|
<MenubarSubContent :side-offset="4" :class="subMenuCls.content">
|
|
<template v-for="(sub, j) in menuSubItems(item)" :key="j">
|
|
<MenubarSeparator v-if="isMenuSeparator(sub)" :class="menuCls.separator" />
|
|
<MenubarCheckboxItem
|
|
v-else-if="isMenuCheckbox(sub)"
|
|
:model-value="menuChecked(sub)"
|
|
:class="menuCls.item"
|
|
@update:model-value="updateMenuChecked(sub, $event as boolean)"
|
|
>
|
|
<span class="flex-1">{{ menuLabel(sub) }}</span>
|
|
<MenubarItemIndicator class="text-surface">
|
|
<icon-lucide-check class="size-3.5" />
|
|
</MenubarItemIndicator>
|
|
</MenubarCheckboxItem>
|
|
<MenubarItem
|
|
v-else
|
|
:class="menuCls.item"
|
|
:disabled="menuDisabled(sub)"
|
|
@select="runMenuAction(sub)"
|
|
>
|
|
<span class="flex-1">{{ menuLabel(sub) }}</span>
|
|
<AppShortcutText v-if="menuShortcut(sub)">{{
|
|
menuShortcut(sub)
|
|
}}</AppShortcutText>
|
|
</MenubarItem>
|
|
</template>
|
|
</MenubarSubContent>
|
|
</MenubarPortal>
|
|
</MenubarSub>
|
|
<MenubarCheckboxItem
|
|
v-else-if="isMenuCheckbox(item)"
|
|
:model-value="menuChecked(item)"
|
|
:class="menuCls.item"
|
|
@update:model-value="updateMenuChecked(item, $event as boolean)"
|
|
>
|
|
<span class="flex-1">{{ menuLabel(item) }}</span>
|
|
<MenubarItemIndicator class="text-surface">
|
|
<icon-lucide-check class="size-3.5" />
|
|
</MenubarItemIndicator>
|
|
</MenubarCheckboxItem>
|
|
<MenubarItem
|
|
v-else
|
|
:class="menuCls.item"
|
|
:disabled="menuDisabled(item)"
|
|
@select="runMenuAction(item)"
|
|
>
|
|
<span class="flex-1">{{ menuLabel(item) }}</span>
|
|
<AppShortcutText v-if="menuShortcut(item)">{{
|
|
menuShortcut(item)
|
|
}}</AppShortcutText>
|
|
</MenubarItem>
|
|
</template>
|
|
</MenubarContent>
|
|
</MenubarPortal>
|
|
</MenubarMenu>
|
|
</MenubarRoot>
|
|
</div>
|
|
</div>
|
|
</template>
|