自部署多端同步:服务端、实时协作、口令登录与油画加载动画

- 新增 Node 服务端(Hono + Hocuspocus + node:sqlite),数据保存在服务器
- 基于 Yjs 的多端实时同步,本机 IndexedDB 离线缓存,撤销只针对本机操作
- 空间(标签)有独立地址,增删改通过接口同步到所有设备
- 口令登录(签名 Cookie 会话、登录限流、同源校验)
- 图片上传到服务器,过大照片上传前自动压缩
- 历史版本(自动/手动保存、一键恢复)、跨空间搜索、在线设备
- 随机油画加载动画(Painting Loaders,本地收录 p5 / p5.brush)
- 选中样式与拖拽性能优化:拖动结束后才同步位置
- Docker / docker-compose 部署,README 与 CLAUDE.md 更新
This commit is contained in:
吴璨
2026-10-21 18:09:03 +08:00
parent c36123eb82
commit fad9760265
64 changed files with 6656 additions and 1103 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules
dist
data
.e2e-data
.tmp-data
test-results
playwright-report
coverage
.env
.git
.vscode
.idea
*.log
+19
View File
@@ -0,0 +1,19 @@
# 复制为 .env 后修改。生产环境(Docker)请通过环境变量传入。
# 登录口令(生产环境必填)
BOUNDLESS_PASSWORD=change-me
# 会话签名密钥,可选。不填会自动生成并保存在数据目录里
# BOUNDLESS_SECRET=
# 数据目录(SQLite 数据库与图片),默认 ./data
# DATA_DIR=./data
# 服务端口,默认 3000
# PORT=3000
# 登录有效天数,默认 90
# SESSION_DAYS=90
# 单张图片大小上限(MB),默认 20
# MAX_IMAGE_MB=20
+5
View File
@@ -40,3 +40,8 @@ __screenshots__/
test-results/
playwright-report/
# 本地数据与环境变量
/data
/.e2e-data
.env
+1
View File
@@ -0,0 +1 @@
24.22.0
+2 -1
View File
@@ -6,5 +6,6 @@
},
"categories": {
"correctness": "error"
}
},
"ignorePatterns": ["public/painterly/**"]
}
+72
View File
@@ -0,0 +1,72 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
无界笺 Boundless:自部署的无限画布笔记应用(文字便笺、图片卡片、连线、多空间),单用户口令登录,多台设备实时同步。界面文案与提示均为简体中文。
- 前端:Vue 3 + TypeScript 6、Vite 8、Vue Flow(`@vue-flow/*`)、Pinia、Tailwind CSS 4
- 同步:Yjs + Hocuspocus v4(WebSocket),y-indexeddb 做本机离线缓存
- 服务端:Node.js 直接运行 `.ts`(原生类型剥离,无编译步骤)、Hono、`node:sqlite`
- 包管理器 pnpm(lockfile v9,用 pnpm 10),Node `^22.18.0 || >=24.12.0`
## 常用命令
```sh
pnpm install
pnpm dev # run-p:服务端 :3000(--watch)+ Vite :5173(代理 /api 与 /collab)
pnpm start # 只启动服务端;会托管 dist/(如果存在)
pnpm build # vue-tsc --build(含 server/shared)+ vite build
pnpm lint # oxlint + eslint,都带 --fix,会直接改文件
pnpm test:unit -- --run # Vitest(src/__tests__ 与 server/__tests__)
pnpm test:unit -- --run server/__tests__/server.spec.ts # 单个文件
pnpm exec playwright test --project=chromium # e2e:自动 build + 启动真实服务端(:4174,数据目录 .e2e-data)
pnpm exec playwright test -g "多端同步" --project=chromium
```
开发时口令默认 `boundless`;可在 `.env`(见 `.env.example`)里设置 `BOUNDLESS_PASSWORD` 等变量。生产环境未设置口令会拒绝启动。数据在 `DATA_DIR`(默认 `./data`):`boundless.sqlite`、`images/`、`session.secret`。
## 架构
### 目录分工
- `shared/board.ts`:前后端共用的 Yjs 文档结构与读写函数(唯一的数据格式定义处)
- `server/`:`index.ts` 组装 HTTP 服务与 WebSocket 升级;`app.ts` 是 Hono 路由;`collab.ts` 包装 Hocuspocus(鉴权、持久化、空间增删、历史版本、搜索);`db.ts` 是 SQLite;`auth.ts` 是会话令牌与同源校验
- `src/sync/`:客户端同步层(共享 WebSocket、`MetaSession` 空间列表、`BoardSession` 单个画布)
- `src/stores/canvas.ts`:Pinia store,连接同步层与 Vue Flow
### Yjs 文档
- `meta`:空间列表 `spaces`(id → Y.Map)。**客户端连接是只读的**,增删改空间都走 REST(`/api/spaces`),由服务端通过 DirectConnection 写入,再广播给所有设备。卡片数 `cardCount` 由服务端在保存画布时回写。
- `board:<spaceId>`:`cards`(id → 嵌套 Y.Map,字段级合并)与 `edges`(id → 普通对象)。叠放顺序靠卡片的 `z` 字段,不靠数组顺序。
- 新增卡片字段:改 `shared/board.ts` 的 `CardFields`/`readCard`,再改 store 里的 `cardToNode`/`applyCardToNode` 和 `src/types/canvas.ts`。
### 数据流(单向)
本机操作 → store action 用 `board.transact()` 写 Yjs(带 `LOCAL_ORIGIN`)→ Yjs observer → `store.syncFromDoc()` 把文档状态补丁到 `nodes`/`edges`(复用已有节点对象,保留 Vue Flow 的选中、尺寸等字段)→ `v-model` 渲染。远端更新走同一条 observer 路径。**不要直接改 `nodes` 里的位置或内容来"保存"**,那样不会同步。
- 拖拽、缩放进行中的卡片记在 `busyIds`,`syncFromDoc` 不覆盖它们的位置和尺寸;拖拽期间节流写入位置,`beginGesture`/`endGesture` 把整次拖拽合并成一步撤销。
- 撤销/重做使用 `Y.UndoManager`,只跟踪 `LOCAL_ORIGIN`,所以只撤销本机操作。
- Yjs 对象不能放进 Pinia 响应式状态,`meta`/`board` 会话放在 `canvas.ts` 的模块作用域。
### 服务端要点
- 鉴权:HttpOnly Cookie `boundless_session`(HMAC 签名,密钥由 secret + 口令派生,改口令即全部登出)。WebSocket 在 `upgrade` 阶段和 Hocuspocus `onAuthenticate` 都校验;拒绝时 `error.reason` 为 `unauthorized` 或 `not-found`,客户端据此区分"登录过期"和"空间已删除"。
- 非 GET 的 `/api/*` 与 WebSocket 都做同源校验(兼容 `X-Forwarded-Host`)。
- 历史版本:每次保存画布前,若距上个版本超过 10 分钟,就把**上一次保存的状态**存为快照(所以保存的是改动前的样子);每个空间最多 120 个。恢复时先备份当前状态,再通过 DirectConnection 替换内容,所有设备自动更新。
- 图片:`POST /api/images` 原始字节,按文件头识别格式,以 SHA-1 命名去重;读取需要登录。被删除卡片引用的图片不会自动清理。
- 服务端代码由 Node 直接运行:只能用可擦除的 TS 语法(不能用 enum、参数属性),相对导入必须带 `.ts` 后缀(`tsconfig.server.json` 会检查)。
### 前端要点
- 路由:`/login`、`/s/:spaceId`;`/` 会跳到上次打开的空间。`WorkspaceView` 是懒加载的。
- 不要用 `crypto.randomUUID()`(非 HTTPS 下不可用),用 `src/sync/connection.ts` 的 `randomId()`。
- 加载动画:`index.html` 里的 `#boot` 启动画面 + `src/boot.ts`;`PaintingLoader.vue` 用于登录页和切换空间。画作在 `public/painterly/`(第三方代码,原样保留,lint 已忽略),通过 iframe 隔离 p5 全局模式,用 `postMessage` 通知父页面。
- 样式全部在 `src/style.css`(BEM 类名 + `:root` CSS 变量),组件里没有 `<style>`。e2e 依赖 `.note-card`、`.note-card__title`、`.vue-flow__pane` 等类名和按钮的可访问名称。
- 用户提示统一用 `store.showToast(message)`。
### 部署
`Dockerfile`(node:24-alpine,运行时直接 `node server/index.ts`)+ `docker-compose.yml`(仅监听 127.0.0.1:3000,数据卷 `./data`)。前面需要支持 WebSocket 的 HTTPS 反向代理,README 里有 Caddy/Nginx 示例。
+29
View File
@@ -0,0 +1,29 @@
# ---- 构建前端 ----
FROM node:24-alpine AS build
WORKDIR /app
RUN npm install -g pnpm@10.34.7
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build-only
# ---- 运行 ----
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
DATA_DIR=/data
RUN npm install -g pnpm@10.34.7
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --prod --frozen-lockfile && npm uninstall -g pnpm
COPY server ./server
COPY shared ./shared
COPY --from=build /app/dist ./dist
VOLUME /data
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
# Node 24 可以直接运行 TypeScript(类型剥离),不需要额外编译
CMD ["node", "--experimental-strip-types", "--disable-warning=ExperimentalWarning", "server/index.ts"]
+76 -25
View File
@@ -1,46 +1,97 @@
# 无界笺 Boundless
一张可以无限延伸的本地灵感平面。你可以在画布上自由放置文字与图片、连接相关想法,并使用不同空间分开生活中的主题。
一张可以无限延伸的灵感平面,自部署在自己的服务器上。所有内容保存在服务器里,手机、平板、电脑打开的都是同一张画布;在一台设备上修改,其他打开着同一空间的设备会立刻看到变化。
## 已实现
## 功能
- 无限画布平移、缩放、小地图和框选
- 文字便笺的创建、编辑、移动、缩放、复制和换色
- 图片选择、拖入与剪贴板粘贴
- 卡片之间的自由连线
- 多空间创建和切换
- 当前空间搜索
- IndexedDB 自动本地保存
- JSON 完整备份与恢复
- 响应式侧边栏
- 无限画布:平移、缩放、小地图、框选、对齐与均分
- 文字便笺与图片卡片:创建、编辑、移动、缩放、复制、换色、连线
- 多个空间(标签),每个空间有独立的地址 `/s/<空间 id>`
- **多端实时同步**:基于 Yjs CRDT,多台设备同时编辑也不会互相覆盖
- **断网可用**:离线时的修改保存在本机,联网后自动同步
- **撤销只针对本机**:不会把其他设备的修改一起撤掉
- **历史版本**:编辑时每 10 分钟自动存一个版本,也可以手动保存,一键恢复
- **全局搜索**:搜索当前空间并跳转,同时列出其他空间里的匹配卡片
- 在线设备:查看哪些设备在线、各自打开的是哪个空间
- 口令登录:单用户,修改口令后所有设备自动退出
- 图片上传到服务器;过大的照片会在上传前自动压缩
- 随机油画加载动画(见下方致谢)
## 技术栈
- Vue 3 + TypeScript 6
- Vite 8(Rolldown)
- Vue Flow
- Pinia + Dexie / IndexedDB
- Tailwind CSS 4
- Vitest + Playwright
- 前端:Vue 3 + TypeScript、Vite 8、Vue Flow、Pinia、Tailwind CSS 4
- 同步:Yjs、Hocuspocus(WebSocket)、y-indexeddb(本机离线缓存)
- 服务端:Node.js(直接运行 TypeScript)、Hono、node:sqlite
- 测试:Vitest、Playwright
## 本地运行
## 本地开发
需要 Node.js 22.18+ 或 Node.js 24.12+,推荐使用 Node.js 24 LTS。
需要 Node.js 22.18+ 或 24.12+,推荐 Node.js 24 LTS。
```sh
pnpm install
pnpm dev
cp .env.example .env # 可选;不设置时开发口令为 boundless
pnpm dev # 同时启动服务端(3000)与 Vite(5173)
```
浏览器打开终端显示的本地地址即可使用。
浏览器打开 <http://localhost:5173>。服务端数据默认存放在 `./data/`。
## 检查项目
```sh
pnpm build
pnpm test:unit -- --run
pnpm exec playwright test --project=chromium
pnpm build # 类型检查 + 构建
pnpm test:unit -- --run # 单元测试
pnpm exec playwright install chromium # 首次运行 e2e 前
pnpm exec playwright test --project=chromium # 端到端测试(会自动构建并启动服务端)
pnpm lint
```
所有记录默认只保存在当前浏览器的 IndexedDB 中。请定期使用左下角的“导出备份”保存一份 JSON 文件。
## 部署(Docker)
```sh
echo "BOUNDLESS_PASSWORD=换成你的口令" > .env
docker compose up -d --build
```
服务监听 `127.0.0.1:3000`,数据保存在 `./data/`(SQLite 数据库、图片和会话密钥)。**备份整个 `data/` 目录即可完整备份。**
前面需要一个 HTTPS 反向代理,并且要支持 WebSocket。Caddy 最简单(自动申请证书,并会自动转发 WebSocket):
```caddyfile
board.example.com {
reverse_proxy 127.0.0.1:3000
}
```
Nginx 需要显式转发 WebSocket 和 Host:
```nginx
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 1d;
client_max_body_size 25m;
}
```
### 环境变量
| 变量 | 说明 | 默认值 |
| -------------------- | ------------------------------------------- | ------------------- |
| `BOUNDLESS_PASSWORD` | 登录口令(生产环境必填) | 开发时为 `boundless` |
| `BOUNDLESS_SECRET` | 会话签名密钥 | 自动生成并存入数据目录 |
| `DATA_DIR` | 数据目录 | `./data` |
| `PORT` / `HOST` | 监听端口与地址 | `3000` / `0.0.0.0` |
| `SESSION_DAYS` | 登录有效天数 | `90` |
| `MAX_IMAGE_MB` | 单张图片大小上限 | `20` |
不用 Docker 时:`pnpm install && pnpm build-only && BOUNDLESS_PASSWORD=... pnpm start`。
## 致谢
加载动画来自 [Painting Loaders](https://painterly.design-tools.workers.dev)(作者 [Sasha](https://x.com/aleksksaa)),12 幅画作的绘制代码原样收录在 `public/painterly/`,基于 [p5.js](https://p5js.org)(LGPL-2.1)与 [p5.brush](https://github.com/acamposuribe/p5.brush)(MIT)。
+13
View File
@@ -0,0 +1,13 @@
services:
boundless:
build: .
image: boundless:latest
restart: unless-stopped
environment:
# 必填:登录口令
BOUNDLESS_PASSWORD: ${BOUNDLESS_PASSWORD:?请在 .env 或环境变量中设置 BOUNDLESS_PASSWORD}
volumes:
- ./data:/data
# 只监听本机,由前面的 HTTPS 反向代理(Caddy / Nginx)转发
ports:
- '127.0.0.1:3000:3000'
+58
View File
@@ -0,0 +1,58 @@
import { expect, type Browser, type Page } from '@playwright/test'
export const E2E_PASSWORD = 'e2e-password'
export async function login(page: Page, path = '/') {
await page.goto(path)
await expect(page).toHaveURL(/\/login/)
await page.getByLabel('登录口令').fill(E2E_PASSWORD)
await page.getByLabel('登录口令').press('Enter')
await expect(page).toHaveURL(/\/s\//)
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
}
/** 新开一个独立的浏览器上下文,相当于另一台设备 */
export async function openDevice(browser: Browser, path = '/') {
const context = await browser.newContext({ reducedMotion: 'reduce', locale: 'zh-CN' })
const page = await context.newPage()
await login(page, path)
return { context, page }
}
/** 在当前空间正中新建一张便笺并改标题 */
export async function createNote(page: Page, title: string) {
const before = await page.locator('.note-card').count()
await page.getByRole('button', { name: '新便笺' }).click()
await expect(page.locator('.note-card')).toHaveCount(before + 1)
const card = page.locator('.note-card--selected')
await card.locator('.note-card__title').fill(title)
return card
}
/** 当前画布上所有卡片的标题(输入框的值不会出现在 DOM 属性里,只能这样读) */
export function noteTitles(page: Page) {
return page
.locator('.note-card__title')
.evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value))
}
export async function expectNoteTitle(page: Page, title: string, present = true) {
const poll = expect.poll(() => noteTitles(page), { timeout: 10_000 })
if (present) await poll.toContain(title)
else await poll.not.toContain(title)
}
export async function noteByTitle(page: Page, title: string) {
await expectNoteTitle(page, title)
const index = (await noteTitles(page)).indexOf(title)
return page.locator('.note-card').nth(index)
}
/** 新建一个空间并切换过去,避免用例之间互相影响 */
export async function createSpace(page: Page, name: string) {
await page.getByRole('button', { name: '创建空间' }).click()
await page.getByPlaceholder('例如:灵感收纳盒、工作面板…').fill(name)
await page.getByRole('button', { name: '立即创建' }).click()
await expect(page.locator('.space-title')).toContainText(name)
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
}
+191
View File
@@ -0,0 +1,191 @@
import { expect, test } from '@playwright/test'
import {
createNote,
createSpace,
expectNoteTitle,
login,
noteByTitle,
noteTitles,
openDevice,
} from './helpers'
test.describe('登录', () => {
test('未登录时跳转到登录页,口令错误会提示', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveURL(/\/login/)
await page.getByLabel('登录口令').fill('wrong-password')
await page.getByLabel('登录口令').press('Enter')
await expect(page.getByRole('alert')).toHaveText('口令不正确')
})
test('登录后看到初始空间,退出后需要重新登录', async ({ page }) => {
await login(page)
await expectNoteTitle(page, '欢迎来到无界笺')
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page).toHaveURL(/\/login/)
await page.goto('/')
await expect(page).toHaveURL(/\/login/)
})
})
test.describe('多端同步', () => {
test('一台设备的修改会实时出现在另一台设备上', async ({ page, browser }) => {
await login(page)
await createSpace(page, '同步测试')
const spaceUrl = page.url()
const other = await openDevice(browser, spaceUrl)
await expect(other.page.locator('.space-title')).toContainText('同步测试')
// 新建
await createNote(page, '来自设备 A')
await expectNoteTitle(other.page, '来自设备 A')
// 修改内容
const remoteCard = await noteByTitle(other.page, '来自设备 A')
await remoteCard.locator('.note-card__content').fill('设备 B 补充的一句话')
const localCard = await noteByTitle(page, '来自设备 A')
await expect(localCard.locator('.note-card__content')).toHaveValue('设备 B 补充的一句话')
// 换色
await localCard.locator('.color-picker button[data-color="mint"]').click()
await expect(remoteCard).toHaveAttribute('data-color', 'mint')
// 删除
await localCard.locator('.card-icon-button').click()
await page.getByRole('button', { name: '删除' }).click()
await expectNoteTitle(other.page, '来自设备 A', false)
await other.context.close()
})
test('新建的空间会出现在其他设备的列表里', async ({ page, browser }) => {
await login(page)
const other = await openDevice(browser)
await createSpace(page, '新的标签')
await expect(other.page.locator('.space-list')).toContainText('新的标签')
await expect(page.locator('.devices__button')).toHaveText(/2/)
await other.context.close()
})
test('刷新页面后内容仍在', async ({ page }) => {
await login(page)
await createSpace(page, '持久化')
await createNote(page, '刷新也不会丢')
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
await page.reload()
await expectNoteTitle(page, '刷新也不会丢')
})
test('撤销只影响本机的操作', async ({ page, browser }) => {
await login(page)
await createSpace(page, '撤销测试')
const other = await openDevice(browser, page.url())
await createNote(other.page, '别人的便笺')
await expectNoteTitle(page, '别人的便笺')
await createNote(page, '我的便笺')
await page.locator('.vue-flow__pane').click({ position: { x: 40, y: 300 } })
await page.keyboard.press('Control+Z')
await page.keyboard.press('Control+Z')
await expectNoteTitle(page, '我的便笺', false)
await expectNoteTitle(page, '别人的便笺')
await expectNoteTitle(other.page, '我的便笺', false)
await other.context.close()
})
})
test.describe('画布操作', () => {
test('粘贴文字会生成一张新便笺', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await login(page)
await createSpace(page, '粘贴')
const before = await page.locator('.note-card').count()
await page.evaluate(() =>
navigator.clipboard.writeText('临时灵感\n把复制的文字直接变成一张新便笺。'),
)
await page.locator('.vue-flow__pane').click({ position: { x: 90, y: 300 } })
await page.keyboard.press('Control+V')
await expect(page.locator('.note-card')).toHaveCount(before + 1)
const card = await noteByTitle(page, '临时灵感')
await expect(card.locator('.note-card__content')).toHaveValue(
'临时灵感\n把复制的文字直接变成一张新便笺。',
)
})
test('上传图片后保存在服务器上', async ({ page }) => {
await login(page)
await createSpace(page, '图片')
// 1×1 PNG
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
)
await page.locator('input[type="file"]').setInputFiles({
name: '小图.png',
mimeType: 'image/png',
buffer: png,
})
const image = page.locator('.note-card__image-wrap img')
await expect(image).toHaveAttribute('src', /^\/api\/images\/[a-f0-9]{40}\.png$/)
const response = await page.request.get((await image.getAttribute('src'))!)
expect(response.status()).toBe(200)
expect(response.headers()['content-type']).toBe('image/png')
})
test('全局搜索可以跳到其他空间的卡片', async ({ page }) => {
await login(page)
await createSpace(page, '搜索来源')
await createNote(page, '藏在别处的萤火虫')
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
await createSpace(page, '搜索起点')
await page.getByLabel('搜索便笺').fill('萤火虫')
await page.getByRole('button', { name: /藏在别处的萤火虫/ }).click()
await expect(page.locator('.space-title')).toContainText('搜索来源')
await expect(page.locator('.note-card--selected .note-card__title')).toHaveValue(
'藏在别处的萤火虫',
)
})
})
test.describe('历史版本', () => {
test('可以恢复到之前保存的版本', async ({ page }) => {
await login(page)
await createSpace(page, '版本')
await createNote(page, '想留住的想法')
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
await page.getByRole('button', { name: '历史版本' }).click()
await page.getByRole('button', { name: '保存当前版本' }).click()
await expect(page.locator('.version-item', { hasText: '手动保存' })).toHaveCount(1)
await page.getByRole('button', { name: '完成' }).click()
const card = await noteByTitle(page, '想留住的想法')
await card.locator('.card-icon-button').click()
await page.getByRole('button', { name: '删除' }).click()
await expectNoteTitle(page, '想留住的想法', false)
await expect(page.getByText('已同步', { exact: true }).first()).toBeVisible()
await page.getByRole('button', { name: '历史版本' }).click()
const version = page.locator('.version-item', { hasText: '手动保存' })
await version.getByRole('button', { name: '恢复' }).click()
await version.getByRole('button', { name: '确认恢复' }).click()
await expectNoteTitle(page, '想留住的想法')
expect(await noteTitles(page)).toHaveLength(1)
})
})
test('加载动画会随机作画', async ({ browser }) => {
const context = await browser.newContext({ reducedMotion: 'no-preference' })
const page = await context.newPage()
await page.goto('/login')
const frame = page.frameLocator('.painting__tile iframe')
await expect(frame.locator('canvas').first()).toBeAttached({ timeout: 15_000 })
await expect(page.locator('.painting__caption')).toHaveText(/ · /)
await context.close()
})
-31
View File
@@ -1,31 +0,0 @@
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把复制的文字直接变成一张新便笺。',
)
})
+87 -3
View File
@@ -1,14 +1,98 @@
<!DOCTYPE html>
<html lang="">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.svg">
<link rel="manifest" href="/manifest.webmanifest">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#f4f3ee">
<meta name="description" content="无界笺是一张可以无限延伸的本地灵感平面。">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="无界笺">
<meta name="description" content="无界笺是一张可以无限延伸、在所有设备间同步的灵感平面。">
<title>无界笺 · Boundless</title>
<style>
/* 启动画面:在主程序下载完成之前就显示,样式内联避免闪烁 */
#boot {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 22px;
background: #f4f3ee;
color: #7b7b73;
font-family: Georgia, 'Songti SC', 'STSong', serif;
transition: opacity 480ms ease;
}
#boot.boot--gone {
opacity: 0;
pointer-events: none;
}
#boot .boot__tile {
position: relative;
width: min(232px, 58vw);
aspect-ratio: 1;
overflow: hidden;
border: 1px solid rgba(29, 30, 29, 0.08);
border-radius: 14px;
background: #efebe2;
box-shadow: 0 1px 2px rgba(27, 26, 23, 0.04), 0 18px 40px -22px rgba(27, 26, 23, 0.32);
transition: background 600ms ease;
}
#boot iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
opacity: 0;
transition: opacity 600ms ease;
}
#boot.boot--painting iframe {
opacity: 1;
}
#boot .boot__text {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
font-size: 13px;
letter-spacing: 0.06em;
}
#boot .boot__caption {
min-height: 1.2em;
color: #a3a39a;
font-size: 11px;
font-style: italic;
letter-spacing: 0.04em;
opacity: 0;
transition: opacity 600ms ease;
}
#boot.boot--painting .boot__caption {
opacity: 1;
}
</style>
</head>
<body>
<div id="boot" role="status" aria-live="polite">
<div class="boot__tile" aria-hidden="true">
<iframe src="/painterly/index.html" title="加载动画" tabindex="-1"></iframe>
</div>
<div class="boot__text">
<span class="boot__status">正在展开你的平面…</span>
<span class="boot__caption"></span>
</div>
</div>
<script>
// 保护:页面被嵌进 iframe 时不再嵌套加载动画,避免无限递归
if (window.top !== window.self) {
document.querySelector('#boot iframe')?.remove()
}
</script>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
+14 -4
View File
@@ -4,9 +4,12 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"dev": "run-p dev:server dev:web",
"dev:web": "vite",
"dev:server": "node --experimental-strip-types --disable-warning=ExperimentalWarning --env-file-if-exists=.env --watch server/index.ts",
"start": "node --experimental-strip-types --disable-warning=ExperimentalWarning --env-file-if-exists=.env server/index.ts",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"preview": "run-s build-only start",
"test:unit": "vitest",
"test:e2e": "playwright test",
"build-only": "vite build",
@@ -17,6 +20,10 @@
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@hocuspocus/extension-database": "4.7.1",
"@hocuspocus/provider": "4.7.1",
"@hocuspocus/server": "4.7.1",
"@hono/node-server": "2.1.5",
"@lucide/vue": "^1.31.0",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
@@ -24,10 +31,13 @@
"@vue-flow/minimap": "^1.5.4",
"@vue-flow/node-resizer": "^1.5.1",
"@vueuse/core": "^14.4.0",
"dexie": "^4.4.4",
"crossws": "0.4.15",
"hono": "4.13.13",
"pinia": "^4.0.2",
"vue": "^3.5.40",
"vue-router": "^5.2.0"
"vue-router": "^5.2.0",
"y-indexeddb": "9.0.12",
"yjs": "13.6.33"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
+29 -77
View File
@@ -1,110 +1,62 @@
import process from 'node:process'
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
import { E2E_PASSWORD } from './e2e/helpers'
// e2e 跑的是生产构建 + 真实服务端,使用独立的临时数据目录
const PORT = 4174
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
timeout: 45 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
timeout: 8000,
},
fullyParallel: false,
/* 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. */
// 所有用例共用一个服务端和数据目录,串行执行更可靠
workers: 1,
reporter: process.env.CI ? 'github' : 'list',
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 */
baseURL: `http://localhost:${PORT}`,
trace: 'on-first-retry',
/* Only on CI systems run the tests headless */
headless: !!process.env.CI,
// 默认跳过加载动画,专门的用例会单独打开
contextOptions: { reducedMotion: 'reduce' },
locale: 'zh-CN',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
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,
command:
'node -e "require(\'fs\').rmSync(\'.e2e-data\',{recursive:true,force:true})" && npm run build-only && npm run start',
url: `http://localhost:${PORT}/healthz`,
reuseExistingServer: false,
timeout: 180 * 1000,
env: {
PORT: String(PORT),
DATA_DIR: '.e2e-data',
BOUNDLESS_PASSWORD: E2E_PASSWORD,
BOUNDLESS_SECRET: 'e2e-secret',
},
},
})
+170 -8
View File
@@ -8,6 +8,18 @@ importers:
.:
dependencies:
'@hocuspocus/extension-database':
specifier: 4.7.1
version: 4.7.1(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)
'@hocuspocus/provider':
specifier: 4.7.1
version: 4.7.1(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)
'@hocuspocus/server':
specifier: 4.7.1
version: 4.7.1(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)
'@hono/node-server':
specifier: 2.1.5
version: 2.1.5(hono@4.13.13)
'@lucide/vue':
specifier: ^1.31.0
version: 1.31.0(vue@3.5.41(typescript@6.0.3))
@@ -29,9 +41,12 @@ importers:
'@vueuse/core':
specifier: ^14.4.0
version: 14.4.0(vue@3.5.41(typescript@6.0.3))
dexie:
specifier: ^4.4.4
version: 4.4.4
crossws:
specifier: 0.4.15
version: 0.4.15
hono:
specifier: 4.13.13
version: 4.13.13
pinia:
specifier: ^4.0.2
version: 4.0.2(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
@@ -41,6 +56,12 @@ importers:
vue-router:
specifier: ^5.2.0
version: 5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.3)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))
y-indexeddb:
specifier: 9.0.12
version: 9.0.12(yjs@13.6.33)
yjs:
specifier: 13.6.33
version: 13.6.33
devDependencies:
'@playwright/test':
specifier: ^1.61.1
@@ -381,6 +402,33 @@ packages:
'@noble/hashes':
optional: true
'@hocuspocus/common@4.7.1':
resolution: {integrity: sha512-+N4t8AdBIWBUK+yHsuBvCcBm96eV2E3quIewma9+TYomqVMXhrovAf3dA6XFbYHRM7ggtXklHt2mFqTcpiMdAQ==}
'@hocuspocus/extension-database@4.7.1':
resolution: {integrity: sha512-9qpwJAY6QHu/++KNpz7wWHEmo/4zxEYFR3G5UD7Td5crnGT8DcZ5fM4LsZ9Uy3KCFYAdCnidnsSXvjy+7gcWRQ==}
peerDependencies:
yjs: ^13.6.8
'@hocuspocus/provider@4.7.1':
resolution: {integrity: sha512-znulqyCwwuhq86aFmTF6izOomwRWlZGReCAf+SZpLgpvdfvBQJzjoj9HdtuXXfL9wkBM2Ydt86kaMs2rjaicRA==}
peerDependencies:
y-protocols: ^1.0.6
yjs: ^13.6.8
'@hocuspocus/server@4.7.1':
resolution: {integrity: sha512-f2TWNi5rFnju1ttOesxkR1kPIukFmMClHY4pAx0RlIBMzjAoUVlEgilETIAQlrwmw1UMxsy31fse0tmBzYEpwQ==}
engines: {node: '>=22'}
peerDependencies:
y-protocols: ^1.0.6
yjs: ^13.6.8
'@hono/node-server@2.1.5':
resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==}
engines: {node: '>=20'}
peerDependencies:
hono: ^4 || ^5.0.0-0
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -441,6 +489,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lifeomic/attempt@3.1.0':
resolution: {integrity: sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==}
'@lucide/vue@1.31.0':
resolution: {integrity: sha512-NtjEHhcAa7umPh40wrRmlESJqNGdnpc7LziBKFnW3fmlrW0g2xMCDpdWU7WeEHRSl3xOpxbqiFTLKxoK3CoVCg==}
peerDependencies:
@@ -1148,6 +1199,9 @@ packages:
resolution: {integrity: sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==}
engines: {node: '>=20.19.0'}
async-mutex@0.5.0:
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
balanced-match@1.0.0:
resolution: {integrity: sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==}
@@ -1234,6 +1288,14 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
crossws@0.4.15:
resolution: {integrity: sha512-NsUOpDZosz7nyI+qEIl05yaiXOWaXH2oTeM+XBWtiSWZtb5rzcIsl1AST/HzM2jzUn76eFuyrkn83E6iSBRUvw==}
peerDependencies:
srvx: '>=0.11.5'
peerDependenciesMeta:
srvx:
optional: true
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
@@ -1319,9 +1381,6 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dexie@4.4.4:
resolution: {integrity: sha512-jIwsYI8Os2hgnqc6O49YwFDKGc5v5QjGx0wPVp543ip1F53VFAKMLthV2pQosQcVTv3eAskTWYspOx195PM0FQ==}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
@@ -1541,6 +1600,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
hono@4.13.13:
resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==}
engines: {node: '>=16.9.0'}
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
@@ -1619,6 +1682,9 @@ packages:
resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==}
engines: {node: '>=20'}
isomorphic.js@0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
jackspeak@3.1.2:
resolution: {integrity: sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==}
engines: {node: '>=14'}
@@ -1673,6 +1739,10 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
kolorist@1.8.0:
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
@@ -1680,6 +1750,11 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lib0@0.2.119:
resolution: {integrity: sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==}
engines: {node: '>=16'}
hasBin: true
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -2235,6 +2310,9 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -2547,6 +2625,18 @@ packages:
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
y-indexeddb@9.0.12:
resolution: {integrity: sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
peerDependencies:
yjs: ^13.0.0
y-protocols@1.0.7:
resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
peerDependencies:
yjs: ^13.0.0
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -2555,6 +2645,10 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yjs@13.6.33:
resolution: {integrity: sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
snapshots:
'@asamuzakjp/css-color@5.1.11':
@@ -2850,6 +2944,42 @@ snapshots:
'@exodus/bytes@1.15.1': {}
'@hocuspocus/common@4.7.1':
dependencies:
lib0: 0.2.119
'@hocuspocus/extension-database@4.7.1(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)':
dependencies:
'@hocuspocus/server': 4.7.1(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)
yjs: 13.6.33
transitivePeerDependencies:
- srvx
- y-protocols
'@hocuspocus/provider@4.7.1(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)':
dependencies:
'@hocuspocus/common': 4.7.1
'@lifeomic/attempt': 3.1.0
lib0: 0.2.119
y-protocols: 1.0.7(yjs@13.6.33)
yjs: 13.6.33
'@hocuspocus/server@4.7.1(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)':
dependencies:
'@hocuspocus/common': 4.7.1
async-mutex: 0.5.0
crossws: 0.4.15
kleur: 4.1.5
lib0: 0.2.119
y-protocols: 1.0.7(yjs@13.6.33)
yjs: 13.6.33
transitivePeerDependencies:
- srvx
'@hono/node-server@2.1.5(hono@4.13.13)':
dependencies:
hono: 4.13.13
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@@ -2910,6 +3040,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.4.14
'@lifeomic/attempt@3.1.0': {}
'@lucide/vue@1.31.0(vue@3.5.41(typescript@6.0.3))':
dependencies:
vue: 3.5.41(typescript@6.0.3)
@@ -3559,6 +3691,10 @@ snapshots:
'@babel/types': 7.29.8
ast-kit: 2.2.0
async-mutex@0.5.0:
dependencies:
tslib: 2.8.1
balanced-match@1.0.0: {}
balanced-match@4.0.4: {}
@@ -3634,6 +3770,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.1
crossws@0.4.15: {}
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
@@ -3705,8 +3843,6 @@ snapshots:
detect-libc@2.1.2: {}
dexie@4.4.4: {}
eastasianwidth@0.2.0: {}
editorconfig@1.0.7:
@@ -3929,6 +4065,8 @@ snapshots:
graceful-fs@4.2.11: {}
hono@4.13.13: {}
hookable@5.5.3: {}
html-encoding-sniffer@6.0.0:
@@ -3982,6 +4120,8 @@ snapshots:
isexe@4.0.0: {}
isomorphic.js@0.2.5: {}
jackspeak@3.1.2:
dependencies:
'@isaacs/cliui': 8.0.2
@@ -4043,6 +4183,8 @@ snapshots:
dependencies:
json-buffer: 3.0.1
kleur@4.1.5: {}
kolorist@1.8.0: {}
levn@0.4.1:
@@ -4050,6 +4192,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lib0@0.2.119:
dependencies:
isomorphic.js: 0.2.5
lightningcss-android-arm64@1.32.0:
optional: true
@@ -4523,6 +4669,8 @@ snapshots:
dependencies:
typescript: 6.0.3
tslib@2.8.1: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -4796,6 +4944,20 @@ snapshots:
xmlchars@2.2.0: {}
y-indexeddb@9.0.12(yjs@13.6.33):
dependencies:
lib0: 0.2.119
yjs: 13.6.33
y-protocols@1.0.7(yjs@13.6.33):
dependencies:
lib0: 0.2.119
yjs: 13.6.33
yallist@3.1.1: {}
yaml@2.9.0: {}
yjs@13.6.33:
dependencies:
lib0: 0.2.119
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#f4f3ee"/>
<g transform="rotate(-7 256 256)">
<circle cx="256" cy="256" r="168" fill="#1d1e1d"/>
<g stroke="#f4f3ee" stroke-width="16" stroke-linecap="round">
<line x1="176" y1="190" x2="336" y2="190" transform="rotate(33 256 190)"/>
<line x1="176" y1="256" x2="336" y2="256" transform="rotate(-30 256 256)"/>
<line x1="176" y1="322" x2="336" y2="322" transform="rotate(17 256 322)"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 547 B

+13
View File
@@ -0,0 +1,13 @@
{
"name": "无界笺 Boundless",
"short_name": "无界笺",
"description": "一张可以无限延伸、在所有设备间同步的灵感平面。",
"start_url": "/",
"display": "standalone",
"background_color": "#f4f3ee",
"theme_color": "#f4f3ee",
"lang": "zh-CN",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
]
}
+316
View File
@@ -0,0 +1,316 @@
/* =====================================================================
* engine.js — the reveal loop, shared by every style.
*
* Lifted from loader.html without changing how anything paints: each
* stroke is drawn once into an offscreen buffer, then copied onto the
* visible canvas in slices along its direction so it looks drawn.
*
* A styleConfig is:
* {
* label: 'Impressionism',
* canvasSize: 600,
* loopMs: 9000,
* holdMs: 1500,
* palette: { ground, phrases, ...whatever the style's strokes want },
* strokes: (palette) => [ descriptor, ... ],
* vocabulary: [ fn, ... ], // for the gallery's Copy code button
* }
*
* A descriptor is { fn, bb, axis, dir, dur, gap } — see D() below.
*
* Three p5.brush facts this file is built around, all verified in 2.2.2:
* - scaleBrushes() multiplies the shared brush registry in place, so it
* must run exactly once per page. See ensureBrushes().
* - brush.load(target) is a global retarget, so it is re-issued whenever
* a config is (re)loaded rather than only once in setup.
* - brush.wash is deferred until the next drawing call. See flushWash().
* ===================================================================== */
/* Canvas dimensions live at module scope so the style vocabularies can go on
referencing bare W / H / m exactly as they did inside loader.html. The
engine sets them from styleConfig.canvasSize before anything paints. */
let W = 600, H = 600, m = 600;
/* ---- seeded helpers (all randomness goes through p5's seeded random) ---- */
const rr = (a, b) => random(a, b);
const pickOne = (arr) => arr[Math.floor(random(arr.length))];
const clamp01 = (v) => Math.max(0, Math.min(1, v));
const clampN = (v, a, b) => Math.max(a, Math.min(b, v));
/* ---- colour maths ---- */
function hexRgb(h) { h = h.replace('#', ''); if (h.length === 3) h = h.split('').map((c) => c + c).join(''); return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); }
function mixHex(a, b, t) { const A = hexRgb(a), B = hexRgb(b); return '#' + A.map((v, i) => Math.round(v + (B[i] - v) * t).toString(16).padStart(2, '0')).join(''); }
function hexHsl(hex) {
const [r, g, b] = hexRgb(hex).map((v) => v / 255);
const max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 2;
let h = 0, s = 0;
if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); if (max === r) h = (g - b) / d + (g < b ? 6 : 0); else if (max === g) h = (b - r) / d + 2; else h = (r - g) / d + 4; h /= 6; }
return [h, s, l];
}
function hslHex(h, s, l) {
const f = (p, q, t) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; };
let r, g, b;
if (s === 0) { r = g = b = l; } else { const q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q; r = f(p, q, h + 1 / 3); g = f(p, q, h); b = f(p, q, h - 1 / 3); }
return '#' + [r, g, b].map((v) => Math.round(v * 255).toString(16).padStart(2, '0')).join('');
}
function vivid(hex, amt) { if (!amt) return hex; const [h, s, l] = hexHsl(hex); return hslHex(h, s + (1 - s) * amt, l - (l - 0.45) * amt * 0.5); }
/* ---- geometry ---- */
function jitterEllipse(cx, cy, rx, ry, n, jitter, rot = 0) {
const pts = [], a0 = rr(0, TWO_PI), cr = Math.cos(rot), sr = Math.sin(rot);
for (let i = 0; i < n; i++) {
const a = a0 + (i / n) * TWO_PI, k = 1 + rr(-jitter, jitter);
const x = Math.cos(a) * rx * k, y = Math.sin(a) * ry * k;
pts.push([cx + x * cr - y * sr, cy + x * sr + y * cr]);
}
return pts;
}
function rosette(cx, cy, r, lobes, inner, rot = 0, jit = 0.05) {
const n = Math.max(24, lobes * 6), pts = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * TWO_PI, k = inner + (1 - inner) * (0.5 + 0.5 * Math.cos(lobes * a));
const rad = r * k * (1 + rr(-jit, jit));
pts.push([cx + Math.cos(a + rot) * rad, cy + Math.sin(a + rot) * rad]);
}
return pts;
}
function bandPolygon(y0, y1, wobble, n = 9) {
const top = [], bottom = [];
for (let i = 0; i <= n; i++) { const x = -10 + (W + 20) * (i / n); top.push([x, y0 + rr(-wobble, wobble)]); bottom.push([x, y1 + rr(-wobble, wobble)]); }
return top.concat(bottom.reverse());
}
const bboxOf = (pts, pad) => {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const [x, y] of pts) { if (x < x0) x0 = x; if (y < y0) y0 = y; if (x > x1) x1 = x; if (y > y1) y1 = y; }
return [Math.max(0, Math.floor(x0 - pad)), Math.max(0, Math.floor(y0 - pad)), Math.min(W, Math.ceil(x1 + pad)), Math.min(H, Math.ceil(y1 + pad))];
};
/* ---- style switches (fill, wash and stroke are independent in p5.brush,
so each one turns the others off before drawing) ---- */
function fillStyle(color, opacity, bleed, texture, border) { brush.noStroke(); brush.noHatch(); brush.noWash(); brush.fill(color, opacity); brush.fillBleed(bleed, 'out'); brush.fillTexture(texture, border); }
function washStyle(color, opacity) { brush.noStroke(); brush.noHatch(); brush.noFill(); brush.wash(color, opacity); }
function strokeStyle(name, color, weight) { brush.noFill(); brush.noWash(); brush.noHatch(); brush.set(name, color, weight); }
/* ---- descriptor constructor: each stroke is revealed along its axis over
`dur` ms, like a brush travelling across the paper ---- */
const D = (fn, bb, axis, dir, dur, gap = 60) => ({ fn, bb, axis, dir, dur, gap });
const rdir = () => (random() < 0.5 ? 1 : -1);
const raxis = () => (random() < 0.5 ? 'x' : 'y');
/* p5.brush queues flat washes and only renders them on the next drawing call
(bleed fills are immediate). A tiny fully transparent fill off-canvas forces
that flush so the buffer is complete before it is copied. */
function flushWash() {
brush.noStroke(); brush.noHatch(); brush.noWash();
brush.fill('#000000', 0); brush.fillBleed(0); brush.fillTexture(0, 0);
brush.polygon([[-8, -8], [-6, -8], [-6, -6]]);
}
/* scaleBrushes() multiplies the shared brush registry in place, so calling it
twice compounds. This runs once per page; a later call at a different scale
is refused loudly rather than silently doubling every brush. */
let brushScale = null;
function ensureBrushes(scale) {
if (brushScale !== null) {
if (Math.abs(brushScale - scale) > 1e-6) {
console.warn(`engine: brushes already scaled to ${brushScale}; ignoring request for ${scale}. ` +
'scaleBrushes multiplies in place, so all canvases on a page must share one size.');
}
return;
}
brush.add('flick', { weight: 0.7, vibration: 0.12, definition: 0.9, quality: 0.8, opacity: 200, spacing: 0.1, pressure: { curve: [0.25, 0.25], min_max: [1.1, 0.85] } });
brush.add('rim', { weight: 0.45, vibration: 0.35, definition: 0.7, quality: 0.8, opacity: 190, spacing: 0.1, pressure: { curve: [0.15, 0.2], min_max: [1.2, 1] } });
brush.scaleBrushes(scale);
brushScale = scale;
}
/* =====================================================================
* createEngine(styleConfig, opts)
*
* opts:
* mount element the canvas is appended to (moved, if it already exists)
* statusEl element that receives the palette's phrases (optional)
* freeze progress 0..1 to freeze at instead of looping (optional)
* nextConfig (cycle) => styleConfig, called at each cycle boundary.
* Defaults to the config passed in. loader.html uses this to
* keep its garden -> dawn -> willow -> wisteria rotation;
* the engine itself knows nothing about rotation.
* ===================================================================== */
function createEngine(styleConfig, opts = {}) {
let cfg = styleConfig;
let buf, paint, canvasEl;
let strokes = [], idx = 0;
let startMs = null, currentCycle = -1, phaseIndex = -1, fadeTimer = null;
let statusEl = opts.statusEl || null;
const freeze = typeof opts.freeze === 'number' ? clamp01(opts.freeze) : null;
const nextConfig = opts.nextConfig || (() => cfg);
function setup() {
W = cfg.canvasSize; H = cfg.canvasSize; m = Math.min(W, H);
const cnv = createCanvas(W, H, WEBGL);
canvasEl = cnv.elt || cnv.canvas || document.querySelector('canvas');
if (opts.mount) mount(opts.mount);
ensureBrushes(3 * m / 600);
// `paint` holds everything finished so far; `buf` is paint plus the stroke
// being drawn. Drawing each stroke over a copy of the painting lets its wet
// edges mix with the real colours underneath, and every copy between the
// three surfaces is opaque (no fringes).
paint = createGraphics(W, H, WEBGL);
buf = createGraphics(W, H, WEBGL);
paint.imageMode(CORNER); buf.imageMode(CORNER);
brush.load(buf);
// pre-warm the fill shaders into the buffer, then wipe it
buf.push(); buf.translate(-W / 2, -H / 2);
fillStyle('#7a86bd', 200, 0.3, 0.5, 0.3); brush.circle(W / 2, H / 2, 12);
buf.pop(); buf.clear();
imageMode(CORNER);
background(cfg.palette.ground);
}
/* Move the one canvas into a new parent. Used by the gallery to hand the
live canvas from card to card. */
function mount(el) {
if (canvasEl && el && canvasEl.parentElement !== el) el.appendChild(canvasEl);
}
/* Point the engine at a different style and start it from the first stroke. */
function load(nextCfg) {
cfg = nextCfg || cfg;
// The canvas and the scaled brushes are both fixed at setup, so every style
// sharing a page has to share its size. Say so rather than paint at the
// wrong coordinates.
if (cfg.canvasSize !== W) {
console.warn(`engine: "${cfg.label}" wants canvasSize ${cfg.canvasSize} but the canvas is ${W}. ` +
'Painting at ' + W + '. All styles on one page must share a canvasSize.');
}
brush.load(buf); // brush.load is a global retarget
randomSeed(cfg.palette.seed); noiseSeed(cfg.palette.seed);
strokes = cfg.strokes(cfg.palette);
// give every stroke its own seed, then lay the timeline out end to end
// and fit it to loopMs
let cursor = 0;
for (const s of strokes) { s.seed = Math.floor(random() * 1e9); s.begun = false; s.rev = null; s.start = cursor; cursor += s.dur + s.gap; }
const k = cfg.loopMs / cursor;
for (const s of strokes) { s.start *= k; s.dur *= k; s.end = s.start + s.dur; }
idx = 0; phaseIndex = -1;
paint.background(cfg.palette.ground);
background(cfg.palette.ground);
}
function beginStroke(s) {
buf.clear();
buf.push(); buf.translate(-W / 2, -H / 2);
buf.image(paint, 0, 0, W, H); // start from the painting so far
randomSeed(s.seed); noiseSeed(s.seed);
s.fn();
flushWash();
buf.pop();
s.begun = true; s.p = 0;
}
// a finished stroke becomes part of the painting
function foldStroke() {
paint.push(); paint.translate(-W / 2, -H / 2);
paint.image(buf, 0, 0, W, H);
paint.pop();
}
// draw the revealed part of the stroke in progress: a slice of its box,
// growing along its direction
function revealSlice(s) {
const [x0, y0, x1, y1] = s.bb;
if (x1 <= x0 || y1 <= y0 || s.p <= 0) return;
if (s.axis === 'x') {
const w = Math.round((x1 - x0) * s.p); if (w <= 0) return;
const a = s.dir > 0 ? x0 : x1 - w;
image(buf, a, y0, w, y1 - y0, a, y0, w, y1 - y0);
} else {
const h = Math.round((y1 - y0) * s.p); if (h <= 0) return;
const a = s.dir > 0 ? y0 : y1 - h;
image(buf, x0, a, x1 - x0, h, x0, a, x1 - x0, h);
}
}
function paintUpTo(t) {
while (idx < strokes.length && strokes[idx].start <= t) {
const s = strokes[idx];
if (!s.begun) beginStroke(s);
s.p = Math.min(1, (t - s.start) / s.dur);
if (s.p >= 1) { foldStroke(); idx++; } else break;
}
}
function drawFrame(t) {
paintUpTo(t);
image(paint, 0, 0, W, H);
const s = strokes[idx];
if (s && s.begun) revealSlice(s);
}
function draw() {
translate(-W / 2, -H / 2);
if (freeze !== null) {
if (!strokes.length) { load(nextConfig(0)); drawFrame(freeze * cfg.loopMs); updateStatus(freeze); noLoop(); }
return;
}
if (startMs === null) startMs = millis();
const now = millis() - startMs, cycleMs = cfg.loopMs + cfg.holdMs;
const cycle = Math.floor(now / cycleMs), elapsed = now % cycleMs;
if (cycle !== currentCycle) { currentCycle = cycle; load(nextConfig(cycle)); }
drawFrame(Math.min(elapsed, cfg.loopMs));
updateStatus(Math.min(elapsed / cfg.loopMs, 1));
}
/* Restart from the first stroke on the next frame. */
function restart() { startMs = null; currentCycle = -1; }
/* Paint up to t on demand, outside the animation loop. drawFrame is already
incremental — paintUpTo resumes from where it left off — so the caller can
walk t up over several frames to paint a whole picture without blocking.
The gallery uses this to render each card's finished still at load. */
function renderTo(t) { push(); translate(-W / 2, -H / 2); drawFrame(t); pop(); }
/* Point at a different style and restart. The strokes are not rebuilt here —
the next draw() does that once, via nextConfig. Used by the gallery when
the live canvas moves to another card. */
function switchTo(nextCfg, nextStatusEl) {
cfg = nextCfg || cfg;
if (nextStatusEl !== undefined) {
if (statusEl && statusEl !== nextStatusEl) { clearTimeout(fadeTimer); statusEl.textContent = ''; }
statusEl = nextStatusEl;
}
restart();
}
function updateStatus(progress) {
if (!statusEl) return;
const list = cfg.palette.phrases;
if (!list || !list.length) return;
let i;
if (progress >= 1) i = list.length - 1;
else { const active = Math.max(list.length - 1, 1); i = Math.min(Math.floor(progress * active), active - 1); }
if (i === phaseIndex) return;
const fresh = phaseIndex === -1;
phaseIndex = i;
clearTimeout(fadeTimer);
if (fresh) { statusEl.classList.remove('fade'); statusEl.textContent = list[i]; return; }
statusEl.classList.add('fade');
fadeTimer = setTimeout(() => { statusEl.textContent = list[i]; statusEl.classList.remove('fade'); }, 250);
}
return {
setup, draw, load, mount, restart, switchTo, renderTo,
get config() { return cfg; },
get canvas() { return canvasEl; },
};
}
/* Every function above that a copied standalone file needs, in dependency
order. The gallery serialises these with Function.prototype.toString().
Keep in step when adding a shared helper. */
const SHARED_FOR_COPY = [
rr, pickOne, clamp01, clampN,
hexRgb, mixHex, hexHsl, hslHex, vivid,
jitterEllipse, rosette, bandPolygon, bboxOf,
fillStyle, washStyle, strokeStyle,
D, rdir, raxis,
flushWash, ensureBrushes, createEngine,
];
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Painting</title>
<style>
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: transparent;
}
canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
</style>
<!-- 加载顺序不能变:先 p5,再 p5.brush(它会注册为 p5 插件),再引擎和画作 -->
<script src="vendor/p5.min.js"></script>
<script src="vendor/p5.brush.js"></script>
<script src="engine.js"></script>
<script src="style-impressionism.js"></script>
<script src="style-abstraction.js"></script>
<script src="loader.js"></script>
</head>
<body></body>
</html>
+72
View File
@@ -0,0 +1,72 @@
/* =====================================================================
* loader.js — 无界笺的加载动画外壳。
*
* 绘画引擎与 12 幅画作来自 Painting Loaders
* (https://painterly.design-tools.workers.dev,作者 Sasha / x.com/aleksksaa),
* engine.js 与 style-*.js 原样保留。这里只负责:随机挑一幅(不与上次重复),
* 把画布铺满本页,并通知父页面已经开始作画。
*
* 本页通过 iframe 嵌入,p5 的全局模式不会污染主应用。
* ===================================================================== */
const PAINTINGS = [
{ key: 'garden', style: 'impressionismGarden', name: '花园', label: 'Garden' },
{ key: 'dawn', style: 'impressionismDawn', name: '晨曦', label: 'Dawn' },
{ key: 'willow', style: 'impressionismWillow', name: '垂柳', label: 'Willow' },
{ key: 'wisteria', style: 'impressionismWisteria', name: '紫藤', label: 'Wisteria' },
{ key: 'orchard', style: 'impressionismOrchard', name: '果园', label: 'Orchard' },
{ key: 'irises', style: 'impressionismIrises', name: '鸢尾', label: 'Irises' },
{ key: 'poppies', style: 'impressionismPoppies', name: '虞美人', label: 'Poppies' },
{ key: 'koi', style: 'impressionismKoi', name: '锦鲤', label: 'Koi' },
{ key: 'roses', style: 'impressionismRoses', name: '玫瑰', label: 'Roses' },
{ key: 'oranges', style: 'impressionismOranges', name: '橘子', label: 'Oranges' },
{ key: 'sail', style: 'impressionismSail', name: '远帆', label: 'Sail' },
{ key: 'dusk', style: 'abstractionDusk', name: '黄昏', label: 'Dusk' },
];
const LAST_KEY = 'boundless-last-painting';
function choosePainting() {
const wanted = new URLSearchParams(location.search).get('p');
const exact = PAINTINGS.find((item) => item.key === wanted);
if (exact) return exact;
let last = null;
try { last = localStorage.getItem(LAST_KEY); } catch (e) { /* 隐私模式 */ }
const pool = PAINTINGS.filter((item) => item.key !== last);
const picked = pool[Math.floor(Math.random() * pool.length)] || PAINTINGS[0];
try { localStorage.setItem(LAST_KEY, picked.key); } catch (e) { /* 隐私模式 */ }
return picked;
}
const painting = choosePainting();
const config = window.STYLES[painting.style];
document.documentElement.style.background = config.palette.ground;
function notifyParent(type) {
if (window.parent === window) return;
window.parent.postMessage(
{ source: 'boundless-painterly', type, key: painting.key, name: painting.name, label: painting.label, ground: config.palette.ground },
location.origin,
);
}
notifyParent('chosen');
let engine = null;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// p5 全局模式:页面加载后若 window 上有 setup / draw 就会自动开始。
// 系统要求减少动态效果时不作画,只留下画纸底色。
if (!reduceMotion) {
window.setup = function () {
// 画布内部固定 600×600,再由 CSS 缩放;不跟随设备像素比可以省下不少显存
pixelDensity(1);
engine = createEngine(config, { mount: document.body });
engine.setup();
notifyParent('start');
};
window.draw = function () {
if (engine) engine.draw();
};
}
+239
View File
@@ -0,0 +1,239 @@
/* =====================================================================
* style-abstraction.js — Abstraction · Dusk.
*
* The playground's `abstract` generator at its dusk mood: no scene, no
* subject. Wide soft columns of colour stained down the paper, translucent
* marker glazes running with them, then a crowd of tall wet drips clustered
* around two or three attractors, a last veil of glaze over the top and a
* few light touches. Everything travels vertically, which is what makes it
* read as poured rather than composed.
*
* Ported from playground.html rather than rewritten: the build draws the
* same random numbers in the same order as the playground does, so at seed
* 1874 this is the picture the mood was picked from. The one thing that
* cannot carry over is per-stroke brush grain — the engine reseeds before
* each stroke so it can redraw them independently — so the shapes, colours
* and paint order match while the wet texture inside them differs.
*
* Same contract as the other style files: an IIFE exporting onto
* window.STYLES, dials named S (Copy code emits `const S = …`), palette
* carrying ground / seed / phrases, strokes naming its builder literally.
* ===================================================================== */
window.STYLES = window.STYLES || {};
(function () {
// The dusk preset's dials, baked in. pattern picks the mark shape and flow
// the direction everything runs; the rest are the playground's sliders.
// detail and brushSize come from the playground's MARK_DEFAULTS, which the
// abstract moods inherit rather than set.
const S = {
pattern: 'drips', flow: 'vertical',
water: 0.7, coverage: 0.5, density: 0.35, scale: 1, soft: 0.6, glaze: 0.4, touch: 0.2,
detail: 0.6, brushSize: 1,
};
const PALETTE = {
seed: 1874,
ground: '#f4ece6', // warm paper, barely tinted
washes: ['#8f7fa8', '#c68aa0', '#e6b39a', '#f0cfa0'], // violet → rose → apricot → sand
accents: ['#6d5c8a', '#e28f8f', '#d9a56b'], // the drips
highlights: ['#f9dc9c', '#fff3e0'], // the last touches
phrases: ['creating…', 'washing…', 'flowing…', 'blooming…', 'finished'],
};
// =====================================================================
// Geometry
// =====================================================================
// The vertical twin of engine.js's bandPolygon: a column between x0 and x1,
// both edges wobbling, run off the top and bottom of the canvas.
function columnPolygon(x0, x1, wobble, n = 9) {
const left = [], right = [];
for (let i = 0; i <= n; i++) {
const y = -20 + (H + 40) * (i / n);
left.push([x0 + rr(-wobble, wobble), y]);
right.push([x1 + rr(-wobble, wobble), y]);
}
return left.concat(right.reverse());
}
// The playground scheduled every stroke on a 0..1 timeline and painted them
// in t order; the engine paints them in array order. Keeping the schedule and
// sorting on it at the end reproduces the playground's paint order — which
// marks land on top of which — and, because it draws its random numbers in
// the same places, everything built after it.
function schedule(items, t0, t1, jitter = 0.3) {
const step = (t1 - t0) / Math.max(items.length, 1);
items.forEach((it, i) => { it.t = t0 + step * (i + 0.5) + rr(-jitter, jitter) * step; });
return items;
}
// =====================================================================
// Drawing vocabulary
// =====================================================================
function glazeDraw(x0, y0, x1, y1, color, weight, field = 'hand') {
brush.field(field); strokeStyle('marker', color, weight * S.brushSize);
brush.line(x0, y0, x1, y1); brush.noField();
}
function flickDraw(x, y, len, a, color, weight) {
strokeStyle('flick', color, weight * S.brushSize);
brush.line(x - Math.cos(a) * len / 2, y - Math.sin(a) * len / 2, x + Math.cos(a) * len / 2, y + Math.sin(a) * len / 2);
}
// =====================================================================
// Descriptor factories
//
// Nothing here calls raxis() / rdir(): the flow already says which way the
// paint travels, and drawing extra random numbers would move every shape
// built after it off the composition this mood was picked at.
// =====================================================================
const flowAxis = () => (S.flow === 'horizontal' ? 'x' : 'y');
const longAxis = (bb) => ((bb[2] - bb[0]) >= (bb[3] - bb[1]) ? 'x' : 'y');
function dWash(color, opacity) {
const pts = [[-20, -20], [W + 20, -20], [W + 20, H + 20], [-20, H + 20]];
return D(() => { washStyle(color, opacity); brush.polygon(pts); }, [0, 0, W, H], flowAxis(), 1, 700, 120);
}
// One of the wide stained columns (or bands, or pools) the picture is built on.
function dGround(pts, color, opacity, bleed, texture) {
return D(() => { fillStyle(color, opacity, bleed, texture, 0.3); brush.polygon(pts); },
bboxOf(pts, m * 0.25), flowAxis(), 1, 520, 90);
}
function dGlaze(x0, y0, x1, y1, color, weight, field) {
const bb = bboxOf([[x0, y0], [x1, y1]], 14 + weight * 4 * S.brushSize);
const axis = Math.abs(x1 - x0) >= Math.abs(y1 - y0) ? 'x' : 'y';
const dir = axis === 'x' ? (Math.sign(x1 - x0) || 1) : (Math.sign(y1 - y0) || 1);
const len = Math.hypot(x1 - x0, y1 - y0);
return D(() => glazeDraw(x0, y0, x1, y1, color, weight, field), bb, axis, dir, 90 + len * 0.45, 45);
}
// A mark: at this water the bleed drifts well past the polygon, so the reveal
// box is padded generously — the buffer holds only this stroke, so an
// oversized box copies nothing but transparency. Drips are revealed downwards
// whatever their shape, so each one runs rather than appearing.
function dMark(pts, color, opacity, bleed, texture) {
const bb = bboxOf(pts, m * 0.08 + 14);
const axis = S.pattern === 'drips' ? 'y' : longAxis(bb);
const extent = axis === 'x' ? bb[2] - bb[0] : bb[3] - bb[1];
return D(() => { fillStyle(color, opacity, bleed, texture, 0.3); brush.polygon(pts); },
bb, axis, 1, 180 + extent * 0.9, 70);
}
function dFlick(x, y, len, a, color, weight) {
const ends = [[x - Math.cos(a) * len / 2, y - Math.sin(a) * len / 2], [x + Math.cos(a) * len / 2, y + Math.sin(a) * len / 2]];
const axis = Math.abs(Math.cos(a)) >= Math.abs(Math.sin(a)) ? 'x' : 'y';
return D(() => flickDraw(x, y, len, a, color, weight), bboxOf(ends, 10 + weight * 5), axis, 1, 90, 25);
}
// Small light strokes dropped near the marks already placed.
function dTouches(points, C, count, angleFn, sizeMul = 1) {
const items = [];
count = Math.round(count * (0.4 + 0.6 * S.detail));
for (let i = 0; i < count; i++) {
const p = points.length ? pickOne(points) : [rr(0, W), rr(0, H), 20];
const x = p[0] + rr(-p[2], p[2]), y = p[1] + rr(-p[2] * 0.6, p[2] * 0.6);
const color = random() < 0.85 ? pickOne(C.highlights) : pickOne(C.accents);
const len = rr(8, 22) * S.scale * sizeMul, weight = rr(0.6, 1.1) * S.scale;
items.push(dFlick(x, y, len, angleFn(), color, weight));
}
return items;
}
// =====================================================================
// The picture
// =====================================================================
function buildAbstraction(C) {
const out = [];
const group = (items, t0, t1) => { schedule(items, t0, t1); out.push(...items); };
const { water, density: dens, scale: sc, soft } = S;
const { washes, accents, highlights } = C;
const horizontal = S.flow === 'horizontal', vertical = S.flow === 'vertical';
// 1. the coverage wash, flat, under everything
if (S.coverage > 0.02) {
const w = dWash(washes[0], Math.round(S.coverage * 200));
w.t = 0.02;
out.push(w);
}
// 2. the ground: a few wide stains, one per wash colour, overlapping
const nGround = 2 + Math.round(dens * 2);
const ground = [];
for (let i = 0; i < nGround; i++) {
const color = washes[i % washes.length];
const opacity = Math.round(225 - water * 80);
const bleed = 0.25 + water * 0.55, texture = 0.3 + water * 0.5;
let pts;
if (horizontal) { const y0 = -20 + (i / nGround) * H + rr(-30, 30), h = H / nGround * rr(1.2, 1.8); pts = bandPolygon(y0, y0 + h, 14 + soft * 20); }
else if (vertical) { const x0 = -20 + (i / nGround) * W + rr(-30, 30), w = W / nGround * rr(1.2, 1.8); pts = columnPolygon(x0, x0 + w, 14 + soft * 20); }
else pts = jitterEllipse(rr(W * 0.2, W * 0.8), rr(H * 0.2, H * 0.8), W * rr(0.35, 0.6), H * rr(0.25, 0.45), 14, 0.1 + soft * 0.25, rr(-0.6, 0.6));
ground.push(dGround(pts, color, opacity, bleed, texture));
}
group(ground, 0.03, 0.16);
// 3. glazes travelling with the flow. A quarter of them are held back as a
// veil and laid over the marks at the end instead.
const glazes = [];
for (let i = 0; i < Math.round(S.glaze * 22); i++) {
const color = random() < 0.7 ? pickOne(washes) : pickOne(accents);
const weight = rr(1.2, 3.2) * Math.sqrt(sc);
let x0, y0, x1, y1, field;
if (horizontal) { y0 = rr(0, H); x0 = rr(-10, W * 0.5); x1 = x0 + rr(W * 0.3, W * 0.9); y1 = y0 + rr(-8, 8); field = random() < 0.25 ? 'waves' : 'hand'; }
else if (vertical) { x0 = rr(0, W); y0 = rr(-10, H * 0.5); y1 = y0 + rr(H * 0.25, H * 0.8); x1 = x0 + rr(-8, 8); field = 'hand'; }
else { x0 = rr(0, W); y0 = rr(0, H); const a = rr(-0.6, 0.6), len = rr(m * 0.2, m * 0.5); x1 = x0 + Math.cos(a) * len; y1 = y0 + Math.sin(a) * len; field = random() < 0.5 ? 'curved' : 'seabed'; }
glazes.push(dGlaze(x0, y0, x1, y1, color, weight, field));
}
const veil = glazes.splice(0, Math.round(glazes.length * 0.25));
group(glazes, 0.16, 0.30);
// 4. the marks. Most fall near two or three attractors, which is what gives
// the picture its clusters and its empty right-hand side; the rest are
// scattered anywhere. Painted biggest first so the small ones sit on top.
const nMarks = Math.round((3 + dens * 34) * (S.pattern === 'dapple' ? 1.6 : 1));
const attractors = [];
for (let i = 0, n = 2 + Math.floor(rr(0, 3)); i < n; i++) attractors.push([rr(W * 0.15, W * 0.85), rr(H * 0.15, H * 0.85)]);
const s0 = m * 0.06 * sc;
const marks = [], centers = [];
for (let i = 0; i < nMarks; i++) {
let x, y;
if (random() < 0.25) { x = rr(0, W); y = rr(0, H); }
else { const a = pickOne(attractors); const sd = m * (S.pattern === 'dapple' ? 0.1 : 0.16); x = a[0] + randomGaussian(0, sd); y = a[1] + randomGaussian(0, sd); }
const r = s0 * rr(0.5, 1.6);
const color = random() < 0.2 ? pickOne(highlights) : pickOne(accents);
const jit = 0.08 + soft * 0.3;
let pts, opacity, bleed, texture;
switch (S.pattern) {
case 'bands': pts = jitterEllipse(x, y, r * rr(2.5, 4.5), r * rr(0.25, 0.5), 14, jit * 0.6); opacity = 205 - water * 60; bleed = 0.3 + water * 0.5; texture = 0.4 + water * 0.4; break;
case 'petals': pts = jitterEllipse(x, y, r * rr(0.7, 1.1), r * rr(0.28, 0.45), 10, jit * 0.5, rr(0, TWO_PI)); opacity = 225 - water * 45; bleed = 0.12 + water * 0.3; texture = 0.3 + water * 0.3; break;
case 'drips': pts = jitterEllipse(x, y, r * rr(0.3, 0.5), r * rr(2, 4), 14, jit * 0.7); opacity = 200 - water * 60; bleed = 0.4 + water * 0.4; texture = 0.4 + water * 0.4; break;
case 'dapple': pts = jitterEllipse(x, y, r * 0.45, r * rr(0.35, 0.45), 10, jit); opacity = 200 - water * 40; bleed = 0.2 + water * 0.3; texture = 0.3 + water * 0.3; break;
default: pts = jitterEllipse(x, y, r, r * rr(0.6, 1), 12, jit); opacity = 200 - water * 60; bleed = 0.3 + water * 0.5; texture = 0.4 + water * 0.4;
}
centers.push([x, y, r]);
const d = dMark(pts, color, Math.round(opacity), bleed, texture);
d.r = r;
marks.push(d);
}
marks.sort((a, b) => b.r - a.r);
group(marks, 0.30, 0.86);
// 5. the held-back glazes over the top, then the touches
group(veil, 0.86, 0.92);
const angle = () => (horizontal ? rr(-0.1, 0.1) : vertical ? HALF_PI + rr(-0.15, 0.15) : rr(0, TWO_PI));
group(dTouches(centers, C, Math.round(S.touch * 20), angle), 0.92, 1.0);
// the engine paints in array order; t is the playground's paint order
return out.sort((a, b) => a.t - b.t);
}
STYLES.abstractionDusk = {
key: 'abstraction-dusk',
movement: 'Abstraction',
note: 'The hour when color stops trying to be anything specific.',
label: 'Dusk',
canvasSize: 600,
loopMs: 9000,
holdMs: 1500,
dials: S,
palette: PALETTE,
strokes: (palette) => buildAbstraction(palette),
vocabulary: [columnPolygon, schedule, glazeDraw, flickDraw, flowAxis, longAxis,
dWash, dGround, dGlaze, dMark, dFlick, dTouches, buildAbstraction],
};
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+138
View File
@@ -0,0 +1,138 @@
// @vitest-environment node
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import * as Y from 'yjs'
import { LoginLimiter, SessionManager, isSameOrigin, readCookie } from '../auth.ts'
import { Storage } from '../db.ts'
import { detectImageType } from '../images.ts'
describe('SessionManager', () => {
const sessions = new SessionManager('secret', 'password', 30)
it('签发的令牌可以通过校验', () => {
expect(sessions.verify(sessions.issue())).not.toBeNull()
})
it('拒绝被篡改或过期的令牌', () => {
const token = sessions.issue()
const [body, signature] = token.split('.')
expect(sessions.verify(`${body}x.${signature}`)).toBeNull()
expect(sessions.verify(`${body}.${signature}x`)).toBeNull()
expect(sessions.verify('garbage')).toBeNull()
expect(sessions.verify(sessions.issue(Date.now() - 31 * 86_400_000))).toBeNull()
})
it('修改口令后旧令牌失效', () => {
const token = sessions.issue()
expect(new SessionManager('secret', 'new-password', 30).verify(token)).toBeNull()
})
it('校验口令', () => {
expect(sessions.checkPassword('password')).toBe(true)
expect(sessions.checkPassword('Password')).toBe(false)
expect(sessions.checkPassword(undefined)).toBe(false)
})
it('有效期过半时提示续期', () => {
const now = Date.now()
expect(sessions.shouldRenew(sessions.verify(sessions.issue(now))!, now)).toBe(false)
const old = sessions.verify(sessions.issue(now - 20 * 86_400_000), now)!
expect(sessions.shouldRenew(old, now)).toBe(true)
})
})
describe('请求辅助函数', () => {
it('解析 Cookie', () => {
expect(readCookie('a=1; boundless_session=abc%3D; b=2', 'boundless_session')).toBe('abc=')
expect(readCookie(undefined, 'x')).toBeUndefined()
})
it('只接受同源请求,并识别反向代理转发的 Host', () => {
expect(isSameOrigin(undefined, ['example.com'])).toBe(true)
expect(isSameOrigin('https://example.com', ['example.com'])).toBe(true)
expect(isSameOrigin('https://evil.com', ['example.com'])).toBe(false)
expect(isSameOrigin('https://board.me', ['board.me', 'localhost:3000'])).toBe(true)
})
it('登录失败过多时暂时锁定', () => {
const limiter = new LoginLimiter(3, 1000)
limiter.fail('ip', 0)
limiter.fail('ip', 10)
expect(limiter.isBlocked('ip', 20)).toBe(false)
limiter.fail('ip', 30)
expect(limiter.isBlocked('ip', 40)).toBe(true)
expect(limiter.isBlocked('ip', 2000)).toBe(false)
})
})
describe('detectImageType', () => {
const bytes = (...values: Array<number | string>) =>
new Uint8Array(
values.flatMap((value) =>
typeof value === 'string' ? [...value].map((char) => char.charCodeAt(0)) : [value],
),
)
it('根据文件头识别格式', () => {
expect(detectImageType(bytes(0x89, 'PNG\r\n', 0x1a, '\n', 0, 0, 0, 0))).toBe('png')
expect(detectImageType(bytes(0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0))).toBe('jpg')
expect(detectImageType(bytes('RIFF', 0, 0, 0, 0, 'WEBP'))).toBe('webp')
expect(detectImageType(bytes('<svg xmlns="http://www.w3.org/2000/svg">'))).toBe('svg')
})
it('拒绝非图片文件', () => {
expect(detectImageType(bytes('<!doctype html><script>'))).toBeNull()
expect(detectImageType(bytes('MZ', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))).toBeNull()
})
})
describe('Storage', () => {
let dir = ''
let storage: Storage | null = null
afterEach(() => {
storage?.close()
storage = null
if (dir) rmSync(dir, { recursive: true, force: true })
})
function open() {
dir = mkdtempSync(path.join(tmpdir(), 'boundless-'))
storage = new Storage(path.join(dir, 'test.sqlite'))
return storage
}
it('保存与读取文档', () => {
const db = open()
const doc = new Y.Doc()
doc.getMap('cards').set('a', 1)
db.saveDocument('board:x', Y.encodeStateAsUpdate(doc))
const loaded = new Y.Doc()
Y.applyUpdate(loaded, db.loadDocument('board:x')!)
expect(loaded.getMap('cards').get('a')).toBe(1)
db.deleteDocument('board:x')
expect(db.loadDocument('board:x')).toBeNull()
})
it('历史版本按时间倒序列出', () => {
const db = open()
const input = {
spaceId: 's',
data: new Uint8Array([1]),
stateVector: new Uint8Array([2]),
cardCount: 1,
preview: '预览',
}
const first = db.addSnapshot({ ...input, reason: 'auto' })
const second = db.addSnapshot({ ...input, reason: 'manual' })
expect(db.listSnapshots('s').map((item) => item.id)).toEqual([second, first])
expect(db.loadSnapshot('s', first)).toEqual(new Uint8Array([1]))
expect(db.loadSnapshot('other', first)).toBeNull()
db.deleteSnapshots('s')
expect(db.listSnapshots('s')).toEqual([])
})
})
+261
View File
@@ -0,0 +1,261 @@
import { readFile, stat } from 'node:fs/promises'
import path from 'node:path'
import { Hono, type Context } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import { LoginLimiter, SESSION_COOKIE, isSameOrigin, type SessionManager } from './auth.ts'
import type { Collab } from './collab.ts'
import type { ServerConfig } from './config.ts'
import type { ImageStore } from './images.ts'
interface AppDeps {
config: ServerConfig
sessions: SessionManager
collab: Collab
images: ImageStore
}
const EMOJI_MAX = 8
const NAME_MAX = 30
export function createApp({ config, sessions, collab, images }: AppDeps) {
const app = new Hono()
const limiter = new LoginLimiter()
const isHttps = (c: Context) =>
c.req.header('x-forwarded-proto')?.split(',')[0]?.trim() === 'https' ||
new URL(c.req.url).protocol === 'https:'
const writeSession = (c: Context) => {
setCookie(c, SESSION_COOKIE, sessions.issue(), {
httpOnly: true,
sameSite: 'Lax',
secure: isHttps(c),
path: '/',
maxAge: Math.floor(sessions.maxAgeMs / 1000),
})
}
app.get('/healthz', (c) => c.text('ok'))
// 跨站请求一律拒绝
app.use('/api/*', async (c, next) => {
if (c.req.method !== 'GET' && c.req.method !== 'HEAD') {
const allowed = isSameOrigin(c.req.header('origin'), [
c.req.header('x-forwarded-host'),
c.req.header('host'),
])
if (!allowed) return c.json({ error: '请求来源无效' }, 403)
}
await next()
})
// ---------------------------------------------------------------- 登录
app.post('/api/login', async (c) => {
const ip = clientIp(c)
if (limiter.isBlocked(ip)) {
return c.json({ error: '尝试次数过多,请 15 分钟后再试' }, 429)
}
const body = await c.req.json().catch(() => ({}))
const password = (body as { password?: unknown }).password
if (!sessions.checkPassword(password)) {
limiter.fail(ip)
// 稍作停顿,进一步拖慢暴力猜测
await new Promise((resolve) => setTimeout(resolve, 400))
return c.json({ error: '口令不正确' }, 401)
}
limiter.reset(ip)
writeSession(c)
return c.json({ ok: true })
})
app.post('/api/logout', (c) => {
deleteCookie(c, SESSION_COOKIE, { path: '/' })
return c.json({ ok: true })
})
app.get('/api/session', (c) => {
const session = sessions.verify(getCookie(c, SESSION_COOKIE))
if (session && sessions.shouldRenew(session)) writeSession(c)
return c.json({ authenticated: Boolean(session) })
})
// 以下接口都需要登录
app.use('/api/*', async (c, next) => {
if (!sessions.verify(getCookie(c, SESSION_COOKIE))) {
return c.json({ error: '请先登录' }, 401)
}
await next()
})
// ---------------------------------------------------------------- 空间
app.post('/api/spaces', async (c) => {
const input = readSpaceInput(await c.req.json().catch(() => ({})))
if (!input.name) return c.json({ error: '请填写空间名称' }, 400)
const space = await collab.createSpace(input.name, input.emoji ?? '✦')
return c.json(space, 201)
})
app.patch('/api/spaces/:id', async (c) => {
const input = readSpaceInput(await c.req.json().catch(() => ({})))
if (input.name === '') return c.json({ error: '请填写空间名称' }, 400)
const space = await collab.updateSpace(c.req.param('id'), input)
return space ? c.json(space) : c.json({ error: '空间不存在' }, 404)
})
app.delete('/api/spaces/:id', async (c) => {
if (collab.listSpaces().length <= 1) return c.json({ error: '至少保留一个空间' }, 400)
const deleted = await collab.deleteSpace(c.req.param('id'))
return deleted ? c.json({ ok: true }) : c.json({ error: '空间不存在' }, 404)
})
// ---------------------------------------------------------------- 历史版本
app.get('/api/spaces/:id/versions', (c) => {
const id = c.req.param('id')
if (!collab.spaceExists(id)) return c.json({ error: '空间不存在' }, 404)
return c.json(collab.listSnapshots(id))
})
app.post('/api/spaces/:id/versions', (c) => {
const id = c.req.param('id')
if (!collab.spaceExists(id)) return c.json({ error: '空间不存在' }, 404)
const snapshotId = collab.saveSnapshot(id, 'manual')
return snapshotId === null ? c.json({ error: '空间还没有内容' }, 400) : c.json({ id: snapshotId })
})
app.post('/api/spaces/:id/versions/:versionId/restore', async (c) => {
const id = c.req.param('id')
const versionId = Number(c.req.param('versionId'))
if (!collab.spaceExists(id) || !Number.isInteger(versionId)) {
return c.json({ error: '版本不存在' }, 404)
}
const restored = await collab.restoreSnapshot(id, versionId)
return restored ? c.json({ ok: true }) : c.json({ error: '版本不存在' }, 404)
})
// ---------------------------------------------------------------- 搜索
app.get('/api/search', (c) => {
const query = (c.req.query('q') ?? '').slice(0, 100)
return c.json(collab.search(query))
})
// ---------------------------------------------------------------- 图片
app.post(
'/api/images',
bodyLimit({
maxSize: config.maxImageBytes,
onError: (c) =>
c.json({ error: `图片不能超过 ${Math.round(config.maxImageBytes / 1048576)}MB` }, 413),
}),
async (c) => {
const bytes = new Uint8Array(await c.req.arrayBuffer())
const name = await images.save(bytes)
if (!name) return c.json({ error: '不支持的图片格式' }, 415)
return c.json({ url: `/api/images/${name}` }, 201)
},
)
app.get('/api/images/:name', async (c) => {
const image = await images.read(c.req.param('name'))
if (!image) return c.json({ error: '图片不存在' }, 404)
return c.body(new Uint8Array(image.data), 200, {
'Content-Type': image.type,
'Cache-Control': 'private, max-age=31536000, immutable',
// SVG 里可能带脚本,直接打开时也只当作图片
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
'X-Content-Type-Options': 'nosniff',
})
})
app.all('/api/*', (c) => c.json({ error: '接口不存在' }, 404))
// ---------------------------------------------------------------- 前端页面
if (config.staticDir) {
const root = config.staticDir
app.get('*', async (c) => {
const pathname = decodeURIComponent(new URL(c.req.url).pathname)
const file = await resolveStatic(root, pathname)
if (file) {
return c.body(new Uint8Array(await readFile(file)), 200, {
'Content-Type': contentType(file),
'Cache-Control': cacheControl(pathname),
})
}
// 前端路由:未知路径都返回 index.html
if (path.extname(pathname)) return c.notFound()
return c.body(new Uint8Array(await readFile(path.join(root, 'index.html'))), 200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache',
})
})
}
return app
}
function readSpaceInput(body: unknown): { name?: string; emoji?: string } {
const input = (body ?? {}) as { name?: unknown; emoji?: unknown }
return {
name: typeof input.name === 'string' ? input.name.trim().slice(0, NAME_MAX) : undefined,
emoji:
typeof input.emoji === 'string' && input.emoji.trim()
? [...input.emoji.trim()].slice(0, EMOJI_MAX).join('')
: undefined,
}
}
function clientIp(c: Context) {
return (
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ||
c.req.header('x-real-ip') ||
'local'
)
}
async function resolveStatic(root: string, pathname: string) {
const file = path.resolve(root, `.${pathname}`)
if (file !== root && !file.startsWith(root + path.sep)) return null
// 目录(例如 /painterly/)返回其中的 index.html
for (const candidate of [file, path.join(file, 'index.html')]) {
try {
if ((await stat(candidate)).isFile()) return candidate
} catch {
// 继续尝试下一个
}
}
return null
}
const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.webmanifest': 'application/manifest+json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.webp': 'image/webp',
'.woff2': 'font/woff2',
'.txt': 'text/plain; charset=utf-8',
}
function contentType(file: string) {
return MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream'
}
function cacheControl(pathname: string) {
if (pathname.startsWith('/assets/')) return 'public, max-age=31536000, immutable'
if (pathname.startsWith('/painterly/vendor/')) return 'public, max-age=2592000'
if (pathname.startsWith('/painterly/')) return 'public, max-age=86400'
return 'no-cache'
}
+144
View File
@@ -0,0 +1,144 @@
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
export const SESSION_COOKIE = 'boundless_session'
interface SessionPayload {
/** 签发时间(毫秒) */
iat: number
/** 过期时间(毫秒) */
exp: number
}
/**
* 会话令牌:`base64url(payload).签名`。
* 签名密钥由服务端密钥和登录口令共同派生,所以修改口令后所有设备都会退出登录。
*/
export class SessionManager {
private readonly key: Buffer
private readonly passwordHash: Buffer
readonly maxAgeMs: number
constructor(secret: string, password: string, sessionDays: number) {
this.key = createHmac('sha256', secret).update(`session:${password}`).digest()
this.passwordHash = sha256(password)
this.maxAgeMs = sessionDays * 24 * 60 * 60 * 1000
}
checkPassword(candidate: unknown) {
if (typeof candidate !== 'string' || candidate.length > 1024) return false
return timingSafeEqual(sha256(candidate), this.passwordHash)
}
issue(now = Date.now()) {
const payload: SessionPayload = { iat: now, exp: now + this.maxAgeMs }
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
return `${body}.${this.sign(body)}`
}
verify(token: string | undefined | null, now = Date.now()): SessionPayload | null {
if (!token) return null
const [body, signature, extra] = token.split('.')
if (!body || !signature || extra !== undefined) return null
const expected = Buffer.from(this.sign(body))
const actual = Buffer.from(signature)
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null
try {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as SessionPayload
if (typeof payload.exp !== 'number' || payload.exp <= now) return null
return payload
} catch {
return null
}
}
/** 已用过一半有效期的令牌需要续期,常用设备就不会突然被登出。 */
shouldRenew(payload: SessionPayload, now = Date.now()) {
return payload.exp - now < this.maxAgeMs / 2
}
private sign(body: string) {
return createHmac('sha256', this.key).update(body).digest('base64url')
}
}
function sha256(value: string) {
return createHash('sha256').update(value).digest()
}
export function readCookie(header: string | null | undefined, name: string) {
if (!header) return undefined
for (const part of header.split(';')) {
const index = part.indexOf('=')
if (index === -1) continue
if (part.slice(0, index).trim() === name) {
try {
return decodeURIComponent(part.slice(index + 1).trim())
} catch {
return undefined
}
}
}
return undefined
}
/**
* 登录失败限流:同一来源 15 分钟内最多失败 10 次。
* 只有一个人使用,内存记录就够了。
*/
export class LoginLimiter {
private readonly failures = new Map<string, number[]>()
private readonly maxFailures: number
private readonly windowMs: number
constructor(maxFailures = 10, windowMs = 15 * 60 * 1000) {
this.maxFailures = maxFailures
this.windowMs = windowMs
}
isBlocked(key: string, now = Date.now()) {
return this.recent(key, now).length >= this.maxFailures
}
fail(key: string, now = Date.now()) {
const list = this.recent(key, now)
list.push(now)
this.failures.set(key, list)
}
reset(key: string) {
this.failures.delete(key)
}
private recent(key: string, now: number) {
const list = (this.failures.get(key) ?? []).filter((time) => now - time < this.windowMs)
if (list.length === 0) this.failures.delete(key)
else this.failures.set(key, list)
return list
}
}
/**
* 校验请求来源与当前站点一致,阻止其他网站借用登录状态发起请求或建立 WebSocket。
* 没有 Origin 头(例如服务端脚本、curl)时放行,因为它们本来就带不上浏览器 Cookie。
* 反向代理可能改写 Host,所以同时接受 X-Forwarded-Host。
*/
export function isSameOrigin(
origin: string | null | undefined,
hosts: Array<string | null | undefined>,
) {
if (!origin) return true
let originHost: string
try {
originHost = new URL(origin).host.toLowerCase()
} catch {
return false
}
return hosts.some((host) =>
host
?.split(',')
.map((item) => item.trim().toLowerCase())
.includes(originHost),
)
}
+331
View File
@@ -0,0 +1,331 @@
import { randomUUID } from 'node:crypto'
import { Database } from '@hocuspocus/extension-database'
import { Hocuspocus } from '@hocuspocus/server'
import * as Y from 'yjs'
import {
META_DOC,
boardCards,
boardDocName,
createCardMap,
docFromUpdate,
metaSpaces,
readCards,
readSpace,
readSpaces,
replaceBoardContent,
spaceIdFromDocName,
type CardFields,
type SpaceInfo,
} from '../shared/board.ts'
import { SESSION_COOKIE, readCookie, type SessionManager } from './auth.ts'
import type { SnapshotReason, Storage } from './db.ts'
/** 自动历史版本的最小间隔 */
const AUTO_SNAPSHOT_INTERVAL = 10 * 60 * 1000
export interface SearchHit {
spaceId: string
cardId: string
title: string
snippet: string
}
type DirectConnection = Awaited<ReturnType<Hocuspocus['openDirectConnection']>>
export class Collab {
readonly hocuspocus: Hocuspocus
private readonly storage: Storage
private readonly sessions: SessionManager
private meta: DirectConnection | null = null
constructor(storage: Storage, sessions: SessionManager) {
this.storage = storage
this.sessions = sessions
this.hocuspocus = new Hocuspocus({
name: 'boundless',
quiet: true,
debounce: 1000,
maxDebounce: 5000,
extensions: [
new Database({
fetch: async ({ documentName }) => this.storage.loadDocument(documentName),
store: async ({ documentName, state, document }) => {
this.persist(documentName, state, document)
},
}),
],
onAuthenticate: async ({ requestHeaders, documentName, connectionConfig }) => {
const token = readCookie(requestHeaders.get('cookie'), SESSION_COOKIE)
if (!this.sessions.verify(token)) throw rejection('unauthorized')
if (documentName === META_DOC) {
// 空间列表只能由服务端修改
connectionConfig.readOnly = true
return
}
const spaceId = spaceIdFromDocName(documentName)
if (!spaceId || !this.spaceExists(spaceId)) throw rejection('not-found')
},
})
}
async start() {
this.meta = await this.hocuspocus.openDirectConnection(META_DOC, { system: true })
if (metaSpaces(this.metaDoc).size === 0) await this.seed()
}
async stop() {
await this.meta?.disconnect()
this.meta = null
}
private get metaDoc(): Y.Doc {
const doc = this.meta?.document
if (!doc) throw new Error('collab not started')
return doc
}
// ---------------------------------------------------------------- 空间
spaceExists(spaceId: string) {
return metaSpaces(this.metaDoc).has(spaceId)
}
listSpaces(): SpaceInfo[] {
return readSpaces(this.metaDoc)
}
async createSpace(name: string, emoji: string): Promise<SpaceInfo> {
const id = randomUUID()
const now = Date.now()
await this.meta!.transact((doc) => {
const map = new Y.Map<unknown>()
map.set('name', name)
map.set('emoji', emoji)
map.set('createdAt', now)
map.set('updatedAt', now)
map.set('cardCount', 0)
metaSpaces(doc).set(id, map)
})
return readSpace(id, metaSpaces(this.metaDoc).get(id)!)
}
async updateSpace(spaceId: string, patch: { name?: string; emoji?: string }) {
const map = metaSpaces(this.metaDoc).get(spaceId)
if (!map) return null
await this.meta!.transact(() => {
if (patch.name !== undefined) map.set('name', patch.name)
if (patch.emoji !== undefined) map.set('emoji', patch.emoji)
map.set('updatedAt', Date.now())
})
return readSpace(spaceId, map)
}
async deleteSpace(spaceId: string) {
if (!this.spaceExists(spaceId)) return false
await this.meta!.transact((doc) => {
metaSpaces(doc).delete(spaceId)
})
const name = boardDocName(spaceId)
// 断开仍打开这个空间的设备;persist() 会忽略已删除空间的延迟写入
this.hocuspocus.closeConnections(name)
this.storage.deleteDocument(name)
this.storage.deleteSnapshots(spaceId)
return true
}
// ---------------------------------------------------------------- 持久化
private persist(documentName: string, state: Uint8Array, document: Y.Doc) {
const spaceId = spaceIdFromDocName(documentName)
if (spaceId) {
if (!this.spaceExists(spaceId)) return
this.autoSnapshot(spaceId, documentName)
}
this.storage.saveDocument(documentName, state)
if (spaceId) this.updateCardCount(spaceId, boardCards(document).size)
}
/**
* 写入新状态前,把上一次保存的状态存成历史版本。
* 保存的是“改动之前”的样子,误删一大片之后也能找回来。
*/
private autoSnapshot(spaceId: string, documentName: string) {
const latest = this.storage.latestSnapshot(spaceId)
if (latest && Date.now() - latest.createdAt < AUTO_SNAPSHOT_INTERVAL) return
const previous = this.storage.loadDocument(documentName)
if (!previous) return
this.snapshotFromDoc(spaceId, docFromUpdate(previous), 'auto', latest?.stateVector)
}
private snapshotFromDoc(
spaceId: string,
doc: Y.Doc,
reason: SnapshotReason,
skipIfStateVector?: Uint8Array,
) {
const stateVector = Y.encodeStateVector(doc)
if (skipIfStateVector && sameBytes(stateVector, skipIfStateVector)) return null
const cards = readCards(doc)
const preview = cards
.map((card) => card.title.trim())
.filter(Boolean)
.slice(0, 4)
.join('、')
return this.storage.addSnapshot({
spaceId,
data: Y.encodeStateAsUpdate(doc),
stateVector,
cardCount: cards.length,
preview,
reason,
})
}
private updateCardCount(spaceId: string, count: number) {
const map = metaSpaces(this.metaDoc).get(spaceId)
if (!map || map.get('cardCount') === count) return
void this.meta!.transact(() => {
map.set('cardCount', count)
})
}
/** 读取空间画布:已在内存中的用实时文档,否则从数据库加载一份只读副本。 */
private boardDoc(spaceId: string): Y.Doc | null {
const name = boardDocName(spaceId)
const live = this.hocuspocus.documents.get(name)
if (live) return live
const saved = this.storage.loadDocument(name)
return saved ? docFromUpdate(saved) : null
}
// ---------------------------------------------------------------- 历史版本
listSnapshots(spaceId: string) {
return this.storage.listSnapshots(spaceId)
}
saveSnapshot(spaceId: string, reason: SnapshotReason = 'manual') {
const doc = this.boardDoc(spaceId)
if (!doc) return null
return this.snapshotFromDoc(spaceId, doc, reason)
}
async restoreSnapshot(spaceId: string, snapshotId: number) {
const data = this.storage.loadSnapshot(spaceId, snapshotId)
if (!data) return false
// 先备份当前状态,恢复操作本身也可以撤回
this.saveSnapshot(spaceId, 'restore')
const connection = await this.hocuspocus.openDirectConnection(boardDocName(spaceId), {
system: true,
})
try {
await connection.transact((doc) => replaceBoardContent(doc, docFromUpdate(data)))
} finally {
await connection.disconnect()
}
return true
}
// ---------------------------------------------------------------- 搜索
search(query: string, limit = 60): SearchHit[] {
const needle = query.trim().toLocaleLowerCase()
if (!needle) return []
const hits: SearchHit[] = []
for (const space of this.listSpaces()) {
const doc = this.boardDoc(space.id)
if (!doc) continue
for (const card of readCards(doc)) {
const haystack = `${card.title}\n${card.content}`
const index = haystack.toLocaleLowerCase().indexOf(needle)
if (index === -1) continue
hits.push({
spaceId: space.id,
cardId: card.id,
title: card.title,
snippet: snippetAround(haystack, index, needle.length),
})
if (hits.length >= limit) return hits
}
}
return hits
}
// ---------------------------------------------------------------- 初始内容
private async seed() {
const space = await this.createSpace('我的宇宙', '✦')
const now = Date.now()
const cards: Array<Omit<CardFields, 'createdAt' | 'updatedAt' | 'z'>> = [
{
kind: 'note',
title: '欢迎来到无界笺',
content:
'双击空白处,马上记下一件事。\n\n这里的内容保存在你的服务器上,在任何一台设备上打开都是同一张画布。',
color: 'paper',
x: -180,
y: -80,
width: 330,
height: 240,
},
{
kind: 'note',
title: '今天想做的事',
content: '□ 收集灵感\n□ 整理一个想法\n□ 留一点时间发呆',
color: 'sun',
x: 220,
y: -140,
width: 290,
height: 210,
},
{
kind: 'note',
title: '一个小提示',
content:
'拖入或粘贴图片,卡片右侧圆点可以拉出连线。Shift+拖拽批量框选,右上角可以找回历史版本。',
color: 'mint',
x: 120,
y: 170,
width: 300,
height: 200,
},
]
const connection = await this.hocuspocus.openDirectConnection(boardDocName(space.id), {
system: true,
})
try {
await connection.transact((doc) => {
const map = boardCards(doc)
cards.forEach((card, index) => {
map.set(
randomUUID(),
createCardMap({ ...card, z: index, createdAt: now + index, updatedAt: now + index }),
)
})
})
} finally {
await connection.disconnect()
}
}
}
/** Hocuspocus 会把 error.reason 原样发给客户端,客户端据此区分“未登录”和“空间不存在” */
function rejection(reason: 'unauthorized' | 'not-found') {
return Object.assign(new Error(reason), { reason })
}
function sameBytes(a: Uint8Array, b: Uint8Array) {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
return true
}
function snippetAround(text: string, index: number, length: number) {
const start = Math.max(0, index - 24)
const end = Math.min(text.length, index + length + 48)
const body = text.slice(start, end).replace(/\s+/g, ' ').trim()
return `${start > 0 ? '…' : ''}${body}${end < text.length ? '…' : ''}`
}
+63
View File
@@ -0,0 +1,63 @@
import { randomBytes } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
export interface ServerConfig {
port: number
host: string
dataDir: string
imagesDir: string
databaseFile: string
/** 构建后的前端目录;为空表示不托管静态文件(开发时由 Vite 负责) */
staticDir: string | null
password: string
secret: string
sessionDays: number
maxImageBytes: number
production: boolean
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
const production = env.NODE_ENV === 'production'
const dataDir = path.resolve(env.DATA_DIR || './data')
const imagesDir = path.join(dataDir, 'images')
mkdirSync(imagesDir, { recursive: true })
let password = env.BOUNDLESS_PASSWORD ?? ''
if (!password) {
if (production) {
throw new Error('请通过环境变量 BOUNDLESS_PASSWORD 设置登录口令')
}
password = 'boundless'
console.warn('[boundless] 未设置 BOUNDLESS_PASSWORD,开发模式下使用默认口令 "boundless"')
}
// 默认托管构建好的 dist/;开发时页面由 Vite 提供,这里有没有都不影响
const staticDir = path.resolve(env.STATIC_DIR || 'dist')
return {
port: Number(env.PORT) || 3000,
host: env.HOST || '0.0.0.0',
dataDir,
imagesDir,
databaseFile: path.join(dataDir, 'boundless.sqlite'),
staticDir: staticDir && existsSync(staticDir) ? staticDir : null,
password,
secret: env.BOUNDLESS_SECRET || loadOrCreateSecret(dataDir),
sessionDays: Number(env.SESSION_DAYS) || 90,
maxImageBytes: (Number(env.MAX_IMAGE_MB) || 20) * 1024 * 1024,
production,
}
}
/** 未显式提供密钥时,生成一份并保存在数据目录,重启后登录状态仍然有效。 */
function loadOrCreateSecret(dataDir: string) {
const file = path.join(dataDir, 'session.secret')
if (existsSync(file)) {
const saved = readFileSync(file, 'utf8').trim()
if (saved) return saved
}
const secret = randomBytes(32).toString('base64url')
writeFileSync(file, secret, { mode: 0o600 })
return secret
}
+149
View File
@@ -0,0 +1,149 @@
import { DatabaseSync } from 'node:sqlite'
export interface SnapshotRow {
id: number
spaceId: string
createdAt: number
cardCount: number
preview: string
reason: SnapshotReason
}
/** auto:编辑时定时保存;manual:手动保存;restore:恢复前自动备份当前状态 */
export type SnapshotReason = 'auto' | 'manual' | 'restore'
/** 每个空间最多保留的历史版本数 */
const MAX_SNAPSHOTS_PER_SPACE = 120
/**
* SQLite 存储,使用 Node 内置的 node:sqlite,不依赖原生编译模块,部署更省心。
* - documents:Yjs 文档的二进制状态
* - snapshots:空间的历史版本
*/
export class Storage {
private readonly db: DatabaseSync
constructor(file: string) {
this.db = new DatabaseSync(file)
this.db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
CREATE TABLE IF NOT EXISTS documents (
name TEXT PRIMARY KEY,
data BLOB NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
space_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
data BLOB NOT NULL,
state_vector BLOB NOT NULL,
card_count INTEGER NOT NULL,
preview TEXT NOT NULL,
reason TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS snapshots_space ON snapshots (space_id, created_at);
`)
}
loadDocument(name: string): Uint8Array | null {
const row = this.db.prepare('SELECT data FROM documents WHERE name = ?').get(name) as
| { data: Uint8Array }
| undefined
return row ? new Uint8Array(row.data) : null
}
saveDocument(name: string, data: Uint8Array) {
this.db
.prepare(
`INSERT INTO documents (name, data, updated_at) VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`,
)
.run(name, data, Date.now())
}
deleteDocument(name: string) {
this.db.prepare('DELETE FROM documents WHERE name = ?').run(name)
}
latestSnapshot(spaceId: string): { createdAt: number; stateVector: Uint8Array } | null {
const row = this.db
.prepare(
'SELECT created_at, state_vector FROM snapshots WHERE space_id = ? ORDER BY id DESC LIMIT 1',
)
.get(spaceId) as { created_at: number; state_vector: Uint8Array } | undefined
return row ? { createdAt: row.created_at, stateVector: new Uint8Array(row.state_vector) } : null
}
addSnapshot(input: {
spaceId: string
data: Uint8Array
stateVector: Uint8Array
cardCount: number
preview: string
reason: SnapshotReason
}) {
const result = this.db
.prepare(
`INSERT INTO snapshots (space_id, created_at, data, state_vector, card_count, preview, reason)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.run(
input.spaceId,
Date.now(),
input.data,
input.stateVector,
input.cardCount,
input.preview,
input.reason,
)
this.db
.prepare(
`DELETE FROM snapshots WHERE space_id = ? AND id NOT IN (
SELECT id FROM snapshots WHERE space_id = ? ORDER BY id DESC LIMIT ?
)`,
)
.run(input.spaceId, input.spaceId, MAX_SNAPSHOTS_PER_SPACE)
return Number(result.lastInsertRowid)
}
listSnapshots(spaceId: string): SnapshotRow[] {
const rows = this.db
.prepare(
`SELECT id, space_id, created_at, card_count, preview, reason
FROM snapshots WHERE space_id = ? ORDER BY id DESC`,
)
.all(spaceId) as Array<{
id: number
space_id: string
created_at: number
card_count: number
preview: string
reason: SnapshotReason
}>
return rows.map((row) => ({
id: Number(row.id),
spaceId: row.space_id,
createdAt: row.created_at,
cardCount: row.card_count,
preview: row.preview,
reason: row.reason,
}))
}
loadSnapshot(spaceId: string, id: number): Uint8Array | null {
const row = this.db
.prepare('SELECT data FROM snapshots WHERE space_id = ? AND id = ?')
.get(spaceId, id) as { data: Uint8Array } | undefined
return row ? new Uint8Array(row.data) : null
}
deleteSnapshots(spaceId: string) {
this.db.prepare('DELETE FROM snapshots WHERE space_id = ?').run(spaceId)
}
close() {
this.db.close()
}
}
+67
View File
@@ -0,0 +1,67 @@
import { createHash } from 'node:crypto'
import { existsSync } from 'node:fs'
import { readFile, rename, writeFile } from 'node:fs/promises'
import path from 'node:path'
const TYPES = {
png: 'image/png',
jpg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
avif: 'image/avif',
bmp: 'image/bmp',
svg: 'image/svg+xml',
} as const
type ImageExtension = keyof typeof TYPES
const FILE_NAME = /^[a-f0-9]{40}\.(png|jpg|gif|webp|avif|bmp|svg)$/
/** 根据文件头判断真实格式,不信任浏览器给的 Content-Type。 */
export function detectImageType(bytes: Uint8Array): ImageExtension | null {
const ascii = (start: number, end: number) => String.fromCharCode(...bytes.subarray(start, end))
if (bytes.length < 12) return null
if (bytes[0] === 0x89 && ascii(1, 4) === 'PNG') return 'png'
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'jpg'
if (ascii(0, 4) === 'GIF8') return 'gif'
if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP') return 'webp'
if (ascii(4, 8) === 'ftyp' && ['avif', 'avis'].includes(ascii(8, 12))) return 'avif'
if (ascii(0, 2) === 'BM') return 'bmp'
const head = new TextDecoder().decode(bytes.subarray(0, 1024)).trimStart().toLowerCase()
if ((head.startsWith('<svg') || head.startsWith('<?xml')) && head.includes('<svg')) return 'svg'
return null
}
export class ImageStore {
private readonly dir: string
constructor(dir: string) {
this.dir = dir
}
/** 按内容哈希命名,同一张图片重复上传只存一份。 */
async save(bytes: Uint8Array) {
const ext = detectImageType(bytes)
if (!ext) return null
const hash = createHash('sha1').update(bytes).digest('hex')
const name = `${hash}.${ext}`
const file = path.join(this.dir, name)
if (!existsSync(file)) {
const temp = `${file}.${process.pid}.tmp`
await writeFile(temp, bytes)
await rename(temp, file)
}
return name
}
async read(name: string) {
if (!FILE_NAME.test(name)) return null
try {
const data = await readFile(path.join(this.dir, name))
const ext = name.slice(name.lastIndexOf('.') + 1) as ImageExtension
return { data, type: TYPES[ext] }
} catch {
return null
}
}
}
+87
View File
@@ -0,0 +1,87 @@
import { createServer } from 'node:http'
import type { Duplex } from 'node:stream'
import { getRequestListener } from '@hono/node-server'
import type { WebSocketLike } from '@hocuspocus/server'
import crossws from 'crossws/adapters/node'
import { createApp } from './app.ts'
import { SESSION_COOKIE, SessionManager, isSameOrigin, readCookie } from './auth.ts'
import { Collab } from './collab.ts'
import { loadConfig } from './config.ts'
import { Storage } from './db.ts'
import { ImageStore } from './images.ts'
type ClientConnection = ReturnType<Collab['hocuspocus']['handleConnection']>
const config = loadConfig()
const storage = new Storage(config.databaseFile)
const sessions = new SessionManager(config.secret, config.password, config.sessionDays)
const collab = new Collab(storage, sessions)
await collab.start()
const app = createApp({ config, sessions, collab, images: new ImageStore(config.imagesDir) })
const server = createServer(getRequestListener(app.fetch))
const connections = new WeakMap<object, ClientConnection>()
const ws = crossws({
hooks: {
open(peer) {
const connection = collab.hocuspocus.handleConnection(
peer.websocket as unknown as WebSocketLike,
peer.request as Request,
)
connections.set(peer, connection)
},
message(peer, message) {
connections.get(peer)?.handleMessage(message.uint8Array())
},
close(peer, event) {
connections.get(peer)?.handleClose({ code: event.code ?? 1000, reason: event.reason ?? '' })
connections.delete(peer)
},
error(peer, error) {
console.error('[boundless] websocket error', error)
},
},
})
server.on('upgrade', (request, socket: Duplex, head) => {
const url = new URL(request.url ?? '/', 'http://localhost')
const forwardedHost = request.headers['x-forwarded-host']
const allowed =
url.pathname === '/collab' &&
isSameOrigin(request.headers.origin, [
Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost,
request.headers.host,
]) &&
sessions.verify(readCookie(request.headers.cookie, SESSION_COOKIE))
if (!allowed) {
socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
return
}
void ws.handleUpgrade(request, socket, head)
})
server.listen(config.port, config.host, () => {
console.log(`[boundless] 已启动 http://${config.host}:${config.port}`)
console.log(`[boundless] 数据目录 ${config.dataDir}`)
if (!config.staticDir) console.log('[boundless] 未托管前端页面(开发模式请访问 Vite 地址)')
})
let shuttingDown = false
async function shutdown() {
if (shuttingDown) return
shuttingDown = true
server.close()
// 先把还没写盘的修改落盘,再关闭数据库
await collab.stop()
await collab.hocuspocus.closeConnections()
await new Promise((resolve) => setTimeout(resolve, 200))
storage.close()
process.exit(0)
}
process.on('SIGINT', () => void shutdown())
process.on('SIGTERM', () => void shutdown())
+184
View File
@@ -0,0 +1,184 @@
// 前后端共用的 Yjs 文档结构。
//
// 文档划分:
// - `meta`:空间(标签)列表。客户端只读,增删改都走 REST 接口由服务端写入。
// - `board:<spaceId>`:一个空间的画布。`cards` 和 `edges` 两个 Y.Map。
//
// 每张卡片是一个嵌套 Y.Map,这样位置、尺寸、文字等字段可以分别合并,
// 两台设备同时改同一张卡片的不同字段不会互相覆盖。
import * as Y from 'yjs'
export const META_DOC = 'meta'
const BOARD_PREFIX = 'board:'
export type CardKind = 'note' | 'image'
export type CardColor = 'paper' | 'sun' | 'mint' | 'sky' | 'rose'
export const CARD_COLORS: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
export interface SpaceInfo {
id: string
name: string
emoji: string
createdAt: number
updatedAt: number
cardCount: number
}
export interface CardFields {
kind: CardKind
title: string
content: string
/** 图片地址(服务端 /api/images/...),仅图片卡片有 */
image?: string
color: CardColor
x: number
y: number
width: number
height: number
/** 叠放顺序,越大越靠上 */
z: number
createdAt: number
updatedAt: number
}
export interface Card extends CardFields {
id: string
}
export interface EdgeFields {
source: string
target: string
sourceHandle?: string | null
targetHandle?: string | null
}
export interface Edge extends EdgeFields {
id: string
}
export function boardDocName(spaceId: string) {
return `${BOARD_PREFIX}${spaceId}`
}
export function spaceIdFromDocName(name: string): string | null {
return name.startsWith(BOARD_PREFIX) ? name.slice(BOARD_PREFIX.length) : null
}
export function metaSpaces(doc: Y.Doc) {
return doc.getMap<Y.Map<unknown>>('spaces')
}
export function boardCards(doc: Y.Doc) {
return doc.getMap<Y.Map<unknown>>('cards')
}
export function boardEdges(doc: Y.Doc) {
return doc.getMap<EdgeFields>('edges')
}
function num(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
function str(value: unknown, fallback = '') {
return typeof value === 'string' ? value : fallback
}
export function readSpace(id: string, map: Y.Map<unknown>): SpaceInfo {
return {
id,
name: str(map.get('name'), '未命名空间'),
emoji: str(map.get('emoji'), '✦'),
createdAt: num(map.get('createdAt'), 0),
updatedAt: num(map.get('updatedAt'), 0),
cardCount: num(map.get('cardCount'), 0),
}
}
export function readSpaces(doc: Y.Doc): SpaceInfo[] {
const spaces: SpaceInfo[] = []
metaSpaces(doc).forEach((map, id) => spaces.push(readSpace(id, map)))
return spaces.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id))
}
export function readCard(id: string, map: Y.Map<unknown>): Card {
const kind = map.get('kind') === 'image' ? 'image' : 'note'
const color = map.get('color')
const image = map.get('image')
return {
id,
kind,
title: str(map.get('title')),
content: str(map.get('content')),
image: typeof image === 'string' && image ? image : undefined,
color: CARD_COLORS.includes(color as CardColor) ? (color as CardColor) : 'paper',
x: num(map.get('x'), 0),
y: num(map.get('y'), 0),
width: num(map.get('width'), 300),
height: num(map.get('height'), 220),
z: num(map.get('z'), 0),
createdAt: num(map.get('createdAt'), 0),
updatedAt: num(map.get('updatedAt'), 0),
}
}
export function readCards(doc: Y.Doc): Card[] {
const cards: Card[] = []
boardCards(doc).forEach((map, id) => cards.push(readCard(id, map)))
return cards.sort((a, b) => a.z - b.z || a.createdAt - b.createdAt)
}
export function readEdges(doc: Y.Doc): Edge[] {
const edges: Edge[] = []
boardEdges(doc).forEach((edge, id) => edges.push({ ...edge, id }))
return edges
}
/** 新建卡片的 Y.Map。只能在事务内调用并立即放进 cards。 */
export function createCardMap(fields: CardFields): Y.Map<unknown> {
const map = new Y.Map<unknown>()
for (const [key, value] of Object.entries(fields)) {
if (value !== undefined) map.set(key, value)
}
return map
}
/** 只写入真正变化的字段,避免产生多余的同步数据。 */
export function patchCardMap(map: Y.Map<unknown>, patch: Partial<CardFields>) {
let changed = false
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) {
if (map.has(key)) {
map.delete(key)
changed = true
}
} else if (map.get(key) !== value) {
map.set(key, value)
changed = true
}
}
return changed
}
/** 用另一份文档的内容整体替换画布(恢复历史版本时使用)。 */
export function replaceBoardContent(target: Y.Doc, source: Y.Doc) {
const cards = boardCards(target)
const edges = boardEdges(target)
for (const id of [...cards.keys()]) cards.delete(id)
for (const id of [...edges.keys()]) edges.delete(id)
for (const card of readCards(source)) {
const { id, ...fields } = card
cards.set(id, createCardMap(fields))
}
for (const edge of readEdges(source)) {
const { id, ...fields } = edge
if (cards.has(fields.source) && cards.has(fields.target)) edges.set(id, fields)
}
}
export function docFromUpdate(update: Uint8Array) {
const doc = new Y.Doc()
Y.applyUpdate(doc, update)
return doc
}
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import * as Y from 'yjs'
import {
boardCards,
boardEdges,
createCardMap,
docFromUpdate,
patchCardMap,
readCards,
readEdges,
replaceBoardContent,
spaceIdFromDocName,
boardDocName,
type CardFields,
} from '@shared/board'
const baseCard: CardFields = {
kind: 'note',
title: '标题',
content: '内容',
color: 'paper',
x: 0,
y: 0,
width: 300,
height: 220,
z: 0,
createdAt: 1,
updatedAt: 1,
}
function sync(a: Y.Doc, b: Y.Doc) {
Y.applyUpdate(b, Y.encodeStateAsUpdate(a, Y.encodeStateVector(b)))
Y.applyUpdate(a, Y.encodeStateAsUpdate(b, Y.encodeStateVector(a)))
}
describe('shared/board', () => {
it('文档名与空间 id 互相转换', () => {
expect(spaceIdFromDocName(boardDocName('abc'))).toBe('abc')
expect(spaceIdFromDocName('meta')).toBeNull()
})
it('按叠放顺序读出卡片,并修正异常字段', () => {
const doc = new Y.Doc()
const cards = boardCards(doc)
cards.set('top', createCardMap({ ...baseCard, z: 5 }))
cards.set('bottom', createCardMap({ ...baseCard, z: -1 }))
const broken = new Y.Map<unknown>()
broken.set('color', 'neon')
broken.set('x', 'oops')
cards.set('broken', broken)
const result = readCards(doc)
expect(result.map((card) => card.id)).toEqual(['bottom', 'broken', 'top'])
expect(result[1]).toMatchObject({ color: 'paper', x: 0, kind: 'note' })
})
it('两台设备同时改同一张卡片的不同字段,修改都会保留', () => {
const a = new Y.Doc()
const b = new Y.Doc()
boardCards(a).set('card', createCardMap(baseCard))
sync(a, b)
patchCardMap(boardCards(a).get('card')!, { x: 120, y: 80 })
patchCardMap(boardCards(b).get('card')!, { title: '新标题', color: 'mint' })
sync(a, b)
for (const doc of [a, b]) {
expect(readCards(doc)[0]).toMatchObject({ x: 120, y: 80, title: '新标题', color: 'mint' })
}
})
it('只写入真正变化的字段', () => {
const doc = new Y.Doc()
const map = createCardMap(baseCard)
boardCards(doc).set('card', map)
expect(patchCardMap(map, { title: '标题', x: 0 })).toBe(false)
expect(patchCardMap(map, { title: '改了' })).toBe(true)
})
it('恢复历史版本时替换全部卡片和连线,并丢弃悬空连线', () => {
const snapshot = new Y.Doc()
boardCards(snapshot).set('a', createCardMap({ ...baseCard, title: '旧 A' }))
boardCards(snapshot).set('b', createCardMap({ ...baseCard, title: '旧 B' }))
boardEdges(snapshot).set('ab', { source: 'a', target: 'b' })
boardEdges(snapshot).set('dangling', { source: 'a', target: 'gone' })
const live = new Y.Doc()
boardCards(live).set('c', createCardMap({ ...baseCard, title: '新 C' }))
replaceBoardContent(live, docFromUpdate(Y.encodeStateAsUpdate(snapshot)))
expect(readCards(live).map((card) => card.title).sort()).toEqual(['旧 A', '旧 B'])
expect(readEdges(live).map((edge) => edge.id)).toEqual(['ab'])
})
})
+90
View File
@@ -0,0 +1,90 @@
import type { SpaceInfo } from '@shared/board'
export interface VersionInfo {
id: number
spaceId: string
createdAt: number
cardCount: number
preview: string
reason: 'auto' | 'manual' | 'restore'
}
export interface SearchHit {
spaceId: string
cardId: string
title: string
snippet: string
}
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message)
}
}
type UnauthorizedHandler = () => void
let onUnauthorized: UnauthorizedHandler = () => {}
/** 任何接口返回 401 时调用(通常是跳转到登录页)。 */
export function setUnauthorizedHandler(handler: UnauthorizedHandler) {
onUnauthorized = handler
}
export function notifyUnauthorized() {
onUnauthorized()
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers)
if (init.body && typeof init.body === 'string') headers.set('Content-Type', 'application/json')
let response: Response
try {
response = await fetch(path, { ...init, headers, credentials: 'same-origin' })
} catch {
throw new ApiError('无法连接服务器,请检查网络', 0)
}
const data = (await response.json().catch(() => ({}))) as { error?: string }
if (!response.ok) {
if (response.status === 401 && path !== '/api/login') onUnauthorized()
throw new ApiError(data.error ?? `请求失败(${response.status})`, response.status)
}
return data as T
}
const json = (body: unknown) => JSON.stringify(body)
export const api = {
session: () => request<{ authenticated: boolean }>('/api/session'),
login: (password: string) =>
request<{ ok: true }>('/api/login', { method: 'POST', body: json({ password }) }),
logout: () => request<{ ok: true }>('/api/logout', { method: 'POST' }),
createSpace: (name: string, emoji: string) =>
request<SpaceInfo>('/api/spaces', { method: 'POST', body: json({ name, emoji }) }),
updateSpace: (id: string, patch: { name?: string; emoji?: string }) =>
request<SpaceInfo>(`/api/spaces/${id}`, { method: 'PATCH', body: json(patch) }),
deleteSpace: (id: string) => request<{ ok: true }>(`/api/spaces/${id}`, { method: 'DELETE' }),
listVersions: (spaceId: string) => request<VersionInfo[]>(`/api/spaces/${spaceId}/versions`),
saveVersion: (spaceId: string) =>
request<{ id: number }>(`/api/spaces/${spaceId}/versions`, { method: 'POST' }),
restoreVersion: (spaceId: string, versionId: number) =>
request<{ ok: true }>(`/api/spaces/${spaceId}/versions/${versionId}/restore`, {
method: 'POST',
}),
search: (query: string, signal?: AbortSignal) =>
request<SearchHit[]>(`/api/search?q=${encodeURIComponent(query)}`, { signal }),
uploadImage: (file: Blob) =>
request<{ url: string }>('/api/images', {
method: 'POST',
body: file,
headers: { 'Content-Type': file.type || 'application/octet-stream' },
}),
}
+65
View File
@@ -0,0 +1,65 @@
// 启动画面(index.html 里的 #boot)的控制逻辑。
//
// 画面在主程序下载前就已显示。应用准备好后调用 dismissBoot() 关闭,
// 但会让画至少画上一小会儿,免得刚落笔就消失;同时设置上限,
// 不会因为动画而拖慢进入速度。
import { isPaintingMessage, prefersReducedMotion } from '@/painterly'
const MIN_PAINT_MS = 1400
const MAX_EXTRA_MS = 2600
const bootElement = document.getElementById('boot')
let paintingStartedAt: number | null = null
let dismissed = false
function onMessage(event: MessageEvent) {
if (!bootElement || !isPaintingMessage(event)) return
const tile = bootElement.querySelector<HTMLElement>('.boot__tile')
const caption = bootElement.querySelector<HTMLElement>('.boot__caption')
if (event.data.type === 'chosen') {
if (tile) tile.style.background = event.data.ground
if (caption) caption.textContent = `${event.data.name} · ${event.data.label}`
} else if (event.data.type === 'start') {
paintingStartedAt = performance.now()
bootElement.classList.add('boot--painting')
}
}
if (bootElement) window.addEventListener('message', onMessage)
/** 启动画面是否还在显示(此时不需要再显示其他加载动画) */
export function isBootActive() {
return Boolean(bootElement) && !dismissed
}
export function setBootStatus(text: string) {
const status = bootElement?.querySelector('.boot__status')
if (status) status.textContent = text
}
export function dismissBoot() {
if (!bootElement || dismissed) return
dismissed = true
const readyAt = performance.now()
const close = () => {
window.removeEventListener('message', onMessage)
bootElement.classList.add('boot--gone')
window.setTimeout(() => bootElement.remove(), 520)
}
if (prefersReducedMotion()) {
close()
return
}
const deadline = readyAt + MAX_EXTRA_MS
const tick = () => {
const now = performance.now()
const paintedLongEnough =
paintingStartedAt !== null && now - paintingStartedAt >= MIN_PAINT_MS
if (paintedLongEnough || now >= deadline) close()
else window.setTimeout(tick, 100)
}
tick()
}
+130 -91
View File
@@ -1,90 +1,113 @@
<script setup lang="ts">
import { Download, FileUp, Pencil, Plus, Trash2, X } from '@lucide/vue'
import { ref } from 'vue'
import { LogOut, MonitorSmartphone, Pencil, Plus, Trash2, X } from '@lucide/vue'
import { onClickOutside } from '@vueuse/core'
import { computed, nextTick, ref, useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
import { ApiError, api } from '@/api'
import BrandMark from '@/components/BrandMark.vue'
import { setAuthenticated } from '@/router'
import { useCanvasStore } from '@/stores/canvas'
import type { BackupFile, SpaceRecord } from '@/types/canvas'
import type { SpaceInfo } from '@/types/canvas'
const store = useCanvasStore()
const fileInput = ref<HTMLInputElement>()
const router = useRouter()
// 新建/编辑空间弹窗状态
// 新建/编辑空间弹窗
const isModalOpen = ref(false)
const modalMode = ref<'create' | 'edit'>('create')
const editingSpaceId = ref('')
const spaceNameInput = ref('')
const selectedEmoji = ref('✦')
const saving = ref(false)
const confirmingDelete = ref(false)
const nameInput = useTemplateRef<HTMLInputElement>('nameInput')
const devicesOpen = ref(false)
const devicesRef = useTemplateRef<HTMLElement>('devicesRef')
onClickOutside(devicesRef, () => (devicesOpen.value = false))
const availableEmojis = ['✦', '◌', '◇', '✺', '◎', '🌿', '💡', '🪐', '☕', '📌', '🎨', '📝']
function openCreateModal() {
modalMode.value = 'create'
editingSpaceId.value = ''
spaceNameInput.value = ''
selectedEmoji.value = availableEmojis[store.spaces.length % availableEmojis.length] ?? '✦'
const syncLabel = computed(() => {
switch (store.syncStatus) {
case 'synced':
return '已同步'
case 'syncing':
return '正在同步…'
case 'connecting':
return '正在连接…'
default:
return '离线 · 修改会在联网后同步'
}
})
const spaceNames = computed(
() => new Map(store.spaces.map((space) => [space.id, `${space.emoji} ${space.name}`])),
)
function openModal(mode: 'create' | 'edit', space?: SpaceInfo) {
modalMode.value = mode
editingSpaceId.value = space?.id ?? ''
spaceNameInput.value = space?.name ?? ''
selectedEmoji.value =
space?.emoji ?? availableEmojis[store.spaces.length % availableEmojis.length] ?? '✦'
confirmingDelete.value = false
isModalOpen.value = true
void nextTick(() => nameInput.value?.focus())
}
function openEditModal(space: SpaceRecord) {
modalMode.value = 'edit'
editingSpaceId.value = space.id
spaceNameInput.value = space.name
selectedEmoji.value = space.emoji
isModalOpen.value = true
function closeSidebarOnNarrowScreen() {
if (window.matchMedia('(max-width: 900px)').matches) store.sidebarOpen = false
}
async function saveSpace() {
const name = spaceNameInput.value.trim()
if (!name) return
if (!name || saving.value) return
saving.value = true
try {
if (modalMode.value === 'create') {
await store.createSpace(name, selectedEmoji.value)
} else if (editingSpaceId.value) {
await store.updateSpace(editingSpaceId.value, {
name,
emoji: selectedEmoji.value,
})
const space = await store.createSpace(name, selectedEmoji.value)
await router.push({ name: 'space', params: { spaceId: space.id } })
closeSidebarOnNarrowScreen()
} else {
await store.updateSpace(editingSpaceId.value, { name, emoji: selectedEmoji.value })
}
isModalOpen.value = false
} catch (error) {
store.showToast(error instanceof ApiError ? error.message : '保存失败')
} finally {
saving.value = false
}
}
async function confirmDeleteSpace() {
if (!editingSpaceId.value) return
const confirmed = window.confirm('确认删除此空间吗?该空间内的所有便笺与连线将被彻底移除。')
if (confirmed) {
async function deleteSpace() {
if (!confirmingDelete.value) {
confirmingDelete.value = true
return
}
saving.value = true
try {
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 : '备份导入失败')
store.showToast(error instanceof ApiError ? error.message : '删除失败')
} finally {
input.value = ''
saving.value = false
confirmingDelete.value = false
}
}
async function logout() {
try {
await api.logout()
} catch {
// 网络不通也照样在本机退出
}
setAuthenticated(false)
store.teardown()
await router.replace({ name: 'login' })
}
</script>
<template>
@@ -108,7 +131,7 @@ async function importData(event: Event) {
<div class="sidebar__section-heading">
<span>我的空间</span>
<button class="icon-button" aria-label="创建空间" title="创建空间" @click="openCreateModal">
<button class="icon-button" aria-label="创建空间" title="创建空间" @click="openModal('create')">
<Plus :size="17" />
</button>
</div>
@@ -120,20 +143,21 @@ async function importData(event: Event) {
class="space-item-wrapper"
:class="{ 'space-item-wrapper--active': store.activeSpaceId === space.id }"
>
<button
<RouterLink
class="space-item"
:class="{ 'space-item--active': store.activeSpaceId === space.id }"
@click="store.openSpace(space.id)"
:to="{ name: 'space', params: { spaceId: space.id } }"
@click="closeSidebarOnNarrowScreen"
>
<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>
<span class="space-item__count">{{ space.cardCount }}</span>
</RouterLink>
<button
class="space-item__edit-btn"
title="空间设置"
aria-label="编辑空间设置"
@click.stop="openEditModal(space)"
@click.stop="openModal('edit', space)"
>
<Pencil :size="12" />
</button>
@@ -146,35 +170,49 @@ async function importData(event: Event) {
</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" />
导出备份
<div class="sync-row">
<span class="sync-status" :data-status="store.syncStatus">
<i></i>
{{ syncLabel }}
</span>
<div ref="devicesRef" class="devices">
<button
v-if="store.onlineDevices > 0"
class="devices__button"
:class="{ active: devicesOpen }"
:title="`${store.onlineDevices} 台设备在线`"
:aria-expanded="devicesOpen"
@click="devicesOpen = !devicesOpen"
>
<MonitorSmartphone :size="13" />
{{ store.onlineDevices }}
</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 v-if="devicesOpen" class="devices__popover" role="dialog" aria-label="在线设备">
<strong>在线设备</strong>
<ul>
<li v-for="device in store.devices" :key="device.id">
<span>
{{ device.label }}
<em v-if="device.current">本机</em>
</span>
<small>{{ spaceNames.get(device.spaceId) ?? '—' }}</small>
</li>
</ul>
</div>
</div>
</div>
<button class="text-button" @click="logout">
<LogOut :size="14" />
退出登录
</button>
</div>
<!-- 空间创建/编辑对话框 -->
<div v-if="isModalOpen" class="modal-overlay" @click="isModalOpen = false">
<div class="dialog-card" @click.stop>
<form class="dialog-card" @click.stop @submit.prevent="saveSpace">
<header class="dialog-header">
<strong>{{ modalMode === 'create' ? '创建新空间' : '空间设置' }}</strong>
<button class="icon-button" aria-label="关闭" @click="isModalOpen = false">
<button type="button" class="icon-button" aria-label="关闭" @click="isModalOpen = false">
<X :size="16" />
</button>
</header>
@@ -183,12 +221,11 @@ async function importData(event: Event) {
<label class="dialog-field">
<span>空间名称</span>
<input
ref="nameInput"
v-model="spaceNameInput"
class="dialog-input"
maxlength="30"
placeholder="例如:灵感收纳盒、工作面板…"
autofocus
@keydown.enter="saveSpace"
/>
</label>
@@ -212,28 +249,30 @@ async function importData(event: Event) {
<footer class="dialog-footer">
<button
v-if="modalMode === 'edit'"
type="button"
class="dialog-btn dialog-btn--danger"
:disabled="store.spaces.length <= 1"
title="删除空间"
@click="confirmDeleteSpace"
:class="{ 'dialog-btn--confirm': confirmingDelete }"
:disabled="store.spaces.length <= 1 || saving"
:title="store.spaces.length <= 1 ? '至少保留一个空间' : '删除空间'"
@click="deleteSpace"
>
<Trash2 :size="14" />
删除空间
{{ confirmingDelete ? '再点一次确认删除' : '删除空间' }}
</button>
<div class="dialog-footer-right">
<button class="dialog-btn dialog-btn--secondary" @click="isModalOpen = false">
<button type="button" class="dialog-btn dialog-btn--secondary" @click="isModalOpen = false">
取消
</button>
<button
type="submit"
class="dialog-btn dialog-btn--primary"
:disabled="!spaceNameInput.trim()"
@click="saveSpace"
:disabled="!spaceNameInput.trim() || saving"
>
{{ modalMode === 'create' ? '立即创建' : '保存更改' }}
</button>
</div>
</footer>
</div>
</form>
</div>
</aside>
</template>
+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { ChevronDown, ChevronUp, Search, X } from '@lucide/vue'
import { useVueFlow } from '@vue-flow/core'
import { onClickOutside, useDebounceFn } from '@vueuse/core'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { useRouter } from 'vue-router'
import { api, type SearchHit } from '@/api'
import { useCanvasStore } from '@/stores/canvas'
// 搜索:当前空间内逐个定位,同时列出其他空间里的匹配卡片
const store = useCanvasStore()
const router = useRouter()
const { fitView } = useVueFlow('boundless-canvas')
const root = useTemplateRef<HTMLElement>('root')
const focused = ref(false)
const activeMatchIndex = ref(0)
const otherHits = ref<SearchHit[]>([])
const searching = ref(false)
let controller: AbortController | null = null
onClickOutside(root, () => (focused.value = false))
const query = computed(() => store.searchQuery.trim())
const matchingNodes = computed(() => {
const needle = query.value.toLocaleLowerCase()
if (!needle) return []
return store.nodes.filter((node) =>
`${node.data.title} ${node.data.content}`.toLocaleLowerCase().includes(needle),
)
})
const spaceLabels = computed(
() => new Map(store.spaces.map((space) => [space.id, `${space.emoji} ${space.name}`])),
)
const searchOtherSpaces = useDebounceFn(async (text: string) => {
controller?.abort()
if (!text || store.spaces.length < 2) {
otherHits.value = []
searching.value = false
return
}
controller = new AbortController()
try {
const hits = await api.search(text, controller.signal)
if (text === query.value) {
otherHits.value = hits.filter((hit) => hit.spaceId !== store.activeSpaceId).slice(0, 12)
}
} catch {
// 离线或请求被取消时只显示当前空间的结果
} finally {
if (text === query.value) searching.value = false
}
}, 280)
watch(query, (text) => {
activeMatchIndex.value = 0
searching.value = Boolean(text)
void searchOtherSpaces(text)
})
async function focusMatch(index: number) {
const total = matchingNodes.value.length
if (total === 0) return
activeMatchIndex.value = (index + total) % total
const match = matchingNodes.value[activeMatchIndex.value]
if (!match) return
store.selectOnly([match.id])
await nextTick()
void fitView({ nodes: [match.id], duration: 350, padding: 0.75, maxZoom: 1.2 })
}
function openHit(hit: SearchHit) {
focused.value = false
store.requestFocus(hit.cardId)
void router.push({ name: 'space', params: { spaceId: hit.spaceId } })
}
function clear() {
store.searchQuery = ''
otherHits.value = []
}
</script>
<template>
<div ref="root" class="canvas-search">
<label class="search-box">
<Search :size="16" />
<input
v-model="store.searchQuery"
placeholder="搜索便笺"
aria-label="搜索便笺"
@focus="focused = true"
@keydown.enter.prevent="focusMatch(activeMatchIndex + (matchingNodes.length > 1 ? 1 : 0))"
@keydown.esc="($event.target as HTMLInputElement).blur()"
/>
<span v-if="query && matchingNodes.length > 0" class="search-count">
{{ activeMatchIndex + 1 }}/{{ matchingNodes.length }}
</span>
<div v-if="query && matchingNodes.length > 1" class="search-nav">
<button title="上一个结果" aria-label="上一个结果" @click.prevent="focusMatch(activeMatchIndex - 1)">
<ChevronUp :size="13" />
</button>
<button title="下一个结果" aria-label="下一个结果" @click.prevent="focusMatch(activeMatchIndex + 1)">
<ChevronDown :size="13" />
</button>
</div>
<button v-if="query" title="清空搜索" aria-label="清空搜索" @click.prevent="clear">
<X :size="14" />
</button>
</label>
<div v-if="focused && query" class="search-results">
<p class="search-results__summary">
{{ matchingNodes.length ? `当前空间 ${matchingNodes.length} 处 · 回车定位` : '当前空间没有匹配' }}
</p>
<template v-if="otherHits.length">
<p class="search-results__heading">其他空间</p>
<button
v-for="hit in otherHits"
:key="`${hit.spaceId}:${hit.cardId}`"
class="search-hit"
@click="openHit(hit)"
>
<span class="search-hit__space">{{ spaceLabels.get(hit.spaceId) }}</span>
<strong>{{ hit.title || '未命名想法' }}</strong>
<small>{{ hit.snippet }}</small>
</button>
</template>
<p v-else-if="searching" class="search-results__heading">正在搜索其他空间…</p>
</div>
</div>
</template>
+31 -14
View File
@@ -15,8 +15,10 @@ import { NodeResizer, type OnResizeEnd } from '@vue-flow/node-resizer'
import { onClickOutside } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { CARD_COLORS } from '@shared/board'
import { useCanvasStore } from '@/stores/canvas'
import type { CardColor, CardNodeData } from '@/types/canvas'
import type { CardNodeData } from '@/types/canvas'
defineOptions({ inheritAttrs: false })
@@ -33,7 +35,6 @@ const emit = defineEmits<{
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
@@ -45,16 +46,21 @@ const searchMatch = computed(() => {
return `${props.data.title} ${props.data.content}`.toLocaleLowerCase().includes(query)
})
function patch(patchData: Partial<CardNodeData>) {
function patch(patchData: Partial<Pick<CardNodeData, 'title' | 'content' | 'color'>>) {
store.updateNodeData(props.id, patchData)
}
function resize(event: OnResizeEnd) {
store.resizeNode(props.id, event.params.width, event.params.height)
function resizeStart() {
store.beginGesture([props.id])
}
function resizeEnd(event: OnResizeEnd) {
store.resizeNode(props.id, event.params)
store.endGesture([props.id])
}
function duplicate() {
store.duplicateNode(props.id)
store.duplicateNodes([props.id])
menuOpen.value = false
}
@@ -68,6 +74,11 @@ function sendToBack() {
menuOpen.value = false
}
function remove() {
menuOpen.value = false
store.removeNodes([props.id])
}
async function copyText() {
const fullText = props.data.title
? (props.data.title + '\n\n' + (props.data.content || '')).trim()
@@ -89,9 +100,9 @@ function insertTodo() {
}
function openImagePreview() {
if (props.data.kind === 'image' && props.data.imageData) {
if (props.data.kind === 'image' && props.data.image) {
emit('preview-image', {
src: props.data.imageData,
src: props.data.image,
title: props.data.title || '图片预览',
})
}
@@ -103,8 +114,8 @@ function openImagePreview() {
:min-width="240"
:min-height="160"
:is-visible="selected"
color="#1c1e1f"
@resize-end="resize"
@resize-start="resizeStart"
@resize-end="resizeEnd"
/>
<article
@@ -151,7 +162,7 @@ function openImagePreview() {
置于底层
</button>
<hr class="card-menu__divider" />
<button class="card-menu__danger" @click="store.removeNodes([id])">
<button class="card-menu__danger" @click="remove">
<Trash2 :size="14" />
删除
</button>
@@ -160,12 +171,18 @@ function openImagePreview() {
</header>
<div
v-if="data.kind === 'image' && data.imageData"
v-if="data.kind === 'image' && data.image"
class="note-card__image-wrap nodrag"
title="点击查看大图"
@click="openImagePreview"
>
<img :src="data.imageData" :alt="data.title || '记录的图片'" draggable="false" />
<img
:src="data.image"
:alt="data.title || '记录的图片'"
draggable="false"
loading="lazy"
decoding="async"
/>
<div class="note-card__image-overlay">
<ZoomIn :size="18" />
</div>
@@ -197,7 +214,7 @@ function openImagePreview() {
<footer class="note-card__footer nodrag">
<div class="color-picker" aria-label="卡片颜色">
<button
v-for="color in colors"
v-for="color in CARD_COLORS"
:key="color"
:class="{ active: data.color === color }"
:data-color="color"
+170 -205
View File
@@ -8,11 +8,10 @@ import {
AlignStartHorizontal,
AlignStartVertical,
AlignVerticalDistributeCenter,
ChevronDown,
ChevronUp,
CircleHelp,
Copy,
Grid2x2,
History,
ImagePlus,
Map,
Maximize2,
@@ -20,7 +19,6 @@ import {
MousePointer,
Plus,
Redo2,
Search,
SquareDashed,
StickyNote,
Trash2,
@@ -32,22 +30,27 @@ import {
import { Background } from '@vue-flow/background'
import {
ConnectionMode,
MarkerType,
Panel,
VueFlow,
useVueFlow,
type Connection,
type Edge,
type Node,
type NodeDragEvent,
} from '@vue-flow/core'
import { MiniMap } from '@vue-flow/minimap'
import { useDebounceFn, useEventListener } from '@vueuse/core'
import { useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, nextTick, ref, watch } from 'vue'
import { CARD_COLORS } from '@shared/board'
import { isBootActive } from '@/boot'
import CanvasSearch from '@/components/CanvasSearch.vue'
import CardNode from '@/components/CardNode.vue'
import PaintingLoader from '@/components/PaintingLoader.vue'
import VersionHistoryDialog from '@/components/VersionHistoryDialog.vue'
import { useCanvasStore } from '@/stores/canvas'
import type { CanvasEdge, CardColor } from '@/types/canvas'
const store = useCanvasStore()
const { nodes, edges } = storeToRefs(store)
@@ -55,16 +58,76 @@ const fileInput = ref<HTMLInputElement>()
const canvasElement = ref<HTMLElement>()
const showMiniMap = ref(true)
const showShortcuts = ref(false)
const showHistory = ref(false)
const previewImage = ref<{ src: string; title: string } | null>(null)
const isSpacePressed = ref(false)
const { screenToFlowCoordinate, fitView, zoomIn, zoomOut, zoomTo, viewport } =
const { screenToFlowCoordinate, fitView, zoomIn, zoomOut, zoomTo, viewport, onNodesInitialized } =
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 })
const syncLabel = computed(
() =>
({
synced: '已同步',
syncing: '正在同步',
connecting: '正在连接',
offline: '离线',
})[store.syncStatus],
)
// ---------------------------------------------------------------- 打开空间
/** 空间加载完成后把视角对准内容;如果是从搜索跳转过来,就对准那张卡片 */
let pendingFit = false
const showBoardLoader = ref(false)
let loaderTimer: number | undefined
watch(
() => [store.activeSpaceId, store.boardReady] as const,
([, ready]) => {
window.clearTimeout(loaderTimer)
if (ready) {
showBoardLoader.value = false
pendingFit = true
void nextTick(() => window.setTimeout(fitWhenReady, 240))
} else {
// 加载很快时不闪动画;启动画面还在时也不用再显示一次
loaderTimer = window.setTimeout(() => {
showBoardLoader.value = !store.boardReady && !isBootActive()
}, 280)
}
},
{ immediate: true },
)
onNodesInitialized(() => fitWhenReady())
function fitWhenReady() {
if (!pendingFit) return
pendingFit = false
if (store.focusRequest && focusCard(store.focusRequest.cardId)) return
if (store.nodes.length) void fitView({ padding: 0.25, maxZoom: 1 })
}
function focusCard(cardId: string) {
store.focusRequest = null
if (!store.nodes.some((node) => node.id === cardId)) return false
store.selectOnly([cardId])
void nextTick(() => fitView({ nodes: [cardId], duration: 350, padding: 0.75, maxZoom: 1.2 }))
return true
}
// 在当前空间内搜索跳转(目标空间已经打开)
watch(
() => store.focusRequest,
(request) => {
if (request && store.boardReady && !pendingFit) focusCard(request.cardId)
},
)
// ---------------------------------------------------------------- 添加内容
function canvasCenter() {
const rect = canvasElement.value?.getBoundingClientRect()
@@ -85,32 +148,15 @@ function addNoteAtEvent(event: MouseEvent) {
store.addCard('note', { x: point.x - 150, y: point.y - 80 })
}
function readImage(file: File, position = canvasCenter()) {
function addImage(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)
void store.addImageCard(file, position)
}
function selectImage(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) readImage(file)
if (file) addImage(file)
input.value = ''
}
@@ -118,22 +164,23 @@ 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)
addImage(file, screenToFlowCoordinate({ x: event.clientX, y: event.clientY }))
}
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 isEditing(target: EventTarget | null) {
return (target as HTMLElement | null)?.matches?.('input, textarea, [contenteditable="true"]')
}
function pasteContent(event: ClipboardEvent) {
const target = event.target as HTMLElement | null
if (target?.matches('input, textarea, [contenteditable="true"]')) return
if (isEditing(event.target) || !store.boardReady) return
const files = [...(event.clipboardData?.files ?? [])]
const itemFiles = [...(event.clipboardData?.items ?? [])]
@@ -144,7 +191,7 @@ function pasteContent(event: ClipboardEvent) {
if (image) {
event.preventDefault()
readImage(image)
addImage(image)
return
}
@@ -160,130 +207,78 @@ function pasteContent(event: ClipboardEvent) {
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 })
store.addCard('note', { x: point.x - 150, y: point.y - 110 }, { title, 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"]')
const editing = isEditing(event.target)
if (event.code === 'Space' && !editing && !isSpacePressed.value) isSpacePressed.value = true
if (editing) return
if (event.code === 'Space' && !isEditingText && !isSpacePressed.value) {
isSpacePressed.value = true
}
const mod = event.ctrlKey || event.metaKey
const key = event.key.toLowerCase()
if (isEditingText) return
// 撤销/重做
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
if (mod && key === 'z') {
event.preventDefault()
if (event.shiftKey) {
store.redo()
} else {
store.undo()
}
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'y') {
if (event.shiftKey) store.redo()
else store.undo()
} else if (mod && key === 'y') {
event.preventDefault()
store.redo()
}
// 全选
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
} else if (mod && key === 'a') {
event.preventDefault()
const count = store.selectAllNodes()
store.showToast(`已选中全部 ${count} 张便笺`)
}
// 复制选中项
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'd') {
} else if (mod && key === 'd') {
event.preventDefault()
store.duplicateSelectedNodes()
}
// 取消所有选择
if (event.key === 'Escape') {
} else if (mod && event.key === '0') {
event.preventDefault()
resetZoom()
} else if (event.key === 'Escape') {
store.deselectAll()
previewImage.value = null
showShortcuts.value = false
}
// 快捷缩放重置
if ((event.ctrlKey || event.metaKey) && event.key === '0') {
event.preventDefault()
resetZoom()
showHistory.value = false
}
})
useEventListener(window, 'keyup', (event: KeyboardEvent) => {
if (event.code === 'Space') {
isSpacePressed.value = false
}
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 },
// ---------------------------------------------------------------- 拖动与连线
// 拖动过程中只在本机移动,松手时才写入文档并同步,拖动更流畅
function onDragStart(event: NodeDragEvent) {
store.beginGesture(event.nodes.map((node) => node.id))
}
edges.value = [...edges.value, edge]
function onDragStop(event: NodeDragEvent) {
store.writePositions(
event.nodes.map((node) => ({ id: node.id, x: node.position.x, y: node.position.y })),
)
store.endGesture(event.nodes.map((node) => node.id))
}
function connect(connection: Connection) {
store.addEdge(connection)
}
function deleteNodes(deleted: Node[]) {
void store.removeNodes(deleted.map((node) => node.id))
store.removeNodes(deleted.map((node) => node.id))
}
function deleteEdges(deleted: Edge[]) {
void store.removeEdges(deleted.map((edge) => edge.id))
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% 视图')
@@ -306,8 +301,6 @@ function toggleMode() {
store.interactionMode = store.interactionMode === 'pointer' ? 'marquee' : 'pointer'
store.showToast(store.interactionMode === 'marquee' ? '框选模式:拖拽框选多张卡片' : '指针模式')
}
const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
</script>
<template>
@@ -333,16 +326,17 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
:delete-key-code="['Backspace', 'Delete']"
:multi-selection-key-code="['Meta', 'Control']"
:selection-key-code="['Shift']"
:pan-on-drag="store.interactionMode === 'marquee' ? false : true"
:pan-on-drag="store.interactionMode !== 'marquee'"
: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()"
@node-drag-start="onDragStart"
@node-drag-stop="onDragStop"
@selection-drag-start="onDragStart"
@selection-drag-stop="onDragStop"
>
<Background
v-if="store.gridStyle !== 'none'"
@@ -352,8 +346,14 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
:size="1"
/>
<template #node-card="nodeProps">
<CardNode v-bind="nodeProps" @preview-image="previewImage = $event" />
<!-- 只传卡片用到的属性:位置等每帧都在变的属性不传,拖动时卡片内容不会跟着重渲染 -->
<template #node-card="{ id, data, selected }">
<CardNode
:id="id"
:data="data"
:selected="selected"
@preview-image="previewImage = $event"
/>
</template>
<!-- 移动端侧边栏切换按钮 -->
@@ -385,7 +385,6 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
<span class="toolbar-divider"></span>
<!-- 撤销 / 重做 -->
<button
class="tool-button"
:class="{ 'tool-button--disabled': !store.canUndo }"
@@ -409,7 +408,6 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
<span class="toolbar-divider"></span>
<!-- 交互模式切换(指针平移 vs 框选多选) -->
<button
class="tool-button"
:class="{ 'tool-button--active': store.interactionMode === 'marquee' }"
@@ -427,13 +425,7 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
<span class="multi-select-badge">已选 {{ store.selectedCount }} 项</span>
<span class="toolbar-divider"></span>
<!-- 对齐按钮 -->
<button
class="tool-button"
title="左对齐"
aria-label="左对齐"
@click="store.alignSelectedNodes('left')"
>
<button class="tool-button" title="左对齐" aria-label="左对齐" @click="store.alignSelectedNodes('left')">
<AlignStartHorizontal :size="16" />
</button>
<button
@@ -444,20 +436,10 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
>
<AlignCenterHorizontal :size="16" />
</button>
<button
class="tool-button"
title="右对齐"
aria-label="右对齐"
@click="store.alignSelectedNodes('right')"
>
<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')"
>
<button class="tool-button" title="顶端对齐" aria-label="顶端对齐" @click="store.alignSelectedNodes('top')">
<AlignStartVertical :size="16" />
</button>
<button
@@ -479,7 +461,6 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
<span class="toolbar-divider"></span>
<!-- 均分间距(3 项及以上) -->
<template v-if="store.selectedCount >= 3">
<button
class="tool-button"
@@ -500,10 +481,9 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
<span class="toolbar-divider"></span>
</template>
<!-- 统一换色 -->
<div class="batch-color-picker" aria-label="批量更改颜色">
<button
v-for="color in colors"
v-for="color in CARD_COLORS"
:key="color"
:data-color="color"
:title="`统一设为 ${color} 色`"
@@ -513,7 +493,6 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
<span class="toolbar-divider"></span>
<!-- 批量复制 -->
<button
class="tool-button"
title="批量复制 (Ctrl+D)"
@@ -522,8 +501,6 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
>
<Copy :size="15" />
</button>
<!-- 批量删除 -->
<button
class="tool-button tool-button--danger"
title="批量删除 (Delete)"
@@ -532,65 +509,45 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
>
<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>
<CanvasSearch />
<button
v-if="store.searchQuery"
title="清空搜索"
aria-label="清空搜索"
@click="store.searchQuery = ''"
class="floating-icon floating-icon--small"
title="历史版本"
aria-label="历史版本"
:disabled="!store.boardReady"
@click="showHistory = true"
>
<X :size="14" />
<History :size="16" />
</button>
</label>
<span class="save-pill" :class="{ 'save-pill--saving': store.saveState === 'saving' }">
<i></i>{{ store.saveState === 'saving' ? '正在保存' : '已保存' }}
<span class="save-pill" :data-status="store.syncStatus" :title="syncLabel">
<i></i>{{ syncLabel }}
</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="查看快捷键帮助">
<button class="canvas-hint" @click="showShortcuts = true">
双击添加便笺 · 粘贴图片或文字 · Shift 框选
<span class="hint-help-btn" aria-hidden="true">
<CircleHelp :size="14" />
</span>
</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()">
@@ -626,7 +583,6 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
</div>
</Panel>
<!-- 小地图 -->
<MiniMap
v-if="showMiniMap"
class="boundless-minimap"
@@ -638,14 +594,21 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
/>
</VueFlow>
<!-- 操作提示轻提示 (Toast) -->
<!-- 切换空间时的加载动画 -->
<Transition name="board-loader">
<div v-if="showBoardLoader" class="board-loader" role="status">
<PaintingLoader size="min(168px, 44vw)" />
<p>正在打开「{{ store.activeSpace?.name ?? '空间' }}」…</p>
</div>
</Transition>
<Transition name="fade">
<div v-if="store.toastMessage" class="toast-pill">
<div v-if="store.toastMessage" class="toast-pill" role="status">
{{ 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">
@@ -660,7 +623,9 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
</div>
</div>
<!-- 快捷键指南对话框 -->
<VersionHistoryDialog v-if="showHistory" @close="showHistory = false" />
<!-- 快捷键指南 -->
<div v-if="showShortcuts" class="modal-overlay" @click="showShortcuts = false">
<div class="shortcuts-card" @click.stop>
<header class="shortcuts-header">
@@ -679,7 +644,7 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
</div>
<div class="shortcut-item">
<kbd>拖拽 / 粘贴</kbd>
<span>拖入或从剪贴板粘贴图片与便笺</span>
<span>拖入或从剪贴板粘贴图片与文字</span>
</div>
<div class="shortcut-item">
<kbd>点击卡片</kbd>
@@ -695,15 +660,15 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
</div>
<div class="shortcut-item">
<kbd>Ctrl / ⌘ + D</kbd>
<span>快速复制选中的卡片副本</span>
<span>复制选中的卡片</span>
</div>
<div class="shortcut-item">
<kbd>Ctrl / ⌘ + Z</kbd>
<span>撤销上一步操作</span>
<span>撤销本机的上一步操作</span>
</div>
<div class="shortcut-item">
<kbd>Ctrl / ⌘ + Y</kbd>
<span>重做上一步操作</span>
<span>重做</span>
</div>
<div class="shortcut-item">
<kbd>Delete / Backspace</kbd>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue'
import { isPaintingMessage } from '@/painterly'
// 随机油画加载动画(public/painterly/),用于登录页和切换空间时
withDefaults(defineProps<{ size?: string; caption?: boolean }>(), {
size: 'min(200px, 52vw)',
caption: true,
})
const frame = useTemplateRef<HTMLIFrameElement>('frame')
// 应用本身被嵌进 iframe 时不再嵌套加载动画,避免无限递归
const topLevel = window.top === window.self
const ground = ref('#efebe2')
const title = ref('')
const painting = ref(false)
function onMessage(event: MessageEvent) {
if (!isPaintingMessage(event) || event.source !== frame.value?.contentWindow) return
if (event.data.type === 'chosen') {
ground.value = event.data.ground
title.value = `${event.data.name} · ${event.data.label}`
} else {
painting.value = true
}
}
onMounted(() => window.addEventListener('message', onMessage))
onBeforeUnmount(() => window.removeEventListener('message', onMessage))
</script>
<template>
<figure class="painting" :class="{ 'painting--live': painting }">
<div class="painting__tile" :style="{ width: size, background: ground }" aria-hidden="true">
<iframe
v-if="topLevel"
ref="frame"
src="/painterly/index.html"
title="加载动画"
tabindex="-1"
></iframe>
</div>
<figcaption v-if="caption" class="painting__caption">{{ title }}</figcaption>
</figure>
</template>
+148
View File
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { History, LoaderCircle, RotateCcw, Save, X } from '@lucide/vue'
import { onMounted, ref } from 'vue'
import { ApiError, api, type VersionInfo } from '@/api'
import { useCanvasStore } from '@/stores/canvas'
const emit = defineEmits<{ close: [] }>()
const store = useCanvasStore()
const spaceId = store.activeSpaceId
const versions = ref<VersionInfo[]>([])
const loading = ref(true)
const busy = ref(false)
const confirmingId = ref<number | null>(null)
const error = ref('')
const reasonLabels: Record<VersionInfo['reason'], string> = {
auto: '自动保存',
manual: '手动保存',
restore: '恢复前的备份',
}
const timeFormat = new Intl.DateTimeFormat('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
function relativeTime(time: number) {
const minutes = Math.round((Date.now() - time) / 60000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes} 分钟前`
const hours = Math.round(minutes / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.round(hours / 24)
return days < 30 ? `${days} 天前` : timeFormat.format(time)
}
async function load() {
loading.value = true
error.value = ''
try {
versions.value = await api.listVersions(spaceId)
} catch (err) {
error.value = err instanceof ApiError ? err.message : '读取历史版本失败'
} finally {
loading.value = false
}
}
async function saveNow() {
busy.value = true
try {
await api.saveVersion(spaceId)
await load()
store.showToast('已保存当前版本')
} catch (err) {
store.showToast(err instanceof ApiError ? err.message : '保存失败')
} finally {
busy.value = false
}
}
async function restore(version: VersionInfo) {
if (confirmingId.value !== version.id) {
confirmingId.value = version.id
return
}
busy.value = true
try {
await api.restoreVersion(spaceId, version.id)
store.showToast(`已恢复到 ${timeFormat.format(version.createdAt)} 的版本`)
emit('close')
} catch (err) {
store.showToast(err instanceof ApiError ? err.message : '恢复失败')
} finally {
busy.value = false
confirmingId.value = null
}
}
onMounted(load)
</script>
<template>
<div class="modal-overlay" @click="emit('close')">
<section class="dialog-card history-card" role="dialog" aria-label="历史版本" @click.stop>
<header class="dialog-header">
<div class="history-card__title">
<History :size="17" />
<strong>历史版本</strong>
<span>{{ store.activeSpace?.emoji }} {{ store.activeSpace?.name }}</span>
</div>
<button class="icon-button" aria-label="关闭" @click="emit('close')">
<X :size="16" />
</button>
</header>
<div class="history-card__body">
<p class="history-card__hint">
编辑时每隔十分钟会自动保存一个版本。恢复后,当前内容也会先存为一个版本,随时可以换回来。
</p>
<div v-if="loading" class="history-card__empty">
<LoaderCircle class="spin" :size="18" />
</div>
<p v-else-if="error" class="history-card__empty">{{ error }}</p>
<p v-else-if="versions.length === 0" class="history-card__empty">
还没有历史版本。继续编辑一会儿,或先手动保存一个。
</p>
<ol v-else class="version-list">
<li v-for="version in versions" :key="version.id" class="version-item">
<div class="version-item__main">
<div class="version-item__meta">
<strong :title="timeFormat.format(version.createdAt)">
{{ relativeTime(version.createdAt) }}
</strong>
<span>{{ reasonLabels[version.reason] }} · {{ version.cardCount }} 张卡片</span>
</div>
<p v-if="version.preview" class="version-item__preview">{{ version.preview }}</p>
</div>
<button
class="dialog-btn"
:class="confirmingId === version.id ? 'dialog-btn--primary' : 'dialog-btn--secondary'"
:disabled="busy"
@click="restore(version)"
>
<RotateCcw :size="13" />
{{ confirmingId === version.id ? '确认恢复' : '恢复' }}
</button>
</li>
</ol>
</div>
<footer class="dialog-footer">
<button class="dialog-btn dialog-btn--secondary" :disabled="busy" @click="saveNow">
<Save :size="14" />
保存当前版本
</button>
<div class="dialog-footer-right">
<button class="dialog-btn dialog-btn--primary" @click="emit('close')">完成</button>
</div>
</footer>
</section>
</div>
</template>
-27
View File
@@ -1,27 +0,0 @@
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()
+24 -3
View File
@@ -1,18 +1,39 @@
import { dismissBoot } from './boot'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
import { setUnauthorizedHandler } from './api'
import router, { setAuthenticated } from './router'
import { useCanvasStore } from './stores/canvas'
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'
// 自己的样式放在最后,才能覆盖 Vue Flow 的默认样式
import './style.css'
const app = createApp(App)
const pinia = createPinia()
app.use(createPinia())
app.use(pinia)
app.use(router)
// 会话失效:断开同步,回到登录页
setUnauthorizedHandler(() => {
setAuthenticated(false)
useCanvasStore(pinia).teardown()
const current = router.currentRoute.value
if (current.name !== 'login') {
void router.replace({
name: 'login',
query: current.fullPath !== '/' ? { redirect: current.fullPath } : undefined,
})
}
})
router.onError(() => dismissBoot())
app.mount('#app')
+22
View File
@@ -0,0 +1,22 @@
// 与 public/painterly/ 中加载动画页面的通信。
export interface PaintingMessage {
source: 'boundless-painterly'
type: 'chosen' | 'start'
key: string
name: string
label: string
ground: string
}
export function isPaintingMessage(event: MessageEvent): event is MessageEvent<PaintingMessage> {
return (
event.origin === window.location.origin &&
typeof event.data === 'object' &&
event.data !== null &&
event.data.source === 'boundless-painterly'
)
}
export function prefersReducedMotion() {
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
}
+39 -6
View File
@@ -1,16 +1,49 @@
import { createRouter, createWebHistory } from 'vue-router'
import WorkspaceView from '@/views/WorkspaceView.vue'
import { ApiError, api } from '@/api'
import LoginView from '@/views/LoginView.vue'
// 画布相关的依赖较大,登录页不需要,按需加载
const WorkspaceView = () => import('@/views/WorkspaceView.vue')
let authenticated: boolean | null = null
export function setAuthenticated(value: boolean | null) {
authenticated = value
}
async function isAuthenticated() {
if (authenticated !== null) return authenticated
try {
authenticated = (await api.session()).authenticated
} catch (error) {
// 连不上服务器时先放行,用本机缓存离线打开画布
if (error instanceof ApiError && error.status === 0) return true
authenticated = false
}
return authenticated
}
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'workspace',
component: WorkspaceView,
},
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
{ path: '/', name: 'home', component: WorkspaceView },
{ path: '/s/:spaceId', name: 'space', component: WorkspaceView },
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
router.beforeEach(async (to) => {
const loggedIn = await isAuthenticated()
if (to.meta.public) return loggedIn && to.name === 'login' ? { name: 'home' } : true
if (!loggedIn) {
return {
name: 'login',
query: to.fullPath !== '/' ? { redirect: to.fullPath } : undefined,
}
}
return true
})
export default router
+503 -493
View File
File diff suppressed because it is too large Load Diff
-12
View File
@@ -1,12 +0,0 @@
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 }
})
+584 -34
View File
@@ -280,26 +280,143 @@ textarea:focus-visible {
border-top: 1px solid var(--line);
}
.storage-status {
/* 同步状态与在线设备 */
.sync-row {
display: flex;
align-items: center;
gap: 7px;
justify-content: space-between;
gap: 8px;
min-height: 24px;
margin-bottom: 11px;
}
.sync-status {
--dot: #55a26c;
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
color: #818179;
font-size: 10px;
}
.storage-status span {
.sync-status i,
.save-pill i {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: #55a26c;
box-shadow: 0 0 0 3px rgba(85, 162, 108, 0.13);
background: var(--dot);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--dot) 14%, transparent);
}
.sidebar__actions {
display: flex;
.sync-status[data-status='syncing'],
.sync-status[data-status='connecting'],
.save-pill[data-status='syncing'],
.save-pill[data-status='connecting'] {
--dot: #d9a43d;
}
.sync-status[data-status='syncing'] i,
.sync-status[data-status='connecting'] i,
.save-pill[data-status='syncing'] i,
.save-pill[data-status='connecting'] i {
animation: pulse 900ms infinite alternate;
}
.sync-status[data-status='offline'],
.save-pill[data-status='offline'] {
--dot: #a7a69d;
}
.devices {
position: relative;
}
.devices__button {
display: inline-flex;
height: 24px;
cursor: pointer;
align-items: center;
gap: 5px;
padding: 0 8px;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.3);
color: #6f7068;
font-size: 10px;
font-variant-numeric: tabular-nums;
}
.devices__button:hover,
.devices__button.active {
background: #fffef9;
color: var(--ink);
}
.devices__popover {
position: absolute;
right: -4px;
bottom: calc(100% + 8px);
z-index: 30;
width: 228px;
padding: 12px 6px 6px;
border: 1px solid var(--line);
border-radius: 12px;
background: #fffefa;
box-shadow: 0 14px 36px rgba(42, 40, 33, 0.14);
}
.devices__popover strong {
display: block;
padding: 0 8px 8px;
color: #77776e;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
}
.devices__popover ul {
margin: 0;
padding: 0;
list-style: none;
}
.devices__popover li {
display: flex;
flex-direction: column;
gap: 2px;
padding: 7px 8px;
border-radius: 8px;
}
.devices__popover li:hover {
background: #f5f4ef;
}
.devices__popover li span {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 560;
}
.devices__popover li em {
padding: 1px 6px;
border-radius: 8px;
background: var(--ink);
color: #fff;
font-size: 9px;
font-style: normal;
}
.devices__popover li small {
overflow: hidden;
color: #8a8a82;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-button {
@@ -425,14 +542,21 @@ textarea:focus-visible {
background: #f4ddd9;
}
/* 选中:细墨线描边 + 一圈很淡的光晕,再把卡片"托起来"一点 */
.note-card--selected {
border-color: rgba(29, 30, 29, 0.7);
border-color: rgba(29, 30, 29, 0.42);
box-shadow:
0 0 0 2px var(--ink),
0 18px 45px rgba(54, 51, 42, 0.16);
0 0 0 4px rgba(29, 30, 29, 0.055),
0 22px 48px -16px rgba(54, 51, 42, 0.26),
0 3px 10px rgba(54, 51, 42, 0.07);
z-index: 10;
}
/* 拖动时单独合成一层,移动只做位移不重绘阴影 */
.vue-flow__node.dragging {
will-change: transform;
}
.note-card--muted {
opacity: 0.2;
filter: grayscale(0.6);
@@ -639,16 +763,21 @@ textarea:focus-visible {
box-shadow: 0 0 0 2px rgba(29, 30, 29, 0.28);
}
/* 连线点:左右两侧的小圆点,选中时才出现 */
.vue-flow__handle.card-handle {
width: 9px;
height: 9px;
border: 2px solid #fffefa;
background: #77776f;
min-width: 0;
min-height: 0;
border: 1.5px solid rgba(29, 30, 29, 0.38);
background: #fffefa;
opacity: 0;
cursor: default;
transition:
opacity 120ms ease,
transform 120ms ease;
opacity 140ms ease,
background 140ms ease,
border-color 140ms ease,
transform 140ms ease;
}
.note-card--selected .card-handle {
@@ -657,19 +786,34 @@ textarea:focus-visible {
}
.note-card--selected .vue-flow__handle.card-handle:hover {
border-color: var(--accent);
background: var(--accent);
transform: scale(1.3);
transform: translate(-50%, -50%) scale(1.25);
}
.note-card--selected .vue-flow__handle.card-handle--right:hover {
transform: translate(50%, -50%) scale(1.25);
}
/* 缩放:边线不画出来(仍可拖动),四角是与连线点一致的小圆点 */
.vue-flow__resize-control.line {
border-color: rgba(29, 30, 29, 0.38);
border-color: transparent !important;
}
.vue-flow__resize-control.handle {
width: 7px;
height: 7px;
border: 1px solid white;
width: 9px;
height: 9px;
border: 1.5px solid rgba(29, 30, 29, 0.5);
border-radius: 50%;
background: #fffefa;
box-shadow: 0 1px 3px rgba(29, 30, 29, 0.12);
transition:
background 140ms ease,
border-color 140ms ease;
}
.vue-flow__resize-control.handle:hover {
border-color: var(--ink);
background: var(--ink);
}
@@ -790,27 +934,17 @@ textarea:focus-visible {
}
.save-pill {
--dot: #55a26c;
display: inline-flex;
height: 28px;
align-items: center;
gap: 6px;
padding: 0 9px;
gap: 7px;
padding: 0 10px;
border-radius: 14px;
background: rgba(255, 254, 250, 0.7);
color: #77786f;
font-size: 9px;
}
.save-pill i {
width: 6px;
height: 6px;
border-radius: 50%;
background: #55a26c;
}
.save-pill--saving i {
background: #d9a43d;
animation: pulse 900ms infinite alternate;
white-space: nowrap;
}
@keyframes pulse {
@@ -847,9 +981,10 @@ textarea:focus-visible {
font-size: 11px;
}
/* 放在右下角缩放工具条的上方,避免互相遮挡 */
.boundless-minimap {
right: 15px !important;
bottom: 15px !important;
bottom: 64px !important;
width: 132px !important;
height: 84px !important;
overflow: hidden;
@@ -1465,7 +1600,11 @@ textarea:focus-visible {
display: inline-flex;
align-items: center;
gap: 6px;
border: 0;
margin-left: 0;
cursor: pointer;
font: inherit;
font-size: 9px;
transition:
background 120ms ease,
color 120ms ease;
@@ -1490,6 +1629,417 @@ textarea:focus-visible {
color: var(--ink);
}
/* ------------------------------------------------------------------ 通用 */
.spin {
animation: spin 900ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.floating-icon--small {
display: inline-grid;
width: 36px;
height: 36px;
border-radius: 10px;
background: rgba(255, 254, 250, 0.82);
box-shadow: 0 4px 18px rgba(45, 42, 35, 0.05);
color: #6c6d65;
backdrop-filter: blur(12px);
}
.floating-icon--small:hover:not(:disabled) {
background: #fffef9;
color: var(--ink);
}
.floating-icon--small:disabled {
cursor: default;
opacity: 0.4;
}
.dialog-btn--confirm,
.dialog-btn--confirm:hover:not(:disabled) {
background: #c2412c;
color: #fff;
}
/* ------------------------------------------------------------------ 油画加载动画 */
.painting {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
margin: 0;
}
.painting__tile {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid rgba(29, 30, 29, 0.08);
border-radius: 14px;
box-shadow:
0 1px 2px rgba(27, 26, 23, 0.04),
0 18px 40px -22px rgba(27, 26, 23, 0.32);
transition: background 600ms ease;
}
.painting__tile iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
opacity: 0;
transition: opacity 600ms ease;
}
.painting--live .painting__tile iframe {
opacity: 1;
}
.painting__caption {
min-height: 1.3em;
color: #a3a39a;
font-family: Georgia, 'Songti SC', serif;
font-size: 11px;
font-style: italic;
letter-spacing: 0.04em;
opacity: 0;
transition: opacity 600ms ease;
}
.painting--live .painting__caption {
opacity: 1;
}
.board-loader {
position: absolute;
inset: 0;
z-index: 40;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
background: color-mix(in srgb, var(--paper) 94%, transparent);
backdrop-filter: blur(4px);
}
.board-loader p {
margin: 0;
color: #7b7b73;
font-family: Georgia, 'Songti SC', serif;
font-size: 13px;
letter-spacing: 0.05em;
}
.board-loader-enter-active,
.board-loader-leave-active {
transition: opacity 360ms ease;
}
.board-loader-enter-from,
.board-loader-leave-to {
opacity: 0;
}
/* ------------------------------------------------------------------ 登录 */
.login {
display: flex;
min-height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 30px;
padding: 32px 16px;
overflow-y: auto;
background: var(--paper);
}
.login__form {
display: flex;
width: min(300px, 100%);
flex-direction: column;
align-items: center;
}
.login__title {
margin: 0;
font-family: Georgia, 'Songti SC', serif;
font-size: 26px;
font-weight: 600;
letter-spacing: 0.14em;
}
.login__subtitle {
margin: 8px 0 22px;
color: #8a8a82;
font-size: 12px;
letter-spacing: 0.04em;
}
.login__field {
display: flex;
width: 100%;
height: 44px;
align-items: center;
padding: 4px 4px 4px 14px;
border: 1px solid rgba(29, 30, 29, 0.14);
border-radius: 12px;
background: #fffefa;
box-shadow: 0 6px 22px rgba(45, 42, 35, 0.05);
transition:
border-color 160ms ease,
box-shadow 160ms ease;
}
.login__field:focus-within {
border-color: var(--ink);
box-shadow: 0 6px 26px rgba(45, 42, 35, 0.09);
}
.login__field--error {
border-color: #c2412c;
animation: shake 320ms ease;
}
.login__field input {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
background: transparent;
color: var(--ink);
font-size: 14px;
letter-spacing: 0.08em;
}
.login__field input:focus-visible {
outline: 0;
}
.login__field button {
display: grid;
width: 34px;
height: 34px;
flex: none;
cursor: pointer;
place-items: center;
border: 0;
border-radius: 9px;
background: var(--ink);
color: #fff;
transition:
opacity 160ms ease,
transform 160ms ease;
}
.login__field button:hover:not(:disabled) {
transform: translateX(1px);
}
.login__field button:disabled {
cursor: default;
opacity: 0.25;
}
.login__error {
min-height: 1.4em;
margin: 10px 0 0;
color: #c2412c;
font-size: 12px;
}
@keyframes shake {
25% {
transform: translateX(-4px);
}
75% {
transform: translateX(4px);
}
}
/* ------------------------------------------------------------------ 搜索 */
.canvas-search {
position: relative;
}
.search-results {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 30;
width: min(300px, calc(100vw - 24px));
max-height: min(420px, 70vh);
overflow-y: auto;
padding: 6px;
border: 1px solid var(--line);
border-radius: 12px;
background: #fffefa;
box-shadow: 0 16px 40px rgba(42, 40, 33, 0.14);
}
.search-results__summary,
.search-results__heading {
margin: 0;
padding: 7px 9px;
color: #8a8a82;
font-size: 10px;
}
.search-results__heading {
padding-top: 10px;
border-top: 1px solid var(--line);
font-weight: 700;
letter-spacing: 0.08em;
}
.search-hit {
display: flex;
width: 100%;
cursor: pointer;
flex-direction: column;
gap: 3px;
padding: 8px 9px;
border: 0;
border-radius: 8px;
background: transparent;
text-align: left;
}
.search-hit:hover {
background: #f5f4ef;
}
.search-hit__space {
color: #8a8a82;
font-size: 10px;
}
.search-hit strong {
font-size: 12px;
font-weight: 600;
}
.search-hit small {
display: -webkit-box;
overflow: hidden;
color: #6f7068;
font-size: 11px;
line-height: 1.5;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
/* ------------------------------------------------------------------ 历史版本 */
.history-card {
display: flex;
width: 460px;
max-height: min(640px, 88vh);
flex-direction: column;
}
.history-card__title {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.history-card__title span {
overflow: hidden;
color: #8a8a82;
font-size: 12px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-card__body {
min-height: 0;
flex: 1;
overflow-y: auto;
padding: 14px 20px 18px;
}
.history-card__hint {
margin: 0 0 12px;
color: #8a8a82;
font-size: 11px;
line-height: 1.6;
}
.history-card__empty {
display: grid;
min-height: 120px;
margin: 0;
place-items: center;
color: #8a8a82;
font-size: 12px;
text-align: center;
}
.version-list {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.version-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 10px;
border-radius: 10px;
}
.version-item:hover {
background: #f5f4ef;
}
.version-item__main {
min-width: 0;
flex: 1;
}
.version-item__meta {
display: flex;
align-items: baseline;
gap: 8px;
}
.version-item__meta strong {
font-size: 13px;
font-weight: 600;
}
.version-item__meta span {
color: #8a8a82;
font-size: 11px;
}
.version-item__preview {
margin: 3px 0 0;
overflow: hidden;
color: #6f7068;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 900px) {
.sidebar {
position: absolute;
+135
View File
@@ -0,0 +1,135 @@
import { HocuspocusProvider, WebSocketStatus } from '@hocuspocus/provider'
import { IndexeddbPersistence } from 'y-indexeddb'
import * as Y from 'yjs'
import { boardCards, boardDocName, boardEdges } from '@shared/board'
import { getSocket } from './connection'
/** 本机发起的修改都带这个来源标记,撤销只针对它们 */
export const LOCAL_ORIGIN = Symbol('boundless-local')
/** 本地缓存里没有内容时,最多等服务器这么久就先显示画布 */
const OFFLINE_FALLBACK_MS = 4000
export interface BoardHandlers {
/** 文档内容变化(本机或其他设备) */
onChange: () => void
/** 首次有可显示的内容 */
onReady: () => void
onUndoStackChange: (canUndo: boolean, canRedo: boolean) => void
onSyncState: (state: { status: WebSocketStatus; unsynced: number }) => void
onAuthenticationFailed: (reason: string) => void
}
/**
* 一个空间画布的同步会话:
* - IndexedDB 本地缓存,打开更快,断网时修改也不会丢;
* - 通过共享 WebSocket 与服务器同步;
* - 只撤销本机操作的 UndoManager。
*/
export class BoardSession {
readonly doc = new Y.Doc()
readonly cards = boardCards(this.doc)
readonly edges = boardEdges(this.doc)
readonly undoManager: Y.UndoManager
private readonly provider: HocuspocusProvider
private readonly local: IndexeddbPersistence
private ready = false
private destroyed = false
private fallbackTimer: number | undefined
private readonly onSocketStatus = () => this.emitSyncState()
constructor(
readonly spaceId: string,
private readonly handlers: BoardHandlers,
) {
this.undoManager = new Y.UndoManager([this.cards, this.edges], {
trackedOrigins: new Set([LOCAL_ORIGIN]),
captureTimeout: 600,
})
const emitStack = () =>
handlers.onUndoStackChange(this.undoManager.canUndo(), this.undoManager.canRedo())
this.undoManager.on('stack-item-added', emitStack)
this.undoManager.on('stack-item-popped', emitStack)
this.undoManager.on('stack-cleared', emitStack)
this.cards.observeDeep(() => handlers.onChange())
this.edges.observe(() => handlers.onChange())
this.local = new IndexeddbPersistence(localCacheName(spaceId), this.doc)
void this.local.whenSynced.then(() => {
if (this.cards.size > 0) this.markReady()
})
const socket = getSocket()
this.provider = new HocuspocusProvider({
websocketProvider: socket,
name: boardDocName(spaceId),
document: this.doc,
// 身份由 Cookie 校验,这里的值只是让服务器触发鉴权
token: 'cookie',
onSynced: ({ state }) => {
if (state) this.markReady()
this.emitSyncState()
},
onUnsyncedChanges: () => this.emitSyncState(),
onAuthenticationFailed: ({ reason }) => handlers.onAuthenticationFailed(reason),
})
this.provider.attach()
socket.on('status', this.onSocketStatus)
this.emitSyncState()
this.fallbackTimer = window.setTimeout(() => this.markReady(), OFFLINE_FALLBACK_MS)
}
transact(fn: () => void) {
this.doc.transact(fn, LOCAL_ORIGIN)
}
/** 拖动、缩放各自单独成为一步撤销,不和前后的输入合并 */
beginGesture() {
this.undoManager.stopCapturing()
}
endGesture() {
this.undoManager.stopCapturing()
}
destroy() {
if (this.destroyed) return
this.destroyed = true
window.clearTimeout(this.fallbackTimer)
getSocket().off('status', this.onSocketStatus)
this.undoManager.destroy()
this.provider.destroy()
void this.local.destroy()
this.doc.destroy()
}
private markReady() {
if (this.ready || this.destroyed) return
this.ready = true
window.clearTimeout(this.fallbackTimer)
this.handlers.onReady()
}
private emitSyncState() {
if (this.destroyed) return
this.handlers.onSyncState({
status: getSocket().status,
unsynced: this.provider.unsyncedChanges,
})
}
}
export function localCacheName(spaceId: string) {
return `boundless:${boardDocName(spaceId)}`
}
/** 空间被删除后清理本机缓存 */
export async function clearLocalCache(spaceId: string) {
const persistence = new IndexeddbPersistence(localCacheName(spaceId), new Y.Doc())
await persistence.clearData()
}
+79
View File
@@ -0,0 +1,79 @@
import { HocuspocusProviderWebsocket } from '@hocuspocus/provider'
let socket: HocuspocusProviderWebsocket | null = null
function collabUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${window.location.host}/collab`
}
/** 所有文档共用一条 WebSocket 连接。 */
export function getSocket() {
if (!socket) {
socket = new HocuspocusProviderWebsocket({
url: collabUrl(),
// 断线后持续重连,间隔最多 10 秒
maxDelay: 10_000,
})
}
return socket
}
export function closeSocket() {
socket?.destroy()
socket = null
}
/** 本机标识,用于在“在线设备”里区分不同设备。 */
export function deviceId() {
const key = 'boundless-device-id'
try {
const saved = localStorage.getItem(key)
if (saved) return saved
const id = randomId()
localStorage.setItem(key, id)
return id
} catch {
return randomId()
}
}
/** 简单描述当前设备,例如“Windows · Chrome”。 */
export function deviceLabel(userAgent = navigator.userAgent) {
const os = /iPhone/.test(userAgent)
? 'iPhone'
: /iPad/.test(userAgent) || (/Macintosh/.test(userAgent) && 'ontouchend' in document)
? 'iPad'
: /Android/.test(userAgent)
? 'Android'
: /Windows/.test(userAgent)
? 'Windows'
: /Mac OS X/.test(userAgent)
? 'Mac'
: /Linux/.test(userAgent)
? 'Linux'
: '设备'
const browser = /Edg\//.test(userAgent)
? 'Edge'
: /Firefox\//.test(userAgent)
? 'Firefox'
: /Chrome\//.test(userAgent)
? 'Chrome'
: /Safari\//.test(userAgent)
? 'Safari'
: '浏览器'
return `${os} · ${browser}`
}
/**
* 生成随机 id。不使用 crypto.randomUUID(),
* 因为它只在 HTTPS 下可用,局域网用 http 访问时会直接报错。
*/
export function randomId() {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
bytes[6] = (bytes[6]! & 0x0f) | 0x40
bytes[8] = (bytes[8]! & 0x3f) | 0x80
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
+114
View File
@@ -0,0 +1,114 @@
import { HocuspocusProvider } from '@hocuspocus/provider'
import * as Y from 'yjs'
import { META_DOC, readSpaces, type SpaceInfo } from '@shared/board'
import { deviceId, deviceLabel, getSocket } from './connection'
export interface DeviceInfo {
id: string
label: string
spaceId: string
current: boolean
}
export interface MetaHandlers {
onSpaces: (spaces: SpaceInfo[], fromServer: boolean) => void
onDevices: (devices: DeviceInfo[]) => void
onAuthenticationFailed: () => void
}
const SPACES_CACHE_KEY = 'boundless-spaces-cache'
/**
* 空间列表(只读)与在线设备。
* 空间列表额外缓存在 localStorage,断网时也能打开上次的画布。
*/
export class MetaSession {
private readonly doc = new Y.Doc()
private readonly provider: HocuspocusProvider
private readonly id = deviceId()
constructor(private readonly handlers: MetaHandlers) {
const cached = readCachedSpaces()
if (cached.length) handlers.onSpaces(cached, false)
this.provider = new HocuspocusProvider({
websocketProvider: getSocket(),
name: META_DOC,
document: this.doc,
token: 'cookie',
onSynced: ({ state }) => {
if (state) this.emitSpaces()
},
onAuthenticationFailed: () => handlers.onAuthenticationFailed(),
onAwarenessChange: () => this.emitDevices(),
})
this.doc.on('update', () => {
if (this.provider.isSynced) this.emitSpaces()
})
this.provider.attach()
this.provider.awareness?.setLocalStateField('device', {
id: this.id,
label: deviceLabel(),
spaceId: '',
})
}
setActiveSpace(spaceId: string) {
const awareness = this.provider.awareness
const current = awareness?.getLocalState()?.device as DeviceInfo | undefined
awareness?.setLocalStateField('device', { ...current, id: this.id, spaceId })
}
destroy() {
this.provider.destroy()
this.doc.destroy()
}
private emitSpaces() {
const spaces = readSpaces(this.doc)
writeCachedSpaces(spaces)
this.handlers.onSpaces(spaces, true)
}
private emitDevices() {
const awareness = this.provider.awareness
if (!awareness) return
const devices = new Map<string, DeviceInfo>()
awareness.getStates().forEach((state) => {
const device = state.device as Omit<DeviceInfo, 'current'> | undefined
if (!device?.id) return
// 同一台设备开了多个标签页,只算一台
devices.set(device.id, { ...device, current: device.id === this.id })
})
this.handlers.onDevices(
[...devices.values()].sort((a, b) => Number(b.current) - Number(a.current)),
)
}
}
function readCachedSpaces(): SpaceInfo[] {
try {
const parsed = JSON.parse(localStorage.getItem(SPACES_CACHE_KEY) ?? '[]')
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function writeCachedSpaces(spaces: SpaceInfo[]) {
try {
localStorage.setItem(SPACES_CACHE_KEY, JSON.stringify(spaces))
} catch {
// 存储不可用时忽略
}
}
export function clearCachedSpaces() {
try {
localStorage.removeItem(SPACES_CACHE_KEY)
} catch {
// 忽略
}
}
+7 -36
View File
@@ -1,42 +1,18 @@
export type CardKind = 'note' | 'image'
export type CardColor = 'paper' | 'sun' | 'mint' | 'sky' | 'rose'
import type { CardColor, CardKind } from '@shared/board'
export interface SpaceRecord {
id: string
name: string
emoji: string
createdAt: number
updatedAt: number
}
export type { CardColor, CardKind, SpaceInfo } from '@shared/board'
/** Vue Flow 节点上的卡片数据 */
export interface CardNodeData extends Record<string, unknown> {
spaceId: string
kind: CardKind
title: string
content: string
imageData?: string
image?: 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'
@@ -44,6 +20,7 @@ export interface CanvasNode {
data: CardNodeData
style?: Record<string, string | number>
selected?: boolean
dragging?: boolean
dimensions?: { width: number; height: number }
}
@@ -55,13 +32,7 @@ export interface CanvasEdge {
targetHandle?: string | null
type?: string
markerEnd?: string
data: { spaceId: string }
selected?: boolean
}
export interface BackupFile {
version: 1
exportedAt: string
spaces: SpaceRecord[]
cards: CardRecord[]
edges: EdgeRecord[]
}
export type SyncStatus = 'connecting' | 'synced' | 'syncing' | 'offline'
+41
View File
@@ -0,0 +1,41 @@
// 上传前压缩过大的照片:手机拍的照片动辄 5–10MB,画布上用不到这么高的分辨率。
const MAX_EDGE = 2560
const COMPRESS_ABOVE_BYTES = 1.5 * 1024 * 1024
const COMPRESSIBLE = ['image/jpeg', 'image/png', 'image/webp']
export async function prepareImage(file: File): Promise<Blob> {
if (!COMPRESSIBLE.includes(file.type)) return file
let bitmap: ImageBitmap
try {
bitmap = await createImageBitmap(file)
} catch {
return file
}
const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height))
if (scale === 1 && file.size <= COMPRESS_ABOVE_BYTES) {
bitmap.close()
return file
}
const width = Math.round(bitmap.width * scale)
const height = Math.round(bitmap.height * scale)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')
if (!context) {
bitmap.close()
return file
}
context.drawImage(bitmap, 0, 0, width, height)
bitmap.close()
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/webp', 0.88),
)
// 个别浏览器不支持导出 webp,或者压缩后反而更大,就用原图
if (!blob || blob.type !== 'image/webp' || blob.size >= file.size) return file
return blob
}
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { ArrowRight, LoaderCircle } from '@lucide/vue'
import { onMounted, ref, useTemplateRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ApiError, api } from '@/api'
import { dismissBoot } from '@/boot'
import PaintingLoader from '@/components/PaintingLoader.vue'
import { setAuthenticated } from '@/router'
const route = useRoute()
const router = useRouter()
const password = ref('')
const error = ref('')
const pending = ref(false)
const input = useTemplateRef<HTMLInputElement>('input')
onMounted(() => {
dismissBoot()
input.value?.focus()
})
async function submit() {
if (!password.value || pending.value) return
pending.value = true
error.value = ''
try {
await api.login(password.value)
setAuthenticated(true)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
await router.replace(redirect.startsWith('/') ? redirect : '/')
} catch (err) {
error.value = err instanceof ApiError ? err.message : '登录失败,请稍后再试'
password.value = ''
input.value?.focus()
} finally {
pending.value = false
}
}
</script>
<template>
<main class="login">
<PaintingLoader size="min(184px, 48vw)" />
<form class="login__form" @submit.prevent="submit">
<h1 class="login__title">无界笺</h1>
<p class="login__subtitle">输入口令,回到你的平面</p>
<div class="login__field" :class="{ 'login__field--error': error }">
<input
ref="input"
v-model="password"
type="password"
autocomplete="current-password"
placeholder="口令"
aria-label="登录口令"
:disabled="pending"
/>
<button type="submit" :disabled="!password || pending" aria-label="进入">
<LoaderCircle v-if="pending" class="spin" :size="17" />
<ArrowRight v-else :size="17" />
</button>
</div>
<p class="login__error" role="alert">{{ error }}</p>
</form>
</main>
</template>
+38 -10
View File
@@ -1,25 +1,53 @@
<script setup lang="ts">
import { Menu } from '@lucide/vue'
import { onMounted } from 'vue'
import { onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { dismissBoot, setBootStatus } from '@/boot'
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()
const route = useRoute()
const router = useRouter()
onMounted(() => store.initialize())
onMounted(() => {
store.initialize()
window.setTimeout(() => {
if (!store.boardReady) setBootStatus('正在连接服务器…')
}, 2500)
})
// 地址栏决定打开哪个空间;空间不存在(或被删除)时切到可用的空间
watch(
() => [route.params.spaceId, store.metaReady, store.spaces] as const,
() => {
if (!store.metaReady) return
const requested = typeof route.params.spaceId === 'string' ? route.params.spaceId : ''
if (requested && store.spaces.some((space) => space.id === requested)) {
store.openSpace(requested)
return
}
const fallback = store.preferredSpaceId()
if (fallback && fallback !== requested) {
void router.replace({ name: 'space', params: { spaceId: fallback } })
}
},
{ immediate: true, deep: 1 },
)
watch(
() => store.boardReady,
(ready) => {
if (ready) dismissBoot()
},
{ immediate: true },
)
</script>
<template>
<div v-if="store.ready" class="workspace">
<div 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>
+3 -2
View File
@@ -1,6 +1,6 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "shared/**/*.ts"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
@@ -8,7 +8,8 @@
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@shared/*": ["./shared/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
+3
View File
@@ -7,6 +7,9 @@
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.server.json"
},
{
"path": "./tsconfig.vitest.json"
}
+1
View File
@@ -19,6 +19,7 @@
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
"allowImportingTsExtensions": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
+19
View File
@@ -0,0 +1,19 @@
// 服务端代码由 Node.js 直接运行(原生类型剥离),只能使用可擦除的 TypeScript 语法。
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": ["server/**/*.ts", "shared/**/*.ts"],
"exclude": ["server/**/__tests__/*"],
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": false,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"noUncheckedIndexedAccess": true,
"types": ["node"],
"lib": ["es2024"],
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo"
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
// 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"],
"include": ["src/**/__tests__/*", "server/**/__tests__/*", "env.d.ts"],
"exclude": [],
"compilerOptions": {
+11 -5
View File
@@ -5,16 +5,22 @@ import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import tailwindcss from '@tailwindcss/vite'
// 开发时 Vite 负责前端,接口与同步请求转发给本地服务端(默认 3000 端口)
const serverOrigin = `http://localhost:${process.env.SERVER_PORT || 3000}`
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
tailwindcss(),
],
plugins: [vue(), vueDevTools(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@shared': fileURLToPath(new URL('./shared', import.meta.url)),
},
},
server: {
proxy: {
'/api': { target: serverOrigin },
'/collab': { target: serverOrigin, ws: true },
},
},
})
+1 -1
View File
@@ -1,6 +1,6 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
import viteConfig from './vite.config.ts'
export default mergeConfig(
viteConfig,