20260920173439
This commit is contained in:
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",
|
||||
})
|
||||
Reference in new issue
Block a user