Initial commit

This commit is contained in:
jocay
2026-09-29 22:08:05 +08:00
commit c36123eb82
37 changed files with 8903 additions and 0 deletions
+8
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+42
View File
@@ -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/
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
+11
View File
@@ -0,0 +1,11 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"ms-playwright.playwright",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}
+46
View File
@@ -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 文件。
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": ["./**/*"]
}
+31
View File
@@ -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把复制的文字直接变成一张新便笺。',
)
})
Vendored
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+38
View File
@@ -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
View File
@@ -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>
+64
View File
@@ -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"
}
}
+110
View File
@@ -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,
},
})
+4801
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
vue-demi: set this to true or false
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+3
View File
@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>
+18
View File
@@ -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('无界笺')
})
})
+239
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
<template>
<span class="brand-mark" aria-hidden="true">
<i></i>
<i></i>
<i></i>
</span>
</template>
+234
View File
@@ -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>
+728
View File
@@ -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>
+27
View File
@@ -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
View File
@@ -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')
+16
View File
@@ -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
+654
View File
@@ -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('备份导入成功')
},
},
})
+12
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -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[]
}
+25
View File
@@ -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>
+18
View File
@@ -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"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}
+27
View File
@@ -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"
}
}
+19
View File
@@ -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"
}
}
+20
View File
@@ -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)),
},
},
})
+14
View File
@@ -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)),
},
}),
)