Initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"vitest.explorer",
|
||||
"ms-playwright.playwright",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"oxc.oxc-vscode",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# 无界笺 Boundless
|
||||
|
||||
一张可以无限延伸的本地灵感平面。你可以在画布上自由放置文字与图片、连接相关想法,并使用不同空间分开生活中的主题。
|
||||
|
||||
## 已实现
|
||||
|
||||
- 无限画布平移、缩放、小地图和框选
|
||||
- 文字便笺的创建、编辑、移动、缩放、复制和换色
|
||||
- 图片选择、拖入与剪贴板粘贴
|
||||
- 卡片之间的自由连线
|
||||
- 多空间创建和切换
|
||||
- 当前空间搜索
|
||||
- IndexedDB 自动本地保存
|
||||
- JSON 完整备份与恢复
|
||||
- 响应式侧边栏
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vue 3 + TypeScript 6
|
||||
- Vite 8(Rolldown)
|
||||
- Vue Flow
|
||||
- Pinia + Dexie / IndexedDB
|
||||
- Tailwind CSS 4
|
||||
- Vitest + Playwright
|
||||
|
||||
## 本地运行
|
||||
|
||||
需要 Node.js 22.18+ 或 Node.js 24.12+,推荐使用 Node.js 24 LTS。
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
浏览器打开终端显示的本地地址即可使用。
|
||||
|
||||
## 检查项目
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
pnpm test:unit -- --run
|
||||
pnpm exec playwright test --project=chromium
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
所有记录默认只保存在当前浏览器的 IndexedDB 中。请定期使用左下角的“导出备份”保存一份 JSON 文件。
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": ["./**/*"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// See here how to get started:
|
||||
// https://playwright.dev/docs/intro
|
||||
test('opens the canvas and creates a note', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.getByText('无界笺', { exact: true })).toBeVisible()
|
||||
const notesBefore = await page.locator('.note-card').count()
|
||||
await page.getByRole('button', { name: '新便笺' }).click()
|
||||
await expect(page.locator('.note-card')).toHaveCount(notesBefore + 1)
|
||||
})
|
||||
|
||||
test('pastes clipboard text into a new note on the canvas', async ({ page }) => {
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await page.goto('/')
|
||||
await expect(page.getByText('无界笺', { exact: true })).toBeVisible()
|
||||
const notesBefore = await page.locator('.note-card').count()
|
||||
|
||||
await page.evaluate(() =>
|
||||
navigator.clipboard.writeText('临时灵感\n把复制的文字直接变成一张新便笺。'),
|
||||
)
|
||||
await page.locator('.vue-flow__pane').click({ position: { x: 90, y: 90 } })
|
||||
await page.keyboard.press('Control+V')
|
||||
|
||||
await expect(page.locator('.note-card')).toHaveCount(notesBefore + 1)
|
||||
const pastedCard = page.locator('.note-card').last()
|
||||
await expect(pastedCard.locator('.note-card__title')).toHaveValue('临时灵感')
|
||||
await expect(pastedCard.locator('.note-card__content')).toHaveValue(
|
||||
'临时灵感\n把复制的文字直接变成一张新便笺。',
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginPlaywright from 'eslint-plugin-playwright'
|
||||
import pluginVitest from '@vitest/eslint-plugin'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from 'eslint-config-prettier/flat'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{vue,ts,mts,tsx}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
...pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
|
||||
{
|
||||
...pluginPlaywright.configs['flat/recommended'],
|
||||
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||
},
|
||||
|
||||
{
|
||||
...pluginVitest.configs.recommended,
|
||||
files: ['src/**/__tests__/*'],
|
||||
},
|
||||
|
||||
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||
|
||||
skipFormatting,
|
||||
)
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#f4f3ee">
|
||||
<meta name="description" content="无界笺是一张可以无限延伸的本地灵感平面。">
|
||||
<title>无界笺 · Boundless</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "boundless",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "run-s \"lint:*\"",
|
||||
"lint:oxlint": "oxlint . --fix",
|
||||
"lint:eslint": "eslint . --fix --cache",
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.31.0",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.3",
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"@vue-flow/minimap": "^1.5.4",
|
||||
"@vue-flow/node-resizer": "^1.5.1",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"dexie": "^4.4.4",
|
||||
"pinia": "^4.0.2",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vitest/eslint-plugin": "^1.6.23",
|
||||
"@vue/eslint-config-typescript": "^14.9.0",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.73.0",
|
||||
"eslint-plugin-playwright": "^2.10.5",
|
||||
"eslint-plugin-vue": "~10.9.2",
|
||||
"jiti": "^2.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"npm-run-all2": "^9.0.2",
|
||||
"oxlint": "~1.74.0",
|
||||
"prettier": "3.9.5",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.1.5",
|
||||
"vite-plugin-vue-devtools": "^8.1.5",
|
||||
"vitest": "^4.1.10",
|
||||
"vue-eslint-parser": "^10.4.1",
|
||||
"vue-tsc": "^3.3.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.12.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import process from 'node:process'
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Maximum time one test can run for. */
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
/**
|
||||
* Maximum time expect() should wait for the condition to be met.
|
||||
* For example in `await expect(locator).toHaveText();`
|
||||
*/
|
||||
timeout: 5000,
|
||||
},
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||
actionTimeout: 0,
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
|
||||
/* Only on CI systems run the tests headless */
|
||||
headless: !!process.env.CI,
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: {
|
||||
...devices['Desktop Firefox'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: {
|
||||
...devices['Desktop Safari'],
|
||||
},
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: {
|
||||
// ...devices['Pixel 5'],
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: {
|
||||
// ...devices['iPhone 12'],
|
||||
// },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: {
|
||||
// channel: 'msedge',
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: {
|
||||
// channel: 'chrome',
|
||||
// },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||
// outputDir: 'test-results/',
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
/**
|
||||
* Use the dev server by default for faster feedback loop.
|
||||
* Use the preview server on CI for more realistic testing.
|
||||
* Playwright will re-use the local server if there is already a dev-server running.
|
||||
*/
|
||||
command: process.env.CI ? 'npm run preview' : 'npm run dev',
|
||||
port: process.env.CI ? 4173 : 5173,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
Generated
+4801
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
vue-demi: set this to true or false
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import App from '../App.vue'
|
||||
|
||||
describe('App', () => {
|
||||
it('renders the active route', async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<main>无界笺</main>' } }],
|
||||
})
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
const wrapper = mount(App, { global: { plugins: [router] } })
|
||||
expect(wrapper.text()).toContain('无界笺')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
import { Download, FileUp, Pencil, Plus, Trash2, X } from '@lucide/vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import BrandMark from '@/components/BrandMark.vue'
|
||||
import { useCanvasStore } from '@/stores/canvas'
|
||||
import type { BackupFile, SpaceRecord } from '@/types/canvas'
|
||||
|
||||
const store = useCanvasStore()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
|
||||
// 新建/编辑空间弹窗状态
|
||||
const isModalOpen = ref(false)
|
||||
const modalMode = ref<'create' | 'edit'>('create')
|
||||
const editingSpaceId = ref('')
|
||||
const spaceNameInput = ref('')
|
||||
const selectedEmoji = ref('✦')
|
||||
|
||||
const availableEmojis = ['✦', '◌', '◇', '✺', '◎', '🌿', '💡', '🪐', '☕', '📌', '🎨', '📝']
|
||||
|
||||
function openCreateModal() {
|
||||
modalMode.value = 'create'
|
||||
editingSpaceId.value = ''
|
||||
spaceNameInput.value = ''
|
||||
selectedEmoji.value = availableEmojis[store.spaces.length % availableEmojis.length] ?? '✦'
|
||||
isModalOpen.value = true
|
||||
}
|
||||
|
||||
function openEditModal(space: SpaceRecord) {
|
||||
modalMode.value = 'edit'
|
||||
editingSpaceId.value = space.id
|
||||
spaceNameInput.value = space.name
|
||||
selectedEmoji.value = space.emoji
|
||||
isModalOpen.value = true
|
||||
}
|
||||
|
||||
async function saveSpace() {
|
||||
const name = spaceNameInput.value.trim()
|
||||
if (!name) return
|
||||
|
||||
if (modalMode.value === 'create') {
|
||||
await store.createSpace(name, selectedEmoji.value)
|
||||
} else if (editingSpaceId.value) {
|
||||
await store.updateSpace(editingSpaceId.value, {
|
||||
name,
|
||||
emoji: selectedEmoji.value,
|
||||
})
|
||||
}
|
||||
|
||||
isModalOpen.value = false
|
||||
}
|
||||
|
||||
async function confirmDeleteSpace() {
|
||||
if (!editingSpaceId.value) return
|
||||
const confirmed = window.confirm('确认删除此空间吗?该空间内的所有便笺与连线将被彻底移除。')
|
||||
if (confirmed) {
|
||||
await store.deleteSpace(editingSpaceId.value)
|
||||
isModalOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function exportData() {
|
||||
const backup = await store.exportBackup()
|
||||
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `无界笺备份-${new Date().toISOString().slice(0, 10)}.json`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
store.showToast('已导出备份文件')
|
||||
}
|
||||
|
||||
async function importData(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const backup = JSON.parse(await file.text()) as BackupFile
|
||||
await store.importBackup(backup)
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : '备份导入失败')
|
||||
} finally {
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar" :class="{ 'sidebar--closed': !store.sidebarOpen }">
|
||||
<div class="sidebar__header">
|
||||
<div class="brand-lockup">
|
||||
<BrandMark />
|
||||
<div>
|
||||
<strong>无界笺</strong>
|
||||
<span>BOUNDLESS</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="icon-button sidebar__close"
|
||||
aria-label="收起侧边栏"
|
||||
@click="store.sidebarOpen = false"
|
||||
>
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar__section-heading">
|
||||
<span>我的空间</span>
|
||||
<button class="icon-button" aria-label="创建空间" title="创建空间" @click="openCreateModal">
|
||||
<Plus :size="17" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="space-list" aria-label="空间列表">
|
||||
<div
|
||||
v-for="space in store.spaces"
|
||||
:key="space.id"
|
||||
class="space-item-wrapper"
|
||||
:class="{ 'space-item-wrapper--active': store.activeSpaceId === space.id }"
|
||||
>
|
||||
<button
|
||||
class="space-item"
|
||||
:class="{ 'space-item--active': store.activeSpaceId === space.id }"
|
||||
@click="store.openSpace(space.id)"
|
||||
>
|
||||
<span class="space-item__symbol">{{ space.emoji }}</span>
|
||||
<span class="space-item__name">{{ space.name }}</span>
|
||||
<span class="space-item__count">{{ store.cardCounts[space.id] ?? 0 }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="space-item__edit-btn"
|
||||
title="空间设置"
|
||||
aria-label="编辑空间设置"
|
||||
@click.stop="openEditModal(space)"
|
||||
>
|
||||
<Pencil :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar__tip">
|
||||
<span class="sidebar__tip-star">✦</span>
|
||||
<p>不用急着整理。<br />先把念头放在这里。</p>
|
||||
</div>
|
||||
|
||||
<div class="sidebar__footer">
|
||||
<div class="storage-status">
|
||||
<span></span>
|
||||
数据仅保存在此设备
|
||||
</div>
|
||||
<div class="sidebar__actions">
|
||||
<button class="text-button" @click="exportData">
|
||||
<Download :size="15" />
|
||||
导出备份
|
||||
</button>
|
||||
<button class="text-button" @click="fileInput?.click()">
|
||||
<FileUp :size="15" />
|
||||
导入
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
hidden
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
@change="importData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空间创建/编辑对话框 -->
|
||||
<div v-if="isModalOpen" class="modal-overlay" @click="isModalOpen = false">
|
||||
<div class="dialog-card" @click.stop>
|
||||
<header class="dialog-header">
|
||||
<strong>{{ modalMode === 'create' ? '创建新空间' : '空间设置' }}</strong>
|
||||
<button class="icon-button" aria-label="关闭" @click="isModalOpen = false">
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="dialog-body">
|
||||
<label class="dialog-field">
|
||||
<span>空间名称</span>
|
||||
<input
|
||||
v-model="spaceNameInput"
|
||||
class="dialog-input"
|
||||
maxlength="30"
|
||||
placeholder="例如:灵感收纳盒、工作面板…"
|
||||
autofocus
|
||||
@keydown.enter="saveSpace"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="dialog-field">
|
||||
<span>空间图标</span>
|
||||
<div class="emoji-grid">
|
||||
<button
|
||||
v-for="emoji in availableEmojis"
|
||||
:key="emoji"
|
||||
class="emoji-choice-btn"
|
||||
:class="{ active: selectedEmoji === emoji }"
|
||||
type="button"
|
||||
@click="selectedEmoji = emoji"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="dialog-footer">
|
||||
<button
|
||||
v-if="modalMode === 'edit'"
|
||||
class="dialog-btn dialog-btn--danger"
|
||||
:disabled="store.spaces.length <= 1"
|
||||
title="删除空间"
|
||||
@click="confirmDeleteSpace"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
删除空间
|
||||
</button>
|
||||
<div class="dialog-footer-right">
|
||||
<button class="dialog-btn dialog-btn--secondary" @click="isModalOpen = false">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="dialog-btn dialog-btn--primary"
|
||||
:disabled="!spaceNameInput.trim()"
|
||||
@click="saveSpace"
|
||||
>
|
||||
{{ modalMode === 'create' ? '立即创建' : '保存更改' }}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<span class="brand-mark" aria-hidden="true">
|
||||
<i></i>
|
||||
<i></i>
|
||||
<i></i>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,234 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpToLine,
|
||||
ClipboardCopy,
|
||||
Copy,
|
||||
GripHorizontal,
|
||||
ListTodo,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
ZoomIn,
|
||||
} from '@lucide/vue'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { NodeResizer, type OnResizeEnd } from '@vue-flow/node-resizer'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/stores/canvas'
|
||||
import type { CardColor, CardNodeData } from '@/types/canvas'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
data: CardNodeData
|
||||
selected?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'preview-image', data: { src: string; title: string }): void
|
||||
}>()
|
||||
|
||||
const store = useCanvasStore()
|
||||
const menuOpen = ref(false)
|
||||
const menuRef = useTemplateRef<HTMLElement>('menuRef')
|
||||
const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
||||
|
||||
onClickOutside(menuRef, () => {
|
||||
menuOpen.value = false
|
||||
})
|
||||
|
||||
const searchMatch = computed(() => {
|
||||
const query = store.searchQuery.trim().toLocaleLowerCase()
|
||||
if (!query) return true
|
||||
return `${props.data.title} ${props.data.content}`.toLocaleLowerCase().includes(query)
|
||||
})
|
||||
|
||||
function patch(patchData: Partial<CardNodeData>) {
|
||||
store.updateNodeData(props.id, patchData)
|
||||
}
|
||||
|
||||
function resize(event: OnResizeEnd) {
|
||||
store.resizeNode(props.id, event.params.width, event.params.height)
|
||||
}
|
||||
|
||||
function duplicate() {
|
||||
store.duplicateNode(props.id)
|
||||
menuOpen.value = false
|
||||
}
|
||||
|
||||
function bringToFront() {
|
||||
store.bringNodeToFront(props.id)
|
||||
menuOpen.value = false
|
||||
}
|
||||
|
||||
function sendToBack() {
|
||||
store.sendNodeToBack(props.id)
|
||||
menuOpen.value = false
|
||||
}
|
||||
|
||||
async function copyText() {
|
||||
const fullText = props.data.title
|
||||
? (props.data.title + '\n\n' + (props.data.content || '')).trim()
|
||||
: props.data.content || ''
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullText)
|
||||
store.showToast('已复制内容到剪贴板')
|
||||
} catch {
|
||||
store.showToast('复制失败,请手动复制')
|
||||
}
|
||||
menuOpen.value = false
|
||||
}
|
||||
|
||||
function insertTodo() {
|
||||
const prefix = props.data.content ? props.data.content + '\n□ ' : '□ '
|
||||
patch({ content: prefix })
|
||||
store.showToast('已插入待办项')
|
||||
menuOpen.value = false
|
||||
}
|
||||
|
||||
function openImagePreview() {
|
||||
if (props.data.kind === 'image' && props.data.imageData) {
|
||||
emit('preview-image', {
|
||||
src: props.data.imageData,
|
||||
title: props.data.title || '图片预览',
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NodeResizer
|
||||
:min-width="240"
|
||||
:min-height="160"
|
||||
:is-visible="selected"
|
||||
color="#1c1e1f"
|
||||
@resize-end="resize"
|
||||
/>
|
||||
|
||||
<article
|
||||
class="note-card"
|
||||
:class="{
|
||||
'note-card--selected': selected,
|
||||
'note-card--muted': !searchMatch,
|
||||
'note-card--image': data.kind === 'image',
|
||||
}"
|
||||
:data-color="data.color"
|
||||
>
|
||||
<header class="note-card__header drag-handle">
|
||||
<GripHorizontal :size="16" aria-hidden="true" />
|
||||
<span>{{ data.kind === 'image' ? '图像' : '便笺' }}</span>
|
||||
<div ref="menuRef" class="note-card__menu-wrap nodrag">
|
||||
<button
|
||||
class="card-icon-button"
|
||||
:class="{ active: menuOpen }"
|
||||
aria-label="卡片操作菜单"
|
||||
title="卡片选项"
|
||||
@click.stop="menuOpen = !menuOpen"
|
||||
>
|
||||
<MoreHorizontal :size="17" />
|
||||
</button>
|
||||
<div v-if="menuOpen" class="card-menu">
|
||||
<button @click="duplicate">
|
||||
<Copy :size="14" />
|
||||
复制一份
|
||||
</button>
|
||||
<button @click="copyText">
|
||||
<ClipboardCopy :size="14" />
|
||||
复制内容
|
||||
</button>
|
||||
<button @click="insertTodo">
|
||||
<ListTodo :size="14" />
|
||||
插入待办框
|
||||
</button>
|
||||
<button @click="bringToFront">
|
||||
<ArrowUpToLine :size="14" />
|
||||
置于顶层
|
||||
</button>
|
||||
<button @click="sendToBack">
|
||||
<ArrowDownToLine :size="14" />
|
||||
置于底层
|
||||
</button>
|
||||
<hr class="card-menu__divider" />
|
||||
<button class="card-menu__danger" @click="store.removeNodes([id])">
|
||||
<Trash2 :size="14" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="data.kind === 'image' && data.imageData"
|
||||
class="note-card__image-wrap nodrag"
|
||||
title="点击查看大图"
|
||||
@click="openImagePreview"
|
||||
>
|
||||
<img :src="data.imageData" :alt="data.title || '记录的图片'" draggable="false" />
|
||||
<div class="note-card__image-overlay">
|
||||
<ZoomIn :size="18" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note-card__body nodrag">
|
||||
<input
|
||||
class="note-card__title"
|
||||
:value="data.title"
|
||||
aria-label="卡片标题"
|
||||
placeholder="未命名想法"
|
||||
maxlength="80"
|
||||
@input="patch({ title: ($event.target as HTMLInputElement).value })"
|
||||
@keydown.stop
|
||||
@keydown.esc="($event.target as HTMLInputElement).blur()"
|
||||
/>
|
||||
<textarea
|
||||
class="note-card__content"
|
||||
:value="data.content"
|
||||
:placeholder="data.kind === 'image' ? '写一句说明…' : '写点什么,或插入待办清单…'"
|
||||
aria-label="卡片内容"
|
||||
spellcheck="false"
|
||||
@input="patch({ content: ($event.target as HTMLTextAreaElement).value })"
|
||||
@keydown.stop
|
||||
@keydown.esc="($event.target as HTMLTextAreaElement).blur()"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<footer class="note-card__footer nodrag">
|
||||
<div class="color-picker" aria-label="卡片颜色">
|
||||
<button
|
||||
v-for="color in colors"
|
||||
:key="color"
|
||||
:class="{ active: data.color === color }"
|
||||
:data-color="color"
|
||||
:title="`设置为 ${color} 色`"
|
||||
:aria-label="`设置为 ${color} 色`"
|
||||
@click="patch({ color })"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<div class="note-card__footer-actions">
|
||||
<button
|
||||
v-if="data.kind !== 'image'"
|
||||
class="card-mini-btn"
|
||||
title="快速添加待办清单"
|
||||
@click="insertTodo"
|
||||
>
|
||||
<ListTodo :size="13" />
|
||||
待办
|
||||
</button>
|
||||
<span>{{
|
||||
new Date(data.updatedAt).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
||||
}}</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<Handle id="in" type="target" :position="Position.Left" class="card-handle card-handle--left" />
|
||||
<Handle
|
||||
id="out"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="card-handle card-handle--right"
|
||||
/>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,728 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AlignCenterHorizontal,
|
||||
AlignCenterVertical,
|
||||
AlignEndHorizontal,
|
||||
AlignEndVertical,
|
||||
AlignHorizontalDistributeCenter,
|
||||
AlignStartHorizontal,
|
||||
AlignStartVertical,
|
||||
AlignVerticalDistributeCenter,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CircleHelp,
|
||||
Copy,
|
||||
Grid2x2,
|
||||
ImagePlus,
|
||||
Map,
|
||||
Maximize2,
|
||||
Menu,
|
||||
MousePointer,
|
||||
Plus,
|
||||
Redo2,
|
||||
Search,
|
||||
SquareDashed,
|
||||
StickyNote,
|
||||
Trash2,
|
||||
Undo2,
|
||||
X,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from '@lucide/vue'
|
||||
import { Background } from '@vue-flow/background'
|
||||
import {
|
||||
ConnectionMode,
|
||||
MarkerType,
|
||||
Panel,
|
||||
VueFlow,
|
||||
useVueFlow,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type Node,
|
||||
} from '@vue-flow/core'
|
||||
import { MiniMap } from '@vue-flow/minimap'
|
||||
import { useDebounceFn, useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import CardNode from '@/components/CardNode.vue'
|
||||
import { useCanvasStore } from '@/stores/canvas'
|
||||
import type { CanvasEdge, CardColor } from '@/types/canvas'
|
||||
|
||||
const store = useCanvasStore()
|
||||
const { nodes, edges } = storeToRefs(store)
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const canvasElement = ref<HTMLElement>()
|
||||
const showMiniMap = ref(true)
|
||||
const showShortcuts = ref(false)
|
||||
const previewImage = ref<{ src: string; title: string } | null>(null)
|
||||
const isSpacePressed = ref(false)
|
||||
|
||||
const { screenToFlowCoordinate, fitView, zoomIn, zoomOut, zoomTo, viewport } =
|
||||
useVueFlow('boundless-canvas')
|
||||
|
||||
const currentZoom = computed(() => Math.round((viewport.value?.zoom ?? 1) * 100))
|
||||
|
||||
const saveSoon = useDebounceFn(() => store.persistSnapshot(), 500)
|
||||
watch([nodes, edges], saveSoon, { deep: true })
|
||||
|
||||
function canvasCenter() {
|
||||
const rect = canvasElement.value?.getBoundingClientRect()
|
||||
return screenToFlowCoordinate({
|
||||
x: rect ? rect.left + rect.width / 2 : window.innerWidth / 2,
|
||||
y: rect ? rect.top + rect.height / 2 : window.innerHeight / 2,
|
||||
})
|
||||
}
|
||||
|
||||
function addNoteAtCenter() {
|
||||
const point = canvasCenter()
|
||||
store.addCard('note', { x: point.x - 150, y: point.y - 110 })
|
||||
}
|
||||
|
||||
function addNoteAtEvent(event: MouseEvent) {
|
||||
if (event.detail !== 2) return
|
||||
const point = screenToFlowCoordinate({ x: event.clientX, y: event.clientY })
|
||||
store.addCard('note', { x: point.x - 150, y: point.y - 80 })
|
||||
}
|
||||
|
||||
function readImage(file: File, position = canvasCenter()) {
|
||||
if (!file.type.startsWith('image/')) return
|
||||
if (file.size > 12 * 1024 * 1024) {
|
||||
window.alert('图片请不要超过 12MB')
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
store.addCard(
|
||||
'image',
|
||||
{ x: position.x - 180, y: position.y - 150 },
|
||||
{
|
||||
imageData: String(reader.result),
|
||||
title: file.name.replace(/\.[^.]+$/, ''),
|
||||
color: 'paper',
|
||||
},
|
||||
)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
function selectImage(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) readImage(file)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function dropImage(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
const file = [...(event.dataTransfer?.files ?? [])].find((item) => item.type.startsWith('image/'))
|
||||
if (!file) return
|
||||
const position = screenToFlowCoordinate({ x: event.clientX, y: event.clientY })
|
||||
readImage(file, position)
|
||||
}
|
||||
|
||||
function clipboardText(event: ClipboardEvent) {
|
||||
const plainText = event.clipboardData?.getData('text/plain') ?? ''
|
||||
if (plainText.trim()) return plainText
|
||||
|
||||
const html = event.clipboardData?.getData('text/html') ?? ''
|
||||
if (!html) return ''
|
||||
return new DOMParser().parseFromString(html, 'text/html').body.textContent ?? ''
|
||||
}
|
||||
|
||||
function pasteContent(event: ClipboardEvent) {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target?.matches('input, textarea, [contenteditable="true"]')) return
|
||||
|
||||
const files = [...(event.clipboardData?.files ?? [])]
|
||||
const itemFiles = [...(event.clipboardData?.items ?? [])]
|
||||
.filter((item) => item.kind === 'file')
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null)
|
||||
const image = [...files, ...itemFiles].find((file) => file.type.startsWith('image/'))
|
||||
|
||||
if (image) {
|
||||
event.preventDefault()
|
||||
readImage(image)
|
||||
return
|
||||
}
|
||||
|
||||
const text = clipboardText(event).replace(/\r\n/g, '\n').trim()
|
||||
if (!text) return
|
||||
|
||||
event.preventDefault()
|
||||
const firstLine =
|
||||
text
|
||||
.split('\n')
|
||||
.find((line) => line.trim())
|
||||
?.trim() ?? ''
|
||||
const isShortSingleLine = !text.includes('\n') && text.length <= 48
|
||||
const title = isShortSingleLine ? '粘贴的内容' : firstLine.slice(0, 48) || '粘贴的内容'
|
||||
const point = canvasCenter()
|
||||
const node = store.addCard(
|
||||
'note',
|
||||
{ x: point.x - 150, y: point.y - 110 },
|
||||
{ title, color: 'paper' },
|
||||
)
|
||||
store.updateNodeData(node.id, { content: text })
|
||||
}
|
||||
|
||||
useEventListener(window, 'paste', pasteContent)
|
||||
|
||||
// 全局快捷键
|
||||
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null
|
||||
const isEditingText = target?.matches('input, textarea, [contenteditable="true"]')
|
||||
|
||||
if (event.code === 'Space' && !isEditingText && !isSpacePressed.value) {
|
||||
isSpacePressed.value = true
|
||||
}
|
||||
|
||||
if (isEditingText) return
|
||||
|
||||
// 撤销/重做
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) {
|
||||
store.redo()
|
||||
} else {
|
||||
store.undo()
|
||||
}
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'y') {
|
||||
event.preventDefault()
|
||||
store.redo()
|
||||
}
|
||||
|
||||
// 全选
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
|
||||
event.preventDefault()
|
||||
const count = store.selectAllNodes()
|
||||
store.showToast(`已选中全部 ${count} 张便笺`)
|
||||
}
|
||||
|
||||
// 复制选中项
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'd') {
|
||||
event.preventDefault()
|
||||
store.duplicateSelectedNodes()
|
||||
}
|
||||
|
||||
// 取消所有选择
|
||||
if (event.key === 'Escape') {
|
||||
store.deselectAll()
|
||||
previewImage.value = null
|
||||
showShortcuts.value = false
|
||||
}
|
||||
|
||||
// 快捷缩放重置
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === '0') {
|
||||
event.preventDefault()
|
||||
resetZoom()
|
||||
}
|
||||
})
|
||||
|
||||
useEventListener(window, 'keyup', (event: KeyboardEvent) => {
|
||||
if (event.code === 'Space') {
|
||||
isSpacePressed.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function connect(connection: Connection) {
|
||||
store.recordHistory()
|
||||
const edge: CanvasEdge = {
|
||||
...connection,
|
||||
id: crypto.randomUUID(),
|
||||
type: 'smoothstep',
|
||||
markerEnd: MarkerType.ArrowClosed,
|
||||
data: { spaceId: store.activeSpaceId },
|
||||
}
|
||||
edges.value = [...edges.value, edge]
|
||||
}
|
||||
|
||||
function deleteNodes(deleted: Node[]) {
|
||||
void store.removeNodes(deleted.map((node) => node.id))
|
||||
}
|
||||
|
||||
function deleteEdges(deleted: Edge[]) {
|
||||
void store.removeEdges(deleted.map((edge) => edge.id))
|
||||
}
|
||||
|
||||
// 搜索轮巡与定位
|
||||
const matchingNodes = computed(() => {
|
||||
const query = store.searchQuery.trim().toLocaleLowerCase()
|
||||
if (!query) return []
|
||||
return nodes.value.filter((node) =>
|
||||
`${node.data.title} ${node.data.content}`.toLocaleLowerCase().includes(query),
|
||||
)
|
||||
})
|
||||
|
||||
const activeMatchIndex = ref(0)
|
||||
watch(
|
||||
() => store.searchQuery,
|
||||
() => {
|
||||
activeMatchIndex.value = 0
|
||||
},
|
||||
)
|
||||
|
||||
async function focusMatch(index: number) {
|
||||
if (matchingNodes.value.length === 0) return
|
||||
const total = matchingNodes.value.length
|
||||
activeMatchIndex.value = (index + total) % total
|
||||
const match = matchingNodes.value[activeMatchIndex.value]
|
||||
|
||||
nodes.value.forEach((node) => (node.selected = node.id === match.id))
|
||||
await nextTick()
|
||||
void fitView({ nodes: [match.id], duration: 350, padding: 0.75 })
|
||||
}
|
||||
|
||||
function nextSearchResult() {
|
||||
void focusMatch(activeMatchIndex.value + 1)
|
||||
}
|
||||
|
||||
function prevSearchResult() {
|
||||
void focusMatch(activeMatchIndex.value - 1)
|
||||
}
|
||||
|
||||
// 缩放控制
|
||||
function resetZoom() {
|
||||
void zoomTo(1, { duration: 300 })
|
||||
store.showToast('已恢复 100% 视图')
|
||||
}
|
||||
|
||||
function fitAll() {
|
||||
void fitView({ padding: 0.25, duration: 350 })
|
||||
store.showToast('已自适应全图')
|
||||
}
|
||||
|
||||
function cycleGrid() {
|
||||
const nextStyle =
|
||||
store.gridStyle === 'dots' ? 'lines' : store.gridStyle === 'lines' ? 'none' : 'dots'
|
||||
store.gridStyle = nextStyle
|
||||
const labels: Record<string, string> = { dots: '点阵背景', lines: '方格背景', none: '纯净空白' }
|
||||
store.showToast(`已切换为${labels[nextStyle]}`)
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
store.interactionMode = store.interactionMode === 'pointer' ? 'marquee' : 'pointer'
|
||||
store.showToast(store.interactionMode === 'marquee' ? '框选模式:拖拽框选多张卡片' : '指针模式')
|
||||
}
|
||||
|
||||
const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
ref="canvasElement"
|
||||
class="canvas-shell"
|
||||
:class="{
|
||||
'is-space-panning': isSpacePressed,
|
||||
'is-marquee-mode': store.interactionMode === 'marquee',
|
||||
}"
|
||||
@drop="dropImage"
|
||||
@dragover.prevent
|
||||
>
|
||||
<VueFlow
|
||||
id="boundless-canvas"
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
class="boundless-flow"
|
||||
:min-zoom="0.12"
|
||||
:max-zoom="3"
|
||||
:default-viewport="{ x: 0, y: 0, zoom: 1 }"
|
||||
:connection-mode="ConnectionMode.Loose"
|
||||
:delete-key-code="['Backspace', 'Delete']"
|
||||
:multi-selection-key-code="['Meta', 'Control']"
|
||||
:selection-key-code="['Shift']"
|
||||
:pan-on-drag="store.interactionMode === 'marquee' ? false : true"
|
||||
:zoom-on-double-click="false"
|
||||
fit-view-on-init
|
||||
elevate-nodes-on-select
|
||||
@pane-click="addNoteAtEvent"
|
||||
@connect="connect"
|
||||
@nodes-delete="deleteNodes"
|
||||
@edges-delete="deleteEdges"
|
||||
@node-drag-start="store.recordHistory()"
|
||||
@node-drag-stop="store.persistSnapshot()"
|
||||
>
|
||||
<Background
|
||||
v-if="store.gridStyle !== 'none'"
|
||||
:variant="store.gridStyle === 'lines' ? 'lines' : 'dots'"
|
||||
:pattern-color="store.gridStyle === 'lines' ? '#dcdad2' : '#c9c8c2'"
|
||||
:gap="store.gridStyle === 'lines' ? 28 : 24"
|
||||
:size="1"
|
||||
/>
|
||||
|
||||
<template #node-card="nodeProps">
|
||||
<CardNode v-bind="nodeProps" @preview-image="previewImage = $event" />
|
||||
</template>
|
||||
|
||||
<!-- 移动端侧边栏切换按钮 -->
|
||||
<Panel position="top-left" class="mobile-menu-panel">
|
||||
<button class="floating-icon" aria-label="打开空间列表" @click="store.sidebarOpen = true">
|
||||
<Menu :size="19" />
|
||||
</button>
|
||||
</Panel>
|
||||
|
||||
<!-- 顶栏主要操作工具条 -->
|
||||
<Panel position="top-center" class="canvas-toolbar">
|
||||
<button class="tool-button tool-button--primary" @click="addNoteAtCenter">
|
||||
<Plus :size="18" />
|
||||
新便笺
|
||||
</button>
|
||||
<span class="toolbar-divider"></span>
|
||||
<button class="tool-button" title="添加便笺" aria-label="添加便笺" @click="addNoteAtCenter">
|
||||
<StickyNote :size="17" />
|
||||
</button>
|
||||
<button
|
||||
class="tool-button"
|
||||
title="添加图片"
|
||||
aria-label="添加图片"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
<ImagePlus :size="17" />
|
||||
</button>
|
||||
<input ref="fileInput" hidden type="file" accept="image/*" @change="selectImage" />
|
||||
|
||||
<span class="toolbar-divider"></span>
|
||||
|
||||
<!-- 撤销 / 重做 -->
|
||||
<button
|
||||
class="tool-button"
|
||||
:class="{ 'tool-button--disabled': !store.canUndo }"
|
||||
:disabled="!store.canUndo"
|
||||
title="撤销 (Ctrl+Z)"
|
||||
aria-label="撤销"
|
||||
@click="store.undo()"
|
||||
>
|
||||
<Undo2 :size="17" />
|
||||
</button>
|
||||
<button
|
||||
class="tool-button"
|
||||
:class="{ 'tool-button--disabled': !store.canRedo }"
|
||||
:disabled="!store.canRedo"
|
||||
title="重做 (Ctrl+Y)"
|
||||
aria-label="重做"
|
||||
@click="store.redo()"
|
||||
>
|
||||
<Redo2 :size="17" />
|
||||
</button>
|
||||
|
||||
<span class="toolbar-divider"></span>
|
||||
|
||||
<!-- 交互模式切换(指针平移 vs 框选多选) -->
|
||||
<button
|
||||
class="tool-button"
|
||||
:class="{ 'tool-button--active': store.interactionMode === 'marquee' }"
|
||||
:title="store.interactionMode === 'marquee' ? '框选模式启用中' : '切换框选模式 (Shift+拖拽)'"
|
||||
aria-label="框选多选"
|
||||
@click="toggleMode"
|
||||
>
|
||||
<SquareDashed v-if="store.interactionMode === 'marquee'" :size="17" />
|
||||
<MousePointer v-else :size="17" />
|
||||
</button>
|
||||
</Panel>
|
||||
|
||||
<!-- 多选卡片时的浮动对齐与批处理工具栏 -->
|
||||
<div v-if="store.selectedCount >= 2" class="multi-select-bar">
|
||||
<span class="multi-select-badge">已选 {{ store.selectedCount }} 项</span>
|
||||
<span class="toolbar-divider"></span>
|
||||
|
||||
<!-- 对齐按钮 -->
|
||||
<button
|
||||
class="tool-button"
|
||||
title="左对齐"
|
||||
aria-label="左对齐"
|
||||
@click="store.alignSelectedNodes('left')"
|
||||
>
|
||||
<AlignStartHorizontal :size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="tool-button"
|
||||
title="水平居中对齐"
|
||||
aria-label="水平居中对齐"
|
||||
@click="store.alignSelectedNodes('center')"
|
||||
>
|
||||
<AlignCenterHorizontal :size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="tool-button"
|
||||
title="右对齐"
|
||||
aria-label="右对齐"
|
||||
@click="store.alignSelectedNodes('right')"
|
||||
>
|
||||
<AlignEndHorizontal :size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="tool-button"
|
||||
title="顶端对齐"
|
||||
aria-label="顶端对齐"
|
||||
@click="store.alignSelectedNodes('top')"
|
||||
>
|
||||
<AlignStartVertical :size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="tool-button"
|
||||
title="垂直居中对齐"
|
||||
aria-label="垂直居中对齐"
|
||||
@click="store.alignSelectedNodes('middle')"
|
||||
>
|
||||
<AlignCenterVertical :size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="tool-button"
|
||||
title="底端对齐"
|
||||
aria-label="底端对齐"
|
||||
@click="store.alignSelectedNodes('bottom')"
|
||||
>
|
||||
<AlignEndVertical :size="16" />
|
||||
</button>
|
||||
|
||||
<span class="toolbar-divider"></span>
|
||||
|
||||
<!-- 均分间距(3 项及以上) -->
|
||||
<template v-if="store.selectedCount >= 3">
|
||||
<button
|
||||
class="tool-button"
|
||||
title="水平等间距分布"
|
||||
aria-label="水平等间距分布"
|
||||
@click="store.distributeSelectedNodes('horizontal')"
|
||||
>
|
||||
<AlignHorizontalDistributeCenter :size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="tool-button"
|
||||
title="垂直等间距分布"
|
||||
aria-label="垂直等间距分布"
|
||||
@click="store.distributeSelectedNodes('vertical')"
|
||||
>
|
||||
<AlignVerticalDistributeCenter :size="16" />
|
||||
</button>
|
||||
<span class="toolbar-divider"></span>
|
||||
</template>
|
||||
|
||||
<!-- 统一换色 -->
|
||||
<div class="batch-color-picker" aria-label="批量更改颜色">
|
||||
<button
|
||||
v-for="color in colors"
|
||||
:key="color"
|
||||
:data-color="color"
|
||||
:title="`统一设为 ${color} 色`"
|
||||
@click="store.setBatchColor(color)"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<span class="toolbar-divider"></span>
|
||||
|
||||
<!-- 批量复制 -->
|
||||
<button
|
||||
class="tool-button"
|
||||
title="批量复制 (Ctrl+D)"
|
||||
aria-label="批量复制"
|
||||
@click="store.duplicateSelectedNodes()"
|
||||
>
|
||||
<Copy :size="15" />
|
||||
</button>
|
||||
|
||||
<!-- 批量删除 -->
|
||||
<button
|
||||
class="tool-button tool-button--danger"
|
||||
title="批量删除 (Delete)"
|
||||
aria-label="批量删除"
|
||||
@click="store.deleteSelectedNodes()"
|
||||
>
|
||||
<Trash2 :size="15" />
|
||||
</button>
|
||||
|
||||
<!-- 取消选择 -->
|
||||
<button class="tool-button" title="取消全选 (Esc)" aria-label="取消全选" @click="store.deselectAll()">
|
||||
<X :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 右上角搜索与保存状态 -->
|
||||
<Panel position="top-right" class="canvas-top-right">
|
||||
<label class="search-box">
|
||||
<Search :size="16" />
|
||||
<input
|
||||
v-model="store.searchQuery"
|
||||
placeholder="搜索这片空间"
|
||||
aria-label="搜索当前空间"
|
||||
@keydown.enter="nextSearchResult"
|
||||
/>
|
||||
<span v-if="store.searchQuery && matchingNodes.length > 0" class="search-count">
|
||||
{{ activeMatchIndex + 1 }}/{{ matchingNodes.length }}
|
||||
</span>
|
||||
<div v-if="store.searchQuery && matchingNodes.length > 1" class="search-nav">
|
||||
<button title="上一个结果" aria-label="上一个结果" @click="prevSearchResult">
|
||||
<ChevronUp :size="13" />
|
||||
</button>
|
||||
<button title="下一个结果" aria-label="下一个结果" @click="nextSearchResult">
|
||||
<ChevronDown :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="store.searchQuery"
|
||||
title="清空搜索"
|
||||
aria-label="清空搜索"
|
||||
@click="store.searchQuery = ''"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</label>
|
||||
<span class="save-pill" :class="{ 'save-pill--saving': store.saveState === 'saving' }">
|
||||
<i></i>{{ store.saveState === 'saving' ? '正在保存' : '已保存' }}
|
||||
</span>
|
||||
</Panel>
|
||||
|
||||
<!-- 左下角操作提示与快捷键入口 -->
|
||||
<Panel position="bottom-left" class="canvas-hint-wrap">
|
||||
<div class="canvas-hint" @click="showShortcuts = true">
|
||||
双击添加便笺 · 选中物体改变指针 · Shift框选
|
||||
<button class="hint-help-btn" title="查看键盘快捷键" aria-label="查看快捷键帮助">
|
||||
<CircleHelp :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- 底部空间名称标签 -->
|
||||
<Panel position="bottom-center" class="space-title">
|
||||
<span>{{ store.activeSpace?.emoji }}</span>
|
||||
{{ store.activeSpace?.name }}
|
||||
</Panel>
|
||||
|
||||
<!-- 右下角视口控制器(缩放与背景切换) -->
|
||||
<Panel position="bottom-right" class="canvas-viewport-controls">
|
||||
<div class="viewport-dock">
|
||||
<button class="dock-btn" title="缩小画布" aria-label="缩小画布" @click="zoomOut()">
|
||||
<ZoomOut :size="15" />
|
||||
</button>
|
||||
<button
|
||||
class="dock-btn dock-btn--zoom"
|
||||
title="点击重置缩放为 100%"
|
||||
aria-label="重置缩放为 100%"
|
||||
@click="resetZoom"
|
||||
>
|
||||
{{ currentZoom }}%
|
||||
</button>
|
||||
<button class="dock-btn" title="放大画布" aria-label="放大画布" @click="zoomIn()">
|
||||
<ZoomIn :size="15" />
|
||||
</button>
|
||||
<span class="dock-divider"></span>
|
||||
<button class="dock-btn" title="自适应居中全览" aria-label="自适应居中全览" @click="fitAll">
|
||||
<Maximize2 :size="15" />
|
||||
</button>
|
||||
<button
|
||||
class="dock-btn"
|
||||
:class="{ active: showMiniMap }"
|
||||
title="小地图显隐"
|
||||
aria-label="小地图显隐"
|
||||
@click="showMiniMap = !showMiniMap"
|
||||
>
|
||||
<Map :size="15" />
|
||||
</button>
|
||||
<button class="dock-btn" title="切换背景网格" aria-label="切换背景网格" @click="cycleGrid">
|
||||
<Grid2x2 :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- 小地图 -->
|
||||
<MiniMap
|
||||
v-if="showMiniMap"
|
||||
class="boundless-minimap"
|
||||
node-color="#dad7cd"
|
||||
node-stroke-color="#8a877f"
|
||||
mask-color="rgba(244, 243, 238, 0.76)"
|
||||
pannable
|
||||
zoomable
|
||||
/>
|
||||
</VueFlow>
|
||||
|
||||
<!-- 操作提示轻提示 (Toast) -->
|
||||
<Transition name="fade">
|
||||
<div v-if="store.toastMessage" class="toast-pill">
|
||||
{{ store.toastMessage }}
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 图片大图预览浮层 (Lightbox) -->
|
||||
<div v-if="previewImage" class="lightbox-overlay" @click="previewImage = null">
|
||||
<div class="lightbox-card" @click.stop>
|
||||
<header class="lightbox-header">
|
||||
<strong>{{ previewImage.title }}</strong>
|
||||
<button class="icon-button" aria-label="关闭预览" @click="previewImage = null">
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="lightbox-body">
|
||||
<img :src="previewImage.src" :alt="previewImage.title" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷键指南对话框 -->
|
||||
<div v-if="showShortcuts" class="modal-overlay" @click="showShortcuts = false">
|
||||
<div class="shortcuts-card" @click.stop>
|
||||
<header class="shortcuts-header">
|
||||
<div class="shortcuts-title-wrap">
|
||||
<CircleHelp :size="20" />
|
||||
<strong>键盘快捷键指南</strong>
|
||||
</div>
|
||||
<button class="icon-button" aria-label="关闭指南" @click="showShortcuts = false">
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="shortcuts-grid">
|
||||
<div class="shortcut-item">
|
||||
<kbd>双击空白处</kbd>
|
||||
<span>快速添加便笺</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>拖拽 / 粘贴</kbd>
|
||||
<span>拖入或从剪贴板粘贴图片与便笺</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>点击卡片</kbd>
|
||||
<span>选中卡片(此时出现移动抓手与缩放把手)</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Shift + 拖拽</kbd>
|
||||
<span>批量框选卡片</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl / ⌘ + A</kbd>
|
||||
<span>全选当前空间的所有卡片</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl / ⌘ + D</kbd>
|
||||
<span>快速复制选中的卡片副本</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl / ⌘ + Z</kbd>
|
||||
<span>撤销上一步操作</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl / ⌘ + Y</kbd>
|
||||
<span>重做上一步操作</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Delete / Backspace</kbd>
|
||||
<span>删除选中的卡片或连线</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>空格 + 拖拽</kbd>
|
||||
<span>临时平移画布视角</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl / ⌘ + 0</kbd>
|
||||
<span>恢复 100% 缩放</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Esc</kbd>
|
||||
<span>取消全选 / 退出当前浮层</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
import Dexie, { type EntityTable } from 'dexie'
|
||||
|
||||
import type { CardRecord, EdgeRecord, SpaceRecord } from '@/types/canvas'
|
||||
|
||||
class BoundlessDatabase extends Dexie {
|
||||
spaces!: EntityTable<SpaceRecord, 'id'>
|
||||
cards!: EntityTable<CardRecord, 'id'>
|
||||
edges!: EntityTable<EdgeRecord, 'id'>
|
||||
|
||||
constructor() {
|
||||
super('boundless-notes')
|
||||
|
||||
this.version(1).stores({
|
||||
spaces: 'id, updatedAt',
|
||||
cards: 'id, spaceId, updatedAt',
|
||||
edges: 'id, spaceId, source, target',
|
||||
})
|
||||
|
||||
this.version(2).stores({
|
||||
spaces: 'id, createdAt, updatedAt',
|
||||
cards: 'id, spaceId, updatedAt',
|
||||
edges: 'id, spaceId, source, target',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new BoundlessDatabase()
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
import '@vue-flow/core/dist/style.css'
|
||||
import '@vue-flow/core/dist/theme-default.css'
|
||||
import '@vue-flow/minimap/dist/style.css'
|
||||
import '@vue-flow/node-resizer/dist/style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import WorkspaceView from '@/views/WorkspaceView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'workspace',
|
||||
component: WorkspaceView,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,654 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { db } from '@/db'
|
||||
import type {
|
||||
BackupFile,
|
||||
CanvasEdge,
|
||||
CanvasNode,
|
||||
CardColor,
|
||||
CardKind,
|
||||
CardRecord,
|
||||
EdgeRecord,
|
||||
SpaceRecord,
|
||||
} from '@/types/canvas'
|
||||
|
||||
const ACTIVE_SPACE_KEY = 'boundless-active-space'
|
||||
const DEFAULT_NODE_WIDTH = 300
|
||||
const DEFAULT_NODE_HEIGHT = 220
|
||||
|
||||
interface HistorySnapshot {
|
||||
nodes: CanvasNode[]
|
||||
edges: CanvasEdge[]
|
||||
}
|
||||
|
||||
function nodeToRecord(node: CanvasNode): CardRecord {
|
||||
const style = (node.style ?? {}) as Record<string, string | number>
|
||||
const dimensions = node.dimensions ?? { width: 0, height: 0 }
|
||||
const width = Number.parseFloat(String(style.width ?? dimensions.width ?? DEFAULT_NODE_WIDTH))
|
||||
const height = Number.parseFloat(String(style.height ?? dimensions.height ?? DEFAULT_NODE_HEIGHT))
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
...node.data,
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
width: Number.isFinite(width) && width > 0 ? width : DEFAULT_NODE_WIDTH,
|
||||
height: Number.isFinite(height) && height > 0 ? height : DEFAULT_NODE_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
function recordToNode(card: CardRecord): CanvasNode {
|
||||
return {
|
||||
id: card.id,
|
||||
type: 'card',
|
||||
position: { x: card.x, y: card.y },
|
||||
data: {
|
||||
spaceId: card.spaceId,
|
||||
kind: card.kind,
|
||||
title: card.title,
|
||||
content: card.content,
|
||||
imageData: card.imageData,
|
||||
color: card.color,
|
||||
createdAt: card.createdAt,
|
||||
updatedAt: card.updatedAt,
|
||||
},
|
||||
style: {
|
||||
width: `${card.width}px`,
|
||||
height: `${card.height}px`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function edgeToRecord(edge: CanvasEdge, fallbackSpaceId: string): EdgeRecord {
|
||||
return {
|
||||
id: edge.id,
|
||||
spaceId: edge.data?.spaceId ?? fallbackSpaceId,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
}
|
||||
}
|
||||
|
||||
function recordToEdge(edge: EdgeRecord): CanvasEdge {
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
type: 'smoothstep',
|
||||
data: { spaceId: edge.spaceId },
|
||||
}
|
||||
}
|
||||
|
||||
function cloneCanvasState(nodes: CanvasNode[], edges: CanvasEdge[]): HistorySnapshot {
|
||||
return {
|
||||
nodes: JSON.parse(JSON.stringify(nodes)),
|
||||
edges: JSON.parse(JSON.stringify(edges)),
|
||||
}
|
||||
}
|
||||
|
||||
export const useCanvasStore = defineStore('canvas', {
|
||||
state: () => ({
|
||||
spaces: [] as SpaceRecord[],
|
||||
nodes: [] as CanvasNode[],
|
||||
edges: [] as CanvasEdge[],
|
||||
cardCounts: {} as Record<string, number>,
|
||||
activeSpaceId: '',
|
||||
ready: false,
|
||||
saveState: 'saved' as 'saved' | 'saving',
|
||||
searchQuery: '',
|
||||
sidebarOpen: true,
|
||||
|
||||
// 交互与状态增强
|
||||
historyPast: [] as HistorySnapshot[],
|
||||
historyFuture: [] as HistorySnapshot[],
|
||||
isRestoringHistory: false,
|
||||
interactionMode: 'pointer' as 'pointer' | 'marquee',
|
||||
gridStyle: 'dots' as 'dots' | 'lines' | 'none',
|
||||
toastMessage: '',
|
||||
toastTimeout: null as ReturnType<typeof setTimeout> | null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
activeSpace(state): SpaceRecord | undefined {
|
||||
return state.spaces.find((space) => space.id === state.activeSpaceId)
|
||||
},
|
||||
canUndo(state): boolean {
|
||||
return state.historyPast.length > 0
|
||||
},
|
||||
canRedo(state): boolean {
|
||||
return state.historyFuture.length > 0
|
||||
},
|
||||
selectedNodes(state): CanvasNode[] {
|
||||
return state.nodes.filter((node) => node.selected)
|
||||
},
|
||||
selectedCount(state): number {
|
||||
return state.nodes.filter((node) => node.selected).length
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
showToast(message: string) {
|
||||
if (this.toastTimeout) {
|
||||
clearTimeout(this.toastTimeout)
|
||||
}
|
||||
this.toastMessage = message
|
||||
this.toastTimeout = setTimeout(() => {
|
||||
this.toastMessage = ''
|
||||
this.toastTimeout = null
|
||||
}, 2200)
|
||||
},
|
||||
|
||||
recordHistory() {
|
||||
if (this.isRestoringHistory) return
|
||||
const snapshot = cloneCanvasState(this.nodes, this.edges)
|
||||
this.historyPast.push(snapshot)
|
||||
if (this.historyPast.length > 35) {
|
||||
this.historyPast.shift()
|
||||
}
|
||||
this.historyFuture = []
|
||||
},
|
||||
|
||||
undo(): boolean {
|
||||
if (this.historyPast.length === 0) return false
|
||||
const previous = this.historyPast.pop()!
|
||||
this.historyFuture.push(cloneCanvasState(this.nodes, this.edges))
|
||||
|
||||
this.isRestoringHistory = true
|
||||
this.nodes = previous.nodes
|
||||
this.edges = previous.edges
|
||||
this.isRestoringHistory = false
|
||||
|
||||
void this.persistSnapshot()
|
||||
this.showToast('已撤销')
|
||||
return true
|
||||
},
|
||||
|
||||
redo(): boolean {
|
||||
if (this.historyFuture.length === 0) return false
|
||||
const next = this.historyFuture.pop()!
|
||||
this.historyPast.push(cloneCanvasState(this.nodes, this.edges))
|
||||
|
||||
this.isRestoringHistory = true
|
||||
this.nodes = next.nodes
|
||||
this.edges = next.edges
|
||||
this.isRestoringHistory = false
|
||||
|
||||
void this.persistSnapshot()
|
||||
this.showToast('已重做')
|
||||
return true
|
||||
},
|
||||
|
||||
async initialize() {
|
||||
let spaces = await db.spaces.orderBy('createdAt').toArray()
|
||||
|
||||
if (spaces.length === 0) {
|
||||
await this.seedStarterSpace()
|
||||
spaces = await db.spaces.orderBy('createdAt').toArray()
|
||||
}
|
||||
|
||||
this.spaces = spaces
|
||||
const preferred = localStorage.getItem(ACTIVE_SPACE_KEY)
|
||||
const initialSpace = spaces.find((space) => space.id === preferred) ?? spaces[0]
|
||||
if (!initialSpace) throw new Error('无法创建初始空间')
|
||||
await this.openSpace(initialSpace.id)
|
||||
await this.refreshCounts()
|
||||
this.ready = true
|
||||
},
|
||||
|
||||
async seedStarterSpace() {
|
||||
const now = Date.now()
|
||||
const spaceId = crypto.randomUUID()
|
||||
const space: SpaceRecord = {
|
||||
id: spaceId,
|
||||
name: '我的宇宙',
|
||||
emoji: '✦',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
const cards: CardRecord[] = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
spaceId,
|
||||
kind: 'note',
|
||||
title: '欢迎来到无界笺',
|
||||
content:
|
||||
'双击空白处,马上记下一件事。\n\n你也可以拖入图片、移动卡片,并用连线把想法串起来。',
|
||||
color: 'paper',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
x: -180,
|
||||
y: -80,
|
||||
width: 330,
|
||||
height: 240,
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
spaceId,
|
||||
kind: 'note',
|
||||
title: '今天想做的事',
|
||||
content: '□ 收集灵感\n□ 整理一个想法\n□ 留一点时间发呆',
|
||||
color: 'sun',
|
||||
createdAt: now + 1,
|
||||
updatedAt: now + 1,
|
||||
x: 220,
|
||||
y: -140,
|
||||
width: 290,
|
||||
height: 210,
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
spaceId,
|
||||
kind: 'note',
|
||||
title: '一个小提示',
|
||||
content: '鼠标指针只在选中卡片时变为移动抓手。Shift+拖拽可批量框选。卡片右侧圆点拉出连线。',
|
||||
color: 'mint',
|
||||
createdAt: now + 2,
|
||||
updatedAt: now + 2,
|
||||
x: 120,
|
||||
y: 170,
|
||||
width: 300,
|
||||
height: 190,
|
||||
},
|
||||
]
|
||||
|
||||
await db.transaction('rw', db.spaces, db.cards, async () => {
|
||||
await db.spaces.add(space)
|
||||
await db.cards.bulkAdd(cards)
|
||||
})
|
||||
},
|
||||
|
||||
async openSpace(spaceId: string) {
|
||||
if (this.activeSpaceId && this.activeSpaceId !== spaceId) {
|
||||
await this.persistSnapshot()
|
||||
}
|
||||
|
||||
const [cards, edges] = await Promise.all([
|
||||
db.cards.where('spaceId').equals(spaceId).toArray(),
|
||||
db.edges.where('spaceId').equals(spaceId).toArray(),
|
||||
])
|
||||
|
||||
this.activeSpaceId = spaceId
|
||||
this.nodes = cards.map(recordToNode)
|
||||
this.edges = edges.map(recordToEdge)
|
||||
this.searchQuery = ''
|
||||
this.historyPast = []
|
||||
this.historyFuture = []
|
||||
localStorage.setItem(ACTIVE_SPACE_KEY, spaceId)
|
||||
},
|
||||
|
||||
async createSpace(name: string, emoji = '✦') {
|
||||
const cleanName = name.trim().slice(0, 30)
|
||||
if (!cleanName) return
|
||||
|
||||
const now = Date.now()
|
||||
const space: SpaceRecord = {
|
||||
id: crypto.randomUUID(),
|
||||
name: cleanName,
|
||||
emoji,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
await db.spaces.add(space)
|
||||
this.spaces.push(space)
|
||||
this.cardCounts[space.id] = 0
|
||||
await this.openSpace(space.id)
|
||||
this.showToast(`已创建空间「${cleanName}」`)
|
||||
},
|
||||
|
||||
async updateSpace(spaceId: string, updates: { name?: string; emoji?: string }) {
|
||||
const space = this.spaces.find((s) => s.id === spaceId)
|
||||
if (!space) return
|
||||
if (updates.name !== undefined) {
|
||||
const cleanName = updates.name.trim().slice(0, 30)
|
||||
if (cleanName) space.name = cleanName
|
||||
}
|
||||
if (updates.emoji !== undefined) {
|
||||
space.emoji = updates.emoji
|
||||
}
|
||||
space.updatedAt = Date.now()
|
||||
await db.spaces.put(JSON.parse(JSON.stringify(space)))
|
||||
this.showToast('空间设置已更新')
|
||||
},
|
||||
|
||||
async deleteSpace(spaceId: string): Promise<boolean> {
|
||||
if (this.spaces.length <= 1) {
|
||||
this.showToast('至少保留一个空间')
|
||||
return false
|
||||
}
|
||||
const index = this.spaces.findIndex((s) => s.id === spaceId)
|
||||
if (index === -1) return false
|
||||
|
||||
const deletedSpace = this.spaces[index]
|
||||
await db.transaction('rw', db.spaces, db.cards, db.edges, async () => {
|
||||
await db.spaces.delete(spaceId)
|
||||
await db.cards.where('spaceId').equals(spaceId).delete()
|
||||
await db.edges.where('spaceId').equals(spaceId).delete()
|
||||
})
|
||||
|
||||
this.spaces.splice(index, 1)
|
||||
delete this.cardCounts[spaceId]
|
||||
|
||||
if (this.activeSpaceId === spaceId) {
|
||||
const fallback = this.spaces[Math.max(0, index - 1)] ?? this.spaces[0]
|
||||
if (fallback) {
|
||||
await this.openSpace(fallback.id)
|
||||
}
|
||||
}
|
||||
this.showToast(`已删除空间「${deletedSpace.name}」`)
|
||||
return true
|
||||
},
|
||||
|
||||
addCard(
|
||||
kind: CardKind,
|
||||
position: { x: number; y: number },
|
||||
options: { imageData?: string; title?: string; color?: CardColor } = {},
|
||||
) {
|
||||
this.recordHistory()
|
||||
const now = Date.now()
|
||||
const node: CanvasNode = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'card',
|
||||
position,
|
||||
selected: true,
|
||||
data: {
|
||||
spaceId: this.activeSpaceId,
|
||||
kind,
|
||||
title: options.title ?? (kind === 'image' ? '图片' : '未命名想法'),
|
||||
content: '',
|
||||
imageData: options.imageData,
|
||||
color: options.color ?? 'paper',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
style: {
|
||||
width: kind === 'image' ? '360px' : `${DEFAULT_NODE_WIDTH}px`,
|
||||
height: kind === 'image' ? '300px' : `${DEFAULT_NODE_HEIGHT}px`,
|
||||
},
|
||||
}
|
||||
|
||||
this.nodes.forEach((item) => (item.selected = false))
|
||||
this.nodes.push(node)
|
||||
this.cardCounts[this.activeSpaceId] = (this.cardCounts[this.activeSpaceId] ?? 0) + 1
|
||||
void this.persistSnapshot()
|
||||
return node
|
||||
},
|
||||
|
||||
updateNodeData(id: string, patch: Partial<CanvasNode['data']>) {
|
||||
const node = this.nodes.find((item) => item.id === id)
|
||||
if (!node) return
|
||||
node.data = { ...node.data, ...patch, updatedAt: Date.now() }
|
||||
},
|
||||
|
||||
resizeNode(id: string, width: number, height: number) {
|
||||
const node = this.nodes.find((item) => item.id === id)
|
||||
if (!node) return
|
||||
this.recordHistory()
|
||||
node.style = { ...((node.style ?? {}) as object), width: `${width}px`, height: `${height}px` }
|
||||
},
|
||||
|
||||
duplicateNode(id: string) {
|
||||
const source = this.nodes.find((item) => item.id === id)
|
||||
if (!source) return
|
||||
this.recordHistory()
|
||||
const duplicate = this.addCard(source.data.kind, {
|
||||
x: source.position.x + 36,
|
||||
y: source.position.y + 36,
|
||||
}, {
|
||||
title: `${source.data.title} · 副本`,
|
||||
color: source.data.color,
|
||||
imageData: source.data.imageData,
|
||||
})
|
||||
duplicate.data.content = source.data.content
|
||||
duplicate.style = { ...((source.style ?? {}) as object) }
|
||||
this.showToast('已复制便笺')
|
||||
return duplicate
|
||||
},
|
||||
|
||||
duplicateSelectedNodes(): number {
|
||||
const selected = this.nodes.filter((n) => n.selected)
|
||||
if (selected.length === 0) return 0
|
||||
this.recordHistory()
|
||||
|
||||
const created: CanvasNode[] = []
|
||||
selected.forEach((source) => {
|
||||
const now = Date.now()
|
||||
const node: CanvasNode = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'card',
|
||||
position: {
|
||||
x: source.position.x + 36,
|
||||
y: source.position.y + 36,
|
||||
},
|
||||
selected: true,
|
||||
data: {
|
||||
...source.data,
|
||||
spaceId: this.activeSpaceId,
|
||||
title: `${source.data.title} · 副本`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
style: { ...((source.style ?? {}) as object) },
|
||||
}
|
||||
created.push(node)
|
||||
})
|
||||
|
||||
selected.forEach((n) => (n.selected = false))
|
||||
this.nodes.push(...created)
|
||||
this.cardCounts[this.activeSpaceId] = this.nodes.length
|
||||
void this.persistSnapshot()
|
||||
this.showToast(`已复制 ${created.length} 张卡片`)
|
||||
return created.length
|
||||
},
|
||||
|
||||
bringNodeToFront(id: string) {
|
||||
const index = this.nodes.findIndex((n) => n.id === id)
|
||||
if (index === -1 || index === this.nodes.length - 1) return
|
||||
this.recordHistory()
|
||||
const [node] = this.nodes.splice(index, 1)
|
||||
this.nodes.push(node)
|
||||
void this.persistSnapshot()
|
||||
this.showToast('已置于顶层')
|
||||
},
|
||||
|
||||
sendNodeToBack(id: string) {
|
||||
const index = this.nodes.findIndex((n) => n.id === id)
|
||||
if (index === -1 || index === 0) return
|
||||
this.recordHistory()
|
||||
const [node] = this.nodes.splice(index, 1)
|
||||
this.nodes.unshift(node)
|
||||
void this.persistSnapshot()
|
||||
this.showToast('已置于底层')
|
||||
},
|
||||
|
||||
alignSelectedNodes(alignment: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') {
|
||||
const selected = this.nodes.filter((n) => n.selected)
|
||||
if (selected.length < 2) return
|
||||
|
||||
const getWidth = (node: CanvasNode) => {
|
||||
const style = (node.style ?? {}) as Record<string, string | number>
|
||||
const dimensions = node.dimensions ?? { width: 0, height: 0 }
|
||||
return Number.parseFloat(String(style.width ?? dimensions.width ?? DEFAULT_NODE_WIDTH))
|
||||
}
|
||||
const getHeight = (node: CanvasNode) => {
|
||||
const style = (node.style ?? {}) as Record<string, string | number>
|
||||
const dimensions = node.dimensions ?? { width: 0, height: 0 }
|
||||
return Number.parseFloat(String(style.height ?? dimensions.height ?? DEFAULT_NODE_HEIGHT))
|
||||
}
|
||||
|
||||
this.recordHistory()
|
||||
|
||||
const minX = Math.min(...selected.map((n) => n.position.x))
|
||||
const maxX = Math.max(...selected.map((n) => n.position.x + getWidth(n)))
|
||||
const minY = Math.min(...selected.map((n) => n.position.y))
|
||||
const maxY = Math.max(...selected.map((n) => n.position.y + getHeight(n)))
|
||||
|
||||
const centerX = (minX + maxX) / 2
|
||||
const centerY = (minY + maxY) / 2
|
||||
|
||||
selected.forEach((node) => {
|
||||
const w = getWidth(node)
|
||||
const h = getHeight(node)
|
||||
if (alignment === 'left') node.position.x = minX
|
||||
else if (alignment === 'right') node.position.x = maxX - w
|
||||
else if (alignment === 'center') node.position.x = centerX - w / 2
|
||||
else if (alignment === 'top') node.position.y = minY
|
||||
else if (alignment === 'bottom') node.position.y = maxY - h
|
||||
else if (alignment === 'middle') node.position.y = centerY - h / 2
|
||||
})
|
||||
|
||||
void this.persistSnapshot()
|
||||
this.showToast('已对齐选中的卡片')
|
||||
},
|
||||
|
||||
distributeSelectedNodes(direction: 'horizontal' | 'vertical') {
|
||||
const selected = [...this.nodes.filter((n) => n.selected)]
|
||||
if (selected.length < 3) return
|
||||
|
||||
this.recordHistory()
|
||||
|
||||
if (direction === 'horizontal') {
|
||||
selected.sort((a, b) => a.position.x - b.position.x)
|
||||
const first = selected[0]
|
||||
const last = selected[selected.length - 1]
|
||||
const totalDistance = last.position.x - first.position.x
|
||||
const step = totalDistance / (selected.length - 1)
|
||||
selected.forEach((node, i) => {
|
||||
node.position.x = Math.round(first.position.x + i * step)
|
||||
})
|
||||
} else {
|
||||
selected.sort((a, b) => a.position.y - b.position.y)
|
||||
const first = selected[0]
|
||||
const last = selected[selected.length - 1]
|
||||
const totalDistance = last.position.y - first.position.y
|
||||
const step = totalDistance / (selected.length - 1)
|
||||
selected.forEach((node, i) => {
|
||||
node.position.y = Math.round(first.position.y + i * step)
|
||||
})
|
||||
}
|
||||
|
||||
void this.persistSnapshot()
|
||||
this.showToast('已均分间距')
|
||||
},
|
||||
|
||||
setBatchColor(color: CardColor) {
|
||||
const selected = this.nodes.filter((n) => n.selected)
|
||||
if (selected.length === 0) return
|
||||
this.recordHistory()
|
||||
selected.forEach((node) => {
|
||||
node.data.color = color
|
||||
node.data.updatedAt = Date.now()
|
||||
})
|
||||
void this.persistSnapshot()
|
||||
this.showToast('已更换选中卡片颜色')
|
||||
},
|
||||
|
||||
selectAllNodes(): number {
|
||||
this.nodes.forEach((n) => (n.selected = true))
|
||||
return this.nodes.length
|
||||
},
|
||||
|
||||
deselectAll() {
|
||||
this.nodes.forEach((n) => (n.selected = false))
|
||||
},
|
||||
|
||||
async removeNodes(ids: string[]) {
|
||||
if (ids.length === 0) return
|
||||
this.recordHistory()
|
||||
this.nodes = this.nodes.filter((node) => !ids.includes(node.id))
|
||||
this.edges = this.edges.filter(
|
||||
(edge) => !ids.includes(edge.source) && !ids.includes(edge.target),
|
||||
)
|
||||
await db.transaction('rw', db.cards, db.edges, async () => {
|
||||
await db.cards.bulkDelete(ids)
|
||||
const connected = await db.edges
|
||||
.where('spaceId')
|
||||
.equals(this.activeSpaceId)
|
||||
.filter((edge) => ids.includes(edge.source) || ids.includes(edge.target))
|
||||
.primaryKeys()
|
||||
await db.edges.bulkDelete(connected)
|
||||
})
|
||||
this.cardCounts[this.activeSpaceId] = this.nodes.length
|
||||
this.showToast(ids.length > 1 ? `已删除 ${ids.length} 张卡片` : '已删除便笺')
|
||||
},
|
||||
|
||||
async deleteSelectedNodes(): Promise<number> {
|
||||
const selectedIds = this.nodes.filter((n) => n.selected).map((n) => n.id)
|
||||
if (selectedIds.length === 0) return 0
|
||||
await this.removeNodes(selectedIds)
|
||||
return selectedIds.length
|
||||
},
|
||||
|
||||
async removeEdges(ids: string[]) {
|
||||
if (ids.length === 0) return
|
||||
this.recordHistory()
|
||||
this.edges = this.edges.filter((edge) => !ids.includes(edge.id))
|
||||
await db.edges.bulkDelete(ids)
|
||||
this.showToast('已删除连线')
|
||||
},
|
||||
|
||||
async persistSnapshot() {
|
||||
if (!this.activeSpaceId) return
|
||||
this.saveState = 'saving'
|
||||
const spaceId = this.activeSpaceId
|
||||
const cardRecords = this.nodes.map(nodeToRecord)
|
||||
const edgeRecords = this.edges.map((edge) => edgeToRecord(edge, spaceId))
|
||||
|
||||
await db.transaction('rw', db.cards, db.edges, async () => {
|
||||
const existingCardIds = await db.cards.where('spaceId').equals(spaceId).primaryKeys()
|
||||
const currentCardIds = new Set(cardRecords.map((card) => card.id))
|
||||
await db.cards.bulkDelete(existingCardIds.filter((id) => !currentCardIds.has(id)))
|
||||
if (cardRecords.length) await db.cards.bulkPut(cardRecords)
|
||||
|
||||
const existingEdgeIds = await db.edges.where('spaceId').equals(spaceId).primaryKeys()
|
||||
const currentEdgeIds = new Set(edgeRecords.map((edge) => edge.id))
|
||||
await db.edges.bulkDelete(existingEdgeIds.filter((id) => !currentEdgeIds.has(id)))
|
||||
if (edgeRecords.length) await db.edges.bulkPut(edgeRecords)
|
||||
})
|
||||
|
||||
this.cardCounts[spaceId] = cardRecords.length
|
||||
this.saveState = 'saved'
|
||||
},
|
||||
|
||||
async refreshCounts() {
|
||||
const cards = await db.cards.toArray()
|
||||
this.cardCounts = cards.reduce<Record<string, number>>((counts, card) => {
|
||||
counts[card.spaceId] = (counts[card.spaceId] ?? 0) + 1
|
||||
return counts
|
||||
}, {})
|
||||
},
|
||||
|
||||
async exportBackup(): Promise<BackupFile> {
|
||||
await this.persistSnapshot()
|
||||
const [spaces, cards, edges] = await Promise.all([
|
||||
db.spaces.toArray(),
|
||||
db.cards.toArray(),
|
||||
db.edges.toArray(),
|
||||
])
|
||||
return { version: 1, exportedAt: new Date().toISOString(), spaces, cards, edges }
|
||||
},
|
||||
|
||||
async importBackup(backup: BackupFile) {
|
||||
if (backup.version !== 1 || !Array.isArray(backup.spaces) || !Array.isArray(backup.cards)) {
|
||||
throw new Error('无法识别这个备份文件')
|
||||
}
|
||||
|
||||
await db.transaction('rw', db.spaces, db.cards, db.edges, async () => {
|
||||
await Promise.all([db.spaces.clear(), db.cards.clear(), db.edges.clear()])
|
||||
await db.spaces.bulkAdd(backup.spaces)
|
||||
if (backup.cards.length) await db.cards.bulkAdd(backup.cards)
|
||||
if (backup.edges?.length) await db.edges.bulkAdd(backup.edges)
|
||||
})
|
||||
|
||||
this.spaces = await db.spaces.orderBy('createdAt').toArray()
|
||||
const firstSpace = this.spaces[0]
|
||||
if (!firstSpace) throw new Error('备份中没有可用空间')
|
||||
await this.openSpace(firstSpace.id)
|
||||
await this.refreshCounts()
|
||||
this.showToast('备份导入成功')
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
+1553
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
export type CardKind = 'note' | 'image'
|
||||
export type CardColor = 'paper' | 'sun' | 'mint' | 'sky' | 'rose'
|
||||
|
||||
export interface SpaceRecord {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface CardNodeData extends Record<string, unknown> {
|
||||
spaceId: string
|
||||
kind: CardKind
|
||||
title: string
|
||||
content: string
|
||||
imageData?: string
|
||||
color: CardColor
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface CardRecord extends CardNodeData {
|
||||
id: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface EdgeRecord {
|
||||
id: string
|
||||
spaceId: string
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string | null
|
||||
targetHandle?: string | null
|
||||
}
|
||||
|
||||
export interface CanvasNode {
|
||||
id: string
|
||||
type: 'card'
|
||||
position: { x: number; y: number }
|
||||
data: CardNodeData
|
||||
style?: Record<string, string | number>
|
||||
selected?: boolean
|
||||
dimensions?: { width: number; height: number }
|
||||
}
|
||||
|
||||
export interface CanvasEdge {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string | null
|
||||
targetHandle?: string | null
|
||||
type?: string
|
||||
markerEnd?: string
|
||||
data: { spaceId: string }
|
||||
}
|
||||
|
||||
export interface BackupFile {
|
||||
version: 1
|
||||
exportedAt: string
|
||||
spaces: SpaceRecord[]
|
||||
cards: CardRecord[]
|
||||
edges: EdgeRecord[]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { Menu } from '@lucide/vue'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import AppSidebar from '@/components/AppSidebar.vue'
|
||||
import BrandMark from '@/components/BrandMark.vue'
|
||||
import InfiniteCanvas from '@/components/InfiniteCanvas.vue'
|
||||
import { useCanvasStore } from '@/stores/canvas'
|
||||
|
||||
const store = useCanvasStore()
|
||||
|
||||
onMounted(() => store.initialize())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.ready" class="workspace">
|
||||
<AppSidebar />
|
||||
<InfiniteCanvas />
|
||||
</div>
|
||||
<div v-else class="app-loading">
|
||||
<BrandMark />
|
||||
<p>正在展开你的平面…</p>
|
||||
<button class="floating-icon" aria-label="菜单" disabled><Menu :size="18" /></button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
// Extra safety for array and object lookups, but may have false positives.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Path mapping for cleaner imports.
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||
// Bundler mode provides a smoother developer experience.
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||
"types": ["node"],
|
||||
|
||||
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||
"noEmit": true,
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
|
||||
// Override to include only test files and clear exclusions.
|
||||
// Application code imported in tests is automatically included via module resolution.
|
||||
"include": ["src/**/__tests__/*", "env.d.ts"],
|
||||
"exclude": [],
|
||||
|
||||
"compilerOptions": {
|
||||
// Vitest runs in a different environment than the application code.
|
||||
// Adjust lib and types accordingly.
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"],
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||
import viteConfig from './vite.config'
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
Reference in New Issue
Block a user