refactor(app): polish files workspace and New tabs
- Keep New tabs provisional so opening or creating a design reuses the active tab - Separate recent document, storage, menu, worker, and workspace responsibilities - Add source-aware recents, responsive files UI, loading states, and localized copy - Preserve native local Open Recent behavior while supporting remote storage history
This commit is contained in:
parent
376447a5dd
commit
bb5960cd62
|
|
@ -4,6 +4,7 @@ import type { SceneGraph } from '@open-pencil/scene-graph'
|
|||
|
||||
import { IS_BROWSER } from '#core/constants'
|
||||
import { importNodeChanges } from '#core/kiwi/fig/import'
|
||||
import { createFigParseWorker } from '#core/kiwi/fig/parse/client'
|
||||
import { deserializeSceneGraph } from '#core/kiwi/fig/parse/transfer'
|
||||
import type { SerializedSceneGraph } from '#core/kiwi/fig/parse/transfer'
|
||||
import { registerFigPopulationWorker } from '#core/kiwi/fig/population/client'
|
||||
|
|
@ -42,9 +43,7 @@ type WorkerParseResult = WorkerGraphResult | WorkerPageManifestResult
|
|||
|
||||
function parseViaWorker(buffer: ArrayBuffer, options: ParseFigFileOptions): Promise<SceneGraph> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(new URL('../../../kiwi/fig/parse/worker.ts', import.meta.url), {
|
||||
type: 'module'
|
||||
})
|
||||
const worker = createFigParseWorker()
|
||||
|
||||
worker.onmessage = (e: MessageEvent<WorkerParseResult>) => {
|
||||
if (e.data.type === 'page-manifest') {
|
||||
|
|
|
|||
5
packages/core/src/kiwi/fig/parse/client.ts
Normal file
5
packages/core/src/kiwi/fig/parse/client.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export function createFigParseWorker(): Worker {
|
||||
return new Worker(new URL('./worker.ts', import.meta.url), {
|
||||
type: 'module'
|
||||
})
|
||||
}
|
||||
|
|
@ -159,6 +159,15 @@
|
|||
"recentFilesDescription": "Arbeite an einem Entwurf weiter oder beginne einen neuen.",
|
||||
"noRecentFiles": "Noch keine zuletzt verwendeten Dateien",
|
||||
"noRecentFilesDescription": "Dateien, die du in der Desktop-App öffnest, werden hier angezeigt.",
|
||||
"searchFiles": "Dateien suchen…",
|
||||
"searchRecentAndStorageFiles": "Zuletzt verwendete und gespeicherte Dateien suchen…",
|
||||
"newDesign": "Neues Design",
|
||||
"noMatchingFiles": "Keine Dateien entsprechen „{query}“.",
|
||||
"storageProviderS3": "S3-Speicher",
|
||||
"storageProviderR2": "Cloudflare R2",
|
||||
"storageProviderAmazonS3": "Amazon S3",
|
||||
"storageProviderBackblaze": "Backblaze B2",
|
||||
"loadingStorageWorkspace": "Speicher-Arbeitsbereich wird geladen…",
|
||||
"copyStorageCors": "CORS-JSON kopieren",
|
||||
"storageEndpoint": "Endpunkt",
|
||||
"storageBucket": "Bucket",
|
||||
|
|
|
|||
|
|
@ -159,6 +159,15 @@
|
|||
"recentFilesDescription": "Continúa trabajando en un diseño o crea uno nuevo.",
|
||||
"noRecentFiles": "Aún no hay archivos recientes",
|
||||
"noRecentFilesDescription": "Los archivos que abras en la aplicación de escritorio aparecerán aquí.",
|
||||
"searchFiles": "Buscar archivos…",
|
||||
"searchRecentAndStorageFiles": "Buscar en archivos recientes y almacenados…",
|
||||
"newDesign": "Nuevo diseño",
|
||||
"noMatchingFiles": "Ningún archivo coincide con «{query}».",
|
||||
"storageProviderS3": "Almacenamiento S3",
|
||||
"storageProviderR2": "Cloudflare R2",
|
||||
"storageProviderAmazonS3": "Amazon S3",
|
||||
"storageProviderBackblaze": "Backblaze B2",
|
||||
"loadingStorageWorkspace": "Cargando espacio de almacenamiento…",
|
||||
"copyStorageCors": "Copiar JSON de CORS",
|
||||
"storageEndpoint": "Punto de conexión",
|
||||
"storageBucket": "Bucket",
|
||||
|
|
|
|||
|
|
@ -159,6 +159,15 @@
|
|||
"recentFilesDescription": "Continuez un design ou commencez-en un nouveau.",
|
||||
"noRecentFiles": "Aucun fichier récent",
|
||||
"noRecentFilesDescription": "Les fichiers ouverts dans l’application de bureau apparaîtront ici.",
|
||||
"searchFiles": "Rechercher des fichiers…",
|
||||
"searchRecentAndStorageFiles": "Rechercher dans les fichiers récents et stockés…",
|
||||
"newDesign": "Nouveau design",
|
||||
"noMatchingFiles": "Aucun fichier ne correspond à « {query} ».",
|
||||
"storageProviderS3": "Stockage S3",
|
||||
"storageProviderR2": "Cloudflare R2",
|
||||
"storageProviderAmazonS3": "Amazon S3",
|
||||
"storageProviderBackblaze": "Backblaze B2",
|
||||
"loadingStorageWorkspace": "Chargement de l’espace de stockage…",
|
||||
"copyStorageCors": "Copier le JSON CORS",
|
||||
"storageEndpoint": "Point de terminaison",
|
||||
"storageBucket": "Bucket",
|
||||
|
|
|
|||
|
|
@ -159,6 +159,15 @@
|
|||
"recentFilesDescription": "Continua a lavorare su un progetto o iniziane uno nuovo.",
|
||||
"noRecentFiles": "Nessun file recente",
|
||||
"noRecentFilesDescription": "I file aperti nell’app desktop appariranno qui.",
|
||||
"searchFiles": "Cerca file…",
|
||||
"searchRecentAndStorageFiles": "Cerca nei file recenti e archiviati…",
|
||||
"newDesign": "Nuovo design",
|
||||
"noMatchingFiles": "Nessun file corrisponde a «{query}».",
|
||||
"storageProviderS3": "Archiviazione S3",
|
||||
"storageProviderR2": "Cloudflare R2",
|
||||
"storageProviderAmazonS3": "Amazon S3",
|
||||
"storageProviderBackblaze": "Backblaze B2",
|
||||
"loadingStorageWorkspace": "Caricamento area di archiviazione…",
|
||||
"copyStorageCors": "Copia JSON CORS",
|
||||
"storageEndpoint": "Endpoint",
|
||||
"storageBucket": "Bucket",
|
||||
|
|
|
|||
|
|
@ -159,6 +159,15 @@
|
|||
"recentFilesDescription": "デザインの作業を続けるか、新しく始めます。",
|
||||
"noRecentFiles": "最近使用したファイルはありません",
|
||||
"noRecentFilesDescription": "デスクトップアプリで開いたファイルがここに表示されます。",
|
||||
"searchFiles": "ファイルを検索…",
|
||||
"searchRecentAndStorageFiles": "最近使用したファイルとストレージを検索…",
|
||||
"newDesign": "新しいデザイン",
|
||||
"noMatchingFiles": "「{query}」に一致するファイルはありません。",
|
||||
"storageProviderS3": "S3 ストレージ",
|
||||
"storageProviderR2": "Cloudflare R2",
|
||||
"storageProviderAmazonS3": "Amazon S3",
|
||||
"storageProviderBackblaze": "Backblaze B2",
|
||||
"loadingStorageWorkspace": "ストレージワークスペースを読み込み中…",
|
||||
"copyStorageCors": "CORS JSONをコピー",
|
||||
"storageEndpoint": "エンドポイント",
|
||||
"storageBucket": "バケット",
|
||||
|
|
|
|||
|
|
@ -159,6 +159,15 @@
|
|||
"recentFilesDescription": "Kontynuuj pracę nad projektem lub rozpocznij nowy.",
|
||||
"noRecentFiles": "Brak ostatnich plików",
|
||||
"noRecentFilesDescription": "Pliki otwarte w aplikacji komputerowej pojawią się tutaj.",
|
||||
"searchFiles": "Szukaj plików…",
|
||||
"searchRecentAndStorageFiles": "Szukaj w ostatnich i zapisanych plikach…",
|
||||
"newDesign": "Nowy projekt",
|
||||
"noMatchingFiles": "Brak plików pasujących do „{query}”.",
|
||||
"storageProviderS3": "Przechowywanie S3",
|
||||
"storageProviderR2": "Cloudflare R2",
|
||||
"storageProviderAmazonS3": "Amazon S3",
|
||||
"storageProviderBackblaze": "Backblaze B2",
|
||||
"loadingStorageWorkspace": "Ładowanie obszaru przechowywania…",
|
||||
"copyStorageCors": "Kopiuj JSON CORS",
|
||||
"storageEndpoint": "Punkt końcowy",
|
||||
"storageBucket": "Zasobnik",
|
||||
|
|
|
|||
|
|
@ -159,6 +159,15 @@
|
|||
"recentFilesDescription": "Продолжите работу над дизайном или начните новый.",
|
||||
"noRecentFiles": "Недавних файлов пока нет",
|
||||
"noRecentFilesDescription": "Файлы, открытые в приложении для компьютера, появятся здесь.",
|
||||
"searchFiles": "Поиск файлов…",
|
||||
"searchRecentAndStorageFiles": "Поиск в недавних файлах и хранилище…",
|
||||
"newDesign": "Новый дизайн",
|
||||
"noMatchingFiles": "Нет файлов по запросу «{query}».",
|
||||
"storageProviderS3": "Хранилище S3",
|
||||
"storageProviderR2": "Cloudflare R2",
|
||||
"storageProviderAmazonS3": "Amazon S3",
|
||||
"storageProviderBackblaze": "Backblaze B2",
|
||||
"loadingStorageWorkspace": "Загрузка рабочей области хранилища…",
|
||||
"copyStorageCors": "Скопировать JSON CORS",
|
||||
"storageEndpoint": "Конечная точка",
|
||||
"storageBucket": "Бакет",
|
||||
|
|
|
|||
|
|
@ -159,6 +159,15 @@
|
|||
"recentFilesDescription": "继续编辑设计或新建设计。",
|
||||
"noRecentFiles": "暂无最近使用的文件",
|
||||
"noRecentFilesDescription": "在桌面应用中打开的文件会显示在这里。",
|
||||
"searchFiles": "搜索文件…",
|
||||
"searchRecentAndStorageFiles": "搜索最近使用和存储的文件…",
|
||||
"newDesign": "新建设计",
|
||||
"noMatchingFiles": "没有与“{query}”匹配的文件。",
|
||||
"storageProviderS3": "S3 存储",
|
||||
"storageProviderR2": "Cloudflare R2",
|
||||
"storageProviderAmazonS3": "Amazon S3",
|
||||
"storageProviderBackblaze": "Backblaze B2",
|
||||
"loadingStorageWorkspace": "正在加载存储工作区…",
|
||||
"copyStorageCors": "复制 CORS JSON",
|
||||
"storageEndpoint": "端点",
|
||||
"storageBucket": "存储桶",
|
||||
|
|
|
|||
|
|
@ -262,6 +262,15 @@ export const dialogMessageDefaults = {
|
|||
recentFilesDescription: 'Continue working on a design or start a new one.',
|
||||
noRecentFiles: 'No recent files yet',
|
||||
noRecentFilesDescription: 'Files you open in the desktop app will appear here.',
|
||||
searchFiles: 'Search files…',
|
||||
searchRecentAndStorageFiles: 'Search recent and storage files…',
|
||||
newDesign: 'New design',
|
||||
noMatchingFiles: params('No files match “{query}”.'),
|
||||
storageProviderS3: 'S3 storage',
|
||||
storageProviderR2: 'Cloudflare R2',
|
||||
storageProviderAmazonS3: 'Amazon S3',
|
||||
storageProviderBackblaze: 'Backblaze B2',
|
||||
loadingStorageWorkspace: 'Loading storage workspace…',
|
||||
copyStorageCors: 'Copy CORS JSON',
|
||||
storageEndpoint: 'Endpoint',
|
||||
storageBucket: 'Bucket',
|
||||
|
|
|
|||
19
src/app/cache/index.ts
vendored
19
src/app/cache/index.ts
vendored
|
|
@ -116,25 +116,6 @@ export async function removeCachePrefix(prefix: string): Promise<void> {
|
|||
removeStorageEntriesWithPrefix(prefix)
|
||||
}
|
||||
|
||||
export async function removeCacheEntriesWithPrefix(prefix: string): Promise<void> {
|
||||
if (isTauriRuntime()) {
|
||||
try {
|
||||
const { BaseDirectory, readDir, remove } = await import('@tauri-apps/plugin-fs')
|
||||
const entries = await readDir(APP_CACHE_DIR, { baseDir: BaseDirectory.AppLocalData })
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile && entry.name.startsWith(prefix))
|
||||
.map((entry) => remove(cachePath(entry.name), { baseDir: BaseDirectory.AppLocalData }))
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(`Cache entry cleanup skipped for "${prefix}":`, error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
removeStorageEntriesWithPrefix(prefix)
|
||||
}
|
||||
|
||||
type JSONCacheEnvelope<T> = {
|
||||
updatedAt: number
|
||||
value: T
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { FigPageManifestEntry } from '@open-pencil/kiwi/fig'
|
|||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
/** Show lightweight page shells while the FIG worker continues decoding the full document. */
|
||||
export function showFigPageManifest(editor: Editor, pages: readonly FigPageManifestEntry[]): void {
|
||||
function showFigPageManifest(editor: Editor, pages: readonly FigPageManifestEntry[]): void {
|
||||
if (pages.length === 0) return
|
||||
|
||||
const graph = new SceneGraph()
|
||||
|
|
@ -21,7 +21,7 @@ export function showFigPageManifest(editor: Editor, pages: readonly FigPageManif
|
|||
editor.state.loading = true
|
||||
}
|
||||
|
||||
export function readFigFileProgressively(file: File, editor: Editor) {
|
||||
export function readFigDocument(file: File, editor: Editor) {
|
||||
return readFigFile(file, {
|
||||
populate: 'first-page',
|
||||
onPages: (pages) => showFigPageManifest(editor, pages)
|
||||
|
|
@ -2,7 +2,7 @@ import type { Editor, EditorState } from '@open-pencil/core/editor'
|
|||
import { computeAllLayouts } from '@open-pencil/core/layout'
|
||||
|
||||
import { yieldToUI } from '@/app/document/io/browser'
|
||||
import { readFigFileProgressively } from '@/app/document/io/fig-page-manifest'
|
||||
import { readFigDocument } from '@/app/document/io/fig'
|
||||
import { applyImportedDocument } from '@/app/document/io/imported-document'
|
||||
import { readReloadSource } from '@/app/document/io/reload-source'
|
||||
import { captureReloadState, restoreReloadState } from '@/app/document/io/reload-state'
|
||||
|
|
@ -46,7 +46,7 @@ export function createOpenActions({
|
|||
try {
|
||||
state.loading = true
|
||||
await yieldToUI()
|
||||
const imported = await readFigFileProgressively(file, editor)
|
||||
const imported = await readFigDocument(file, editor)
|
||||
await yieldToUI()
|
||||
await applyImportedDocument(editor, imported)
|
||||
state.documentName = file.name.replace(/\.fig$/i, '')
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { createS3StorageAdapter } from './s3/adapter'
|
|||
|
||||
export const S3_STORAGE_PROVIDER = defineStorageProvider({
|
||||
id: 's3-compatible',
|
||||
label: 'S3 compatible',
|
||||
label: 'S3 storage',
|
||||
description: 'AWS S3, Backblaze B2, Cloudflare R2, MinIO, and compatible storage',
|
||||
preferenceFields: [
|
||||
{ id: 'endpoint', label: 'Endpoint', kind: 'url', required: true },
|
||||
|
|
|
|||
19
src/app/recent-files/index.ts
Normal file
19
src/app/recent-files/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export {
|
||||
clearRecentFiles,
|
||||
forgetRecentDocument,
|
||||
forgetRecentFile,
|
||||
recentDocuments,
|
||||
recentFiles,
|
||||
recentLocalFileAt,
|
||||
recentLocalFilePaths,
|
||||
rememberRecentFile,
|
||||
rememberRecentStorageDocument,
|
||||
type RecentDocument,
|
||||
type RecentLocalDocument,
|
||||
type RecentStorageDocument
|
||||
} from './store'
|
||||
export {
|
||||
cacheRecentFileThumbnail,
|
||||
loadCachedRecentFileThumbnail,
|
||||
loadRecentFileThumbnail
|
||||
} from './thumbnails'
|
||||
103
src/app/recent-files/store.ts
Normal file
103
src/app/recent-files/store.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { StorageProviderID } from '@/app/integrations/storage'
|
||||
|
||||
import { clearRecentFileThumbnails } from './thumbnails'
|
||||
|
||||
const MAX_RECENT_DOCUMENTS = 10
|
||||
const RECENT_DOCUMENTS_STORAGE_KEY = 'open-pencil:recent-documents'
|
||||
|
||||
export interface RecentLocalDocument {
|
||||
id: string
|
||||
kind: 'local'
|
||||
path: string
|
||||
name: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface RecentStorageDocument {
|
||||
id: string
|
||||
kind: 'storage'
|
||||
providerId: StorageProviderID
|
||||
documentId: string
|
||||
name: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type RecentDocument = RecentLocalDocument | RecentStorageDocument
|
||||
|
||||
export const recentDocuments = useLocalStorage<RecentDocument[]>(RECENT_DOCUMENTS_STORAGE_KEY, [])
|
||||
|
||||
function fileName(path: string): string {
|
||||
return path.split(/[\\/]/).pop() ?? path
|
||||
}
|
||||
|
||||
function localDocumentId(path: string): string {
|
||||
return `local:${path}`
|
||||
}
|
||||
|
||||
function storageDocumentId(providerId: StorageProviderID, documentId: string): string {
|
||||
return `storage:${providerId}:${documentId}`
|
||||
}
|
||||
|
||||
function normalizedRecentDocuments(): RecentDocument[] {
|
||||
return recentDocuments.value.slice(0, MAX_RECENT_DOCUMENTS)
|
||||
}
|
||||
|
||||
export const recentFiles = computed<RecentDocument[]>(normalizedRecentDocuments)
|
||||
|
||||
export const recentLocalFilePaths = computed<string[]>(() =>
|
||||
normalizedRecentDocuments().flatMap((document) =>
|
||||
document.kind === 'local' ? [document.path] : []
|
||||
)
|
||||
)
|
||||
|
||||
function remember(document: RecentDocument): void {
|
||||
recentDocuments.value = [
|
||||
document,
|
||||
...normalizedRecentDocuments().filter((recent) => recent.id !== document.id)
|
||||
].slice(0, MAX_RECENT_DOCUMENTS)
|
||||
}
|
||||
|
||||
export function rememberRecentFile(path: string): void {
|
||||
remember({
|
||||
id: localDocumentId(path),
|
||||
kind: 'local',
|
||||
path,
|
||||
name: fileName(path),
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
export function rememberRecentStorageDocument(
|
||||
providerId: StorageProviderID,
|
||||
documentId: string,
|
||||
name: string
|
||||
): void {
|
||||
remember({
|
||||
id: storageDocumentId(providerId, documentId),
|
||||
kind: 'storage',
|
||||
providerId,
|
||||
documentId,
|
||||
name,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
export function forgetRecentDocument(id: string): void {
|
||||
recentDocuments.value = normalizedRecentDocuments().filter((document) => document.id !== id)
|
||||
}
|
||||
|
||||
export function forgetRecentFile(path: string): void {
|
||||
forgetRecentDocument(localDocumentId(path))
|
||||
}
|
||||
|
||||
export async function clearRecentFiles(): Promise<void> {
|
||||
recentDocuments.value = []
|
||||
await clearRecentFileThumbnails()
|
||||
}
|
||||
|
||||
export function recentLocalFileAt(index: number): string | null {
|
||||
return recentLocalFilePaths.value[index] ?? null
|
||||
}
|
||||
65
src/app/recent-files/thumbnails.ts
Normal file
65
src/app/recent-files/thumbnails.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { extractFigThumbnailFromReader } from '@open-pencil/fig'
|
||||
|
||||
import { readCacheBytes, removeCachePrefix, writeCacheBytes } from '@/app/cache'
|
||||
import { isTauri } from '@/app/tauri/env'
|
||||
|
||||
const RECENT_FILE_THUMBNAIL_CACHE_DIR = 'recent-file-thumbnails/v2'
|
||||
|
||||
async function recentFileThumbnailCacheKey(path: string): Promise<string> {
|
||||
let fingerprint = path
|
||||
try {
|
||||
const { stat } = await import('@tauri-apps/plugin-fs')
|
||||
const info = await stat(path)
|
||||
fingerprint = `${path}\0${info.size}\0${info.mtime?.getTime() ?? 0}`
|
||||
} catch (error) {
|
||||
// A path-only key still permits a preview when metadata is unavailable.
|
||||
console.warn('[Recent files] Could not stat the file for thumbnail caching', error)
|
||||
}
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(fingerprint))
|
||||
const hash = [...new Uint8Array(digest)]
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
return `${RECENT_FILE_THUMBNAIL_CACHE_DIR}/${hash}.png`
|
||||
}
|
||||
|
||||
export async function loadCachedRecentFileThumbnail(path: string): Promise<Uint8Array | null> {
|
||||
if (!isTauri()) return null
|
||||
const bytes = await readCacheBytes(await recentFileThumbnailCacheKey(path))
|
||||
return bytes ? new Uint8Array(bytes) : null
|
||||
}
|
||||
|
||||
export async function cacheRecentFileThumbnail(path: string, bytes: Uint8Array): Promise<void> {
|
||||
if (!isTauri()) return
|
||||
await writeCacheBytes(await recentFileThumbnailCacheKey(path), Uint8Array.from(bytes).buffer)
|
||||
}
|
||||
|
||||
export function clearRecentFileThumbnails(): Promise<void> {
|
||||
return removeCachePrefix(RECENT_FILE_THUMBNAIL_CACHE_DIR)
|
||||
}
|
||||
|
||||
export async function loadRecentFileThumbnail(path: string): Promise<Uint8Array | null> {
|
||||
if (!isTauri() || !path.toLowerCase().endsWith('.fig')) return null
|
||||
const cached = await loadCachedRecentFileThumbnail(path)
|
||||
if (cached) return cached
|
||||
const { open, SeekMode } = await import('@tauri-apps/plugin-fs')
|
||||
const file = await open(path, { read: true })
|
||||
try {
|
||||
const info = await file.stat()
|
||||
return await extractFigThumbnailFromReader({
|
||||
size: info.size,
|
||||
async read(start, endExclusive) {
|
||||
await file.seek(start, SeekMode.Start)
|
||||
const bytes = new Uint8Array(endExclusive - start)
|
||||
let offset = 0
|
||||
while (offset < bytes.byteLength) {
|
||||
const count = await file.read(bytes.subarray(offset))
|
||||
if (count === null) break
|
||||
offset += count
|
||||
}
|
||||
return offset === bytes.byteLength ? bytes : bytes.subarray(0, offset)
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ import { isInputElement } from '@/app/shell/keyboard/focus'
|
|||
import { bindNudgeKeys } from '@/app/shell/keyboard/nudging'
|
||||
import { registerKeyboardShortcuts } from '@/app/shell/keyboard/registry'
|
||||
import { openFileDialog } from '@/app/shell/menu/use'
|
||||
import { closeTab, createTab, activeTab as activeTabRef } from '@/app/tabs'
|
||||
import { closeTab, createHomeTab, activeTab as activeTabRef } from '@/app/tabs'
|
||||
|
||||
export function useKeyboard() {
|
||||
const { activeTab } = useAIChat()
|
||||
|
|
@ -43,6 +43,6 @@ export function useKeyboard() {
|
|||
closeActiveTab: () => {
|
||||
if (activeTabRef.value) void closeTab(activeTabRef.value.id)
|
||||
},
|
||||
createTab: () => createTab()
|
||||
createTab: () => createHomeTab()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,11 @@ import { useEditorCommands } from '@open-pencil/vue'
|
|||
import type { EditorCommandId } from '@open-pencil/vue'
|
||||
|
||||
import { useEditorStore } from '@/app/editor/active-store'
|
||||
import { clearRecentFiles, forgetRecentFile, recentLocalFileAt } from '@/app/recent-files'
|
||||
import { createSharedEditorMenuActions } from '@/app/shell/menu/editor-actions'
|
||||
import { openFileDialog, openFileFromPath } from '@/app/shell/menu/files'
|
||||
import { useNativeMenuEvents } from '@/app/shell/menu/native-events'
|
||||
import {
|
||||
clearRecentFiles,
|
||||
forgetRecentFile,
|
||||
OPEN_RECENT_EVENT_PREFIX,
|
||||
recentFileAt,
|
||||
syncRecentFilesMenu
|
||||
} from '@/app/shell/menu/recent-files'
|
||||
import { OPEN_RECENT_EVENT_PREFIX, watchRecentFilesMenu } from '@/app/shell/menu/recent-files'
|
||||
import { APP_MENU_SCHEMA, type AppMenuEntry } from '@/app/shell/menu/schema'
|
||||
import { createSelectionMenuActions } from '@/app/shell/menu/selection-actions'
|
||||
import { SHELL_MENU_IDS } from '@/app/shell/menu/shell'
|
||||
|
|
@ -34,9 +29,7 @@ const COMMAND_MENU_IDS = new Set<EditorCommandId>(
|
|||
export function useEditorMenu() {
|
||||
if (!isTauri()) return
|
||||
|
||||
void syncRecentFilesMenu().catch((error) => {
|
||||
console.warn('[Recent files] Failed to initialize the native menu', error)
|
||||
})
|
||||
watchRecentFilesMenu()
|
||||
|
||||
const { setTheme } = useAppTheme()
|
||||
const { runCommand } = useEditorCommands()
|
||||
|
|
@ -78,7 +71,7 @@ export function useEditorMenu() {
|
|||
}
|
||||
if (id.startsWith(OPEN_RECENT_EVENT_PREFIX)) {
|
||||
const index = Number(id.slice(OPEN_RECENT_EVENT_PREFIX.length))
|
||||
const path = Number.isInteger(index) ? recentFileAt(index) : null
|
||||
const path = Number.isInteger(index) ? recentLocalFileAt(index) : null
|
||||
if (path) {
|
||||
void openFileFromPath(path).catch((error) => {
|
||||
forgetRecentFile(path)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,30 @@
|
|||
import { useFileDialog } from '@vueuse/core'
|
||||
|
||||
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
|
||||
|
||||
import { setOpenPencilOpenFileHandler } from '@/app/browser-bridge'
|
||||
import { resolveBrowserFileURL } from '@/app/document/io/browser'
|
||||
import { notificationMessages } from '@/app/i18n/notifications'
|
||||
import { rememberRecentFile } from '@/app/shell/menu/recent-files'
|
||||
import { rememberRecentFile } from '@/app/recent-files'
|
||||
import { toast } from '@/app/shell/ui'
|
||||
import { openFileInNewTab } from '@/app/tabs'
|
||||
import { isTauri } from '@/app/tauri/env'
|
||||
import { IS_BROWSER } from '@/constants'
|
||||
|
||||
const io = new IORegistry(BUILTIN_IO_FORMATS)
|
||||
const DOM_DOCUMENT_EXTENSIONS = ['html', 'htm', 'xhtml'] as const
|
||||
const READABLE_DOCUMENT_EXTENSIONS = [
|
||||
...new Set([
|
||||
...io.listReadableFormats().flatMap((format) => format.extensions),
|
||||
...DOM_DOCUMENT_EXTENSIONS
|
||||
])
|
||||
]
|
||||
const DESIGN_FILE_ACCEPT = READABLE_DOCUMENT_EXTENSIONS.map((extension) => `.${extension}`).join(
|
||||
','
|
||||
)
|
||||
|
||||
const fileDialog = useFileDialog({
|
||||
accept: '.fig,.pen,.html,.htm,.xhtml',
|
||||
accept: DESIGN_FILE_ACCEPT,
|
||||
multiple: true,
|
||||
reset: true
|
||||
})
|
||||
|
|
@ -20,7 +34,10 @@ fileDialog.onChange((files) => {
|
|||
void openDesignFileBatch(
|
||||
files,
|
||||
(file) => file.name,
|
||||
(file) => openFileInNewTab(file)
|
||||
(file) => {
|
||||
assertSupportedDesignFile(file.name)
|
||||
return openFileInNewTab(file)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -30,11 +47,26 @@ if (IS_BROWSER && 'window' in globalThis) {
|
|||
const response = await fetch(resourceURL)
|
||||
const blob = await response.blob()
|
||||
const name = resourceURL.pathname.split('/').pop() ?? 'file.fig'
|
||||
assertSupportedDesignFile(name)
|
||||
const file = new File([blob], name, { type: 'application/octet-stream' })
|
||||
await openFileInNewTab(file, undefined, resourceURL.href)
|
||||
})
|
||||
}
|
||||
|
||||
function isSupportedDesignFile(fileName: string): boolean {
|
||||
const lowerName = fileName.toLowerCase()
|
||||
return (
|
||||
io.findReader(fileName) !== null ||
|
||||
DOM_DOCUMENT_EXTENSIONS.some((extension) => lowerName.endsWith(`.${extension}`))
|
||||
)
|
||||
}
|
||||
|
||||
function assertSupportedDesignFile(fileName: string): void {
|
||||
if (!isSupportedDesignFile(fileName)) {
|
||||
throw new Error(`Unsupported document format: ${fileName}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function openDesignFileBatch<T>(
|
||||
items: Iterable<T>,
|
||||
displayName: (item: T) => string,
|
||||
|
|
@ -53,6 +85,7 @@ export async function openDesignFileBatch<T>(
|
|||
}
|
||||
|
||||
export async function readTauriDesignFile(path: string): Promise<File> {
|
||||
assertSupportedDesignFile(path)
|
||||
const { readFile } = await import('@tauri-apps/plugin-fs')
|
||||
const bytes = await readFile(path)
|
||||
return new File([bytes], path.split('/').pop() ?? 'file.fig')
|
||||
|
|
@ -61,7 +94,7 @@ export async function readTauriDesignFile(path: string): Promise<File> {
|
|||
export async function chooseTauriOpenPaths(): Promise<string[]> {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const paths = await open({
|
||||
filters: [{ name: 'Design file', extensions: ['fig', 'pen', 'html', 'htm', 'xhtml'] }],
|
||||
filters: [{ name: 'Design file', extensions: READABLE_DOCUMENT_EXTENSIONS }],
|
||||
multiple: true
|
||||
})
|
||||
if (!paths) return []
|
||||
|
|
@ -104,6 +137,7 @@ export async function openFileDialog() {
|
|||
(handle) => handle.name,
|
||||
async (handle) => {
|
||||
const file = await handle.getFile()
|
||||
assertSupportedDesignFile(file.name)
|
||||
await openFileInNewTab(file, handle)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,5 +4,5 @@ export function openStorageWorkspace(router: Router): void {
|
|||
void router
|
||||
.push('/')
|
||||
.then(() => import('@/app/tabs'))
|
||||
.then(({ showRecentFiles }) => showRecentFiles())
|
||||
.then(({ showNewTab }) => showNewTab())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,152 +1,24 @@
|
|||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { extractFigThumbnailFromReader } from '@open-pencil/fig'
|
||||
|
||||
import {
|
||||
readCacheBytes,
|
||||
removeCacheEntriesWithPrefix,
|
||||
removeCachePrefix,
|
||||
writeCacheBytes
|
||||
} from '@/app/cache'
|
||||
import { recentLocalFilePaths } from '@/app/recent-files'
|
||||
import { isTauri } from '@/app/tauri/env'
|
||||
|
||||
const MAX_RECENT_FILES = 10
|
||||
const RECENT_FILES_STORAGE_KEY = 'open-pencil:recent-files'
|
||||
const RECENT_FILE_OPENED_AT_STORAGE_KEY = 'open-pencil:recent-file-opened-at'
|
||||
const RECENT_FILE_THUMBNAIL_CACHE_DIR = 'recent-file-thumbnails/v2'
|
||||
const LEGACY_RECENT_FILE_THUMBNAIL_CACHE_PREFIX = 'recent-file-thumbnail-v2-'
|
||||
|
||||
export type RecentFile = {
|
||||
id: string
|
||||
path: string
|
||||
name: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export const OPEN_RECENT_EVENT_PREFIX = 'open-recent:'
|
||||
export const recentFilePaths = useLocalStorage<string[]>(RECENT_FILES_STORAGE_KEY, [])
|
||||
const recentFileOpenedAt = useLocalStorage<Record<string, string>>(
|
||||
RECENT_FILE_OPENED_AT_STORAGE_KEY,
|
||||
{}
|
||||
)
|
||||
|
||||
function fileName(path: string): string {
|
||||
return path.split(/[\\/]/).pop() ?? path
|
||||
}
|
||||
|
||||
export const recentFiles = computed<RecentFile[]>(() =>
|
||||
normalizedRecentFiles().map((path) => ({
|
||||
id: path,
|
||||
path,
|
||||
name: fileName(path),
|
||||
updatedAt: recentFileOpenedAt.value[path] ?? new Date(0).toISOString()
|
||||
}))
|
||||
)
|
||||
|
||||
function normalizedRecentFiles(): string[] {
|
||||
return recentFilePaths.value
|
||||
.filter((path): path is string => typeof path === 'string' && path.length > 0)
|
||||
.slice(0, MAX_RECENT_FILES)
|
||||
}
|
||||
|
||||
export async function syncRecentFilesMenu(): Promise<void> {
|
||||
if (!isTauri()) return
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
await invoke('set_recent_files', { paths: normalizedRecentFiles() })
|
||||
await invoke('set_recent_files', { paths: recentLocalFilePaths.value })
|
||||
}
|
||||
|
||||
function updateRecentFiles(paths: string[]): void {
|
||||
const nextPaths = paths.slice(0, MAX_RECENT_FILES)
|
||||
const retainedPaths = new Set(nextPaths)
|
||||
recentFilePaths.value = nextPaths
|
||||
recentFileOpenedAt.value = Object.fromEntries(
|
||||
Object.entries(recentFileOpenedAt.value).filter(([path]) => retainedPaths.has(path))
|
||||
export function watchRecentFilesMenu(): () => void {
|
||||
return watch(
|
||||
recentLocalFilePaths,
|
||||
() => {
|
||||
void syncRecentFilesMenu().catch((error) => {
|
||||
console.warn('[Recent files] Failed to update the native menu', error)
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
void syncRecentFilesMenu().catch((error) => {
|
||||
console.warn('[Recent files] Failed to update the native menu', error)
|
||||
})
|
||||
}
|
||||
|
||||
export function rememberRecentFile(path: string): void {
|
||||
recentFileOpenedAt.value = {
|
||||
...recentFileOpenedAt.value,
|
||||
[path]: new Date().toISOString()
|
||||
}
|
||||
updateRecentFiles([path, ...normalizedRecentFiles().filter((recent) => recent !== path)])
|
||||
}
|
||||
|
||||
export function forgetRecentFile(path: string): void {
|
||||
recentFileOpenedAt.value = Object.fromEntries(
|
||||
Object.entries(recentFileOpenedAt.value).filter(([recent]) => recent !== path)
|
||||
)
|
||||
updateRecentFiles(normalizedRecentFiles().filter((recent) => recent !== path))
|
||||
}
|
||||
|
||||
export async function clearRecentFiles(): Promise<void> {
|
||||
recentFileOpenedAt.value = {}
|
||||
updateRecentFiles([])
|
||||
await Promise.all([
|
||||
removeCachePrefix(RECENT_FILE_THUMBNAIL_CACHE_DIR),
|
||||
removeCacheEntriesWithPrefix(LEGACY_RECENT_FILE_THUMBNAIL_CACHE_PREFIX)
|
||||
])
|
||||
}
|
||||
|
||||
export function recentFileAt(index: number): string | null {
|
||||
return normalizedRecentFiles()[index] ?? null
|
||||
}
|
||||
|
||||
async function recentFileThumbnailCacheKey(path: string): Promise<string> {
|
||||
let fingerprint = path
|
||||
try {
|
||||
const { stat } = await import('@tauri-apps/plugin-fs')
|
||||
const info = await stat(path)
|
||||
fingerprint = `${path}\0${info.size}\0${info.mtime?.getTime() ?? 0}`
|
||||
} catch (error) {
|
||||
// A path-only key still permits a preview when metadata is unavailable.
|
||||
console.warn('[Recent files] Could not stat the file for thumbnail caching', error)
|
||||
}
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(fingerprint))
|
||||
const hash = [...new Uint8Array(digest)]
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
return `${RECENT_FILE_THUMBNAIL_CACHE_DIR}/${hash}.png`
|
||||
}
|
||||
|
||||
export async function loadCachedRecentFileThumbnail(path: string): Promise<Uint8Array | null> {
|
||||
if (!isTauri()) return null
|
||||
const bytes = await readCacheBytes(await recentFileThumbnailCacheKey(path))
|
||||
return bytes ? new Uint8Array(bytes) : null
|
||||
}
|
||||
|
||||
export async function cacheRecentFileThumbnail(path: string, bytes: Uint8Array): Promise<void> {
|
||||
if (!isTauri()) return
|
||||
await writeCacheBytes(await recentFileThumbnailCacheKey(path), Uint8Array.from(bytes).buffer)
|
||||
}
|
||||
|
||||
export async function loadRecentFileThumbnail(path: string): Promise<Uint8Array | null> {
|
||||
if (!isTauri() || !path.toLowerCase().endsWith('.fig')) return null
|
||||
const cached = await loadCachedRecentFileThumbnail(path)
|
||||
if (cached) return cached
|
||||
const { open, SeekMode } = await import('@tauri-apps/plugin-fs')
|
||||
const file = await open(path, { read: true })
|
||||
try {
|
||||
const info = await file.stat()
|
||||
return await extractFigThumbnailFromReader({
|
||||
size: info.size,
|
||||
async read(start, endExclusive) {
|
||||
await file.seek(start, SeekMode.Start)
|
||||
const bytes = new Uint8Array(endExclusive - start)
|
||||
let offset = 0
|
||||
while (offset < bytes.byteLength) {
|
||||
const count = await file.read(bytes.subarray(offset))
|
||||
if (count === null) break
|
||||
offset += count
|
||||
}
|
||||
return offset === bytes.byteLength ? bytes : bytes.subarray(0, offset)
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { computeAllLayouts } from '@open-pencil/core/layout'
|
|||
import type { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import { setOpenPencilStore } from '@/app/browser-bridge'
|
||||
import { readFigFileProgressively } from '@/app/document/io/fig-page-manifest'
|
||||
import { readFigDocument } from '@/app/document/io/fig'
|
||||
import type { DocumentSourceIdentity } from '@/app/document/io/types'
|
||||
import { getRecoveryStore, type RecoverySnapshotMeta } from '@/app/document/recovery'
|
||||
import { setActiveEditorStore } from '@/app/editor/active-store'
|
||||
|
|
@ -22,17 +22,20 @@ import {
|
|||
} from '@/app/integrations/storage'
|
||||
import {
|
||||
cacheRecentFileThumbnail,
|
||||
loadCachedRecentFileThumbnail
|
||||
} from '@/app/shell/menu/recent-files'
|
||||
loadCachedRecentFileThumbnail,
|
||||
rememberRecentStorageDocument
|
||||
} from '@/app/recent-files'
|
||||
import { getLocalCanvasStore } from '@/app/storage/local-store'
|
||||
import { seedStorageCanvasFromRemote } from '@/app/storage/sync/persist'
|
||||
import { createFileOpenCoordinator } from '@/app/tabs/open/coordinator'
|
||||
import { findTabByFileIdentity } from '@/app/tabs/open/identity'
|
||||
|
||||
export type TabKind = 'home' | 'document'
|
||||
|
||||
export interface Tab {
|
||||
id: string
|
||||
store: EditorStore
|
||||
showHome: boolean
|
||||
kind: TabKind
|
||||
}
|
||||
|
||||
const io = new IORegistry(BUILTIN_IO_FORMATS)
|
||||
|
|
@ -55,7 +58,7 @@ export const allTabs = computed(() =>
|
|||
tabsRef.value.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.store.state.documentName,
|
||||
isHome: t.showHome,
|
||||
isHome: t.kind === 'home',
|
||||
isActive: t.id === activeTabId.value
|
||||
}))
|
||||
)
|
||||
|
|
@ -82,9 +85,16 @@ export function getTabsSnapshot(): Tab[] {
|
|||
return [...tabsRef.value]
|
||||
}
|
||||
|
||||
export function createTab(store?: EditorStore, initialGraph?: SceneGraph, showHome = false): Tab {
|
||||
export function createTab(store?: EditorStore, initialGraph?: SceneGraph): Tab {
|
||||
const s = store ?? createEditorStore(initialGraph)
|
||||
const tab: Tab = { id: generateTabId(), store: s, showHome }
|
||||
const tab: Tab = { id: generateTabId(), store: s, kind: 'document' }
|
||||
tabsRef.value = [...tabsRef.value, tab]
|
||||
activateTab(tab)
|
||||
return tab
|
||||
}
|
||||
|
||||
export function createHomeTab(): Tab {
|
||||
const tab: Tab = { id: generateTabId(), store: createEditorStore(), kind: 'home' }
|
||||
tabsRef.value = [...tabsRef.value, tab]
|
||||
activateTab(tab)
|
||||
return tab
|
||||
|
|
@ -94,17 +104,24 @@ export function leaveHome(tabId: string): void {
|
|||
const tabIndex = tabsRef.value.findIndex((candidate) => candidate.id === tabId)
|
||||
if (tabIndex === -1) return
|
||||
const tab = tabsRef.value[tabIndex]
|
||||
if (!tab.showHome) return
|
||||
tabsRef.value = tabsRef.value.with(tabIndex, { ...tab, showHome: false })
|
||||
if (tab.kind !== 'home') return
|
||||
tabsRef.value = tabsRef.value.with(tabIndex, { ...tab, kind: 'document' })
|
||||
}
|
||||
|
||||
export function showRecentFiles(): void {
|
||||
const homeTab = tabsRef.value.find((tab) => tab.showHome)
|
||||
export function createDocumentInCurrentTab(): Tab {
|
||||
const current = activeTab.value
|
||||
if (current?.kind !== 'home') return createTab()
|
||||
leaveHome(current.id)
|
||||
return getTabById(current.id) ?? current
|
||||
}
|
||||
|
||||
export function showNewTab(): void {
|
||||
const homeTab = tabsRef.value.find((tab) => tab.kind === 'home')
|
||||
if (homeTab) {
|
||||
switchTab(homeTab.id)
|
||||
return
|
||||
}
|
||||
createTab(undefined, undefined, true)
|
||||
createHomeTab()
|
||||
}
|
||||
|
||||
function activateTab(tab: Tab) {
|
||||
|
|
@ -129,7 +146,7 @@ export async function closeTab(tabId: string): Promise<void> {
|
|||
if (idx === -1) return
|
||||
|
||||
const closingTab = tabsRef.value[idx]
|
||||
if (closingTab.showHome) return
|
||||
if (closingTab.kind === 'home' && tabsRef.value.length === 1) return
|
||||
const wasActive = activeTabId.value === tabId
|
||||
coverThumbnailListeners.get(closingTab.store)?.()
|
||||
coverThumbnailListeners.delete(closingTab.store)
|
||||
|
|
@ -138,7 +155,7 @@ export async function closeTab(tabId: string): Promise<void> {
|
|||
tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId)
|
||||
|
||||
if (tabsRef.value.length === 0) {
|
||||
createTab(undefined, undefined, true)
|
||||
createHomeTab()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -160,7 +177,10 @@ function isDOMImportFile(file: File): boolean {
|
|||
|
||||
function reusableTabStore(): { store: EditorStore; created: boolean } {
|
||||
const current = activeTab.value
|
||||
if (current?.showHome) return { store: createTab().store, created: true }
|
||||
if (current?.kind === 'home') {
|
||||
leaveHome(current.id)
|
||||
return { store: current.store, created: false }
|
||||
}
|
||||
const isUntouched =
|
||||
current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo
|
||||
if (isUntouched) {
|
||||
|
|
@ -171,7 +191,7 @@ function reusableTabStore(): { store: EditorStore; created: boolean } {
|
|||
}
|
||||
|
||||
async function readFigForTab(file: File, store: EditorStore): Promise<SceneGraph> {
|
||||
const imported = await readFigFileProgressively(file, store)
|
||||
const imported = await readFigDocument(file, store)
|
||||
const firstPageId = imported.getPages()[0]?.id
|
||||
if (firstPageId) computeAllLayouts(imported, firstPageId)
|
||||
const coverPageId = findFigThumbnailPageId(imported.getPages())
|
||||
|
|
@ -250,6 +270,7 @@ export async function openStorageDocumentInNewTab(document: StorageDocument): Pr
|
|||
const existing = findStorageTab(providerId, document.id)
|
||||
if (existing) {
|
||||
switchTab(existing.id)
|
||||
rememberRecentStorageDocument(providerId, document.id, document.name)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -286,6 +307,7 @@ export async function openStorageDocumentInNewTab(document: StorageDocument): Pr
|
|||
await showImportedGraph(store, imported, () =>
|
||||
store.setStorageDocumentSource({ providerId, documentId: document.id }, document.name)
|
||||
)
|
||||
rememberRecentStorageDocument(providerId, document.id, document.name)
|
||||
} catch (error) {
|
||||
if (created) {
|
||||
const tab = getTabForStore(store)
|
||||
|
|
@ -431,6 +453,8 @@ export function useTabsStore() {
|
|||
return {
|
||||
tabs: allTabs,
|
||||
activeTabId,
|
||||
createHomeTab,
|
||||
createDocumentInCurrentTab,
|
||||
createTab,
|
||||
leaveHome,
|
||||
switchTab,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { tv } from 'tailwind-variants'
|
|||
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
import tabBarTheme from '@/theme/tab-bar'
|
||||
import { useTabsStore, showRecentFiles } from '@/app/tabs'
|
||||
import { useTabsStore, createHomeTab } from '@/app/tabs'
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
|
||||
const { dialogs } = useI18n()
|
||||
|
|
@ -19,8 +19,14 @@ const modelValue = computed({
|
|||
set: (id: string) => switchTab(id)
|
||||
})
|
||||
|
||||
function createNewTab(event: MouseEvent): void {
|
||||
event.preventDefault()
|
||||
if (event.currentTarget instanceof HTMLElement) event.currentTarget.blur()
|
||||
createHomeTab()
|
||||
}
|
||||
|
||||
function onMiddleClick(e: MouseEvent, tabId: string, isHome: boolean) {
|
||||
if (e.button === 1 && !isHome) {
|
||||
if (e.button === 1 && (!isHome || tabs.value.length > 1)) {
|
||||
e.preventDefault()
|
||||
void closeTab(tabId)
|
||||
}
|
||||
|
|
@ -51,13 +57,16 @@ function onClose(e: MouseEvent, tabId: string) {
|
|||
>
|
||||
<icon-lucide-house v-if="tab.isHome" :class="baseStyles.icon()" />
|
||||
<icon-lucide-file v-else :class="baseStyles.icon()" />
|
||||
<span :class="baseStyles.label()">{{ tab.isHome ? dialogs.recentFiles : tab.name }}</span>
|
||||
<Tip v-if="!tab.isHome" :label="dialogs.closeTab({ name: tab.name })">
|
||||
<span :class="baseStyles.label()">{{ tab.isHome ? dialogs.newTab : tab.name }}</span>
|
||||
<Tip
|
||||
v-if="!tab.isHome || tabs.length > 1"
|
||||
:label="dialogs.closeTab({ name: tab.isHome ? dialogs.newTab : tab.name })"
|
||||
>
|
||||
<button
|
||||
data-test-id="tabbar-close"
|
||||
:class="tabBarStyles({ active: tab.isActive }).close()"
|
||||
:data-active="tab.isActive || undefined"
|
||||
:aria-label="dialogs.closeTab({ name: tab.name })"
|
||||
:aria-label="dialogs.closeTab({ name: tab.isHome ? dialogs.newTab : tab.name })"
|
||||
tabindex="-1"
|
||||
@click="onClose($event, tab.id)"
|
||||
>
|
||||
|
|
@ -71,7 +80,7 @@ function onClose(e: MouseEvent, tabId: string) {
|
|||
data-test-id="tabbar-new"
|
||||
:class="baseStyles.newAction()"
|
||||
:aria-label="dialogs.newTab"
|
||||
@click="showRecentFiles"
|
||||
@click="createNewTab"
|
||||
>
|
||||
<icon-lucide-plus :class="baseStyles.newIcon()" />
|
||||
</button>
|
||||
|
|
|
|||
126
src/components/editor/EditorWorkspace.vue
Normal file
126
src/components/editor/EditorWorkspace.vue
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<script setup lang="ts">
|
||||
import { tv } from 'tailwind-variants'
|
||||
import { useUrlSearchParams } from '@vueuse/core'
|
||||
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
|
||||
|
||||
import { formatShortcut, useI18n, useViewportKind } from '@open-pencil/vue'
|
||||
|
||||
import { useEditorStore } from '@/app/editor/active-store'
|
||||
import { appMenuShortcut } from '@/app/shell/menu/shortcut'
|
||||
import { activeTab } from '@/app/tabs'
|
||||
import CanvasSplitRoot from '@/components/canvas/CanvasSplitRoot.vue'
|
||||
import CollabPanel from '@/components/CollabPanel/CollabPanel.vue'
|
||||
import EditorCanvas from '@/components/EditorCanvas.vue'
|
||||
import LayersPanel from '@/components/LayersPanel.vue'
|
||||
import MobileDrawer from '@/components/MobileDrawer.vue'
|
||||
import MobileHud from '@/components/MobileHud/MobileHud.vue'
|
||||
import PropertiesPanel from '@/components/PropertiesPanel.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
import Toolbar from '@/components/Toolbar/Toolbar.vue'
|
||||
import { loadEditorLayout, saveEditorLayout } from '@/app/shell/layout-storage'
|
||||
import splitterTheme from '@/theme/splitter'
|
||||
|
||||
const params = useUrlSearchParams('history')
|
||||
const showChrome = !('no-chrome' in params)
|
||||
const store = useEditorStore()
|
||||
const { dialogs } = useI18n()
|
||||
const { isMobile } = useViewportKind()
|
||||
const initialEditorLayout = loadEditorLayout()
|
||||
const horizontalSplitterStyles = tv(splitterTheme)({ direction: 'horizontal' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SplitterGroup
|
||||
v-if="!isMobile && showChrome && store.state.showUI"
|
||||
:key="activeTab?.id"
|
||||
direction="horizontal"
|
||||
class="flex-1 overflow-hidden"
|
||||
@layout="saveEditorLayout"
|
||||
>
|
||||
<SplitterPanel
|
||||
id="layers"
|
||||
:default-size="initialEditorLayout[0]"
|
||||
:min-size="10"
|
||||
:max-size="30"
|
||||
class="flex"
|
||||
>
|
||||
<LayersPanel />
|
||||
</SplitterPanel>
|
||||
<SplitterResizeHandle
|
||||
data-test-id="left-splitter-handle"
|
||||
:class="horizontalSplitterStyles.handle()"
|
||||
>
|
||||
<div :class="horizontalSplitterStyles.divider()" />
|
||||
</SplitterResizeHandle>
|
||||
<SplitterPanel id="canvas" :default-size="initialEditorLayout[1]" :min-size="30" class="flex">
|
||||
<div class="relative flex min-w-0 flex-1">
|
||||
<CanvasSplitRoot />
|
||||
<Toolbar />
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
<SplitterResizeHandle :class="horizontalSplitterStyles.handle()">
|
||||
<div :class="horizontalSplitterStyles.divider()" />
|
||||
</SplitterResizeHandle>
|
||||
<SplitterPanel
|
||||
id="properties"
|
||||
:default-size="initialEditorLayout[2]"
|
||||
:min-size="10"
|
||||
:max-size="30"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-border px-1.5 py-1.5">
|
||||
<CollabPanel />
|
||||
</div>
|
||||
<PropertiesPanel />
|
||||
</SplitterPanel>
|
||||
</SplitterGroup>
|
||||
|
||||
<div
|
||||
v-else-if="isMobile && showChrome && store.state.showUI"
|
||||
:key="'mobile-' + activeTab?.id"
|
||||
class="flex flex-1 overflow-hidden"
|
||||
>
|
||||
<div class="relative flex min-w-0 flex-1">
|
||||
<EditorCanvas />
|
||||
<MobileHud />
|
||||
<Toolbar />
|
||||
</div>
|
||||
<MobileDrawer />
|
||||
</div>
|
||||
|
||||
<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 />
|
||||
<div
|
||||
v-if="!isMobile"
|
||||
class="absolute top-7 left-7 z-10 flex items-center gap-2 rounded-lg border border-border bg-panel px-2 py-1 shadow-sm"
|
||||
>
|
||||
<img src="/favicon-32.png" class="size-4" alt="OpenPencil" />
|
||||
<span data-test-id="editor-document-name" class="text-xs text-surface">{{
|
||||
store.state.documentName
|
||||
}}</span>
|
||||
<Tip
|
||||
:label="dialogs.showUI({ shortcut: formatShortcut(appMenuShortcut('toggle-ui')) ?? '' })"
|
||||
>
|
||||
<button
|
||||
data-test-id="editor-show-ui"
|
||||
class="ml-1 flex size-6 cursor-pointer items-center justify-center rounded text-muted transition-colors hover:bg-hover hover:text-surface"
|
||||
@click="store.state.showUI = true"
|
||||
>
|
||||
<icon-lucide-sidebar class="size-3.5" />
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else :key="'bare-' + activeTab?.id" class="flex flex-1 overflow-hidden">
|
||||
<div class="relative flex min-w-0 flex-1">
|
||||
<EditorCanvas />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
440
src/components/home/HomeWorkspace.vue
Normal file
440
src/components/home/HomeWorkspace.vue
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
|
||||
import { useDocumentWorkspace, useI18n } from '@open-pencil/vue'
|
||||
|
||||
import {
|
||||
activeStorageProviderID,
|
||||
readStoragePreferences,
|
||||
storagePreferencesComplete,
|
||||
storageProviderRegistry,
|
||||
type StorageDocument
|
||||
} from '@/app/integrations/storage'
|
||||
import {
|
||||
clearRecentFiles,
|
||||
forgetRecentDocument,
|
||||
loadRecentFileThumbnail,
|
||||
recentFiles,
|
||||
type RecentDocument
|
||||
} from '@/app/recent-files'
|
||||
import { openSettingsDialog } from '@/app/settings/dialog'
|
||||
import { openFileFromPath } from '@/app/shell/menu/use'
|
||||
import { createStorageWorkspaceSource } from '@/app/storage/workspace/source'
|
||||
import { openStorageDocumentInNewTab } from '@/app/tabs'
|
||||
import HomeSearchActions from '@/components/home/search/HomeSearchActions.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
|
||||
const emit = defineEmits<{ 'new-document': [] }>()
|
||||
const { dialogs, panels, locale } = useI18n()
|
||||
const view = useLocalStorage<'grid' | 'list'>('open-pencil:home-files-view', 'grid')
|
||||
const query = ref('')
|
||||
const openError = ref<string | null>(null)
|
||||
const storageConfigured = ref(storagePreferencesComplete(activeStorageProviderID.value))
|
||||
|
||||
const workspace = useDocumentWorkspace<RecentDocument>({
|
||||
source: {
|
||||
async refresh() {
|
||||
return recentFiles.value
|
||||
},
|
||||
loadPreview(documentId) {
|
||||
const document = recentFiles.value.find((candidate) => candidate.id === documentId)
|
||||
if (!document) return Promise.resolve(null)
|
||||
if (document.kind === 'local') return loadRecentFileThumbnail(document.path)
|
||||
return createStorageWorkspaceSource(() => undefined).loadPreview(document.documentId)
|
||||
}
|
||||
},
|
||||
refreshOnFocus: false,
|
||||
refreshOnReconnect: false,
|
||||
previewConcurrency: 2
|
||||
})
|
||||
|
||||
const documents = workspace.documents
|
||||
const previewURL = workspace.previewURL
|
||||
const vWorkspacePreview = workspace.previewDirective
|
||||
|
||||
const storageWorkspace = useDocumentWorkspace<StorageDocument>({
|
||||
source: createStorageWorkspaceSource((snapshot) => {
|
||||
storageConfigured.value = snapshot.configured
|
||||
}),
|
||||
refreshInterval: 60_000,
|
||||
previewConcurrency: 6
|
||||
})
|
||||
const storageDocuments = storageWorkspace.documents
|
||||
const storageLoading = storageWorkspace.loading
|
||||
const storageError = computed(() => {
|
||||
const error = storageWorkspace.error.value
|
||||
if (error == null) return null
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
const storageDescription = computed(() => {
|
||||
const provider = storageProviderRegistry.get(activeStorageProviderID.value)
|
||||
const preferences = readStoragePreferences(activeStorageProviderID.value)
|
||||
const bucket = preferences.bucket?.trim()
|
||||
const endpoint = preferences.endpoint?.trim()
|
||||
let label = provider.label
|
||||
if (endpoint) {
|
||||
try {
|
||||
const hostname = new URL(endpoint).hostname
|
||||
if (hostname.endsWith('.r2.cloudflarestorage.com')) label = dialogs.value.storageProviderR2
|
||||
else if (hostname.includes('amazonaws.com')) label = dialogs.value.storageProviderAmazonS3
|
||||
else if (hostname.includes('backblazeb2.com')) label = dialogs.value.storageProviderBackblaze
|
||||
else if (hostname) label = dialogs.value.storageProviderS3
|
||||
} catch {
|
||||
label = provider.label
|
||||
}
|
||||
}
|
||||
return bucket ? `${label} · ${bucket}` : label
|
||||
})
|
||||
const storagePreviewURL = storageWorkspace.previewURL
|
||||
const vStoragePreview = storageWorkspace.previewDirective
|
||||
const normalizedQuery = computed(() => query.value.trim().toLocaleLowerCase(locale.value))
|
||||
const filteredRecentFiles = computed(() => {
|
||||
if (!normalizedQuery.value) return documents.value
|
||||
return documents.value.filter((document) =>
|
||||
`${document.name}\n${document.kind === 'local' ? document.path : document.documentId}`
|
||||
.toLocaleLowerCase(locale.value)
|
||||
.includes(normalizedQuery.value)
|
||||
)
|
||||
})
|
||||
const filteredStorageDocuments = computed(() => {
|
||||
if (!normalizedQuery.value) return storageDocuments.value
|
||||
return storageDocuments.value.filter((document) =>
|
||||
document.name.toLocaleLowerCase(locale.value).includes(normalizedQuery.value)
|
||||
)
|
||||
})
|
||||
const hasRecentFiles = computed(() => documents.value.length > 0)
|
||||
const noSearchMatches = computed(
|
||||
() =>
|
||||
Boolean(normalizedQuery.value) &&
|
||||
filteredRecentFiles.value.length === 0 &&
|
||||
filteredStorageDocuments.value.length === 0
|
||||
)
|
||||
|
||||
watch(recentFiles, () => void workspace.invalidate())
|
||||
|
||||
async function openRecent(document: RecentDocument): Promise<void> {
|
||||
openError.value = null
|
||||
try {
|
||||
if (document.kind === 'local') {
|
||||
await openFileFromPath(document.path)
|
||||
return
|
||||
}
|
||||
const storageDocument = storageDocuments.value.find(
|
||||
(candidate) => candidate.id === document.documentId
|
||||
)
|
||||
await openStorageDocumentInNewTab(
|
||||
storageDocument ?? {
|
||||
id: document.documentId,
|
||||
name: document.name,
|
||||
updatedAt: document.updatedAt
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
forgetRecentDocument(document.id)
|
||||
openError.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function openStorageDocument(document: StorageDocument): Promise<void> {
|
||||
openError.value = null
|
||||
try {
|
||||
await openStorageDocumentInNewTab(document)
|
||||
} catch (error) {
|
||||
openError.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
function formattedDate(updatedAt: string): string {
|
||||
const date = new Date(updatedAt)
|
||||
if (date.getTime() === 0) return ''
|
||||
return date.toLocaleString(locale.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="flex min-h-0 flex-1 flex-col overflow-x-hidden overflow-y-auto bg-app text-surface"
|
||||
data-test-id="recent-files-home"
|
||||
>
|
||||
<section
|
||||
class="mx-auto flex w-full max-w-7xl flex-col pt-4 pr-[max(1rem,env(safe-area-inset-right))] pb-4 pl-[max(1rem,env(safe-area-inset-left))] sm:px-6 sm:py-5"
|
||||
>
|
||||
<HomeSearchActions v-model="query" @new-document="emit('new-document')" />
|
||||
|
||||
<p v-if="openError" class="mb-4 text-xs text-danger" role="alert">{{ openError }}</p>
|
||||
<p
|
||||
v-if="noSearchMatches"
|
||||
class="rounded-lg border border-dashed border-border px-4 py-8 text-center text-xs text-muted"
|
||||
>
|
||||
{{ dialogs.noMatchingFiles({ query: query.trim() }) }}
|
||||
</p>
|
||||
|
||||
<section v-if="!noSearchMatches">
|
||||
<div class="mb-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="text-base font-semibold">{{ dialogs.recentFiles }}</h1>
|
||||
<p class="mt-0.5 text-pretty text-xs text-muted">
|
||||
{{ dialogs.recentFilesDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-auto hidden shrink-0 items-center gap-1 sm:flex">
|
||||
<Tip v-if="hasRecentFiles" :label="dialogs.clear">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-10 items-center justify-center rounded text-muted hover:bg-hover hover:text-surface sm:size-7"
|
||||
:aria-label="dialogs.clear"
|
||||
data-test-id="recent-files-clear"
|
||||
@click="clearRecentFiles"
|
||||
>
|
||||
<icon-lucide-trash-2 class="size-3.5" />
|
||||
</button>
|
||||
</Tip>
|
||||
<div class="flex rounded border border-border p-0.5">
|
||||
<Tip :label="panels.gridView">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-10 items-center justify-center rounded-sm text-muted hover:text-surface sm:size-7"
|
||||
:class="{ 'bg-hover text-surface': view === 'grid' }"
|
||||
:aria-label="panels.gridView"
|
||||
@click="view = 'grid'"
|
||||
>
|
||||
<icon-lucide-layout-grid class="size-3.5" />
|
||||
</button>
|
||||
</Tip>
|
||||
<Tip :label="panels.listView">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-10 items-center justify-center rounded-sm text-muted hover:text-surface sm:size-7"
|
||||
:class="{ 'bg-hover text-surface': view === 'list' }"
|
||||
:aria-label="panels.listView"
|
||||
@click="view = 'list'"
|
||||
>
|
||||
<icon-lucide-list class="size-3.5" />
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-end gap-1 sm:hidden">
|
||||
<Tip v-if="hasRecentFiles" :label="dialogs.clear">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-8 items-center justify-center rounded text-muted hover:bg-hover hover:text-surface"
|
||||
:aria-label="dialogs.clear"
|
||||
data-test-id="recent-files-clear"
|
||||
@click="clearRecentFiles"
|
||||
>
|
||||
<icon-lucide-trash-2 class="size-3.5" />
|
||||
</button>
|
||||
</Tip>
|
||||
<div class="flex rounded border border-border p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-8 items-center justify-center rounded-sm text-muted"
|
||||
:class="{ 'bg-hover text-surface': view === 'grid' }"
|
||||
:aria-label="panels.gridView"
|
||||
@click="view = 'grid'"
|
||||
>
|
||||
<icon-lucide-layout-grid class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-8 items-center justify-center rounded-sm text-muted"
|
||||
:class="{ 'bg-hover text-surface': view === 'list' }"
|
||||
:aria-label="panels.listView"
|
||||
@click="view = 'list'"
|
||||
>
|
||||
<icon-lucide-list class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="filteredRecentFiles.length && view === 'grid'"
|
||||
class="grid grid-cols-1 gap-x-5 gap-y-6 sm:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]"
|
||||
>
|
||||
<button
|
||||
v-for="document in filteredRecentFiles"
|
||||
:key="document.id"
|
||||
type="button"
|
||||
class="group min-w-0 text-left"
|
||||
@click="openRecent(document)"
|
||||
>
|
||||
<div
|
||||
v-workspace-preview="document.id"
|
||||
class="flex aspect-video items-center justify-center overflow-hidden rounded-lg border border-border bg-panel-field transition-colors group-hover:border-panel-focus"
|
||||
>
|
||||
<img
|
||||
v-if="previewURL(document.id)"
|
||||
:src="previewURL(document.id) ?? undefined"
|
||||
alt=""
|
||||
class="size-full object-cover transition-transform duration-200 group-hover:scale-[1.015]"
|
||||
/>
|
||||
<icon-lucide-file-image v-else class="size-8 text-muted/40" />
|
||||
</div>
|
||||
<p class="mt-2 truncate text-xs font-medium">{{ document.name }}</p>
|
||||
<p class="mt-0.5 truncate text-[10px] text-muted">
|
||||
{{ formattedDate(document.updatedAt) }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="filteredRecentFiles.length"
|
||||
class="overflow-hidden rounded-lg border border-border"
|
||||
>
|
||||
<button
|
||||
v-for="document in filteredRecentFiles"
|
||||
:key="document.id"
|
||||
type="button"
|
||||
class="flex min-h-14 w-full items-center gap-3 border-b border-border px-3 py-2 text-left last:border-b-0 hover:bg-hover sm:min-h-0 sm:px-4 sm:py-3"
|
||||
@click="openRecent(document)"
|
||||
>
|
||||
<icon-lucide-file-image class="size-4 shrink-0 text-accent" />
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-xs font-medium">{{ document.name }}</span>
|
||||
<span class="mt-0.5 block truncate text-[10px] text-muted sm:hidden">{{
|
||||
formattedDate(document.updatedAt)
|
||||
}}</span>
|
||||
</span>
|
||||
<span class="hidden shrink-0 text-[10px] text-muted sm:inline">{{
|
||||
formattedDate(document.updatedAt)
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!normalizedQuery"
|
||||
class="rounded-lg border border-dashed border-border px-4 py-4 text-center sm:py-6"
|
||||
>
|
||||
<p class="text-xs font-medium">{{ dialogs.noRecentFiles }}</p>
|
||||
<p class="mt-1 text-xs text-muted">{{ dialogs.noRecentFilesDescription }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="storageConfigured || storageLoading || storageError" class="mt-7">
|
||||
<div class="mb-3 flex items-start gap-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-base font-semibold">{{ dialogs.storageWorkspace }}</h2>
|
||||
<p class="mt-0.5 truncate text-xs text-muted sm:whitespace-normal">
|
||||
{{ storageDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Tip :label="dialogs.refresh">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-10 items-center justify-center rounded text-muted hover:bg-hover hover:text-surface sm:size-7"
|
||||
:aria-label="dialogs.refresh"
|
||||
@click="storageWorkspace.refresh"
|
||||
>
|
||||
<icon-lucide-refresh-cw class="size-3.5" />
|
||||
</button>
|
||||
</Tip>
|
||||
<Tip :label="dialogs.settings">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-10 items-center justify-center rounded text-muted hover:bg-hover hover:text-surface sm:size-7"
|
||||
:aria-label="dialogs.settings"
|
||||
@click="openSettingsDialog('storage')"
|
||||
>
|
||||
<icon-lucide-settings-2 class="size-3.5" />
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="storageLoading && storageDocuments.length === 0"
|
||||
class="grid grid-cols-1 gap-x-5 gap-y-6 sm:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]"
|
||||
:aria-label="dialogs.loadingStorageWorkspace"
|
||||
>
|
||||
<div v-for="index in 3" :key="index" class="min-w-0 animate-pulse">
|
||||
<div class="aspect-video rounded-lg border border-border bg-panel-field" />
|
||||
<div class="mt-2 h-3 w-2/3 rounded bg-panel-field" />
|
||||
<div class="mt-1.5 h-2.5 w-1/3 rounded bg-panel-field" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="storageError && storageDocuments.length === 0"
|
||||
class="rounded-lg border border-danger/40 px-4 py-6 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p class="text-xs text-danger">{{ storageError }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 rounded border border-border px-3 py-1.5 text-xs hover:bg-hover"
|
||||
@click="storageWorkspace.refresh"
|
||||
>
|
||||
{{ dialogs.refresh }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="filteredStorageDocuments.length && view === 'grid'"
|
||||
class="grid grid-cols-1 gap-x-5 gap-y-6 sm:grid-cols-[repeat(auto-fill,minmax(200px,1fr))]"
|
||||
>
|
||||
<button
|
||||
v-for="document in filteredStorageDocuments"
|
||||
:key="document.id"
|
||||
type="button"
|
||||
class="group min-w-0 text-left"
|
||||
@click="openStorageDocument(document)"
|
||||
>
|
||||
<div
|
||||
v-storage-preview="document.id"
|
||||
class="flex aspect-video items-center justify-center overflow-hidden rounded-lg border border-border bg-panel-field transition-colors group-hover:border-panel-focus"
|
||||
>
|
||||
<img
|
||||
v-if="storagePreviewURL(document.id)"
|
||||
:src="storagePreviewURL(document.id) ?? undefined"
|
||||
alt=""
|
||||
class="size-full object-cover transition-transform duration-200 group-hover:scale-[1.015]"
|
||||
/>
|
||||
<icon-lucide-file-image v-else class="size-8 text-muted/40" />
|
||||
</div>
|
||||
<p class="mt-2 truncate text-xs font-medium">{{ document.name }}</p>
|
||||
<p class="mt-0.5 truncate text-[10px] text-muted">
|
||||
{{ formattedDate(document.updatedAt) }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="filteredStorageDocuments.length"
|
||||
class="overflow-hidden rounded-lg border border-border"
|
||||
>
|
||||
<button
|
||||
v-for="document in filteredStorageDocuments"
|
||||
:key="document.id"
|
||||
type="button"
|
||||
class="flex min-h-14 w-full items-center gap-3 border-b border-border px-3 py-2 text-left last:border-b-0 hover:bg-hover sm:min-h-0 sm:px-4 sm:py-3"
|
||||
@click="openStorageDocument(document)"
|
||||
>
|
||||
<icon-lucide-file-image class="size-4 shrink-0 text-accent" />
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-xs font-medium">{{ document.name }}</span>
|
||||
<span class="mt-0.5 block truncate text-[10px] text-muted sm:hidden">{{
|
||||
formattedDate(document.updatedAt)
|
||||
}}</span>
|
||||
</span>
|
||||
<span class="hidden shrink-0 text-[10px] text-muted sm:inline">{{
|
||||
formattedDate(document.updatedAt)
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!normalizedQuery"
|
||||
class="rounded-lg border border-dashed border-border px-4 py-4 text-center text-xs text-muted sm:py-6"
|
||||
>
|
||||
{{ dialogs.emptyStorageWorkspace }}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
67
src/components/home/search/HomeSearchActions.vue
Normal file
67
src/components/home/search/HomeSearchActions.vue
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<script setup lang="ts">
|
||||
import { nextTick, watch } from 'vue'
|
||||
import { templateRef } from '@vueuse/core'
|
||||
|
||||
import { useI18n, useViewportKind } from '@open-pencil/vue'
|
||||
|
||||
import { openFileDialog } from '@/app/shell/menu/use'
|
||||
import { activeTab } from '@/app/tabs'
|
||||
|
||||
const emit = defineEmits<{ 'new-document': [] }>()
|
||||
const query = defineModel<string>({ required: true })
|
||||
const { dialogs, menu } = useI18n()
|
||||
const { isMobile } = useViewportKind()
|
||||
const searchInput = templateRef<HTMLInputElement>('searchInput')
|
||||
|
||||
async function focusSearch(): Promise<void> {
|
||||
if (isMobile.value || activeTab.value?.kind !== 'home') return
|
||||
await nextTick()
|
||||
setTimeout(() => searchInput.value?.focus(), 300)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => activeTab.value?.id,
|
||||
() => void focusSearch(),
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-4 flex flex-col gap-3 sm:mb-6 sm:flex-row sm:items-center">
|
||||
<label
|
||||
class="flex h-12 min-w-0 flex-1 items-center gap-2.5 rounded-lg border border-border bg-panel px-3.5 focus-within:border-panel-focus focus-within:ring-1 focus-within:ring-panel-focus sm:h-9 sm:gap-2 sm:bg-panel-field sm:px-3 sm:focus-within:ring-0"
|
||||
>
|
||||
<icon-lucide-search class="size-4.5 shrink-0 text-muted sm:size-4" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="query"
|
||||
type="search"
|
||||
name="file-search"
|
||||
autocomplete="off"
|
||||
class="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted"
|
||||
:placeholder="isMobile ? dialogs.searchFiles : dialogs.searchRecentAndStorageFiles"
|
||||
:aria-label="dialogs.searchFiles"
|
||||
/>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2 sm:contents">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 min-w-0 items-center justify-center rounded border border-border px-3 text-xs text-muted hover:bg-hover hover:text-surface sm:h-auto sm:flex-none sm:border-0 sm:py-2"
|
||||
data-test-id="home-open-file"
|
||||
@click="openFileDialog"
|
||||
>
|
||||
<icon-lucide-folder-open class="mr-1.5 size-3.5" />
|
||||
{{ menu.open }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 min-w-0 items-center justify-center rounded bg-accent px-3 text-xs font-medium text-white hover:bg-accent/90 sm:h-auto sm:flex-none sm:py-2"
|
||||
data-test-id="home-new-document"
|
||||
@click="emit('new-document')"
|
||||
>
|
||||
<icon-lucide-plus class="mr-1 size-3.5" />
|
||||
{{ dialogs.newDesign }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,313 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
|
||||
import { useDocumentWorkspace, useI18n } from '@open-pencil/vue'
|
||||
|
||||
import {
|
||||
clearRecentFiles,
|
||||
forgetRecentFile,
|
||||
loadRecentFileThumbnail,
|
||||
recentFiles,
|
||||
type RecentFile
|
||||
} from '@/app/shell/menu/recent-files'
|
||||
import { openFileDialog, openFileFromPath } from '@/app/shell/menu/use'
|
||||
import { activeStorageProviderID, type StorageDocument } from '@/app/integrations/storage'
|
||||
import { openSettingsDialog } from '@/app/settings/dialog'
|
||||
import { createStorageWorkspaceSource } from '@/app/storage/workspace/source'
|
||||
import { openStorageDocumentInNewTab } from '@/app/tabs'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
|
||||
const emit = defineEmits<{ 'new-document': [] }>()
|
||||
const { dialogs, menu, panels, locale } = useI18n()
|
||||
const view = useLocalStorage<'grid' | 'list'>('open-pencil:home-files-view', 'grid')
|
||||
const openError = ref<string | null>(null)
|
||||
const storageConfigured = ref(false)
|
||||
|
||||
const workspace = useDocumentWorkspace<RecentFile>({
|
||||
source: {
|
||||
async refresh() {
|
||||
return recentFiles.value
|
||||
},
|
||||
loadPreview: loadRecentFileThumbnail
|
||||
},
|
||||
refreshOnFocus: false,
|
||||
refreshOnReconnect: false,
|
||||
previewConcurrency: 2
|
||||
})
|
||||
|
||||
const documents = workspace.documents
|
||||
const previewURL = workspace.previewURL
|
||||
const vWorkspacePreview = workspace.previewDirective
|
||||
const hasRecentFiles = computed(() => documents.value.length > 0)
|
||||
|
||||
const storageWorkspace = useDocumentWorkspace<StorageDocument>({
|
||||
source: createStorageWorkspaceSource((snapshot) => {
|
||||
storageConfigured.value = snapshot.configured
|
||||
}),
|
||||
refreshInterval: 60_000,
|
||||
previewConcurrency: 6
|
||||
})
|
||||
const storageDocuments = storageWorkspace.documents
|
||||
const storagePreviewURL = storageWorkspace.previewURL
|
||||
const vStoragePreview = storageWorkspace.previewDirective
|
||||
|
||||
watch(recentFiles, () => void workspace.invalidate())
|
||||
|
||||
async function refreshHome(): Promise<void> {
|
||||
await Promise.all([workspace.refresh(), storageWorkspace.refresh()])
|
||||
}
|
||||
|
||||
async function openRecent(document: RecentFile): Promise<void> {
|
||||
openError.value = null
|
||||
try {
|
||||
await openFileFromPath(document.path)
|
||||
} catch (error) {
|
||||
forgetRecentFile(document.path)
|
||||
openError.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function openStorageDocument(document: StorageDocument): Promise<void> {
|
||||
openError.value = null
|
||||
try {
|
||||
await openStorageDocumentInNewTab(document)
|
||||
} catch (error) {
|
||||
openError.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
function formattedDate(updatedAt: string): string {
|
||||
const date = new Date(updatedAt)
|
||||
if (date.getTime() === 0) return ''
|
||||
return date.toLocaleString(locale.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flex min-h-0 flex-1 flex-col bg-app text-surface" data-test-id="recent-files-home">
|
||||
<header class="flex h-14 shrink-0 items-center border-b border-border px-6">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<img src="/favicon-32.png" class="size-5" alt="" />
|
||||
<span class="text-sm font-semibold">OpenPencil</span>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<Tip :label="dialogs.refresh">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-7 items-center justify-center rounded text-muted hover:bg-hover hover:text-surface"
|
||||
:aria-label="dialogs.refresh"
|
||||
@click="refreshHome"
|
||||
>
|
||||
<icon-lucide-refresh-cw class="size-3.5" />
|
||||
</button>
|
||||
</Tip>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-3 py-1.5 text-xs text-muted hover:bg-hover hover:text-surface"
|
||||
@click="openSettingsDialog('storage')"
|
||||
>
|
||||
<icon-lucide-settings-2 class="mr-1.5 inline size-3.5" />
|
||||
{{ dialogs.settings }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-3 py-1.5 text-xs text-muted hover:bg-hover hover:text-surface"
|
||||
data-test-id="home-open-file"
|
||||
@click="openFileDialog"
|
||||
>
|
||||
<icon-lucide-folder-open class="mr-1.5 inline size-3.5" />
|
||||
{{ menu.open }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90"
|
||||
data-test-id="home-new-document"
|
||||
@click="emit('new-document')"
|
||||
>
|
||||
<icon-lucide-plus class="mr-1 inline size-3.5" />
|
||||
{{ menu.new }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="mx-auto flex min-h-0 w-full max-w-7xl flex-1 flex-col px-8 py-10">
|
||||
<div class="mb-6 flex items-center">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold">{{ dialogs.recentFiles }}</h1>
|
||||
<p class="mt-1 text-xs text-muted">{{ dialogs.recentFilesDescription }}</p>
|
||||
</div>
|
||||
<div class="ml-auto flex rounded border border-border p-0.5">
|
||||
<Tip v-if="hasRecentFiles" :label="dialogs.clear">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-7 items-center justify-center rounded-sm text-muted hover:text-surface"
|
||||
:aria-label="dialogs.clear"
|
||||
data-test-id="recent-files-clear"
|
||||
@click="clearRecentFiles"
|
||||
>
|
||||
<icon-lucide-trash-2 class="size-3.5" />
|
||||
</button>
|
||||
</Tip>
|
||||
<Tip :label="panels.gridView">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-7 items-center justify-center rounded-sm text-muted hover:text-surface"
|
||||
:class="{ 'bg-hover text-surface': view === 'grid' }"
|
||||
:aria-label="panels.gridView"
|
||||
@click="view = 'grid'"
|
||||
>
|
||||
<icon-lucide-layout-grid class="size-3.5" />
|
||||
</button>
|
||||
</Tip>
|
||||
<Tip :label="panels.listView">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-7 items-center justify-center rounded-sm text-muted hover:text-surface"
|
||||
:class="{ 'bg-hover text-surface': view === 'list' }"
|
||||
:aria-label="panels.listView"
|
||||
@click="view = 'list'"
|
||||
>
|
||||
<icon-lucide-list class="size-3.5" />
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="openError" class="mb-4 text-xs text-danger" role="alert">{{ openError }}</p>
|
||||
|
||||
<div
|
||||
v-if="hasRecentFiles && view === 'grid'"
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-5"
|
||||
>
|
||||
<button
|
||||
v-for="document in documents"
|
||||
:key="document.path"
|
||||
type="button"
|
||||
class="group overflow-hidden rounded-lg border border-border bg-panel text-left transition-colors hover:border-panel-focus hover:bg-hover"
|
||||
@click="openRecent(document)"
|
||||
>
|
||||
<div
|
||||
v-workspace-preview="document.path"
|
||||
class="flex aspect-video items-center justify-center overflow-hidden bg-panel-field"
|
||||
>
|
||||
<img
|
||||
v-if="previewURL(document.path)"
|
||||
:src="previewURL(document.path) ?? undefined"
|
||||
alt=""
|
||||
class="size-full object-cover transition-transform duration-200 group-hover:scale-[1.015]"
|
||||
/>
|
||||
<icon-lucide-file-image v-else class="size-8 text-muted/40" />
|
||||
</div>
|
||||
<div class="border-t border-border p-3">
|
||||
<p class="truncate text-xs font-medium">{{ document.name }}</p>
|
||||
<p class="mt-1 truncate text-[10px] text-muted">
|
||||
{{ formattedDate(document.updatedAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasRecentFiles" class="overflow-hidden rounded-lg border border-border">
|
||||
<button
|
||||
v-for="document in documents"
|
||||
:key="document.path"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 border-b border-border px-4 py-3 text-left last:border-b-0 hover:bg-hover"
|
||||
@click="openRecent(document)"
|
||||
>
|
||||
<icon-lucide-file-image class="size-4 shrink-0 text-accent" />
|
||||
<span class="min-w-0 flex-1 truncate text-xs font-medium">{{ document.name }}</span>
|
||||
<span class="shrink-0 text-[10px] text-muted">{{
|
||||
formattedDate(document.updatedAt)
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!storageConfigured"
|
||||
class="flex flex-1 flex-col items-center justify-center pb-20 text-center"
|
||||
>
|
||||
<div class="mb-4 flex size-12 items-center justify-center rounded-xl bg-panel-field">
|
||||
<icon-lucide-files class="size-5 text-muted" />
|
||||
</div>
|
||||
<p class="text-sm font-medium">{{ dialogs.noRecentFiles }}</p>
|
||||
<p class="mt-1 max-w-sm text-xs text-muted">{{ dialogs.noRecentFilesDescription }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-5 rounded border border-border px-3 py-1.5 text-xs hover:bg-hover"
|
||||
@click="openFileDialog"
|
||||
>
|
||||
{{ menu.open }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-if="storageConfigured" class="mt-10">
|
||||
<div class="mb-6 flex items-center">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">{{ dialogs.storageWorkspace }}</h2>
|
||||
<p class="mt-1 text-xs text-muted">{{ activeStorageProviderID }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="storageDocuments.length && view === 'grid'"
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-5"
|
||||
>
|
||||
<button
|
||||
v-for="document in storageDocuments"
|
||||
:key="document.id"
|
||||
type="button"
|
||||
class="group overflow-hidden rounded-lg border border-border bg-panel text-left transition-colors hover:border-panel-focus hover:bg-hover"
|
||||
@click="openStorageDocument(document)"
|
||||
>
|
||||
<div
|
||||
v-storage-preview="document.id"
|
||||
class="flex aspect-video items-center justify-center overflow-hidden bg-panel-field"
|
||||
>
|
||||
<img
|
||||
v-if="storagePreviewURL(document.id)"
|
||||
:src="storagePreviewURL(document.id) ?? undefined"
|
||||
alt=""
|
||||
class="size-full object-cover transition-transform duration-200 group-hover:scale-[1.015]"
|
||||
/>
|
||||
<icon-lucide-file-image v-else class="size-8 text-muted/40" />
|
||||
</div>
|
||||
<div class="border-t border-border p-3">
|
||||
<p class="truncate text-xs font-medium">{{ document.name }}</p>
|
||||
<p class="mt-1 truncate text-[10px] text-muted">
|
||||
{{ formattedDate(document.updatedAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="storageDocuments.length"
|
||||
class="overflow-hidden rounded-lg border border-border"
|
||||
>
|
||||
<button
|
||||
v-for="document in storageDocuments"
|
||||
:key="document.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 border-b border-border px-4 py-3 text-left last:border-b-0 hover:bg-hover"
|
||||
@click="openStorageDocument(document)"
|
||||
>
|
||||
<icon-lucide-file-image class="size-4 shrink-0 text-accent" />
|
||||
<span class="min-w-0 flex-1 truncate text-xs font-medium">{{ document.name }}</span>
|
||||
<span class="shrink-0 text-[10px] text-muted">{{
|
||||
formattedDate(document.updatedAt)
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-border border-dashed px-4 py-8 text-center text-xs text-muted"
|
||||
>
|
||||
{{ dialogs.emptyStorageWorkspace }}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
|
@ -1,15 +1,14 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import EditorView from './views/EditorView.vue'
|
||||
import StorageView from './views/StorageView.vue'
|
||||
import WorkspaceView from './views/WorkspaceView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: EditorView },
|
||||
{ path: '/storage', component: StorageView },
|
||||
{ path: '/demo', component: EditorView, meta: { demo: true } },
|
||||
{ path: '/share/:roomId', component: EditorView }
|
||||
{ path: '/', component: WorkspaceView },
|
||||
{ path: '/storage', redirect: '/' },
|
||||
{ path: '/demo', component: WorkspaceView, meta: { demo: true } },
|
||||
{ path: '/share/:roomId', component: WorkspaceView }
|
||||
]
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ const tabBarTheme = {
|
|||
root: 'scrollbar-none flex h-9 shrink-0 items-end overflow-x-auto border-b border-border bg-canvas',
|
||||
list: 'flex h-full items-end',
|
||||
trigger:
|
||||
'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-[11px] transition-colors outline-none select-none focus-visible:ring-1 focus-visible:ring-accent',
|
||||
'group/tab flex h-full max-w-48 min-w-0 cursor-pointer touch-manipulation items-center gap-1.5 border-r border-border px-3 text-[11px] transition-colors outline-none select-none focus-visible:ring-1 focus-visible:ring-accent',
|
||||
icon: 'size-3 shrink-0 opacity-50',
|
||||
label: 'min-w-0 flex-1 truncate',
|
||||
close:
|
||||
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded transition-opacity group-hover/tab:opacity-100 hover:bg-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-panel-focus',
|
||||
'flex size-6 shrink-0 cursor-pointer touch-manipulation items-center justify-center rounded transition-opacity group-hover/tab:opacity-100 hover:bg-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-panel-focus sm:size-4',
|
||||
closeIcon: 'size-3',
|
||||
newAction:
|
||||
'flex size-9 shrink-0 cursor-pointer items-center justify-center text-muted transition-colors hover:text-surface focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-panel-focus',
|
||||
'flex size-9 shrink-0 cursor-pointer touch-manipulation items-center justify-center text-muted transition-colors hover:text-surface focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-panel-focus',
|
||||
newIcon: 'size-3.5'
|
||||
},
|
||||
variants: {
|
||||
|
|
|
|||
|
|
@ -1,237 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, provide, ref } from 'vue'
|
||||
import { tv } from 'tailwind-variants'
|
||||
import { useEventListener, useUrlSearchParams } from '@vueuse/core'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useHead } from '@unhead/vue'
|
||||
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
|
||||
|
||||
import { useViewportKind, formatShortcut, useI18n } from '@open-pencil/vue'
|
||||
import { useKeyboard } from '@/app/shell/keyboard/use'
|
||||
import { loadEditorLayout, saveEditorLayout } from '@/app/shell/layout-storage'
|
||||
import { openFileFromPath, useEditorMenu } from '@/app/shell/menu/use'
|
||||
import { useCollab, COLLAB_KEY } from '@/app/collab/use'
|
||||
import { connectAutomation } from '@/app/automation/bridge/server'
|
||||
import { exposeCollaborationActions } from '@/app/browser-bridge'
|
||||
import { spawnMCPIfNeeded } from '@/app/automation/mcp/spawn'
|
||||
import { isTauri } from '@/app/tauri/env'
|
||||
import { appMenuShortcut } from '@/app/shell/menu/shortcut'
|
||||
import { createDemoShapes } from '@/app/demo/document'
|
||||
import { useEditorStore } from '@/app/editor/active-store'
|
||||
import { createTab, activeTab, getActiveStore, tabCount } from '@/app/tabs'
|
||||
|
||||
import CollabPanel from '@/components/CollabPanel/CollabPanel.vue'
|
||||
import EditorCanvas from '@/components/EditorCanvas.vue'
|
||||
import CanvasSplitRoot from '@/components/canvas/CanvasSplitRoot.vue'
|
||||
import FontStatusBanner from '@/components/font-status/FontStatusBanner.vue'
|
||||
import LayersPanel from '@/components/LayersPanel.vue'
|
||||
import MobileDrawer from '@/components/MobileDrawer.vue'
|
||||
import MobileHud from '@/components/MobileHud/MobileHud.vue'
|
||||
import PropertiesPanel from '@/components/PropertiesPanel.vue'
|
||||
import RecentFilesHome from '@/components/recent-files/RecentFilesHome.vue'
|
||||
import RenameSelectionDialog from '@/components/selection/RenameSelectionDialog.vue'
|
||||
import SafariBanner from '@/components/SafariBanner.vue'
|
||||
import TabBar from '@/components/TabBar.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
import Toolbar from '@/components/Toolbar/Toolbar.vue'
|
||||
import splitterTheme from '@/theme/splitter'
|
||||
|
||||
const route = useRoute()
|
||||
const params = useUrlSearchParams('history')
|
||||
const showChrome = !('no-chrome' in params)
|
||||
|
||||
const createdInitialTab = tabCount() === 0
|
||||
const showInitialHome = route.path === '/' && !('test' in params) && !route.meta.demo
|
||||
const firstTab = createdInitialTab
|
||||
? createTab(undefined, undefined, showInitialHome)
|
||||
: (activeTab.value ?? createTab())
|
||||
const store = useEditorStore()
|
||||
const { dialogs } = useI18n()
|
||||
const { isMobile } = useViewportKind()
|
||||
|
||||
if (createdInitialTab && route.meta.demo && !('test' in params)) {
|
||||
void createDemoShapes(firstTab.store)
|
||||
}
|
||||
|
||||
useHead({ title: route.meta.demo ? 'Demo' : undefined })
|
||||
useKeyboard()
|
||||
useEditorMenu()
|
||||
|
||||
const collab = useCollab(getActiveStore)
|
||||
provide(COLLAB_KEY, collab)
|
||||
exposeCollaborationActions(collab)
|
||||
|
||||
useEventListener(
|
||||
document,
|
||||
'wheel',
|
||||
(e: WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) e.preventDefault()
|
||||
},
|
||||
{ passive: false }
|
||||
)
|
||||
|
||||
const automationCleanup = ref<(() => void) | null>(null)
|
||||
const mcpCleanup = ref<(() => void) | null>(null)
|
||||
const fileAssociationCleanup = ref<(() => void) | null>(null)
|
||||
const initialEditorLayout = loadEditorLayout()
|
||||
const horizontalSplitterStyles = tv(splitterTheme)({ direction: 'horizontal' })
|
||||
const showingHome = computed(() => activeTab.value?.showHome === true)
|
||||
|
||||
type PendingOpenFile = {
|
||||
path: string
|
||||
}
|
||||
|
||||
async function openPendingAssociatedFiles() {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const files = await invoke<PendingOpenFile[]>('take_pending_open')
|
||||
for (const file of files) {
|
||||
await openFileFromPath(file.path)
|
||||
}
|
||||
}
|
||||
|
||||
async function bindAssociatedFileOpen() {
|
||||
if (!isTauri()) return
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
fileAssociationCleanup.value = await listen('open-associated-files', () => {
|
||||
void openPendingAssociatedFiles().catch((e) => console.error('[Open With]', e))
|
||||
})
|
||||
await openPendingAssociatedFiles()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const mcp = await spawnMCPIfNeeded()
|
||||
mcpCleanup.value = mcp?.disconnect ?? null
|
||||
const tauri = isTauri()
|
||||
if (import.meta.env.DEV || (tauri && mcp)) {
|
||||
automationCleanup.value = connectAutomation(getActiveStore, mcp?.authToken ?? null).disconnect
|
||||
}
|
||||
|
||||
try {
|
||||
await bindAssociatedFileOpen()
|
||||
} catch (e) {
|
||||
console.error('[Open With]', e)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mcpCleanup.value?.()
|
||||
automationCleanup.value?.()
|
||||
fileAssociationCleanup.value?.()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-test-id="editor-root" class="flex h-screen w-screen flex-col">
|
||||
<SafariBanner />
|
||||
<FontStatusBanner />
|
||||
<RenameSelectionDialog />
|
||||
<TabBar />
|
||||
<RecentFilesHome v-if="showingHome" @new-document="createTab" />
|
||||
|
||||
<!-- Desktop layout -->
|
||||
<SplitterGroup
|
||||
v-if="!showingHome && !isMobile && showChrome && store.state.showUI"
|
||||
:key="activeTab?.id"
|
||||
direction="horizontal"
|
||||
class="flex-1 overflow-hidden"
|
||||
@layout="saveEditorLayout"
|
||||
>
|
||||
<SplitterPanel
|
||||
id="layers"
|
||||
:default-size="initialEditorLayout[0]"
|
||||
:min-size="10"
|
||||
:max-size="30"
|
||||
class="flex"
|
||||
>
|
||||
<LayersPanel />
|
||||
</SplitterPanel>
|
||||
<SplitterResizeHandle
|
||||
data-test-id="left-splitter-handle"
|
||||
:class="horizontalSplitterStyles.handle()"
|
||||
>
|
||||
<div :class="horizontalSplitterStyles.divider()" />
|
||||
</SplitterResizeHandle>
|
||||
<SplitterPanel id="canvas" :default-size="initialEditorLayout[1]" :min-size="30" class="flex">
|
||||
<div class="relative flex min-w-0 flex-1">
|
||||
<CanvasSplitRoot />
|
||||
<Toolbar />
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
<SplitterResizeHandle :class="horizontalSplitterStyles.handle()">
|
||||
<div :class="horizontalSplitterStyles.divider()" />
|
||||
</SplitterResizeHandle>
|
||||
<SplitterPanel
|
||||
id="properties"
|
||||
:default-size="initialEditorLayout[2]"
|
||||
:min-size="10"
|
||||
:max-size="30"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-between border-b border-border px-1.5 py-1.5"
|
||||
>
|
||||
<CollabPanel />
|
||||
</div>
|
||||
<PropertiesPanel />
|
||||
</SplitterPanel>
|
||||
</SplitterGroup>
|
||||
|
||||
<!-- Mobile layout -->
|
||||
<div
|
||||
v-else-if="!showingHome && isMobile && showChrome && store.state.showUI"
|
||||
:key="'mobile-' + activeTab?.id"
|
||||
class="flex flex-1 overflow-hidden"
|
||||
>
|
||||
<div class="relative flex min-w-0 flex-1">
|
||||
<EditorCanvas />
|
||||
<MobileHud />
|
||||
<Toolbar />
|
||||
</div>
|
||||
<MobileDrawer />
|
||||
</div>
|
||||
|
||||
<!-- Collapsed UI (showUI=false) -->
|
||||
<div
|
||||
v-else-if="!showingHome && showChrome"
|
||||
:key="'collapsed-' + activeTab?.id"
|
||||
class="flex flex-1 overflow-hidden"
|
||||
>
|
||||
<div class="relative flex min-w-0 flex-1">
|
||||
<EditorCanvas />
|
||||
<div
|
||||
v-if="!isMobile"
|
||||
class="absolute top-7 left-7 z-10 flex items-center gap-2 rounded-lg border border-border bg-panel px-2 py-1 shadow-sm"
|
||||
>
|
||||
<img src="/favicon-32.png" class="size-4" alt="OpenPencil" />
|
||||
<span data-test-id="editor-document-name" class="text-xs text-surface">{{
|
||||
store.state.documentName
|
||||
}}</span>
|
||||
<Tip
|
||||
:label="
|
||||
dialogs.showUI({ shortcut: formatShortcut(appMenuShortcut('toggle-ui')) ?? '' })
|
||||
"
|
||||
>
|
||||
<button
|
||||
data-test-id="editor-show-ui"
|
||||
class="ml-1 flex size-6 cursor-pointer items-center justify-center rounded text-muted transition-colors hover:bg-hover hover:text-surface"
|
||||
@click="store.state.showUI = true"
|
||||
>
|
||||
<icon-lucide-sidebar class="size-3.5" />
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bare canvas (no chrome, e.g. ?no-chrome) -->
|
||||
<div
|
||||
v-else-if="!showingHome"
|
||||
:key="'bare-' + activeTab?.id"
|
||||
class="flex flex-1 overflow-hidden"
|
||||
>
|
||||
<div class="relative flex min-w-0 flex-1">
|
||||
<EditorCanvas />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useDocumentWorkspace, useI18n } from '@open-pencil/vue'
|
||||
|
||||
import { activeStorageProviderID, type StorageDocument } from '@/app/integrations/storage'
|
||||
import { openSettingsDialog, settingsDialogOpen } from '@/app/settings/dialog'
|
||||
import { createCanvasId } from '@/app/storage/id'
|
||||
import { createStorageWorkspaceSource } from '@/app/storage/workspace/source'
|
||||
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
import { activeTab, createTab, openStorageDocumentInNewTab } from '@/app/tabs'
|
||||
|
||||
const { dialogs } = useI18n()
|
||||
const router = useRouter()
|
||||
const configured = ref(false)
|
||||
const workspace = useDocumentWorkspace({
|
||||
source: createStorageWorkspaceSource((snapshot) => {
|
||||
configured.value = snapshot.configured
|
||||
}),
|
||||
refreshInterval: 60_000,
|
||||
previewConcurrency: 6
|
||||
})
|
||||
const documents = workspace.documents
|
||||
const loading = workspace.loading
|
||||
const errorMessage = computed(() => {
|
||||
const reason = workspace.error.value
|
||||
if (reason == null) return null
|
||||
return reason instanceof Error ? reason.message : String(reason)
|
||||
})
|
||||
const refresh = workspace.refresh
|
||||
const invalidate = workspace.invalidate
|
||||
const clearPreviews = workspace.clearPreviews
|
||||
const previewURL = workspace.previewURL
|
||||
const vWorkspacePreview = workspace.previewDirective
|
||||
|
||||
async function openDocument(document: StorageDocument): Promise<void> {
|
||||
await router.push('/')
|
||||
await nextTick()
|
||||
await openStorageDocumentInNewTab(document)
|
||||
}
|
||||
|
||||
async function createDocument(): Promise<void> {
|
||||
if (!configured.value) return
|
||||
await router.push('/')
|
||||
await nextTick()
|
||||
const current = activeTab.value
|
||||
const store =
|
||||
current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo
|
||||
? current.store
|
||||
: createTab().store
|
||||
const documentId = createCanvasId()
|
||||
store.setStorageDocumentSource(
|
||||
{ providerId: activeStorageProviderID.value, documentId },
|
||||
'Untitled'
|
||||
)
|
||||
await store.saveFigFile()
|
||||
}
|
||||
|
||||
watch(activeStorageProviderID, () => {
|
||||
clearPreviews()
|
||||
void invalidate()
|
||||
})
|
||||
|
||||
watch(settingsDialogOpen, (open, wasOpen) => {
|
||||
if (wasOpen && !open) void invalidate()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flex min-h-screen flex-col bg-app text-surface" data-test-id="storage-workspace">
|
||||
<header class="flex h-14 items-center border-b border-border px-6">
|
||||
<div>
|
||||
<h1 class="text-sm font-semibold">{{ dialogs.storageWorkspace }}</h1>
|
||||
<p class="text-[10px] text-muted">{{ activeStorageProviderID }}</p>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<Tip v-if="configured" :label="dialogs.refresh">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-7 items-center justify-center rounded text-muted hover:bg-hover hover:text-surface disabled:opacity-50"
|
||||
:aria-label="dialogs.refresh"
|
||||
:disabled="loading"
|
||||
@click="refresh"
|
||||
>
|
||||
<icon-lucide-refresh-cw class="size-3.5" :class="{ 'animate-spin': loading }" />
|
||||
</button>
|
||||
</Tip>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-3 py-1.5 text-xs text-muted hover:bg-hover hover:text-surface"
|
||||
@click="openSettingsDialog('storage')"
|
||||
>
|
||||
{{ dialogs.settings }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!configured"
|
||||
data-test-id="storage-new-document"
|
||||
@click="createDocument"
|
||||
>
|
||||
{{ dialogs.newStoredDocument }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col p-6">
|
||||
<p v-if="errorMessage && configured" class="mb-4 shrink-0 text-xs text-danger" role="alert">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="documents.length"
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4"
|
||||
>
|
||||
<button
|
||||
v-for="document in documents"
|
||||
:key="document.id"
|
||||
type="button"
|
||||
class="group overflow-hidden rounded-lg border border-border bg-panel text-left hover:border-panel-focus hover:bg-hover"
|
||||
:data-document-id="document.id"
|
||||
@click="openDocument(document)"
|
||||
>
|
||||
<div
|
||||
v-workspace-preview="document.id"
|
||||
class="flex aspect-[4/3] items-center justify-center bg-panel-field"
|
||||
>
|
||||
<img
|
||||
v-if="previewURL(document.id)"
|
||||
:src="previewURL(document.id) ?? undefined"
|
||||
alt=""
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<icon-lucide-file-image v-else class="size-6 text-muted/50" />
|
||||
</div>
|
||||
<div class="border-t border-border p-3">
|
||||
<p class="truncate text-xs font-medium">{{ document.name }}</p>
|
||||
<p class="mt-1 text-[10px] text-muted">
|
||||
{{ new Date(document.updatedAt).toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AppPlaceholder v-else-if="loading" :label="dialogs.loadingDocuments" size="page">
|
||||
<template #icon>
|
||||
<icon-lucide-loader-circle class="size-5 animate-spin" />
|
||||
</template>
|
||||
</AppPlaceholder>
|
||||
|
||||
<AppPlaceholder v-else-if="configured" :label="dialogs.emptyStorageWorkspace" size="page">
|
||||
<template #icon>
|
||||
<icon-lucide-files class="size-5" />
|
||||
</template>
|
||||
</AppPlaceholder>
|
||||
|
||||
<AppPlaceholder v-else :label="dialogs.storageNotConfigured" size="page">
|
||||
<template #icon>
|
||||
<icon-lucide-cloud class="size-5" />
|
||||
</template>
|
||||
<template #action>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90"
|
||||
@click="openSettingsDialog('storage')"
|
||||
>
|
||||
{{ dialogs.settings }}
|
||||
</button>
|
||||
</template>
|
||||
</AppPlaceholder>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
112
src/views/WorkspaceView.vue
Normal file
112
src/views/WorkspaceView.vue
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, provide, ref } from 'vue'
|
||||
import { useEventListener, useUrlSearchParams } from '@vueuse/core'
|
||||
import { useHead } from '@unhead/vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { connectAutomation } from '@/app/automation/bridge/server'
|
||||
import { spawnMCPIfNeeded } from '@/app/automation/mcp/spawn'
|
||||
import { exposeCollaborationActions } from '@/app/browser-bridge'
|
||||
import { COLLAB_KEY, useCollab } from '@/app/collab/use'
|
||||
import { createDemoShapes } from '@/app/demo/document'
|
||||
import { useKeyboard } from '@/app/shell/keyboard/use'
|
||||
import { openFileFromPath, useEditorMenu } from '@/app/shell/menu/use'
|
||||
import {
|
||||
activeTab,
|
||||
createDocumentInCurrentTab,
|
||||
createHomeTab,
|
||||
createTab,
|
||||
getActiveStore,
|
||||
tabCount
|
||||
} from '@/app/tabs'
|
||||
import { isTauri } from '@/app/tauri/env'
|
||||
import FontStatusBanner from '@/components/font-status/FontStatusBanner.vue'
|
||||
import RenameSelectionDialog from '@/components/selection/RenameSelectionDialog.vue'
|
||||
import SafariBanner from '@/components/SafariBanner.vue'
|
||||
import TabBar from '@/components/TabBar.vue'
|
||||
import EditorWorkspace from '@/components/editor/EditorWorkspace.vue'
|
||||
import HomeWorkspace from '@/components/home/HomeWorkspace.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const params = useUrlSearchParams('history')
|
||||
const createdInitialTab = tabCount() === 0
|
||||
const shouldCreateHome = route.path === '/' && !('test' in params) && !route.meta.demo
|
||||
let firstTab = activeTab.value
|
||||
if (!firstTab) firstTab = shouldCreateHome ? createHomeTab() : createTab()
|
||||
|
||||
if (createdInitialTab && route.meta.demo && !('test' in params)) {
|
||||
void createDemoShapes(firstTab.store)
|
||||
}
|
||||
|
||||
useHead({ title: route.meta.demo ? 'Demo' : undefined })
|
||||
useKeyboard()
|
||||
useEditorMenu()
|
||||
|
||||
const collab = useCollab(getActiveStore)
|
||||
provide(COLLAB_KEY, collab)
|
||||
exposeCollaborationActions(collab)
|
||||
|
||||
useEventListener(
|
||||
document,
|
||||
'wheel',
|
||||
(event: WheelEvent) => {
|
||||
if (event.ctrlKey || event.metaKey) event.preventDefault()
|
||||
},
|
||||
{ passive: false }
|
||||
)
|
||||
|
||||
const automationCleanup = ref<(() => void) | null>(null)
|
||||
const mcpCleanup = ref<(() => void) | null>(null)
|
||||
const fileAssociationCleanup = ref<(() => void) | null>(null)
|
||||
|
||||
interface PendingOpenFile {
|
||||
path: string
|
||||
}
|
||||
|
||||
async function openPendingAssociatedFiles(): Promise<void> {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const files = await invoke<PendingOpenFile[]>('take_pending_open')
|
||||
for (const file of files) await openFileFromPath(file.path)
|
||||
}
|
||||
|
||||
async function bindAssociatedFileOpen(): Promise<void> {
|
||||
if (!isTauri()) return
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
fileAssociationCleanup.value = await listen('open-associated-files', () => {
|
||||
void openPendingAssociatedFiles().catch((error) => console.error('[Open With]', error))
|
||||
})
|
||||
await openPendingAssociatedFiles()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const mcp = await spawnMCPIfNeeded()
|
||||
mcpCleanup.value = mcp?.disconnect ?? null
|
||||
const tauri = isTauri()
|
||||
if (import.meta.env.DEV || (tauri && mcp)) {
|
||||
automationCleanup.value = connectAutomation(getActiveStore, mcp?.authToken ?? null).disconnect
|
||||
}
|
||||
|
||||
try {
|
||||
await bindAssociatedFileOpen()
|
||||
} catch (error) {
|
||||
console.error('[Open With]', error)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mcpCleanup.value?.()
|
||||
automationCleanup.value?.()
|
||||
fileAssociationCleanup.value?.()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-test-id="editor-root" class="flex h-screen w-screen flex-col">
|
||||
<SafariBanner />
|
||||
<FontStatusBanner />
|
||||
<RenameSelectionDialog />
|
||||
<TabBar />
|
||||
<HomeWorkspace v-show="activeTab?.kind === 'home'" @new-document="createDocumentInCurrentTab" />
|
||||
<EditorWorkspace v-if="activeTab?.kind !== 'home'" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,5 +1,21 @@
|
|||
import { expect, test } from '#tests/e2e/fixtures'
|
||||
|
||||
test('keeps the New tab layout usable on mobile', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/?recent-files')
|
||||
|
||||
const search = page.getByLabel('Search files…')
|
||||
await expect(search).toBeVisible()
|
||||
await expect(search).not.toBeFocused()
|
||||
await expect(search).toHaveAttribute('placeholder', 'Search files…')
|
||||
await expect(page.getByTestId('home-open-file')).toBeVisible()
|
||||
await expect(page.getByTestId('home-new-document')).toBeVisible()
|
||||
await expect(page.getByTestId('recent-files-home')).toHaveJSProperty(
|
||||
'scrollWidth',
|
||||
await page.getByTestId('recent-files-home').evaluate((element) => element.clientWidth)
|
||||
)
|
||||
})
|
||||
|
||||
test('opens to the recent-files home and starts a new document', async ({ page }) => {
|
||||
await page.goto('/?recent-files')
|
||||
|
||||
|
|
@ -11,18 +27,21 @@ test('opens to the recent-files home and starts a new document', async ({ page }
|
|||
|
||||
await expect(page.getByTestId('recent-files-home')).toBeHidden()
|
||||
const tab = page.getByTestId('tabbar-tab')
|
||||
await expect(tab).toHaveCount(2)
|
||||
await expect(tab).toHaveCount(1)
|
||||
await expect(tab).toContainText('Untitled')
|
||||
await expect(page.getByTestId('tabbar-close')).toHaveCount(1)
|
||||
|
||||
await page.getByTestId('tabbar-new').click()
|
||||
|
||||
await expect(page.getByTestId('recent-files-home')).toBeVisible()
|
||||
await expect(page.getByTestId('tabbar-tab')).toHaveCount(2)
|
||||
await expect(page.getByTestId('tabbar-tab').first()).toContainText('Recent files')
|
||||
await expect(page.getByTestId('tabbar-close')).toHaveCount(1)
|
||||
await expect(page.getByTestId('tabbar-tab').last()).toContainText('New tab')
|
||||
await expect(page.getByLabel('Search files…')).toBeFocused()
|
||||
await expect(page.getByTestId('tabbar-close')).toHaveCount(2)
|
||||
|
||||
await page.getByTestId('tabbar-tab').last().click()
|
||||
await page.getByTestId('tabbar-tab').last().hover()
|
||||
await page.getByTestId('tabbar-tab').last().getByTestId('tabbar-close').click()
|
||||
await page.getByTestId('tabbar-tab').first().click()
|
||||
await page.getByTestId('tabbar-tab').first().hover()
|
||||
await page.getByTestId('tabbar-tab').first().getByTestId('tabbar-close').click()
|
||||
|
||||
await expect(page.getByTestId('recent-files-home')).toBeVisible()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -56,20 +56,4 @@ describe('app cache', () => {
|
|||
await expect(readCacheText('openrouter/other')).resolves.toBeNull()
|
||||
await expect(readCacheText('fonts/manifest')).resolves.toBe('fonts')
|
||||
})
|
||||
|
||||
test('removes web cache entries sharing a file-name prefix', async () => {
|
||||
installLocalStorage()
|
||||
const { readCacheText, removeCacheEntriesWithPrefix, writeCacheText } =
|
||||
await import('@/app/cache')
|
||||
|
||||
await writeCacheText('recent-file-thumbnail-v2-one.png', 'one')
|
||||
await writeCacheText('recent-file-thumbnail-v2-two.png', 'two')
|
||||
await writeCacheText('font-cache/v1/manifest', 'fonts')
|
||||
|
||||
await removeCacheEntriesWithPrefix('recent-file-thumbnail-v2-')
|
||||
|
||||
await expect(readCacheText('recent-file-thumbnail-v2-one.png')).resolves.toBeNull()
|
||||
await expect(readCacheText('recent-file-thumbnail-v2-two.png')).resolves.toBeNull()
|
||||
await expect(readCacheText('font-cache/v1/manifest')).resolves.toBe('fonts')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
56
tests/engine/app/recent-files/store.test.ts
Normal file
56
tests/engine/app/recent-files/store.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
clearRecentFiles,
|
||||
forgetRecentFile,
|
||||
recentDocuments,
|
||||
recentFiles,
|
||||
recentLocalFileAt,
|
||||
rememberRecentFile,
|
||||
rememberRecentStorageDocument
|
||||
} from '@/app/recent-files'
|
||||
|
||||
afterEach(() => clearRecentFiles())
|
||||
|
||||
describe('recent documents', () => {
|
||||
test('keeps the latest local file first without duplicates', () => {
|
||||
recentDocuments.value = []
|
||||
|
||||
rememberRecentFile('/tmp/first.fig')
|
||||
rememberRecentFile('/tmp/second.fig')
|
||||
rememberRecentFile('/tmp/first.fig')
|
||||
|
||||
expect(recentFiles.value.map(({ kind, name }) => ({ kind, name }))).toEqual([
|
||||
{ kind: 'local', name: 'first.fig' },
|
||||
{ kind: 'local', name: 'second.fig' }
|
||||
])
|
||||
expect(recentLocalFileAt(0)).toBe('/tmp/first.fig')
|
||||
})
|
||||
|
||||
test('tracks storage documents alongside local files', () => {
|
||||
rememberRecentFile('/tmp/local.fig')
|
||||
rememberRecentStorageDocument('s3-compatible', 'remote-1', 'Remote design')
|
||||
|
||||
expect(recentFiles.value.map(({ id, kind, name }) => ({ id, kind, name }))).toEqual([
|
||||
{
|
||||
id: 'storage:s3-compatible:remote-1',
|
||||
kind: 'storage',
|
||||
name: 'Remote design'
|
||||
},
|
||||
{ id: 'local:/tmp/local.fig', kind: 'local', name: 'local.fig' }
|
||||
])
|
||||
expect(recentLocalFileAt(0)).toBe('/tmp/local.fig')
|
||||
})
|
||||
|
||||
test('forgets missing local files and clears the list', () => {
|
||||
rememberRecentFile('/tmp/first.fig')
|
||||
rememberRecentFile('/tmp/second.fig')
|
||||
|
||||
forgetRecentFile('/tmp/first.fig')
|
||||
expect(recentFiles.value.map((document) => document.name)).toEqual(['second.fig'])
|
||||
|
||||
clearRecentFiles()
|
||||
expect(recentFiles.value).toEqual([])
|
||||
expect(recentLocalFileAt(0)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
clearRecentFiles,
|
||||
forgetRecentFile,
|
||||
recentFileAt,
|
||||
recentFilePaths,
|
||||
recentFiles,
|
||||
rememberRecentFile
|
||||
} from '@/app/shell/menu/recent-files'
|
||||
|
||||
afterEach(() => clearRecentFiles())
|
||||
|
||||
describe('recent files', () => {
|
||||
test('keeps the latest file first without duplicates', () => {
|
||||
recentFilePaths.value = []
|
||||
|
||||
rememberRecentFile('/tmp/first.fig')
|
||||
rememberRecentFile('/tmp/second.fig')
|
||||
rememberRecentFile('/tmp/first.fig')
|
||||
|
||||
expect(recentFilePaths.value).toEqual(['/tmp/first.fig', '/tmp/second.fig'])
|
||||
expect(recentFiles.value.map(({ id, name }) => ({ id, name }))).toEqual([
|
||||
{ id: '/tmp/first.fig', name: 'first.fig' },
|
||||
{ id: '/tmp/second.fig', name: 'second.fig' }
|
||||
])
|
||||
expect(recentFileAt(0)).toBe('/tmp/first.fig')
|
||||
})
|
||||
|
||||
test('forgets missing files and clears the list', () => {
|
||||
recentFilePaths.value = ['/tmp/first.fig', '/tmp/second.fig']
|
||||
|
||||
forgetRecentFile('/tmp/first.fig')
|
||||
expect(recentFilePaths.value).toEqual(['/tmp/second.fig'])
|
||||
|
||||
clearRecentFiles()
|
||||
expect(recentFilePaths.value).toEqual([])
|
||||
expect(recentFileAt(0)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -7,11 +7,13 @@ import { SceneGraph } from '@open-pencil/scene-graph'
|
|||
import { resolveBrowserFileURL } from '@/app/document/io/browser'
|
||||
import type { DocumentSourceIdentity } from '@/app/document/io/types'
|
||||
import {
|
||||
createDocumentInCurrentTab,
|
||||
createHomeTab,
|
||||
createTab,
|
||||
getActiveStore,
|
||||
getTabsSnapshot,
|
||||
openFileInNewTab,
|
||||
showRecentFiles,
|
||||
showNewTab,
|
||||
tabCount
|
||||
} from '@/app/tabs'
|
||||
import { fileIdentitiesMatch, findTabByFileIdentity } from '@/app/tabs/open/identity'
|
||||
|
|
@ -122,15 +124,39 @@ describe('openFileInNewTab deduplication', () => {
|
|||
expect(resolveBrowserFileURL('/design.fig#selection').href).toBe('http://localhost/design.fig')
|
||||
})
|
||||
|
||||
test('keeps one permanent Recent Files tab', () => {
|
||||
test('reuses the existing New tab when navigating to the files workspace', () => {
|
||||
const initialCount = tabCount()
|
||||
const initialHomeCount = getTabsSnapshot().filter((tab) => tab.showHome).length
|
||||
const initialHomeCount = getTabsSnapshot().filter((tab) => tab.kind === 'home').length
|
||||
|
||||
showRecentFiles()
|
||||
showRecentFiles()
|
||||
showNewTab()
|
||||
showNewTab()
|
||||
|
||||
expect(tabCount()).toBe(initialCount + (initialHomeCount === 0 ? 1 : 0))
|
||||
expect(getTabsSnapshot().filter((tab) => tab.showHome)).toHaveLength(1)
|
||||
expect(getTabsSnapshot().filter((tab) => tab.kind === 'home')).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('converts the current New tab into a blank document', () => {
|
||||
createHomeTab()
|
||||
const home = getTabsSnapshot().at(-1)
|
||||
const count = tabCount()
|
||||
|
||||
const document = createDocumentInCurrentTab()
|
||||
|
||||
expect(tabCount()).toBe(count)
|
||||
expect(document.id).toBe(home?.id)
|
||||
expect(document.kind).toBe('document')
|
||||
})
|
||||
|
||||
test('opens a file in the current New tab', async () => {
|
||||
createHomeTab()
|
||||
const home = getTabsSnapshot().at(-1)
|
||||
const count = tabCount()
|
||||
|
||||
await openFileInNewTab(new File([], 'design.fig'), undefined, '/tmp/from-home.fig')
|
||||
|
||||
expect(tabCount()).toBe(count)
|
||||
expect(getTabsSnapshot().at(-1)?.id).toBe(home?.id)
|
||||
expect(getTabsSnapshot().at(-1)?.kind).toBe('document')
|
||||
})
|
||||
|
||||
test('activates the existing tab when the same path is opened again', async () => {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ describe('Tauri file actions', () => {
|
|||
await expect(chooseTauriOpenPaths()).resolves.toEqual(['/tmp/design.fig', '/tmp/design.pen'])
|
||||
})
|
||||
|
||||
test('rejects unsupported Tauri design files before reading them', async () => {
|
||||
await expect(readTauriDesignFile('/tmp/design.sketch')).rejects.toThrow(
|
||||
'Unsupported document format: /tmp/design.sketch'
|
||||
)
|
||||
})
|
||||
|
||||
test('reads a Tauri design file into a File object', async () => {
|
||||
await mockTauriIPC((cmd, args) => {
|
||||
expect(cmd).toBe('plugin:fs|read_file')
|
||||
|
|
|
|||
Loading…
Reference in a new issue