20260920173439

This commit is contained in:
oneao committed 2026-09-20 17:34:40 +08:00
1 parent f4bfc1ddfc
commit 6f03d92425
174 files changed
+23490 -157

No files matched your search

@@ -0,0 +1,34 @@
{
"name": "@report/designer",
"version": "0.1.0",
"description": "报表编辑器的状态、命令、选择、坐标转换与交互逻辑(无 UI)",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit -p tsconfig.test.json"
},
"dependencies": {
"@report/core": "workspace:*",
"@report/layout": "workspace:*"
},
"devDependencies": {
"@report/renderer": "workspace:*",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
@@ -0,0 +1,213 @@
/**
* Designer 编辑命令(对应设计文档 §4.5)。
*
* Designer 通过 Core 的命令接口修改文档,不直接实现报表业务规则。
* 这里的命令都是纯函数:接收文档 + payload,返回新文档(整体替换),
* 由 EditSession 负责历史记录与撤销。
*/
import type { ReportDocument, ReportElement, Rect } from "@report/core"
import { cloneDocument, createCommand, createElementId } from "@report/core"
import type { Command } from "@report/core"
/** 移动/缩放元素的 payload。 */
export interface MoveElementPayload {
/** 元素 ID → 新 frame(绝对坐标,mm)。 */
frames: Record<string, Rect>
}
/** 修改元素属性的 payload。 */
export interface UpdateElementPayload {
elementId: string
/** 属性名,如 "name"、"locked"、"hidden"、"props.text"。 */
path: string
value: unknown
}
/** 新增元素的 payload。 */
export interface AddElementPayload {
/** 目标区域 ID。 */
sectionId: string
/** 新增元素(含 id)。 */
element: ReportElement
/** 插入位置索引,省略则追加到末尾。 */
index?: number
}
/** 删除元素的 payload。 */
export interface DeleteElementsPayload {
/** 要删除的元素 ID 集合。 */
elementIds: ReadonlySet<string> | string[]
}
/**
* 移动元素(可合并:拖拽时连续调用会合并为一步撤销)。
*
* payload.frames 给出目标 frame,命令把文档中对应元素替换为新 frame。
*/
export const moveElementsCommand: Command<MoveElementPayload> = createCommand(
"designer.move-elements",
(document, payload) => {
const next = cloneDocument(document)
const targets = new Map(Object.entries(payload.frames))
let moved = 0
for (const section of next.sections) {
moved += applyFrames(section.children, targets)
}
if (moved === 0) return next
return next
},
{ mergeKey: "move-elements" },
)
function applyFrames(
elements: ReportElement[],
targets: Map<string, Rect>,
): number {
let count = 0
for (const element of elements) {
const target = targets.get(element.id)
if (target) {
element.frame = { ...target }
count += 1
}
if (element.children?.length) count += applyFrames(element.children, targets)
const columns = (element as { columns?: { children?: ReportElement[] }[] }).columns
if (Array.isArray(columns)) {
for (const column of columns) {
if (column.children?.length) count += applyFrames(column.children, targets)
}
}
}
return count
}
/**
* 修改元素属性。
*
* path 支持点号分隔的嵌套路径,如 "props.text"、"frame.width"。
* 用简单解析避免引入深层路径库。
*/
export const updateElementCommand: Command<UpdateElementPayload> = createCommand(
"designer.update-element",
(document, payload) => {
const next = cloneDocument(document)
const found = findAndUpdate(next, payload.elementId, payload.path, payload.value)
if (!found) {
return { document: null, diagnostics: [{ severity: "warning", code: "designer.element-missing", message: `未找到元素 "${payload.elementId}"` }] }
}
return next
},
)
function findAndUpdate(
document: ReportDocument,
elementId: string,
path: string,
value: unknown,
): boolean {
const walk = (elements: ReportElement[]): boolean => {
for (const element of elements) {
if (element.id === elementId) {
setByPath(element, path, value)
return true
}
if (element.children?.length && walk(element.children)) return true
const columns = (element as { columns?: { children?: ReportElement[] }[] }).columns
if (Array.isArray(columns)) {
for (const column of columns) {
if (column.children?.length && walk(column.children)) return true
}
}
}
return false
}
for (const section of document.sections) {
if (walk(section.children)) return true
}
return false
}
function setByPath(target: object, path: string, value: unknown): void {
const parts = path.split(".")
let current: Record<string, unknown> = target as Record<string, unknown>
for (let i = 0; i < parts.length - 1; i += 1) {
const key = parts[i]!
const existing = current[key]
if (existing === null || typeof existing !== "object" || Array.isArray(existing)) {
current[key] = {}
}
current = current[key] as Record<string, unknown>
}
current[parts[parts.length - 1]!] = value
}
/**
* 新增元素到指定区域。
*/
export const addElementCommand: Command<AddElementPayload> = createCommand(
"designer.add-element",
(document, payload) => {
const next = cloneDocument(document)
const section = next.sections.find((s) => s.id === payload.sectionId)
if (!section) {
return { document: null, diagnostics: [{ severity: "warning", code: "designer.section-missing", message: `未找到区域 "${payload.sectionId}"` }] }
}
const element = cloneElement(payload.element)
if (payload.index !== undefined && payload.index >= 0 && payload.index <= section.children.length) {
section.children.splice(payload.index, 0, element)
} else {
section.children.push(element)
}
return next
},
)
function cloneElement(element: ReportElement): ReportElement {
return JSON.parse(JSON.stringify(element)) as ReportElement
}
/**
* 删除元素集合。
*/
export const deleteElementsCommand: Command<DeleteElementsPayload> = createCommand(
"designer.delete-elements",
(document, payload) => {
const next = cloneDocument(document)
const ids = new Set(payload.elementIds)
let removed = 0
for (const section of next.sections) {
removed += removeElements(section.children, ids)
}
return next
},
)
function removeElements(elements: ReportElement[], ids: Set<string>): number {
let removed = 0
for (let i = elements.length - 1; i >= 0; i -= 1) {
const element = elements[i]!
if (ids.has(element.id)) {
elements.splice(i, 1)
removed += 1
continue
}
if (element.children?.length) removed += removeElements(element.children, ids)
const columns = (element as { columns?: { children?: ReportElement[] }[] }).columns
if (Array.isArray(columns)) {
for (const column of columns) {
if (column.children?.length) removed += removeElements(column.children, ids)
}
}
}
return removed
}
/** 创建一个新的文本元素(含自动生成的 ID)。 */
export function createTextElement(frame: Rect, text = "文本"): ReportElement {
return { id: createElementId(), type: "text", frame, props: { text } }
}
/** 创建一个新的矩形元素。 */
export function createRectElement(frame: Rect): ReportElement {
return { id: createElementId(), type: "rect", frame }
}
@@ -0,0 +1,87 @@
/**
* 坐标转换(对应设计文档 §4.5「画布和坐标转换」、§7)。
*
* 文档内部统一使用 mm;Designer 显示层使用 px。
* 转换链路:屏幕 px →(除以 zoom、减 pan)→ 画布 px →(mmToPx)→ mm。
*
* 注意:这里的"画布 px"指缩放后用于显示的像素,与文档 mm 的换算
* 走 1in = 96px(§7)。
*/
import { mmToPx, pxToMm } from "@report/layout"
import type { DesignerState } from "./state.js"
/** 屏幕坐标(浏览器事件坐标)。 */
export interface ScreenPoint {
x: number
y: number
}
/** 文档坐标(mm)。 */
export interface DocPoint {
x: number
y: number
}
/**
* 屏幕坐标 → 文档坐标(mm)。
*
* @param point 屏幕/事件坐标(px)
* @param state 编辑器状态(含 zoom 与 pan)
*/
export function screenToDoc(point: ScreenPoint, state: DesignerState): DocPoint {
// 先减去平移,再除以缩放,得到画布 px
const canvasX = (point.x - state.panX) / state.zoom
const canvasY = (point.y - state.panY) / state.zoom
return {
x: pxToMm(canvasX),
y: pxToMm(canvasY),
}
}
/**
* 文档坐标(mm)→ 屏幕坐标(px)。
*/
export function docToScreen(point: DocPoint, state: DesignerState): ScreenPoint {
const canvasX = mmToPx(point.x)
const canvasY = mmToPx(point.y)
return {
x: canvasX * state.zoom + state.panX,
y: canvasY * state.zoom + state.panY,
}
}
/**
* 判断屏幕点是否落在某元素的 frame 内(命中测试)。
*
* frame 是文档 mm 坐标,point 是屏幕坐标,因此需要换算后比较。
*/
export function hitTest(
point: ScreenPoint,
frame: { x: number; y: number; width: number; height: number },
state: DesignerState,
): boolean {
const doc = screenToDoc(point, state)
return (
doc.x >= frame.x &&
doc.x <= frame.x + frame.width &&
doc.y >= frame.y &&
doc.y <= frame.y + frame.height
)
}
/**
* 吸附:把文档坐标吸附到最近的辅助线。
*
* V1 只做"吸附到整数 mm"的简化实现,完整辅助线吸附放到后续。
* 返回吸附后的坐标。
*/
export function snapToGrid(value: number, state: DesignerState): number {
if (!state.snapEnabled) return value
// 吸附到 1mm 整数
return Math.round(value)
}
/** 限制缩放范围。 */
export function clampZoom(zoom: number): number {
return Math.min(2, Math.max(0.5, zoom))
}
@@ -0,0 +1,281 @@
/**
* 编辑器会话(对应设计文档 §4.5)。
*
* 组合 Core 的 EditSession(命令 + 历史)与 DesignerState(编辑器状态),
* 提供 Designer 直接可用的高层 API:
* 选择、移动、缩放、增删元素、复制粘贴、撤销重做、缩放视图。
*
* 所有文档修改都通过命令走 EditSession,保证可撤销;
* 所有编辑器状态(选中、缩放、平移)都留在本对象内,不进文档(§3.2)。
*/
import type { ReportDocument, ReportElement, Rect } from "@report/core"
import { createEditSession, cloneDocument, createElementId } from "@report/core"
import type { EditSession } from "@report/core"
import type { DesignerState, DragState } from "./state.js"
import {
createDesignerState,
setSelection,
clearSelection,
toggleSelection,
findElement,
} from "./state.js"
import {
moveElementsCommand,
updateElementCommand,
addElementCommand,
deleteElementsCommand,
} from "./commands.js"
import { clampZoom, snapToGrid } from "./coords.js"
/** 编辑器会话配置。 */
export interface EditorOptions {
/** 初始缩放。默认 1。 */
zoom?: number
}
/**
* 编辑器会话。
*
* 典型用法:
* ```ts
* const editor = new EditorSession(createDocument())
* editor.select("el-1")
* editor.moveElements({ "el-1": { x: 10, y: 10, width: 50, height: 20 } })
* editor.undo()
* ```
*/
export class EditorSession {
private readonly session: EditSession
private state: DesignerState
constructor(document: ReportDocument, options: EditorOptions = {}) {
this.session = createEditSession(document)
this.state = createDesignerState({ zoom: options.zoom ?? 1 })
}
// -------------------------------------------------------------------------
// 文档访问
// -------------------------------------------------------------------------
/** 当前文档(只读视图)。 */
getDocument(): ReportDocument {
return this.session.getDocument()
}
/** 当前编辑器状态。 */
getState(): DesignerState {
return this.state
}
// -------------------------------------------------------------------------
// 选择
// -------------------------------------------------------------------------
/** 选中单个元素。 */
select(elementId: string): void {
this.state = setSelection(this.state, [elementId])
}
/** 多选。 */
selectMany(elementIds: string[]): void {
this.state = setSelection(this.state, elementIds)
}
/**
* 切换单个元素的选中态(Ctrl/Cmd + 点击的语义)。
*
* 与 select 的区别:已选中的会被取消选中,未选中的会被加入。
*/
toggleSelect(elementId: string): void {
this.state = toggleSelection(this.state, elementId)
}
/** 清空选中。 */
clearSelection(): void {
this.state = clearSelection(this.state)
}
/** 当前选中的元素 ID 列表。 */
getSelectedIds(): string[] {
return [...this.state.selectedIds]
}
// -------------------------------------------------------------------------
// 编辑操作(都通过命令,可撤销)
// -------------------------------------------------------------------------
/** 移动/缩放元素(frame 为绝对 mm 坐标)。 */
moveElements(frames: Record<string, Rect>): void {
this.session.execute(moveElementsCommand, { frames }, { label: "移动元素" })
}
/** 修改元素属性。 */
updateElement(elementId: string, path: string, value: unknown): void {
this.session.execute(updateElementCommand, { elementId, path, value }, { label: "修改属性" })
}
/** 新增元素。 */
addElement(sectionId: string, element: ReportElement, index?: number): void {
this.session.execute(addElementCommand, { sectionId, element, index }, { label: "新增元素" })
}
/** 删除当前选中的元素。 */
deleteSelected(): void {
const ids = [...this.state.selectedIds]
if (ids.length === 0) return
this.session.execute(deleteElementsCommand, { elementIds: ids }, { label: "删除元素" })
this.state = clearSelection(this.state)
}
/**
* 复制选中的元素到剪贴板(内存中),并粘贴为新元素。
*
* 剪贴板属于编辑器状态,不进文档。
*/
private clipboard: ReportElement[] = []
/** 复制当前选中元素。 */
copy(): void {
const doc = this.getDocument()
const selected = [...this.state.selectedIds]
const elements: ReportElement[] = []
for (const id of selected) {
const found = findElement(doc, id)
if (found) elements.push(found.element)
}
this.clipboard = elements.map((e) => JSON.parse(JSON.stringify(e)) as ReportElement)
}
/** 粘贴剪贴板中的元素到指定区域(新 ID,偏移 5mm 避免重叠)。 */
paste(sectionId: string): void {
if (this.clipboard.length === 0) return
const pastedIds: string[] = []
for (const source of this.clipboard) {
const element = JSON.parse(JSON.stringify(source)) as ReportElement
element.id = createElementId()
element.frame = {
...element.frame,
x: element.frame.x + 5,
y: element.frame.y + 5,
}
this.addElement(sectionId, element)
pastedIds.push(element.id)
}
// 粘贴后选中新元素,方便紧接着继续拖拽/微调。
this.state = setSelection(this.state, pastedIds)
}
// -------------------------------------------------------------------------
// 撤销 / 重做
// -------------------------------------------------------------------------
/** 撤销。 */
undo(): boolean {
return this.session.undo()
}
/** 重做。 */
redo(): boolean {
return this.session.redo()
}
canUndo(): boolean {
return this.session.history.canUndo
}
canRedo(): boolean {
return this.session.history.canRedo
}
// -------------------------------------------------------------------------
// 视图(缩放、平移)
// -------------------------------------------------------------------------
/** 设置缩放。 */
setZoom(zoom: number): void {
this.state = { ...this.state, zoom: clampZoom(zoom) }
}
/** 缩放一档。 */
zoomIn(): void {
this.setZoom(this.state.zoom * 1.25)
}
zoomOut(): void {
this.setZoom(this.state.zoom / 1.25)
}
/** 平移画布。 */
pan(dx: number, dy: number): void {
this.state = { ...this.state, panX: this.state.panX + dx, panY: this.state.panY + dy }
}
// -------------------------------------------------------------------------
// 拖拽
// -------------------------------------------------------------------------
/** 开始拖拽移动:记录选中元素的原始 frame,并开启拖拽事务。 */
beginDrag(startX: number, startY: number): void {
const doc = this.getDocument()
const originalFrames: Record<string, { x: number; y: number; width: number; height: number }> = {}
for (const id of this.state.selectedIds) {
const found = findElement(doc, id)
if (found) {
originalFrames[id] = { ...found.element.frame }
}
}
this.state = {
...this.state,
drag: { kind: "move", startX, startY, originalFrames },
}
// 开启跨事件事务,endDrag 时整体提交。
// 不用命令自带的 mergeKey 时间窗合并:拖拽中途停顿超过合并窗口
// 就会把一次拖拽拆成多条撤销记录,撤销时只能回退半截。
this.session.beginTransaction("移动元素")
}
/**
* 拖拽更新:根据位移量移动选中元素。
*
* dx / dy 为**文档坐标(mm)**位移量,由调用方把屏幕像素除以缩放后换算得到。
*
* 吸附作用在「结果坐标」而非「位移量」上:若吸附位移量,元素会落在
* 非整数 mm 上;吸附结果坐标才能保证元素真正对齐到 mm 网格。
*
* 这里走 applyWithinTransaction:只更新文档作为实时预览,不单独记历史,
* 由 endDrag 的事务提交统一落成一步撤销。
*/
updateDrag(dx: number, dy: number): void {
const drag = this.state.drag
if (!drag) return
const frames: Record<string, Rect> = {}
for (const [id, original] of Object.entries(drag.originalFrames)) {
frames[id] = {
x: snapToGrid(original.x + dx, this.state),
y: snapToGrid(original.y + dy, this.state),
width: original.width,
height: original.height,
}
}
this.session.applyWithinTransaction(moveElementsCommand, { frames })
}
/** 结束拖拽:提交事务,整个拖拽过程合成一步撤销。 */
endDrag(): void {
if (this.state.drag === null) return
this.state = { ...this.state, drag: null }
this.session.commitTransaction()
}
/** 是否正在拖拽。 */
isDragging(): boolean {
return this.state.drag !== null
}
}
/** 创建编辑器会话。 */
export function createEditorSession(
document: ReportDocument,
options: EditorOptions = {}): EditorSession {
return new EditorSession(document, options)
}
@@ -0,0 +1,43 @@
/**
* @report/designer 公共入口(对应设计文档 §4.5)。
*
* 报表编辑器的状态、命令、坐标转换与交互逻辑(无 UI)。
* Vue 组件层见 @report/designer-vue。
*
* 关键边界(§3.2、§4.5):
* - 所有编辑器状态(选中、缩放、平移、拖拽、剪贴板)都留在此包,
* 绝不写入 ReportDocument。
* - 所有文档修改通过 Core 的命令接口,保证可撤销。
*/
export type { DesignerState, DragState } from "./state.js"
export {
createDesignerState,
toggleSelection,
setSelection,
clearSelection,
isSelected,
collectAllElementIds,
findElement,
sectionElements,
} from "./state.js"
export type { ScreenPoint, DocPoint } from "./coords.js"
export { screenToDoc, docToScreen, hitTest, snapToGrid, clampZoom } from "./coords.js"
export type {
MoveElementPayload,
UpdateElementPayload,
AddElementPayload,
DeleteElementsPayload,
} from "./commands.js"
export {
moveElementsCommand,
updateElementCommand,
addElementCommand,
deleteElementsCommand,
createTextElement,
createRectElement,
} from "./commands.js"
export type { EditorOptions } from "./editor.js"
export { EditorSession, createEditorSession } from "./editor.js"
@@ -0,0 +1,155 @@
/**
* 编辑器状态模型(对应设计文档 §4.5、§3.2)。
*
* 关键边界:以下所有状态都属于「编辑器状态」,明确禁止写入
* ReportDocument(§3.2)。Designer 持有它们,Core 不感知。
*
* - 当前选中项(selectedIds)
* - 缩放比例(zoom)
* - 画布滚动/平移(panX / panY)
* - 辅助线、吸附设置
* - 悬停元素、拖拽进行中状态
*/
import type { ReportElement, ReportSection } from "@report/core"
/** 编辑器状态。 */
export interface DesignerState {
/** 当前选中元素 ID 集合(支持多选)。 */
selectedIds: ReadonlySet<string>
/** 缩放比例,1 表示 100%。 */
zoom: number
/** 画布水平平移(px,显示层坐标)。 */
panX: number
/** 画布垂直平移(px,显示层坐标)。 */
panY: number
/** 是否启用吸附。 */
snapEnabled: boolean
/** 吸附阈值(px)。 */
snapThreshold: number
/** 当前悬停元素 ID(用于高亮)。 */
hoveredId: string | null
/** 拖拽进行中的临时状态。 */
drag: DragState | null
}
/** 拖拽进行中的状态。 */
export interface DragState {
/** 拖拽类型。 */
kind: "move" | "resize"
/** 起始指针位置(画布坐标 mm)。 */
startX: number
startY: number
/** 拖拽起始时各元素的原始 frame(key 为元素 ID)。 */
originalFrames: Record<string, { x: number; y: number; width: number; height: number }>
}
/** 创建默认编辑器状态。 */
export function createDesignerState(partial: Partial<DesignerState> = {}): DesignerState {
return {
selectedIds: new Set(),
zoom: 1,
panX: 0,
panY: 0,
snapEnabled: true,
snapThreshold: 5,
hoveredId: null,
drag: null,
...partial,
}
}
/** 选中/取消选中一个元素(返回新的 Set,保持不可变)。 */
export function toggleSelection(
state: DesignerState,
elementId: string,
): DesignerState {
const next = new Set(state.selectedIds)
if (next.has(elementId)) next.delete(elementId)
else next.add(elementId)
return { ...state, selectedIds: next }
}
/** 设置选中集合(单选或多选)。 */
export function setSelection(
state: DesignerState,
ids: ReadonlySet<string> | string[],
): DesignerState {
const next = new Set(ids)
return { ...state, selectedIds: next }
}
/** 清空选中。 */
export function clearSelection(state: DesignerState): DesignerState {
return { ...state, selectedIds: new Set() }
}
/** 判断某元素是否被选中。 */
export function isSelected(state: DesignerState, elementId: string): boolean {
return state.selectedIds.has(elementId)
}
/**
* 收集文档中所有元素的 ID(含嵌套、表格列内元素)。
*
* Designer 需要遍历元素树来做命中测试与图层面板。
*/
export function collectAllElementIds(document: import("@report/core").ReportDocument): string[] {
const ids: string[] = []
const walk = (elements: readonly ReportElement[]): void => {
for (const element of elements) {
ids.push(element.id)
if (element.children?.length) walk(element.children)
const columns = (element as { columns?: { children?: ReportElement[] }[] }).columns
if (Array.isArray(columns)) {
for (const column of columns) {
if (column.children?.length) walk(column.children)
}
}
}
}
for (const section of document.sections) walk(section.children)
return ids
}
/**
* 在文档中查找元素(含嵌套),返回元素与它所属的父数组,
* 便于 Designer 做删除/替换操作。
*/
export function findElement(
document: import("@report/core").ReportDocument,
elementId: string,
): { element: ReportElement; parent: ReportElement[] } | null {
const walk = (elements: ReportElement[]): { element: ReportElement; parent: ReportElement[] } | null => {
for (let i = 0; i < elements.length; i += 1) {
const element = elements[i]!
if (element.id === elementId) {
return { element, parent: elements }
}
if (element.children?.length) {
const found = walk(element.children)
if (found) return found
}
const columns = (element as { columns?: { children?: ReportElement[] }[] }).columns
if (Array.isArray(columns)) {
for (const column of columns) {
if (column.children?.length) {
const found = walk(column.children)
if (found) return found
}
}
}
}
return null
}
for (const section of document.sections) {
const found = walk(section.children)
if (found) return found
}
return null
}
/** 获取区域内的顶层元素。 */
export function sectionElements(section: ReportSection): ReportElement[] {
return section.children
}
@@ -0,0 +1,356 @@
/**
* Designer 编辑器测试(对应设计文档 §4.5)。
*
* 核心断言:
* - 编辑器状态(选中、缩放、平移)独立于文档(§3.2)。
* - 所有编辑操作通过命令,可撤销可重做。
* - 坐标转换在屏幕 px 与文档 mm 之间正确换算。
*/
import { describe, expect, it, vi } from "vitest"
import { createDocument, createElementId, type ReportDocument } from "@report/core"
import { EditorSession, createEditorSession } from "../src/editor.js"
import { screenToDoc, docToScreen, hitTest, clampZoom, snapToGrid } from "../src/coords.js"
import { createDesignerState } from "../src/state.js"
import { createTextElement, createRectElement } from "../src/commands.js"
function sampleDoc(): ReportDocument {
const doc = createDocument({ name: "编辑器测试" })
const detail = doc.sections.find((s) => s.kind === "detail")!
detail.children.push(
createTextElement({ x: 0, y: 0, width: 50, height: 8 }, "标题"),
createRectElement({ x: 0, y: 10, width: 50, height: 20 }),
)
return doc
}
describe("编辑器状态独立于文档", () => {
it("选中状态不写入文档", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = doc.sections.find((s) => s.kind === "detail")!
const firstId = detail.children[0]!.id
editor.select(firstId)
// 文档序列化后不应包含任何选中状态
const serialized = JSON.stringify(editor.getDocument())
expect(serialized).not.toContain("selectedIds")
expect(editor.getSelectedIds()).toEqual([firstId])
})
it("缩放与平移不写入文档", () => {
const editor = new EditorSession(sampleDoc())
editor.setZoom(1.5)
editor.pan(10, 20)
const state = editor.getState()
expect(state.zoom).toBe(1.5)
expect(state.panX).toBe(10)
expect(state.panY).toBe(20)
// 文档本身不含这些字段
expect(JSON.stringify(editor.getDocument())).not.toContain("zoom")
})
})
describe("选择", () => {
it("选择单个元素", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = doc.sections.find((s) => s.kind === "detail")!
const id = detail.children[0]!.id
editor.select(id)
expect(editor.getSelectedIds()).toEqual([id])
})
it("多选与清空", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = doc.sections.find((s) => s.kind === "detail")!
const ids = detail.children.map((c) => c.id)
editor.selectMany(ids)
expect(editor.getSelectedIds().length).toBe(2)
editor.clearSelection()
expect(editor.getSelectedIds()).toHaveLength(0)
})
})
describe("编辑命令与撤销重做", () => {
it("移动元素可撤销", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = doc.sections.find((s) => s.kind === "detail")!
const id = detail.children[0]!.id
const before = { ...detail.children[0]!.frame }
editor.select(id)
editor.moveElements({ [id]: { x: 100, y: 100, width: 50, height: 8 } })
const afterMove = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame
expect(afterMove.x).toBe(100)
editor.undo()
const afterUndo = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame
expect(afterUndo.x).toBe(before.x)
editor.redo()
const afterRedo = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame
expect(afterRedo.x).toBe(100)
})
it("新增元素可撤销", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const beforeCount = detail.children.length
const newEl = createTextElement({ x: 0, y: 0, width: 30, height: 8 }, "新元素")
editor.addElement(detail.id, newEl)
const afterAdd = editor.getDocument().sections.find((s) => s.kind === "detail")!.children.length
expect(afterAdd).toBe(beforeCount + 1)
editor.undo()
const afterUndo = editor.getDocument().sections.find((s) => s.kind === "detail")!.children.length
expect(afterUndo).toBe(beforeCount)
})
it("删除选中元素", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const beforeCount = detail.children.length
const id = detail.children[0]!.id
editor.select(id)
editor.deleteSelected()
const afterDelete = editor.getDocument().sections.find((s) => s.kind === "detail")!.children.length
expect(afterDelete).toBe(beforeCount - 1)
expect(editor.getSelectedIds()).toHaveLength(0)
})
it("修改元素属性", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const id = detail.children[0]!.id
editor.updateElement(id, "props.text", "改后的标题")
const after = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!
expect((after.props as { text: string }).text).toBe("改后的标题")
})
})
describe("坐标转换", () => {
it("屏幕 px 与文档 mm 换算(zoom=1, pan=0)", () => {
const state = createDesignerState()
const doc = screenToDoc({ x: 96, y: 96 }, state)
// 96px = 1in = 25.4mm
expect(doc.x).toBeCloseTo(25.4, 2)
expect(doc.y).toBeCloseTo(25.4, 2)
const screen = docToScreen({ x: 25.4, y: 25.4 }, state)
expect(screen.x).toBeCloseTo(96, 2)
expect(screen.y).toBeCloseTo(96, 2)
})
it("缩放影响换算", () => {
const state = createDesignerState({ zoom: 2 })
const doc = screenToDoc({ x: 96, y: 0 }, state)
// 96px / 2 = 48px = 12.7mm
expect(doc.x).toBeCloseTo(12.7, 2)
})
it("命中测试", () => {
const state = createDesignerState()
const frame = { x: 0, y: 0, width: 25.4, height: 25.4 }
// 中心点 (48,48)px = 12.7mm 在 frame 内
expect(hitTest({ x: 48, y: 48 }, frame, state)).toBe(true)
// 外部点不在
expect(hitTest({ x: 200, y: 200 }, frame, state)).toBe(false)
})
it("缩放钳制在 [0.5, 2]", () => {
expect(clampZoom(0.1)).toBe(0.5)
expect(clampZoom(5)).toBe(2)
expect(clampZoom(1)).toBe(1)
})
})
describe("拖拽", () => {
it("拖拽移动选中元素", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const id = detail.children[0]!.id
const before = { ...detail.children[0]!.frame }
editor.select(id)
editor.beginDrag(0, 0)
editor.updateDrag(10, 20)
editor.endDrag()
const after = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame
expect(after.x).toBe(before.x + 10)
expect(after.y).toBe(before.y + 20)
expect(editor.isDragging()).toBe(false)
})
it("吸附作用在结果坐标上,拖动落点为整数 mm", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const id = detail.children[0]!.id
editor.select(id)
editor.beginDrag(0, 0)
editor.updateDrag(10.4, 20.6)
editor.endDrag()
const after = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame
// 起点 x=0 y=0,位移取整后应为 10 / 21
expect(after.x).toBe(10)
expect(after.y).toBe(21)
})
it("吸附取整的是落点而非位移量", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const id = detail.children[1]!.id
// 关键:起点必须是非整数,否则「取整落点」与「取整位移量」结果相同,
// 这个用例就区分不出两种实现。
editor.moveElements({ [id]: { x: 5.4, y: 10.4, width: 50, height: 20 } })
editor.select(id)
editor.beginDrag(0, 0)
editor.updateDrag(0.3, 0.3)
editor.endDrag()
const after = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[1]!.frame
// 落点 5.7 → 6、10.7 → 11。
// 若改成对位移量取整,结果会是 5.4 和 10.4(位移 0.3 取整为 0),与期望不符。
expect(after.x).toBe(6)
expect(after.y).toBe(11)
})
})
describe("拖拽的事务语义", () => {
it("拖拽中途停顿超过时间窗,仍然只产生一步撤销", () => {
vi.useFakeTimers()
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"))
try {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const id = detail.children[0]!.id
const frame = () =>
editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame
editor.select(id)
editor.beginDrag(0, 0)
editor.updateDrag(10, 10)
// 模拟用户拖到一半停手,闲置远超 History 的 500ms 合并窗口后继续拖。
// 若靠 mergeKey 时间窗合并,这里会被拆成两条撤销记录。
vi.setSystemTime(new Date("2026-01-01T00:00:10Z"))
editor.updateDrag(30, 30)
editor.endDrag()
expect(frame().x).toBe(30)
// 关键:一次撤销直接回到原位,而不是停在中间态 10
editor.undo()
expect(frame().x).toBe(0)
} finally {
vi.useRealTimers()
}
})
it("拖拽没有产生位移时不写入撤销记录", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const id = detail.children[0]!.id
editor.select(id)
editor.beginDrag(0, 0)
editor.endDrag()
expect(editor.canUndo()).toBe(false)
})
})
describe("吸附开关(snapToGrid)", () => {
it("开启时取整到整数 mm", () => {
const state = createDesignerState({ snapEnabled: true })
expect(snapToGrid(10.4, state)).toBe(10)
expect(snapToGrid(10.6, state)).toBe(11)
})
it("关闭时原样返回", () => {
const state = createDesignerState({ snapEnabled: false })
expect(snapToGrid(10.4, state)).toBe(10.4)
expect(snapToGrid(-3.7, state)).toBe(-3.7)
})
})
describe("多选切换与剪贴板", () => {
it("toggleSelect 在选中与取消之间切换", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const first = detail.children[0]!.id
const second = detail.children[1]!.id
editor.toggleSelect(first)
expect(editor.getSelectedIds()).toEqual([first])
// 再切一次同一个 → 取消选中
editor.toggleSelect(first)
expect(editor.getSelectedIds()).toHaveLength(0)
// 两个都切上 → 多选
editor.toggleSelect(first)
editor.toggleSelect(second)
expect(editor.getSelectedIds().sort()).toEqual([first, second].sort())
})
it("粘贴后选中新元素,而不是清空选中", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const sourceId = detail.children[0]!.id
const beforeCount = detail.children.length
editor.select(sourceId)
editor.copy()
editor.paste(detail.id)
const afterChildren = editor.getDocument().sections.find((s) => s.kind === "detail")!.children
expect(afterChildren.length).toBe(beforeCount + 1)
// 回归:旧实现这里会把选中清空
const selectedIds = editor.getSelectedIds()
expect(selectedIds).toHaveLength(1)
expect(selectedIds[0]).not.toBe(sourceId)
// 选中的确实是刚粘贴出来的那个
expect(afterChildren.some((c) => c.id === selectedIds[0])).toBe(true)
})
it("粘贴的元素相对源元素偏移 5mm 避免重叠", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const source = detail.children[0]!
editor.select(source.id)
editor.copy()
editor.paste(detail.id)
const pasted = editor.getDocument().sections
.find((s) => s.kind === "detail")!
.children.find((c) => c.id === editor.getSelectedIds()[0])!
expect(pasted.frame.x).toBe(source.frame.x + 5)
expect(pasted.frame.y).toBe(source.frame.y + 5)
})
})
@@ -0,0 +1,91 @@
/**
* 端到端链路冒烟测试(§17 V1 验收标准)。
*
* 验证完整链路:
* ReportDocument(core)
* → 数据注入 + 布局(layout,含分组/聚合)
* → LayoutDocument
* → HTML/SVG Renderer(renderer)
* → Designer 编辑(designer)
*/
import { describe, expect, it } from "vitest"
import { createDocument, createTableElement } from "@report/core"
import { createEstimateMeasurer, createPassthroughFontResolver, layoutDocument } from "@report/layout"
import { renderHtml, renderSvg, createPdfRenderer } from "@report/renderer"
import { EditorSession, createTextElement } from "@report/designer"
const measurer = createEstimateMeasurer()
const fontResolver = createPassthroughFontResolver()
describe("端到端冒烟(§17)", () => {
it("完整链路:建文档 → 布局 → HTML/SVG → Designer 编辑", async () => {
// 1. Core:创建文档 + 明细表
const doc = createDocument({ name: "销售单" })
doc.datasets = { sales: { id: "sales" } }
const detail = doc.sections.find((s) => s.kind === "detail")!
detail.children.push(
createTableElement("tbl", { x: 0, y: 0, width: 180, height: 20 }, "sales", [
{ id: "c1", width: 90, header: "商品", binding: { field: "name" } },
{ id: "c2", width: 90, header: "金额", binding: { field: "amount" }, align: "right" },
]),
)
const header = doc.sections.find((s) => s.kind === "page-header")!
header.children.push({
id: "title",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 8 },
props: { text: "销售单" },
})
const rows = [
{ name: "苹果", amount: 100 },
{ name: "香蕉", amount: 200 },
{ name: "橙子", amount: 300 },
]
// 2. Layout:数据注入 + 布局
const layout = await layoutDocument(
doc,
{ data: { sales: rows }, parameters: { } },
{ textMeasurer: measurer, fontResolver },
)
expect(layout.pageCount).toBeGreaterThanOrEqual(1)
expect(layout.diagnostics.filter((d) => d.severity === "error")).toHaveLength(0)
// 3. Renderer:HTML + SVG 渲染,页数一致
const html = renderHtml(layout)
const svg = renderSvg(layout)
expect(html.pageCount).toBe(layout.pageCount)
expect(svg.pageCount).toBe(layout.pageCount)
expect(html.content).toContain("销售单")
expect(html.content).toContain("苹果")
expect(svg.content).toContain("苹果")
// PDF 缺后端 → 诊断而非抛异常
const pdf = createPdfRenderer().render(layout)
expect(pdf.target).toBe("pdf")
expect(pdf.diagnostics.length).toBeGreaterThan(0)
// 4. Designer:编辑(移动 + 撤销)
const editor = new EditorSession(doc)
editor.select("title")
const before = editor.getDocument().sections
.find((s) => s.kind === "page-header")!.children[0]!.frame
editor.moveElements({ title: { x: 20, y: 0, width: 100, height: 8 } })
const afterMove = editor.getDocument().sections
.find((s) => s.kind === "page-header")!.children[0]!.frame
expect(afterMove.x).toBe(20)
editor.undo()
const afterUndo = editor.getDocument().sections
.find((s) => s.kind === "page-header")!.children[0]!.frame
expect(afterUndo.x).toBe(before.x)
// 新增元素
const beforeCount = editor.getDocument().sections
.find((s) => s.kind === "detail")!.children.length
editor.addElement(detail.id, createTextElement({ x: 0, y: 0, width: 30, height: 8 }, "备注"))
const afterAdd = editor.getDocument().sections
.find((s) => s.kind === "detail")!.children.length
expect(afterAdd).toBe(beforeCount + 1)
})
})
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "tests"]
}
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts", "*.config.ts"]
}
@@ -0,0 +1,11 @@
import { defineConfig } from "tsup"
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
sourcemap: true,
clean: true,
treeshake: true,
target: "es2022",
})