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