自部署多端同步:服务端、实时协作、口令登录与油画加载动画
- 新增 Node 服务端(Hono + Hocuspocus + node:sqlite),数据保存在服务器 - 基于 Yjs 的多端实时同步,本机 IndexedDB 离线缓存,撤销只针对本机操作 - 空间(标签)有独立地址,增删改通过接口同步到所有设备 - 口令登录(签名 Cookie 会话、登录限流、同源校验) - 图片上传到服务器,过大照片上传前自动压缩 - 历史版本(自动/手动保存、一键恢复)、跨空间搜索、在线设备 - 随机油画加载动画(Painting Loaders,本地收录 p5 / p5.brush) - 选中样式与拖拽性能优化:拖动结束后才同步位置 - Docker / docker-compose 部署,README 与 CLAUDE.md 更新
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
.e2e-data
|
||||||
|
.tmp-data
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
@@ -40,3 +40,8 @@ __screenshots__/
|
|||||||
|
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
|
||||||
|
# 本地数据与环境变量
|
||||||
|
/data
|
||||||
|
/.e2e-data
|
||||||
|
.env
|
||||||
|
|||||||
+2
-1
@@ -6,5 +6,6 @@
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"correctness": "error"
|
"correctness": "error"
|
||||||
}
|
},
|
||||||
|
"ignorePatterns": ["public/painterly/**"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
@@ -1,46 +1,97 @@
|
|||||||
# 无界笺 Boundless
|
# 无界笺 Boundless
|
||||||
|
|
||||||
一张可以无限延伸的本地灵感平面。你可以在画布上自由放置文字与图片、连接相关想法,并使用不同空间分开生活中的主题。
|
一张可以无限延伸的灵感平面,自部署在自己的服务器上。所有内容保存在服务器里,手机、平板、电脑打开的都是同一张画布;在一台设备上修改,其他打开着同一空间的设备会立刻看到变化。
|
||||||
|
|
||||||
## 已实现
|
## 功能
|
||||||
|
|
||||||
- 无限画布平移、缩放、小地图和框选
|
- 无限画布:平移、缩放、小地图、框选、对齐与均分
|
||||||
- 文字便笺的创建、编辑、移动、缩放、复制和换色
|
- 文字便笺与图片卡片:创建、编辑、移动、缩放、复制、换色、连线
|
||||||
- 图片选择、拖入与剪贴板粘贴
|
- 多个空间(标签),每个空间有独立的地址 `/s/<空间 id>`
|
||||||
- 卡片之间的自由连线
|
- **多端实时同步**:基于 Yjs CRDT,多台设备同时编辑也不会互相覆盖
|
||||||
- 多空间创建和切换
|
- **断网可用**:离线时的修改保存在本机,联网后自动同步
|
||||||
- 当前空间搜索
|
- **撤销只针对本机**:不会把其他设备的修改一起撤掉
|
||||||
- IndexedDB 自动本地保存
|
- **历史版本**:编辑时每 10 分钟自动存一个版本,也可以手动保存,一键恢复
|
||||||
- JSON 完整备份与恢复
|
- **全局搜索**:搜索当前空间并跳转,同时列出其他空间里的匹配卡片
|
||||||
- 响应式侧边栏
|
- 在线设备:查看哪些设备在线、各自打开的是哪个空间
|
||||||
|
- 口令登录:单用户,修改口令后所有设备自动退出
|
||||||
|
- 图片上传到服务器;过大的照片会在上传前自动压缩
|
||||||
|
- 随机油画加载动画(见下方致谢)
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- Vue 3 + TypeScript 6
|
- 前端:Vue 3 + TypeScript、Vite 8、Vue Flow、Pinia、Tailwind CSS 4
|
||||||
- Vite 8(Rolldown)
|
- 同步:Yjs、Hocuspocus(WebSocket)、y-indexeddb(本机离线缓存)
|
||||||
- Vue Flow
|
- 服务端:Node.js(直接运行 TypeScript)、Hono、node:sqlite
|
||||||
- Pinia + Dexie / IndexedDB
|
- 测试:Vitest、Playwright
|
||||||
- Tailwind CSS 4
|
|
||||||
- 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
|
```sh
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm dev
|
cp .env.example .env # 可选;不设置时开发口令为 boundless
|
||||||
|
pnpm dev # 同时启动服务端(3000)与 Vite(5173)
|
||||||
```
|
```
|
||||||
|
|
||||||
浏览器打开终端显示的本地地址即可使用。
|
浏览器打开 <http://localhost:5173>。服务端数据默认存放在 `./data/`。
|
||||||
|
|
||||||
## 检查项目
|
## 检查项目
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm build
|
pnpm build # 类型检查 + 构建
|
||||||
pnpm test:unit -- --run
|
pnpm test:unit -- --run # 单元测试
|
||||||
pnpm exec playwright test --project=chromium
|
pnpm exec playwright install chromium # 首次运行 e2e 前
|
||||||
|
pnpm exec playwright test --project=chromium # 端到端测试(会自动构建并启动服务端)
|
||||||
pnpm lint
|
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)。
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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
@@ -1,14 +1,98 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<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="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>
|
<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>
|
</head>
|
||||||
<body>
|
<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>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+14
-4
@@ -4,9 +4,12 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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 {@}\" --",
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
"preview": "vite preview",
|
"preview": "run-s build-only start",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"build-only": "vite build",
|
"build-only": "vite build",
|
||||||
@@ -17,6 +20,10 @@
|
|||||||
"format": "prettier --write --experimental-cli src/"
|
"format": "prettier --write --experimental-cli src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@lucide/vue": "^1.31.0",
|
||||||
"@vue-flow/background": "^1.3.2",
|
"@vue-flow/background": "^1.3.2",
|
||||||
"@vue-flow/controls": "^1.1.3",
|
"@vue-flow/controls": "^1.1.3",
|
||||||
@@ -24,10 +31,13 @@
|
|||||||
"@vue-flow/minimap": "^1.5.4",
|
"@vue-flow/minimap": "^1.5.4",
|
||||||
"@vue-flow/node-resizer": "^1.5.1",
|
"@vue-flow/node-resizer": "^1.5.1",
|
||||||
"@vueuse/core": "^14.4.0",
|
"@vueuse/core": "^14.4.0",
|
||||||
"dexie": "^4.4.4",
|
"crossws": "0.4.15",
|
||||||
|
"hono": "4.13.13",
|
||||||
"pinia": "^4.0.2",
|
"pinia": "^4.0.2",
|
||||||
"vue": "^3.5.40",
|
"vue": "^3.5.40",
|
||||||
"vue-router": "^5.2.0"
|
"vue-router": "^5.2.0",
|
||||||
|
"y-indexeddb": "9.0.12",
|
||||||
|
"yjs": "13.6.33"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
|
|||||||
+29
-77
@@ -1,110 +1,62 @@
|
|||||||
import process from 'node:process'
|
import process from 'node:process'
|
||||||
import { defineConfig, devices } from '@playwright/test'
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
/**
|
import { E2E_PASSWORD } from './e2e/helpers'
|
||||||
* Read environment variables from file.
|
|
||||||
* https://github.com/motdotla/dotenv
|
// e2e 跑的是生产构建 + 真实服务端,使用独立的临时数据目录
|
||||||
*/
|
const PORT = 4174
|
||||||
// require('dotenv').config();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See https://playwright.dev/docs/test-configuration.
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
/* Maximum time one test can run for. */
|
timeout: 45 * 1000,
|
||||||
timeout: 30 * 1000,
|
|
||||||
expect: {
|
expect: {
|
||||||
/**
|
timeout: 8000,
|
||||||
* Maximum time expect() should wait for the condition to be met.
|
|
||||||
* For example in `await expect(locator).toHaveText();`
|
|
||||||
*/
|
|
||||||
timeout: 5000,
|
|
||||||
},
|
},
|
||||||
|
fullyParallel: false,
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
/* Retry on CI only */
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
/* Opt out of parallel tests on CI. */
|
// 所有用例共用一个服务端和数据目录,串行执行更可靠
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: 1,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
reporter: process.env.CI ? 'github' : 'list',
|
||||||
reporter: 'html',
|
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
|
||||||
use: {
|
use: {
|
||||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
baseURL: `http://localhost:${PORT}`,
|
||||||
actionTimeout: 0,
|
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
|
||||||
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
|
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
|
// 默认跳过加载动画,专门的用例会单独打开
|
||||||
/* Only on CI systems run the tests headless */
|
contextOptions: { reducedMotion: 'reduce' },
|
||||||
headless: !!process.env.CI,
|
locale: 'zh-CN',
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: {
|
use: { ...devices['Desktop Chrome'] },
|
||||||
...devices['Desktop Chrome'],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'firefox',
|
name: 'firefox',
|
||||||
use: {
|
use: { ...devices['Desktop Firefox'] },
|
||||||
...devices['Desktop Firefox'],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'webkit',
|
name: 'webkit',
|
||||||
use: {
|
use: { ...devices['Desktop Safari'] },
|
||||||
...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: {
|
webServer: {
|
||||||
/**
|
command:
|
||||||
* Use the dev server by default for faster feedback loop.
|
'node -e "require(\'fs\').rmSync(\'.e2e-data\',{recursive:true,force:true})" && npm run build-only && npm run start',
|
||||||
* Use the preview server on CI for more realistic testing.
|
url: `http://localhost:${PORT}/healthz`,
|
||||||
* Playwright will re-use the local server if there is already a dev-server running.
|
reuseExistingServer: false,
|
||||||
*/
|
timeout: 180 * 1000,
|
||||||
command: process.env.CI ? 'npm run preview' : 'npm run dev',
|
env: {
|
||||||
port: process.env.CI ? 4173 : 5173,
|
PORT: String(PORT),
|
||||||
reuseExistingServer: !process.env.CI,
|
DATA_DIR: '.e2e-data',
|
||||||
|
BOUNDLESS_PASSWORD: E2E_PASSWORD,
|
||||||
|
BOUNDLESS_SECRET: 'e2e-secret',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+170
-8
@@ -8,6 +8,18 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
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':
|
'@lucide/vue':
|
||||||
specifier: ^1.31.0
|
specifier: ^1.31.0
|
||||||
version: 1.31.0(vue@3.5.41(typescript@6.0.3))
|
version: 1.31.0(vue@3.5.41(typescript@6.0.3))
|
||||||
@@ -29,9 +41,12 @@ importers:
|
|||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^14.4.0
|
specifier: ^14.4.0
|
||||||
version: 14.4.0(vue@3.5.41(typescript@6.0.3))
|
version: 14.4.0(vue@3.5.41(typescript@6.0.3))
|
||||||
dexie:
|
crossws:
|
||||||
specifier: ^4.4.4
|
specifier: 0.4.15
|
||||||
version: 4.4.4
|
version: 0.4.15
|
||||||
|
hono:
|
||||||
|
specifier: 4.13.13
|
||||||
|
version: 4.13.13
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^4.0.2
|
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))
|
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:
|
vue-router:
|
||||||
specifier: ^5.2.0
|
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))
|
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:
|
devDependencies:
|
||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: ^1.61.1
|
specifier: ^1.61.1
|
||||||
@@ -381,6 +402,33 @@ packages:
|
|||||||
'@noble/hashes':
|
'@noble/hashes':
|
||||||
optional: true
|
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':
|
'@humanfs/core@0.19.2':
|
||||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -441,6 +489,9 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@lifeomic/attempt@3.1.0':
|
||||||
|
resolution: {integrity: sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==}
|
||||||
|
|
||||||
'@lucide/vue@1.31.0':
|
'@lucide/vue@1.31.0':
|
||||||
resolution: {integrity: sha512-NtjEHhcAa7umPh40wrRmlESJqNGdnpc7LziBKFnW3fmlrW0g2xMCDpdWU7WeEHRSl3xOpxbqiFTLKxoK3CoVCg==}
|
resolution: {integrity: sha512-NtjEHhcAa7umPh40wrRmlESJqNGdnpc7LziBKFnW3fmlrW0g2xMCDpdWU7WeEHRSl3xOpxbqiFTLKxoK3CoVCg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1148,6 +1199,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==}
|
resolution: {integrity: sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
|
async-mutex@0.5.0:
|
||||||
|
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
|
||||||
|
|
||||||
balanced-match@1.0.0:
|
balanced-match@1.0.0:
|
||||||
resolution: {integrity: sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==}
|
resolution: {integrity: sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==}
|
||||||
|
|
||||||
@@ -1234,6 +1288,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
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:
|
css-tree@3.2.1:
|
||||||
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
|
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
|
||||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||||
@@ -1319,9 +1381,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
dexie@4.4.4:
|
|
||||||
resolution: {integrity: sha512-jIwsYI8Os2hgnqc6O49YwFDKGc5v5QjGx0wPVp543ip1F53VFAKMLthV2pQosQcVTv3eAskTWYspOx195PM0FQ==}
|
|
||||||
|
|
||||||
eastasianwidth@0.2.0:
|
eastasianwidth@0.2.0:
|
||||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||||
|
|
||||||
@@ -1541,6 +1600,10 @@ packages:
|
|||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
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:
|
hookable@5.5.3:
|
||||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||||
|
|
||||||
@@ -1619,6 +1682,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==}
|
resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
isomorphic.js@0.2.5:
|
||||||
|
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
|
||||||
|
|
||||||
jackspeak@3.1.2:
|
jackspeak@3.1.2:
|
||||||
resolution: {integrity: sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==}
|
resolution: {integrity: sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -1673,6 +1739,10 @@ packages:
|
|||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
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:
|
kolorist@1.8.0:
|
||||||
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
|
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
|
||||||
|
|
||||||
@@ -1680,6 +1750,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
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:
|
lightningcss-android-arm64@1.32.0:
|
||||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -2235,6 +2310,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=4.8.4'
|
typescript: '>=4.8.4'
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -2547,6 +2625,18 @@ packages:
|
|||||||
xmlchars@2.2.0:
|
xmlchars@2.2.0:
|
||||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
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:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
@@ -2555,6 +2645,10 @@ packages:
|
|||||||
engines: {node: '>= 14.6'}
|
engines: {node: '>= 14.6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
yjs@13.6.33:
|
||||||
|
resolution: {integrity: sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==}
|
||||||
|
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@asamuzakjp/css-color@5.1.11':
|
'@asamuzakjp/css-color@5.1.11':
|
||||||
@@ -2850,6 +2944,42 @@ snapshots:
|
|||||||
|
|
||||||
'@exodus/bytes@1.15.1': {}
|
'@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':
|
'@humanfs/core@0.19.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@humanfs/types': 0.15.0
|
'@humanfs/types': 0.15.0
|
||||||
@@ -2910,6 +3040,8 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.4.14
|
'@jridgewell/sourcemap-codec': 1.4.14
|
||||||
|
|
||||||
|
'@lifeomic/attempt@3.1.0': {}
|
||||||
|
|
||||||
'@lucide/vue@1.31.0(vue@3.5.41(typescript@6.0.3))':
|
'@lucide/vue@1.31.0(vue@3.5.41(typescript@6.0.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.41(typescript@6.0.3)
|
vue: 3.5.41(typescript@6.0.3)
|
||||||
@@ -3559,6 +3691,10 @@ snapshots:
|
|||||||
'@babel/types': 7.29.8
|
'@babel/types': 7.29.8
|
||||||
ast-kit: 2.2.0
|
ast-kit: 2.2.0
|
||||||
|
|
||||||
|
async-mutex@0.5.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
balanced-match@1.0.0: {}
|
balanced-match@1.0.0: {}
|
||||||
|
|
||||||
balanced-match@4.0.4: {}
|
balanced-match@4.0.4: {}
|
||||||
@@ -3634,6 +3770,8 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.1
|
which: 2.0.1
|
||||||
|
|
||||||
|
crossws@0.4.15: {}
|
||||||
|
|
||||||
css-tree@3.2.1:
|
css-tree@3.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
mdn-data: 2.27.1
|
mdn-data: 2.27.1
|
||||||
@@ -3705,8 +3843,6 @@ snapshots:
|
|||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
dexie@4.4.4: {}
|
|
||||||
|
|
||||||
eastasianwidth@0.2.0: {}
|
eastasianwidth@0.2.0: {}
|
||||||
|
|
||||||
editorconfig@1.0.7:
|
editorconfig@1.0.7:
|
||||||
@@ -3929,6 +4065,8 @@ snapshots:
|
|||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
|
hono@4.13.13: {}
|
||||||
|
|
||||||
hookable@5.5.3: {}
|
hookable@5.5.3: {}
|
||||||
|
|
||||||
html-encoding-sniffer@6.0.0:
|
html-encoding-sniffer@6.0.0:
|
||||||
@@ -3982,6 +4120,8 @@ snapshots:
|
|||||||
|
|
||||||
isexe@4.0.0: {}
|
isexe@4.0.0: {}
|
||||||
|
|
||||||
|
isomorphic.js@0.2.5: {}
|
||||||
|
|
||||||
jackspeak@3.1.2:
|
jackspeak@3.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@isaacs/cliui': 8.0.2
|
'@isaacs/cliui': 8.0.2
|
||||||
@@ -4043,6 +4183,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
|
|
||||||
|
kleur@4.1.5: {}
|
||||||
|
|
||||||
kolorist@1.8.0: {}
|
kolorist@1.8.0: {}
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
@@ -4050,6 +4192,10 @@ snapshots:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
|
lib0@0.2.119:
|
||||||
|
dependencies:
|
||||||
|
isomorphic.js: 0.2.5
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
lightningcss-android-arm64@1.32.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4523,6 +4669,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
|
|
||||||
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@@ -4796,6 +4944,20 @@ snapshots:
|
|||||||
|
|
||||||
xmlchars@2.2.0: {}
|
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: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
yaml@2.9.0: {}
|
yaml@2.9.0: {}
|
||||||
|
|
||||||
|
yjs@13.6.33:
|
||||||
|
dependencies:
|
||||||
|
lib0: 0.2.119
|
||||||
|
|||||||
@@ -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 |
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
Vendored
+8
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 ? '…' : ''}`
|
||||||
|
}
|
||||||
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
+133
-94
@@ -1,90 +1,113 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Download, FileUp, Pencil, Plus, Trash2, X } from '@lucide/vue'
|
import { LogOut, MonitorSmartphone, Pencil, Plus, Trash2, X } from '@lucide/vue'
|
||||||
import { ref } from '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 BrandMark from '@/components/BrandMark.vue'
|
||||||
|
import { setAuthenticated } from '@/router'
|
||||||
import { useCanvasStore } from '@/stores/canvas'
|
import { useCanvasStore } from '@/stores/canvas'
|
||||||
import type { BackupFile, SpaceRecord } from '@/types/canvas'
|
import type { SpaceInfo } from '@/types/canvas'
|
||||||
|
|
||||||
const store = useCanvasStore()
|
const store = useCanvasStore()
|
||||||
const fileInput = ref<HTMLInputElement>()
|
const router = useRouter()
|
||||||
|
|
||||||
// 新建/编辑空间弹窗状态
|
// 新建/编辑空间弹窗
|
||||||
const isModalOpen = ref(false)
|
const isModalOpen = ref(false)
|
||||||
const modalMode = ref<'create' | 'edit'>('create')
|
const modalMode = ref<'create' | 'edit'>('create')
|
||||||
const editingSpaceId = ref('')
|
const editingSpaceId = ref('')
|
||||||
const spaceNameInput = ref('')
|
const spaceNameInput = ref('')
|
||||||
const selectedEmoji = 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 = ['✦', '◌', '◇', '✺', '◎', '🌿', '💡', '🪐', '☕', '📌', '🎨', '📝']
|
const availableEmojis = ['✦', '◌', '◇', '✺', '◎', '🌿', '💡', '🪐', '☕', '📌', '🎨', '📝']
|
||||||
|
|
||||||
function openCreateModal() {
|
const syncLabel = computed(() => {
|
||||||
modalMode.value = 'create'
|
switch (store.syncStatus) {
|
||||||
editingSpaceId.value = ''
|
case 'synced':
|
||||||
spaceNameInput.value = ''
|
return '已同步'
|
||||||
selectedEmoji.value = availableEmojis[store.spaces.length % availableEmojis.length] ?? '✦'
|
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
|
isModalOpen.value = true
|
||||||
|
void nextTick(() => nameInput.value?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditModal(space: SpaceRecord) {
|
function closeSidebarOnNarrowScreen() {
|
||||||
modalMode.value = 'edit'
|
if (window.matchMedia('(max-width: 900px)').matches) store.sidebarOpen = false
|
||||||
editingSpaceId.value = space.id
|
|
||||||
spaceNameInput.value = space.name
|
|
||||||
selectedEmoji.value = space.emoji
|
|
||||||
isModalOpen.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSpace() {
|
async function saveSpace() {
|
||||||
const name = spaceNameInput.value.trim()
|
const name = spaceNameInput.value.trim()
|
||||||
if (!name) return
|
if (!name || saving.value) return
|
||||||
|
saving.value = true
|
||||||
if (modalMode.value === 'create') {
|
try {
|
||||||
await store.createSpace(name, selectedEmoji.value)
|
if (modalMode.value === 'create') {
|
||||||
} else if (editingSpaceId.value) {
|
const space = await store.createSpace(name, selectedEmoji.value)
|
||||||
await store.updateSpace(editingSpaceId.value, {
|
await router.push({ name: 'space', params: { spaceId: space.id } })
|
||||||
name,
|
closeSidebarOnNarrowScreen()
|
||||||
emoji: selectedEmoji.value,
|
} 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
|
||||||
}
|
}
|
||||||
|
|
||||||
isModalOpen.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDeleteSpace() {
|
async function deleteSpace() {
|
||||||
if (!editingSpaceId.value) return
|
if (!confirmingDelete.value) {
|
||||||
const confirmed = window.confirm('确认删除此空间吗?该空间内的所有便笺与连线将被彻底移除。')
|
confirmingDelete.value = true
|
||||||
if (confirmed) {
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
await store.deleteSpace(editingSpaceId.value)
|
await store.deleteSpace(editingSpaceId.value)
|
||||||
isModalOpen.value = false
|
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) {
|
} catch (error) {
|
||||||
window.alert(error instanceof Error ? error.message : '备份导入失败')
|
store.showToast(error instanceof ApiError ? error.message : '删除失败')
|
||||||
} finally {
|
} 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -108,7 +131,7 @@ async function importData(event: Event) {
|
|||||||
|
|
||||||
<div class="sidebar__section-heading">
|
<div class="sidebar__section-heading">
|
||||||
<span>我的空间</span>
|
<span>我的空间</span>
|
||||||
<button class="icon-button" aria-label="创建空间" title="创建空间" @click="openCreateModal">
|
<button class="icon-button" aria-label="创建空间" title="创建空间" @click="openModal('create')">
|
||||||
<Plus :size="17" />
|
<Plus :size="17" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,20 +143,21 @@ async function importData(event: Event) {
|
|||||||
class="space-item-wrapper"
|
class="space-item-wrapper"
|
||||||
:class="{ 'space-item-wrapper--active': store.activeSpaceId === space.id }"
|
:class="{ 'space-item-wrapper--active': store.activeSpaceId === space.id }"
|
||||||
>
|
>
|
||||||
<button
|
<RouterLink
|
||||||
class="space-item"
|
class="space-item"
|
||||||
:class="{ 'space-item--active': store.activeSpaceId === space.id }"
|
: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__symbol">{{ space.emoji }}</span>
|
||||||
<span class="space-item__name">{{ space.name }}</span>
|
<span class="space-item__name">{{ space.name }}</span>
|
||||||
<span class="space-item__count">{{ store.cardCounts[space.id] ?? 0 }}</span>
|
<span class="space-item__count">{{ space.cardCount }}</span>
|
||||||
</button>
|
</RouterLink>
|
||||||
<button
|
<button
|
||||||
class="space-item__edit-btn"
|
class="space-item__edit-btn"
|
||||||
title="空间设置"
|
title="空间设置"
|
||||||
aria-label="编辑空间设置"
|
aria-label="编辑空间设置"
|
||||||
@click.stop="openEditModal(space)"
|
@click.stop="openModal('edit', space)"
|
||||||
>
|
>
|
||||||
<Pencil :size="12" />
|
<Pencil :size="12" />
|
||||||
</button>
|
</button>
|
||||||
@@ -146,35 +170,49 @@ async function importData(event: Event) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar__footer">
|
<div class="sidebar__footer">
|
||||||
<div class="storage-status">
|
<div class="sync-row">
|
||||||
<span></span>
|
<span class="sync-status" :data-status="store.syncStatus">
|
||||||
数据仅保存在此设备
|
<i></i>
|
||||||
</div>
|
{{ syncLabel }}
|
||||||
<div class="sidebar__actions">
|
</span>
|
||||||
<button class="text-button" @click="exportData">
|
<div ref="devicesRef" class="devices">
|
||||||
<Download :size="15" />
|
<button
|
||||||
导出备份
|
v-if="store.onlineDevices > 0"
|
||||||
</button>
|
class="devices__button"
|
||||||
<button class="text-button" @click="fileInput?.click()">
|
:class="{ active: devicesOpen }"
|
||||||
<FileUp :size="15" />
|
:title="`${store.onlineDevices} 台设备在线`"
|
||||||
导入
|
:aria-expanded="devicesOpen"
|
||||||
</button>
|
@click="devicesOpen = !devicesOpen"
|
||||||
<input
|
>
|
||||||
ref="fileInput"
|
<MonitorSmartphone :size="13" />
|
||||||
hidden
|
{{ store.onlineDevices }}
|
||||||
type="file"
|
</button>
|
||||||
accept="application/json,.json"
|
<div v-if="devicesOpen" class="devices__popover" role="dialog" aria-label="在线设备">
|
||||||
@change="importData"
|
<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>
|
</div>
|
||||||
|
<button class="text-button" @click="logout">
|
||||||
|
<LogOut :size="14" />
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 空间创建/编辑对话框 -->
|
<!-- 空间创建/编辑对话框 -->
|
||||||
<div v-if="isModalOpen" class="modal-overlay" @click="isModalOpen = false">
|
<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">
|
<header class="dialog-header">
|
||||||
<strong>{{ modalMode === 'create' ? '创建新空间' : '空间设置' }}</strong>
|
<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" />
|
<X :size="16" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -183,12 +221,11 @@ async function importData(event: Event) {
|
|||||||
<label class="dialog-field">
|
<label class="dialog-field">
|
||||||
<span>空间名称</span>
|
<span>空间名称</span>
|
||||||
<input
|
<input
|
||||||
|
ref="nameInput"
|
||||||
v-model="spaceNameInput"
|
v-model="spaceNameInput"
|
||||||
class="dialog-input"
|
class="dialog-input"
|
||||||
maxlength="30"
|
maxlength="30"
|
||||||
placeholder="例如:灵感收纳盒、工作面板…"
|
placeholder="例如:灵感收纳盒、工作面板…"
|
||||||
autofocus
|
|
||||||
@keydown.enter="saveSpace"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -212,28 +249,30 @@ async function importData(event: Event) {
|
|||||||
<footer class="dialog-footer">
|
<footer class="dialog-footer">
|
||||||
<button
|
<button
|
||||||
v-if="modalMode === 'edit'"
|
v-if="modalMode === 'edit'"
|
||||||
|
type="button"
|
||||||
class="dialog-btn dialog-btn--danger"
|
class="dialog-btn dialog-btn--danger"
|
||||||
:disabled="store.spaces.length <= 1"
|
:class="{ 'dialog-btn--confirm': confirmingDelete }"
|
||||||
title="删除空间"
|
:disabled="store.spaces.length <= 1 || saving"
|
||||||
@click="confirmDeleteSpace"
|
:title="store.spaces.length <= 1 ? '至少保留一个空间' : '删除空间'"
|
||||||
|
@click="deleteSpace"
|
||||||
>
|
>
|
||||||
<Trash2 :size="14" />
|
<Trash2 :size="14" />
|
||||||
删除空间
|
{{ confirmingDelete ? '再点一次确认删除' : '删除空间' }}
|
||||||
</button>
|
</button>
|
||||||
<div class="dialog-footer-right">
|
<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>
|
||||||
<button
|
<button
|
||||||
|
type="submit"
|
||||||
class="dialog-btn dialog-btn--primary"
|
class="dialog-btn dialog-btn--primary"
|
||||||
:disabled="!spaceNameInput.trim()"
|
:disabled="!spaceNameInput.trim() || saving"
|
||||||
@click="saveSpace"
|
|
||||||
>
|
>
|
||||||
{{ modalMode === 'create' ? '立即创建' : '保存更改' }}
|
{{ modalMode === 'create' ? '立即创建' : '保存更改' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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
@@ -15,8 +15,10 @@ import { NodeResizer, type OnResizeEnd } from '@vue-flow/node-resizer'
|
|||||||
import { onClickOutside } from '@vueuse/core'
|
import { onClickOutside } from '@vueuse/core'
|
||||||
import { computed, ref, useTemplateRef } from 'vue'
|
import { computed, ref, useTemplateRef } from 'vue'
|
||||||
|
|
||||||
|
import { CARD_COLORS } from '@shared/board'
|
||||||
|
|
||||||
import { useCanvasStore } from '@/stores/canvas'
|
import { useCanvasStore } from '@/stores/canvas'
|
||||||
import type { CardColor, CardNodeData } from '@/types/canvas'
|
import type { CardNodeData } from '@/types/canvas'
|
||||||
|
|
||||||
defineOptions({ inheritAttrs: false })
|
defineOptions({ inheritAttrs: false })
|
||||||
|
|
||||||
@@ -33,7 +35,6 @@ const emit = defineEmits<{
|
|||||||
const store = useCanvasStore()
|
const store = useCanvasStore()
|
||||||
const menuOpen = ref(false)
|
const menuOpen = ref(false)
|
||||||
const menuRef = useTemplateRef<HTMLElement>('menuRef')
|
const menuRef = useTemplateRef<HTMLElement>('menuRef')
|
||||||
const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
|
||||||
|
|
||||||
onClickOutside(menuRef, () => {
|
onClickOutside(menuRef, () => {
|
||||||
menuOpen.value = false
|
menuOpen.value = false
|
||||||
@@ -45,16 +46,21 @@ const searchMatch = computed(() => {
|
|||||||
return `${props.data.title} ${props.data.content}`.toLocaleLowerCase().includes(query)
|
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)
|
store.updateNodeData(props.id, patchData)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resize(event: OnResizeEnd) {
|
function resizeStart() {
|
||||||
store.resizeNode(props.id, event.params.width, event.params.height)
|
store.beginGesture([props.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeEnd(event: OnResizeEnd) {
|
||||||
|
store.resizeNode(props.id, event.params)
|
||||||
|
store.endGesture([props.id])
|
||||||
}
|
}
|
||||||
|
|
||||||
function duplicate() {
|
function duplicate() {
|
||||||
store.duplicateNode(props.id)
|
store.duplicateNodes([props.id])
|
||||||
menuOpen.value = false
|
menuOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +74,11 @@ function sendToBack() {
|
|||||||
menuOpen.value = false
|
menuOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function remove() {
|
||||||
|
menuOpen.value = false
|
||||||
|
store.removeNodes([props.id])
|
||||||
|
}
|
||||||
|
|
||||||
async function copyText() {
|
async function copyText() {
|
||||||
const fullText = props.data.title
|
const fullText = props.data.title
|
||||||
? (props.data.title + '\n\n' + (props.data.content || '')).trim()
|
? (props.data.title + '\n\n' + (props.data.content || '')).trim()
|
||||||
@@ -89,9 +100,9 @@ function insertTodo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openImagePreview() {
|
function openImagePreview() {
|
||||||
if (props.data.kind === 'image' && props.data.imageData) {
|
if (props.data.kind === 'image' && props.data.image) {
|
||||||
emit('preview-image', {
|
emit('preview-image', {
|
||||||
src: props.data.imageData,
|
src: props.data.image,
|
||||||
title: props.data.title || '图片预览',
|
title: props.data.title || '图片预览',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -103,8 +114,8 @@ function openImagePreview() {
|
|||||||
:min-width="240"
|
:min-width="240"
|
||||||
:min-height="160"
|
:min-height="160"
|
||||||
:is-visible="selected"
|
:is-visible="selected"
|
||||||
color="#1c1e1f"
|
@resize-start="resizeStart"
|
||||||
@resize-end="resize"
|
@resize-end="resizeEnd"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<article
|
<article
|
||||||
@@ -151,7 +162,7 @@ function openImagePreview() {
|
|||||||
置于底层
|
置于底层
|
||||||
</button>
|
</button>
|
||||||
<hr class="card-menu__divider" />
|
<hr class="card-menu__divider" />
|
||||||
<button class="card-menu__danger" @click="store.removeNodes([id])">
|
<button class="card-menu__danger" @click="remove">
|
||||||
<Trash2 :size="14" />
|
<Trash2 :size="14" />
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
@@ -160,12 +171,18 @@ function openImagePreview() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="data.kind === 'image' && data.imageData"
|
v-if="data.kind === 'image' && data.image"
|
||||||
class="note-card__image-wrap nodrag"
|
class="note-card__image-wrap nodrag"
|
||||||
title="点击查看大图"
|
title="点击查看大图"
|
||||||
@click="openImagePreview"
|
@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">
|
<div class="note-card__image-overlay">
|
||||||
<ZoomIn :size="18" />
|
<ZoomIn :size="18" />
|
||||||
</div>
|
</div>
|
||||||
@@ -197,7 +214,7 @@ function openImagePreview() {
|
|||||||
<footer class="note-card__footer nodrag">
|
<footer class="note-card__footer nodrag">
|
||||||
<div class="color-picker" aria-label="卡片颜色">
|
<div class="color-picker" aria-label="卡片颜色">
|
||||||
<button
|
<button
|
||||||
v-for="color in colors"
|
v-for="color in CARD_COLORS"
|
||||||
:key="color"
|
:key="color"
|
||||||
:class="{ active: data.color === color }"
|
:class="{ active: data.color === color }"
|
||||||
:data-color="color"
|
:data-color="color"
|
||||||
|
|||||||
+174
-209
@@ -8,11 +8,10 @@ import {
|
|||||||
AlignStartHorizontal,
|
AlignStartHorizontal,
|
||||||
AlignStartVertical,
|
AlignStartVertical,
|
||||||
AlignVerticalDistributeCenter,
|
AlignVerticalDistributeCenter,
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
Copy,
|
Copy,
|
||||||
Grid2x2,
|
Grid2x2,
|
||||||
|
History,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
Map,
|
Map,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
@@ -20,7 +19,6 @@ import {
|
|||||||
MousePointer,
|
MousePointer,
|
||||||
Plus,
|
Plus,
|
||||||
Redo2,
|
Redo2,
|
||||||
Search,
|
|
||||||
SquareDashed,
|
SquareDashed,
|
||||||
StickyNote,
|
StickyNote,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -32,22 +30,27 @@ import {
|
|||||||
import { Background } from '@vue-flow/background'
|
import { Background } from '@vue-flow/background'
|
||||||
import {
|
import {
|
||||||
ConnectionMode,
|
ConnectionMode,
|
||||||
MarkerType,
|
|
||||||
Panel,
|
Panel,
|
||||||
VueFlow,
|
VueFlow,
|
||||||
useVueFlow,
|
useVueFlow,
|
||||||
type Connection,
|
type Connection,
|
||||||
type Edge,
|
type Edge,
|
||||||
type Node,
|
type Node,
|
||||||
|
type NodeDragEvent,
|
||||||
} from '@vue-flow/core'
|
} from '@vue-flow/core'
|
||||||
import { MiniMap } from '@vue-flow/minimap'
|
import { MiniMap } from '@vue-flow/minimap'
|
||||||
import { useDebounceFn, useEventListener } from '@vueuse/core'
|
import { useEventListener } from '@vueuse/core'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
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 CardNode from '@/components/CardNode.vue'
|
||||||
|
import PaintingLoader from '@/components/PaintingLoader.vue'
|
||||||
|
import VersionHistoryDialog from '@/components/VersionHistoryDialog.vue'
|
||||||
import { useCanvasStore } from '@/stores/canvas'
|
import { useCanvasStore } from '@/stores/canvas'
|
||||||
import type { CanvasEdge, CardColor } from '@/types/canvas'
|
|
||||||
|
|
||||||
const store = useCanvasStore()
|
const store = useCanvasStore()
|
||||||
const { nodes, edges } = storeToRefs(store)
|
const { nodes, edges } = storeToRefs(store)
|
||||||
@@ -55,16 +58,76 @@ const fileInput = ref<HTMLInputElement>()
|
|||||||
const canvasElement = ref<HTMLElement>()
|
const canvasElement = ref<HTMLElement>()
|
||||||
const showMiniMap = ref(true)
|
const showMiniMap = ref(true)
|
||||||
const showShortcuts = ref(false)
|
const showShortcuts = ref(false)
|
||||||
|
const showHistory = ref(false)
|
||||||
const previewImage = ref<{ src: string; title: string } | null>(null)
|
const previewImage = ref<{ src: string; title: string } | null>(null)
|
||||||
const isSpacePressed = ref(false)
|
const isSpacePressed = ref(false)
|
||||||
|
|
||||||
const { screenToFlowCoordinate, fitView, zoomIn, zoomOut, zoomTo, viewport } =
|
const { screenToFlowCoordinate, fitView, zoomIn, zoomOut, zoomTo, viewport, onNodesInitialized } =
|
||||||
useVueFlow('boundless-canvas')
|
useVueFlow('boundless-canvas')
|
||||||
|
|
||||||
const currentZoom = computed(() => Math.round((viewport.value?.zoom ?? 1) * 100))
|
const currentZoom = computed(() => Math.round((viewport.value?.zoom ?? 1) * 100))
|
||||||
|
|
||||||
const saveSoon = useDebounceFn(() => store.persistSnapshot(), 500)
|
const syncLabel = computed(
|
||||||
watch([nodes, edges], saveSoon, { deep: true })
|
() =>
|
||||||
|
({
|
||||||
|
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() {
|
function canvasCenter() {
|
||||||
const rect = canvasElement.value?.getBoundingClientRect()
|
const rect = canvasElement.value?.getBoundingClientRect()
|
||||||
@@ -85,32 +148,15 @@ function addNoteAtEvent(event: MouseEvent) {
|
|||||||
store.addCard('note', { x: point.x - 150, y: point.y - 80 })
|
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.type.startsWith('image/')) return
|
||||||
if (file.size > 12 * 1024 * 1024) {
|
void store.addImageCard(file, position)
|
||||||
window.alert('图片请不要超过 12MB')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = () => {
|
|
||||||
store.addCard(
|
|
||||||
'image',
|
|
||||||
{ x: position.x - 180, y: position.y - 150 },
|
|
||||||
{
|
|
||||||
imageData: String(reader.result),
|
|
||||||
title: file.name.replace(/\.[^.]+$/, ''),
|
|
||||||
color: 'paper',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectImage(event: Event) {
|
function selectImage(event: Event) {
|
||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
const file = input.files?.[0]
|
const file = input.files?.[0]
|
||||||
if (file) readImage(file)
|
if (file) addImage(file)
|
||||||
input.value = ''
|
input.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,22 +164,23 @@ function dropImage(event: DragEvent) {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const file = [...(event.dataTransfer?.files ?? [])].find((item) => item.type.startsWith('image/'))
|
const file = [...(event.dataTransfer?.files ?? [])].find((item) => item.type.startsWith('image/'))
|
||||||
if (!file) return
|
if (!file) return
|
||||||
const position = screenToFlowCoordinate({ x: event.clientX, y: event.clientY })
|
addImage(file, screenToFlowCoordinate({ x: event.clientX, y: event.clientY }))
|
||||||
readImage(file, position)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clipboardText(event: ClipboardEvent) {
|
function clipboardText(event: ClipboardEvent) {
|
||||||
const plainText = event.clipboardData?.getData('text/plain') ?? ''
|
const plainText = event.clipboardData?.getData('text/plain') ?? ''
|
||||||
if (plainText.trim()) return plainText
|
if (plainText.trim()) return plainText
|
||||||
|
|
||||||
const html = event.clipboardData?.getData('text/html') ?? ''
|
const html = event.clipboardData?.getData('text/html') ?? ''
|
||||||
if (!html) return ''
|
if (!html) return ''
|
||||||
return new DOMParser().parseFromString(html, 'text/html').body.textContent ?? ''
|
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) {
|
function pasteContent(event: ClipboardEvent) {
|
||||||
const target = event.target as HTMLElement | null
|
if (isEditing(event.target) || !store.boardReady) return
|
||||||
if (target?.matches('input, textarea, [contenteditable="true"]')) return
|
|
||||||
|
|
||||||
const files = [...(event.clipboardData?.files ?? [])]
|
const files = [...(event.clipboardData?.files ?? [])]
|
||||||
const itemFiles = [...(event.clipboardData?.items ?? [])]
|
const itemFiles = [...(event.clipboardData?.items ?? [])]
|
||||||
@@ -144,7 +191,7 @@ function pasteContent(event: ClipboardEvent) {
|
|||||||
|
|
||||||
if (image) {
|
if (image) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
readImage(image)
|
addImage(image)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,130 +207,78 @@ function pasteContent(event: ClipboardEvent) {
|
|||||||
const isShortSingleLine = !text.includes('\n') && text.length <= 48
|
const isShortSingleLine = !text.includes('\n') && text.length <= 48
|
||||||
const title = isShortSingleLine ? '粘贴的内容' : firstLine.slice(0, 48) || '粘贴的内容'
|
const title = isShortSingleLine ? '粘贴的内容' : firstLine.slice(0, 48) || '粘贴的内容'
|
||||||
const point = canvasCenter()
|
const point = canvasCenter()
|
||||||
const node = store.addCard(
|
store.addCard('note', { x: point.x - 150, y: point.y - 110 }, { title, content: text })
|
||||||
'note',
|
|
||||||
{ x: point.x - 150, y: point.y - 110 },
|
|
||||||
{ title, color: 'paper' },
|
|
||||||
)
|
|
||||||
store.updateNodeData(node.id, { content: text })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEventListener(window, 'paste', pasteContent)
|
useEventListener(window, 'paste', pasteContent)
|
||||||
|
|
||||||
// 全局快捷键
|
// ---------------------------------------------------------------- 快捷键
|
||||||
|
|
||||||
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
||||||
const target = event.target as HTMLElement | null
|
const editing = isEditing(event.target)
|
||||||
const isEditingText = target?.matches('input, textarea, [contenteditable="true"]')
|
if (event.code === 'Space' && !editing && !isSpacePressed.value) isSpacePressed.value = true
|
||||||
|
if (editing) return
|
||||||
|
|
||||||
if (event.code === 'Space' && !isEditingText && !isSpacePressed.value) {
|
const mod = event.ctrlKey || event.metaKey
|
||||||
isSpacePressed.value = true
|
const key = event.key.toLowerCase()
|
||||||
}
|
|
||||||
|
|
||||||
if (isEditingText) return
|
if (mod && key === 'z') {
|
||||||
|
|
||||||
// 撤销/重做
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) store.redo()
|
||||||
store.redo()
|
else store.undo()
|
||||||
} else {
|
} else if (mod && key === 'y') {
|
||||||
store.undo()
|
|
||||||
}
|
|
||||||
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'y') {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
store.redo()
|
store.redo()
|
||||||
}
|
} else if (mod && key === 'a') {
|
||||||
|
|
||||||
// 全选
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const count = store.selectAllNodes()
|
const count = store.selectAllNodes()
|
||||||
store.showToast(`已选中全部 ${count} 张便笺`)
|
store.showToast(`已选中全部 ${count} 张便笺`)
|
||||||
}
|
} else if (mod && key === 'd') {
|
||||||
|
|
||||||
// 复制选中项
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'd') {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
store.duplicateSelectedNodes()
|
store.duplicateSelectedNodes()
|
||||||
}
|
} else if (mod && event.key === '0') {
|
||||||
|
event.preventDefault()
|
||||||
// 取消所有选择
|
resetZoom()
|
||||||
if (event.key === 'Escape') {
|
} else if (event.key === 'Escape') {
|
||||||
store.deselectAll()
|
store.deselectAll()
|
||||||
previewImage.value = null
|
previewImage.value = null
|
||||||
showShortcuts.value = false
|
showShortcuts.value = false
|
||||||
}
|
showHistory.value = false
|
||||||
|
|
||||||
// 快捷缩放重置
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === '0') {
|
|
||||||
event.preventDefault()
|
|
||||||
resetZoom()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
useEventListener(window, 'keyup', (event: KeyboardEvent) => {
|
useEventListener(window, 'keyup', (event: KeyboardEvent) => {
|
||||||
if (event.code === 'Space') {
|
if (event.code === 'Space') isSpacePressed.value = false
|
||||||
isSpacePressed.value = false
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 拖动与连线
|
||||||
|
|
||||||
|
// 拖动过程中只在本机移动,松手时才写入文档并同步,拖动更流畅
|
||||||
|
function onDragStart(event: NodeDragEvent) {
|
||||||
|
store.beginGesture(event.nodes.map((node) => node.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function connect(connection: Connection) {
|
||||||
store.recordHistory()
|
store.addEdge(connection)
|
||||||
const edge: CanvasEdge = {
|
|
||||||
...connection,
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
type: 'smoothstep',
|
|
||||||
markerEnd: MarkerType.ArrowClosed,
|
|
||||||
data: { spaceId: store.activeSpaceId },
|
|
||||||
}
|
|
||||||
edges.value = [...edges.value, edge]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteNodes(deleted: Node[]) {
|
function deleteNodes(deleted: Node[]) {
|
||||||
void store.removeNodes(deleted.map((node) => node.id))
|
store.removeNodes(deleted.map((node) => node.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteEdges(deleted: Edge[]) {
|
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() {
|
function resetZoom() {
|
||||||
void zoomTo(1, { duration: 300 })
|
void zoomTo(1, { duration: 300 })
|
||||||
store.showToast('已恢复 100% 视图')
|
store.showToast('已恢复 100% 视图')
|
||||||
@@ -306,8 +301,6 @@ function toggleMode() {
|
|||||||
store.interactionMode = store.interactionMode === 'pointer' ? 'marquee' : 'pointer'
|
store.interactionMode = store.interactionMode === 'pointer' ? 'marquee' : 'pointer'
|
||||||
store.showToast(store.interactionMode === 'marquee' ? '框选模式:拖拽框选多张卡片' : '指针模式')
|
store.showToast(store.interactionMode === 'marquee' ? '框选模式:拖拽框选多张卡片' : '指针模式')
|
||||||
}
|
}
|
||||||
|
|
||||||
const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -333,16 +326,17 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
|||||||
:delete-key-code="['Backspace', 'Delete']"
|
:delete-key-code="['Backspace', 'Delete']"
|
||||||
:multi-selection-key-code="['Meta', 'Control']"
|
:multi-selection-key-code="['Meta', 'Control']"
|
||||||
:selection-key-code="['Shift']"
|
:selection-key-code="['Shift']"
|
||||||
:pan-on-drag="store.interactionMode === 'marquee' ? false : true"
|
:pan-on-drag="store.interactionMode !== 'marquee'"
|
||||||
:zoom-on-double-click="false"
|
:zoom-on-double-click="false"
|
||||||
fit-view-on-init
|
|
||||||
elevate-nodes-on-select
|
elevate-nodes-on-select
|
||||||
@pane-click="addNoteAtEvent"
|
@pane-click="addNoteAtEvent"
|
||||||
@connect="connect"
|
@connect="connect"
|
||||||
@nodes-delete="deleteNodes"
|
@nodes-delete="deleteNodes"
|
||||||
@edges-delete="deleteEdges"
|
@edges-delete="deleteEdges"
|
||||||
@node-drag-start="store.recordHistory()"
|
@node-drag-start="onDragStart"
|
||||||
@node-drag-stop="store.persistSnapshot()"
|
@node-drag-stop="onDragStop"
|
||||||
|
@selection-drag-start="onDragStart"
|
||||||
|
@selection-drag-stop="onDragStop"
|
||||||
>
|
>
|
||||||
<Background
|
<Background
|
||||||
v-if="store.gridStyle !== 'none'"
|
v-if="store.gridStyle !== 'none'"
|
||||||
@@ -352,8 +346,14 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
|||||||
:size="1"
|
: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>
|
</template>
|
||||||
|
|
||||||
<!-- 移动端侧边栏切换按钮 -->
|
<!-- 移动端侧边栏切换按钮 -->
|
||||||
@@ -385,7 +385,6 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
|||||||
|
|
||||||
<span class="toolbar-divider"></span>
|
<span class="toolbar-divider"></span>
|
||||||
|
|
||||||
<!-- 撤销 / 重做 -->
|
|
||||||
<button
|
<button
|
||||||
class="tool-button"
|
class="tool-button"
|
||||||
:class="{ 'tool-button--disabled': !store.canUndo }"
|
:class="{ 'tool-button--disabled': !store.canUndo }"
|
||||||
@@ -409,7 +408,6 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
|||||||
|
|
||||||
<span class="toolbar-divider"></span>
|
<span class="toolbar-divider"></span>
|
||||||
|
|
||||||
<!-- 交互模式切换(指针平移 vs 框选多选) -->
|
|
||||||
<button
|
<button
|
||||||
class="tool-button"
|
class="tool-button"
|
||||||
:class="{ 'tool-button--active': store.interactionMode === 'marquee' }"
|
: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="multi-select-badge">已选 {{ store.selectedCount }} 项</span>
|
||||||
<span class="toolbar-divider"></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" />
|
<AlignStartHorizontal :size="16" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -444,20 +436,10 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
|||||||
>
|
>
|
||||||
<AlignCenterHorizontal :size="16" />
|
<AlignCenterHorizontal :size="16" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button class="tool-button" title="右对齐" aria-label="右对齐" @click="store.alignSelectedNodes('right')">
|
||||||
class="tool-button"
|
|
||||||
title="右对齐"
|
|
||||||
aria-label="右对齐"
|
|
||||||
@click="store.alignSelectedNodes('right')"
|
|
||||||
>
|
|
||||||
<AlignEndHorizontal :size="16" />
|
<AlignEndHorizontal :size="16" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button class="tool-button" title="顶端对齐" aria-label="顶端对齐" @click="store.alignSelectedNodes('top')">
|
||||||
class="tool-button"
|
|
||||||
title="顶端对齐"
|
|
||||||
aria-label="顶端对齐"
|
|
||||||
@click="store.alignSelectedNodes('top')"
|
|
||||||
>
|
|
||||||
<AlignStartVertical :size="16" />
|
<AlignStartVertical :size="16" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -479,7 +461,6 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
|||||||
|
|
||||||
<span class="toolbar-divider"></span>
|
<span class="toolbar-divider"></span>
|
||||||
|
|
||||||
<!-- 均分间距(3 项及以上) -->
|
|
||||||
<template v-if="store.selectedCount >= 3">
|
<template v-if="store.selectedCount >= 3">
|
||||||
<button
|
<button
|
||||||
class="tool-button"
|
class="tool-button"
|
||||||
@@ -500,10 +481,9 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
|||||||
<span class="toolbar-divider"></span>
|
<span class="toolbar-divider"></span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 统一换色 -->
|
|
||||||
<div class="batch-color-picker" aria-label="批量更改颜色">
|
<div class="batch-color-picker" aria-label="批量更改颜色">
|
||||||
<button
|
<button
|
||||||
v-for="color in colors"
|
v-for="color in CARD_COLORS"
|
||||||
:key="color"
|
:key="color"
|
||||||
:data-color="color"
|
:data-color="color"
|
||||||
:title="`统一设为 ${color} 色`"
|
:title="`统一设为 ${color} 色`"
|
||||||
@@ -513,7 +493,6 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
|||||||
|
|
||||||
<span class="toolbar-divider"></span>
|
<span class="toolbar-divider"></span>
|
||||||
|
|
||||||
<!-- 批量复制 -->
|
|
||||||
<button
|
<button
|
||||||
class="tool-button"
|
class="tool-button"
|
||||||
title="批量复制 (Ctrl+D)"
|
title="批量复制 (Ctrl+D)"
|
||||||
@@ -522,8 +501,6 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
|||||||
>
|
>
|
||||||
<Copy :size="15" />
|
<Copy :size="15" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 批量删除 -->
|
|
||||||
<button
|
<button
|
||||||
class="tool-button tool-button--danger"
|
class="tool-button tool-button--danger"
|
||||||
title="批量删除 (Delete)"
|
title="批量删除 (Delete)"
|
||||||
@@ -532,65 +509,45 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
|||||||
>
|
>
|
||||||
<Trash2 :size="15" />
|
<Trash2 :size="15" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 取消选择 -->
|
|
||||||
<button class="tool-button" title="取消全选 (Esc)" aria-label="取消全选" @click="store.deselectAll()">
|
<button class="tool-button" title="取消全选 (Esc)" aria-label="取消全选" @click="store.deselectAll()">
|
||||||
<X :size="15" />
|
<X :size="15" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右上角搜索与保存状态 -->
|
<!-- 右上角:搜索、历史版本、同步状态 -->
|
||||||
<Panel position="top-right" class="canvas-top-right">
|
<Panel position="top-right" class="canvas-top-right">
|
||||||
<label class="search-box">
|
<CanvasSearch />
|
||||||
<Search :size="16" />
|
<button
|
||||||
<input
|
class="floating-icon floating-icon--small"
|
||||||
v-model="store.searchQuery"
|
title="历史版本"
|
||||||
placeholder="搜索这片空间"
|
aria-label="历史版本"
|
||||||
aria-label="搜索当前空间"
|
:disabled="!store.boardReady"
|
||||||
@keydown.enter="nextSearchResult"
|
@click="showHistory = true"
|
||||||
/>
|
>
|
||||||
<span v-if="store.searchQuery && matchingNodes.length > 0" class="search-count">
|
<History :size="16" />
|
||||||
{{ activeMatchIndex + 1 }}/{{ matchingNodes.length }}
|
</button>
|
||||||
</span>
|
<span class="save-pill" :data-status="store.syncStatus" :title="syncLabel">
|
||||||
<div v-if="store.searchQuery && matchingNodes.length > 1" class="search-nav">
|
<i></i>{{ syncLabel }}
|
||||||
<button title="上一个结果" aria-label="上一个结果" @click="prevSearchResult">
|
|
||||||
<ChevronUp :size="13" />
|
|
||||||
</button>
|
|
||||||
<button title="下一个结果" aria-label="下一个结果" @click="nextSearchResult">
|
|
||||||
<ChevronDown :size="13" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
v-if="store.searchQuery"
|
|
||||||
title="清空搜索"
|
|
||||||
aria-label="清空搜索"
|
|
||||||
@click="store.searchQuery = ''"
|
|
||||||
>
|
|
||||||
<X :size="14" />
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
<span class="save-pill" :class="{ 'save-pill--saving': store.saveState === 'saving' }">
|
|
||||||
<i></i>{{ store.saveState === 'saving' ? '正在保存' : '已保存' }}
|
|
||||||
</span>
|
</span>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- 左下角操作提示与快捷键入口 -->
|
<!-- 左下角操作提示与快捷键入口 -->
|
||||||
<Panel position="bottom-left" class="canvas-hint-wrap">
|
<Panel position="bottom-left" class="canvas-hint-wrap">
|
||||||
<div class="canvas-hint" @click="showShortcuts = true">
|
<button class="canvas-hint" @click="showShortcuts = true">
|
||||||
双击添加便笺 · 选中物体改变指针 · Shift框选
|
双击添加便笺 · 粘贴图片或文字 · Shift 框选
|
||||||
<button class="hint-help-btn" title="查看键盘快捷键" aria-label="查看快捷键帮助">
|
<span class="hint-help-btn" aria-hidden="true">
|
||||||
<CircleHelp :size="14" />
|
<CircleHelp :size="14" />
|
||||||
</button>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- 底部空间名称标签 -->
|
<!-- 底部空间名称 -->
|
||||||
<Panel position="bottom-center" class="space-title">
|
<Panel position="bottom-center" class="space-title">
|
||||||
<span>{{ store.activeSpace?.emoji }}</span>
|
<span>{{ store.activeSpace?.emoji }}</span>
|
||||||
{{ store.activeSpace?.name }}
|
{{ store.activeSpace?.name }}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- 右下角视口控制器(缩放与背景切换) -->
|
<!-- 右下角视口控制 -->
|
||||||
<Panel position="bottom-right" class="canvas-viewport-controls">
|
<Panel position="bottom-right" class="canvas-viewport-controls">
|
||||||
<div class="viewport-dock">
|
<div class="viewport-dock">
|
||||||
<button class="dock-btn" title="缩小画布" aria-label="缩小画布" @click="zoomOut()">
|
<button class="dock-btn" title="缩小画布" aria-label="缩小画布" @click="zoomOut()">
|
||||||
@@ -626,7 +583,6 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
|||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- 小地图 -->
|
|
||||||
<MiniMap
|
<MiniMap
|
||||||
v-if="showMiniMap"
|
v-if="showMiniMap"
|
||||||
class="boundless-minimap"
|
class="boundless-minimap"
|
||||||
@@ -638,14 +594,21 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
|||||||
/>
|
/>
|
||||||
</VueFlow>
|
</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">
|
<Transition name="fade">
|
||||||
<div v-if="store.toastMessage" class="toast-pill">
|
<div v-if="store.toastMessage" class="toast-pill" role="status">
|
||||||
{{ store.toastMessage }}
|
{{ store.toastMessage }}
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<!-- 图片大图预览浮层 (Lightbox) -->
|
<!-- 图片大图预览 -->
|
||||||
<div v-if="previewImage" class="lightbox-overlay" @click="previewImage = null">
|
<div v-if="previewImage" class="lightbox-overlay" @click="previewImage = null">
|
||||||
<div class="lightbox-card" @click.stop>
|
<div class="lightbox-card" @click.stop>
|
||||||
<header class="lightbox-header">
|
<header class="lightbox-header">
|
||||||
@@ -660,7 +623,9 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 快捷键指南对话框 -->
|
<VersionHistoryDialog v-if="showHistory" @close="showHistory = false" />
|
||||||
|
|
||||||
|
<!-- 快捷键指南 -->
|
||||||
<div v-if="showShortcuts" class="modal-overlay" @click="showShortcuts = false">
|
<div v-if="showShortcuts" class="modal-overlay" @click="showShortcuts = false">
|
||||||
<div class="shortcuts-card" @click.stop>
|
<div class="shortcuts-card" @click.stop>
|
||||||
<header class="shortcuts-header">
|
<header class="shortcuts-header">
|
||||||
@@ -679,7 +644,7 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
|||||||
</div>
|
</div>
|
||||||
<div class="shortcut-item">
|
<div class="shortcut-item">
|
||||||
<kbd>拖拽 / 粘贴</kbd>
|
<kbd>拖拽 / 粘贴</kbd>
|
||||||
<span>拖入或从剪贴板粘贴图片与便笺</span>
|
<span>拖入或从剪贴板粘贴图片与文字</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="shortcut-item">
|
<div class="shortcut-item">
|
||||||
<kbd>点击卡片</kbd>
|
<kbd>点击卡片</kbd>
|
||||||
@@ -695,15 +660,15 @@ const colors: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose']
|
|||||||
</div>
|
</div>
|
||||||
<div class="shortcut-item">
|
<div class="shortcut-item">
|
||||||
<kbd>Ctrl / ⌘ + D</kbd>
|
<kbd>Ctrl / ⌘ + D</kbd>
|
||||||
<span>快速复制选中的卡片副本</span>
|
<span>复制选中的卡片</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="shortcut-item">
|
<div class="shortcut-item">
|
||||||
<kbd>Ctrl / ⌘ + Z</kbd>
|
<kbd>Ctrl / ⌘ + Z</kbd>
|
||||||
<span>撤销上一步操作</span>
|
<span>撤销本机的上一步操作</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="shortcut-item">
|
<div class="shortcut-item">
|
||||||
<kbd>Ctrl / ⌘ + Y</kbd>
|
<kbd>Ctrl / ⌘ + Y</kbd>
|
||||||
<span>重做上一步操作</span>
|
<span>重做</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="shortcut-item">
|
<div class="shortcut-item">
|
||||||
<kbd>Delete / Backspace</kbd>
|
<kbd>Delete / Backspace</kbd>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -1,18 +1,39 @@
|
|||||||
|
import { dismissBoot } from './boot'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import { setUnauthorizedHandler } from './api'
|
||||||
import './style.css'
|
import router, { setAuthenticated } from './router'
|
||||||
|
import { useCanvasStore } from './stores/canvas'
|
||||||
|
|
||||||
import '@vue-flow/core/dist/style.css'
|
import '@vue-flow/core/dist/style.css'
|
||||||
import '@vue-flow/core/dist/theme-default.css'
|
import '@vue-flow/core/dist/theme-default.css'
|
||||||
import '@vue-flow/minimap/dist/style.css'
|
import '@vue-flow/minimap/dist/style.css'
|
||||||
import '@vue-flow/node-resizer/dist/style.css'
|
import '@vue-flow/node-resizer/dist/style.css'
|
||||||
|
// 自己的样式放在最后,才能覆盖 Vue Flow 的默认样式
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(pinia)
|
||||||
app.use(router)
|
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')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -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
@@ -1,16 +1,49 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
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({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
|
||||||
path: '/',
|
{ path: '/', name: 'home', component: WorkspaceView },
|
||||||
name: 'workspace',
|
{ path: '/s/:spaceId', name: 'space', component: WorkspaceView },
|
||||||
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
|
export default router
|
||||||
|
|||||||
+510
-500
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -280,26 +280,143 @@ textarea:focus-visible {
|
|||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.storage-status {
|
/* 同步状态与在线设备 */
|
||||||
|
.sync-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 24px;
|
||||||
margin-bottom: 11px;
|
margin-bottom: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status {
|
||||||
|
--dot: #55a26c;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
color: #818179;
|
color: #818179;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.storage-status span {
|
.sync-status i,
|
||||||
|
.save-pill i {
|
||||||
|
flex: none;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #55a26c;
|
background: var(--dot);
|
||||||
box-shadow: 0 0 0 3px rgba(85, 162, 108, 0.13);
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--dot) 14%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__actions {
|
.sync-status[data-status='syncing'],
|
||||||
display: flex;
|
.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;
|
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 {
|
.text-button {
|
||||||
@@ -425,14 +542,21 @@ textarea:focus-visible {
|
|||||||
background: #f4ddd9;
|
background: #f4ddd9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 选中:细墨线描边 + 一圈很淡的光晕,再把卡片"托起来"一点 */
|
||||||
.note-card--selected {
|
.note-card--selected {
|
||||||
border-color: rgba(29, 30, 29, 0.7);
|
border-color: rgba(29, 30, 29, 0.42);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 2px var(--ink),
|
0 0 0 4px rgba(29, 30, 29, 0.055),
|
||||||
0 18px 45px rgba(54, 51, 42, 0.16);
|
0 22px 48px -16px rgba(54, 51, 42, 0.26),
|
||||||
|
0 3px 10px rgba(54, 51, 42, 0.07);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 拖动时单独合成一层,移动只做位移不重绘阴影 */
|
||||||
|
.vue-flow__node.dragging {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
.note-card--muted {
|
.note-card--muted {
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
filter: grayscale(0.6);
|
filter: grayscale(0.6);
|
||||||
@@ -639,16 +763,21 @@ textarea:focus-visible {
|
|||||||
box-shadow: 0 0 0 2px rgba(29, 30, 29, 0.28);
|
box-shadow: 0 0 0 2px rgba(29, 30, 29, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 连线点:左右两侧的小圆点,选中时才出现 */
|
||||||
.vue-flow__handle.card-handle {
|
.vue-flow__handle.card-handle {
|
||||||
width: 9px;
|
width: 9px;
|
||||||
height: 9px;
|
height: 9px;
|
||||||
border: 2px solid #fffefa;
|
min-width: 0;
|
||||||
background: #77776f;
|
min-height: 0;
|
||||||
|
border: 1.5px solid rgba(29, 30, 29, 0.38);
|
||||||
|
background: #fffefa;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
transition:
|
transition:
|
||||||
opacity 120ms ease,
|
opacity 140ms ease,
|
||||||
transform 120ms ease;
|
background 140ms ease,
|
||||||
|
border-color 140ms ease,
|
||||||
|
transform 140ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-card--selected .card-handle {
|
.note-card--selected .card-handle {
|
||||||
@@ -657,19 +786,34 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.note-card--selected .vue-flow__handle.card-handle:hover {
|
.note-card--selected .vue-flow__handle.card-handle:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
background: 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 {
|
.vue-flow__resize-control.line {
|
||||||
border-color: rgba(29, 30, 29, 0.38);
|
border-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vue-flow__resize-control.handle {
|
.vue-flow__resize-control.handle {
|
||||||
width: 7px;
|
width: 9px;
|
||||||
height: 7px;
|
height: 9px;
|
||||||
border: 1px solid white;
|
border: 1.5px solid rgba(29, 30, 29, 0.5);
|
||||||
border-radius: 50%;
|
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);
|
background: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,27 +934,17 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.save-pill {
|
.save-pill {
|
||||||
|
--dot: #55a26c;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 7px;
|
||||||
padding: 0 9px;
|
padding: 0 10px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: rgba(255, 254, 250, 0.7);
|
background: rgba(255, 254, 250, 0.7);
|
||||||
color: #77786f;
|
color: #77786f;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
white-space: nowrap;
|
||||||
|
|
||||||
.save-pill i {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #55a26c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-pill--saving i {
|
|
||||||
background: #d9a43d;
|
|
||||||
animation: pulse 900ms infinite alternate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
@@ -847,9 +981,10 @@ textarea:focus-visible {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 放在右下角缩放工具条的上方,避免互相遮挡 */
|
||||||
.boundless-minimap {
|
.boundless-minimap {
|
||||||
right: 15px !important;
|
right: 15px !important;
|
||||||
bottom: 15px !important;
|
bottom: 64px !important;
|
||||||
width: 132px !important;
|
width: 132px !important;
|
||||||
height: 84px !important;
|
height: 84px !important;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1465,7 +1600,11 @@ textarea:focus-visible {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
border: 0;
|
||||||
|
margin-left: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 9px;
|
||||||
transition:
|
transition:
|
||||||
background 120ms ease,
|
background 120ms ease,
|
||||||
color 120ms ease;
|
color 120ms ease;
|
||||||
@@ -1490,6 +1629,417 @@ textarea:focus-visible {
|
|||||||
color: var(--ink);
|
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) {
|
@media (max-width: 900px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)}`
|
||||||
|
}
|
||||||
@@ -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
@@ -1,42 +1,18 @@
|
|||||||
export type CardKind = 'note' | 'image'
|
import type { CardColor, CardKind } from '@shared/board'
|
||||||
export type CardColor = 'paper' | 'sun' | 'mint' | 'sky' | 'rose'
|
|
||||||
|
|
||||||
export interface SpaceRecord {
|
export type { CardColor, CardKind, SpaceInfo } from '@shared/board'
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
emoji: string
|
|
||||||
createdAt: number
|
|
||||||
updatedAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** Vue Flow 节点上的卡片数据 */
|
||||||
export interface CardNodeData extends Record<string, unknown> {
|
export interface CardNodeData extends Record<string, unknown> {
|
||||||
spaceId: string
|
|
||||||
kind: CardKind
|
kind: CardKind
|
||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
imageData?: string
|
image?: string
|
||||||
color: CardColor
|
color: CardColor
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: 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 {
|
export interface CanvasNode {
|
||||||
id: string
|
id: string
|
||||||
type: 'card'
|
type: 'card'
|
||||||
@@ -44,6 +20,7 @@ export interface CanvasNode {
|
|||||||
data: CardNodeData
|
data: CardNodeData
|
||||||
style?: Record<string, string | number>
|
style?: Record<string, string | number>
|
||||||
selected?: boolean
|
selected?: boolean
|
||||||
|
dragging?: boolean
|
||||||
dimensions?: { width: number; height: number }
|
dimensions?: { width: number; height: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,13 +32,7 @@ export interface CanvasEdge {
|
|||||||
targetHandle?: string | null
|
targetHandle?: string | null
|
||||||
type?: string
|
type?: string
|
||||||
markerEnd?: string
|
markerEnd?: string
|
||||||
data: { spaceId: string }
|
selected?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupFile {
|
export type SyncStatus = 'connecting' | 'synced' | 'syncing' | 'offline'
|
||||||
version: 1
|
|
||||||
exportedAt: string
|
|
||||||
spaces: SpaceRecord[]
|
|
||||||
cards: CardRecord[]
|
|
||||||
edges: EdgeRecord[]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -1,25 +1,53 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Menu } from '@lucide/vue'
|
import { onMounted, watch } from 'vue'
|
||||||
import { onMounted } from 'vue'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { dismissBoot, setBootStatus } from '@/boot'
|
||||||
import AppSidebar from '@/components/AppSidebar.vue'
|
import AppSidebar from '@/components/AppSidebar.vue'
|
||||||
import BrandMark from '@/components/BrandMark.vue'
|
|
||||||
import InfiniteCanvas from '@/components/InfiniteCanvas.vue'
|
import InfiniteCanvas from '@/components/InfiniteCanvas.vue'
|
||||||
import { useCanvasStore } from '@/stores/canvas'
|
import { useCanvasStore } from '@/stores/canvas'
|
||||||
|
|
||||||
const store = useCanvasStore()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="store.ready" class="workspace">
|
<div class="workspace">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<InfiniteCanvas />
|
<InfiniteCanvas />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="app-loading">
|
|
||||||
<BrandMark />
|
|
||||||
<p>正在展开你的平面…</p>
|
|
||||||
<button class="floating-icon" aria-label="菜单" disabled><Menu :size="18" /></button>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
"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__/*"],
|
"exclude": ["src/**/__tests__/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Extra safety for array and object lookups, but may have false positives.
|
// Extra safety for array and object lookups, but may have false positives.
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
|
|
||||||
// Path mapping for cleaner imports.
|
// Path mapping for cleaner imports.
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"],
|
||||||
|
"@shared/*": ["./shared/*"]
|
||||||
},
|
},
|
||||||
|
|
||||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
{
|
{
|
||||||
"path": "./tsconfig.app.json"
|
"path": "./tsconfig.app.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.server.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "./tsconfig.vitest.json"
|
"path": "./tsconfig.vitest.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
|
||||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
// Specified here to keep it out of the root directory.
|
// Specified here to keep it out of the root directory.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
// Override to include only test files and clear exclusions.
|
// Override to include only test files and clear exclusions.
|
||||||
// Application code imported in tests is automatically included via module resolution.
|
// 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": [],
|
"exclude": [],
|
||||||
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
|||||||
+11
-5
@@ -5,16 +5,22 @@ import vue from '@vitejs/plugin-vue'
|
|||||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// 开发时 Vite 负责前端,接口与同步请求转发给本地服务端(默认 3000 端口)
|
||||||
|
const serverOrigin = `http://localhost:${process.env.SERVER_PORT || 3000}`
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [vue(), vueDevTools(), tailwindcss()],
|
||||||
vue(),
|
|
||||||
vueDevTools(),
|
|
||||||
tailwindcss(),
|
|
||||||
],
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': 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
@@ -1,6 +1,6 @@
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||||
import viteConfig from './vite.config'
|
import viteConfig from './vite.config.ts'
|
||||||
|
|
||||||
export default mergeConfig(
|
export default mergeConfig(
|
||||||
viteConfig,
|
viteConfig,
|
||||||
|
|||||||
Reference in New Issue
Block a user