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,43 @@
{
"name": "@report/designer-vue",
"version": "0.1.0",
"description": "报表设计器的 Vue 组件层(画布、工具栏、面板、状态栏),Headless 思路,宿主可覆写主题",
"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"
},
"./style.css": "./dist/style.css"
},
"files": ["dist"],
"scripts": {
"build": "vite build",
"dev": "vite build --watch",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "vue-tsc --noEmit -p tsconfig.test.json"
},
"dependencies": {
"@report/core": "workspace:*",
"@report/layout": "workspace:*",
"@report/designer": "workspace:*"
},
"peerDependencies": {
"vue": "^3.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"@vue/test-utils": "^2.4.6",
"jsdom": "^25.0.1",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vitest": "^2.1.8",
"vue": "^3.5.13",
"vue-tsc": "^2.1.10"
}
}
@@ -0,0 +1,87 @@
<script setup lang="ts">
/**
* 报表设计器主布局(对应设计文档 §14.3)。
*
* 三栏布局:顶部工具栏 + 左侧工具箱 + 中间画布 + 右侧属性面板 + 底部状态栏。
* 组合各子组件,并通过 useEditor 打通 UI ↔ designer 交互。
*/
import { computed, onMounted } from "vue"
import type { ReportDocument } from "@report/core"
import { createElementId } from "@report/core"
import { createEditorSession } from "@report/designer"
import { useEditor } from "./use-editor.js"
import { useShortcuts } from "./use-shortcuts.js"
import { injectThemeStyle } from "./theme.js"
import ReportToolbar from "./components/ReportToolbar.vue"
import ReportToolbox from "./components/ReportToolbox.vue"
import ReportCanvas from "./components/ReportCanvas.vue"
import ReportProperties from "./components/ReportProperties.vue"
import ReportStatusbar from "./components/ReportStatusbar.vue"
const props = defineProps<{
/** 初始报表文档。 */
document: ReportDocument
/** 初始缩放。 */
zoom?: number
}>()
const editorSession = createEditorSession(props.document, { zoom: props.zoom })
const editor = useEditor(editorSession)
// Delete / Ctrl+Z / Ctrl+C / Ctrl+V / Escape 等编辑器快捷键(§14.9)。
useShortcuts(editor)
const elements = computed(() => editor.elements())
/** 工具箱新增元素。 */
function onAdd(type: "text" | "rect"): void {
const detail = editor.state.document.sections.find((s) => s.kind === "detail")
if (!detail) return
if (type === "text") {
editorSession.addElement(detail.id, {
id: createElementId(),
type: "text",
frame: { x: 10, y: 10, width: 80, height: 16 },
props: { text: "文本" },
})
} else {
editorSession.addElement(detail.id, {
id: createElementId(),
type: "rect",
frame: { x: 10, y: 10, width: 80, height: 40 },
})
}
editor.sync()
}
onMounted(() => {
injectThemeStyle()
})
</script>
<template>
<div class="rd-designer">
<ReportToolbar :editor="editor" />
<div class="rd-designer-body">
<ReportToolbox @add="onAdd" />
<ReportCanvas :editor="editor" />
<ReportProperties :editor="editor" :elements="elements" />
</div>
<ReportStatusbar :editor="editor" />
</div>
</template>
<style scoped>
.rd-designer {
display: flex;
flex-direction: column;
height: 100%;
min-height: 480px;
}
.rd-designer-body {
display: flex;
flex: 1;
min-height: 0;
}
</style>
@@ -0,0 +1,317 @@
<script setup lang="ts">
/**
* 报表画布组件(对应设计文档 §14.7 画布规范)。
*
* 职责:
* - 渲染当前文档的元素(绝对定位,mm → px 换算)。
* - 点击命中测试 → 选中元素;点击空白 → 取消选中。
* - 拖拽移动元素(通过 useEditor 的 updateDrag,可撤销)。
* - 水平/垂直标尺,随缩放与滚动同步。
*
* 画布只负责交互,不重新计算布局——预览走 renderer,这里用 frame 直绘。
*
* 已知保真缺口:这里从**纸张左上角**为原点绘制区域元素,而 LayoutEngine
* 从**页边距**位置排版(resolveContentBox().x/y = margin,默认 15mm)。
* 两者存在一个页边距的偏移,正式预览时需对齐,本次未处理。
*/
import { computed, onBeforeUnmount, ref } from "vue"
import type { ReportElement } from "@report/core"
import { resolvePageSize } from "@report/core"
import { mmToPx, pxToMm } from "@report/layout"
import { useEditor } from "../use-editor.js"
import ReportRuler from "./ReportRuler.vue"
const props = defineProps<{
/** useEditor 返回的编辑器视图。 */
editor: ReturnType<typeof useEditor>
}>()
/** 纸张四周留白(px)。标尺刻度原点即是这里的纸张起点。 */
const GUTTER = 24
const viewport = ref<HTMLElement | null>(null)
const pageEl = ref<HTMLElement | null>(null)
/** 滚动量,用于让标尺跟内容一起移动。 */
const scrollLeft = ref(0)
const scrollTop = ref(0)
const zoom = computed(() => props.editor.state.zoom)
/** 纸张尺寸取自文档页面设置,不再写死 A4(§7)。 */
const pageSize = computed(() => resolvePageSize(props.editor.state.document.page))
const pageWidthPx = computed(() => mmToPx(pageSize.value.width))
const pageHeightPx = computed(() => mmToPx(pageSize.value.height))
/**
* 外层 sizer 必须显式给出缩放后的尺寸。
*
* transform 不参与布局计算,若不给尺寸,放大后滚动区域仍是原始大小,
* 会出现内容被裁掉却没有滚动条的情况。
*/
const sizerStyle = computed(() => ({
width: `${pageWidthPx.value * zoom.value + GUTTER * 2}px`,
height: `${pageHeightPx.value * zoom.value + GUTTER * 2}px`,
}))
/**
* 页面用 transform 缩放,而不是把每个坐标乘 zoom。
*
* 坐标系乘法只缩放位置、不缩放字号,缩放后元素会失真;transform 一次性
* 缩放几何与文字,命中测试也由浏览器按变换后的实际位置处理。
*/
const pageStyle = computed(() => ({
left: `${GUTTER}px`,
top: `${GUTTER}px`,
width: `${pageWidthPx.value}px`,
height: `${pageHeightPx.value}px`,
transform: `scale(${zoom.value})`,
}))
/** 标尺轨道偏移 = 纸张起点 − 滚动量,保证刻度与纸张边缘对齐。 */
const rulerOffsetH = computed(() => GUTTER - scrollLeft.value)
const rulerOffsetV = computed(() => GUTTER - scrollTop.value)
/** 当前选中的元素 ID 集合。 */
const selected = computed(() => new Set(props.editor.state.selectedIds))
/**
* 元素 frame(mm)→ 显示坐标(px)。
*
* 这里**不乘 zoom**:所在的页面已被 transform 整体缩放。
*/
function elementStyle(element: ReportElement): Record<string, string> {
const { x, y, width, height } = element.frame
return {
left: `${mmToPx(x)}px`,
top: `${mmToPx(y)}px`,
width: `${mmToPx(width)}px`,
height: `${mmToPx(height)}px`,
}
}
/** 元素文本内容。 */
function elementText(element: ReportElement): string {
if (element.type === "text") return (element.props?.text as string) ?? ""
return ""
}
/** 判断元素是否被选中。 */
function isSelected(element: ReportElement): boolean {
return selected.value.has(element.id)
}
function onScroll(): void {
const el = viewport.value
if (!el) return
scrollLeft.value = el.scrollLeft
scrollTop.value = el.scrollTop
}
// ---------------------------------------------------------------------------
// 选择与拖拽
// ---------------------------------------------------------------------------
/** 拖拽起点(屏幕坐标 px),为 null 表示当前没有进行中的拖拽。 */
let dragStart: { clientX: number; clientY: number } | null = null
/** 把屏幕坐标换算成文档坐标(mm),以纸张左上角为原点。 */
function pointerToDoc(event: MouseEvent): { x: number; y: number } {
const page = pageEl.value
if (!page) return { x: 0, y: 0 }
// 页面带 scale,getBoundingClientRect 返回的是缩放后的矩形,
// 因此先除以 zoom 还原成未缩放的显示像素,再换算成 mm。
const rect = page.getBoundingClientRect()
return {
x: pxToMm((event.clientX - rect.left) / zoom.value),
y: pxToMm((event.clientY - rect.top) / zoom.value),
}
}
function onElementMouseDown(event: MouseEvent, element: ReportElement): void {
// 阻止冒泡,否则会触发视口的空白点击取消选中。
event.stopPropagation()
if (event.ctrlKey || event.metaKey) {
// Ctrl/Cmd 点击:切换该元素的选中态。
props.editor.toggleSelect(element.id)
} else if (!selected.value.has(element.id)) {
// 未选中则单选;已选中则保留多选状态,便于整体拖动。
props.editor.select(element.id)
}
if (props.editor.state.selectedIds.length === 0) return
dragStart = { clientX: event.clientX, clientY: event.clientY }
const start = pointerToDoc(event)
props.editor.beginDrag(start.x, start.y)
document.addEventListener("mousemove", onDocumentMouseMove)
document.addEventListener("mouseup", onDocumentMouseUp)
}
function onDocumentMouseMove(event: MouseEvent): void {
if (!dragStart) return
// 屏幕位移 → 文档 mm:先扣掉缩放,再换算单位。吸附由 EditorSession 处理。
const dxMm = pxToMm((event.clientX - dragStart.clientX) / zoom.value)
const dyMm = pxToMm((event.clientY - dragStart.clientY) / zoom.value)
props.editor.updateDrag(dxMm, dyMm)
}
function onDocumentMouseUp(): void {
endDrag()
}
/** 结束拖拽并解绑全局监听。 */
function endDrag(): void {
document.removeEventListener("mousemove", onDocumentMouseMove)
document.removeEventListener("mouseup", onDocumentMouseUp)
if (!dragStart) return
dragStart = null
props.editor.endDrag()
}
/** 点击画布空白处取消选中(元素已 stopPropagation,走到这里就是空白)。 */
function onBackgroundMouseDown(): void {
props.editor.clearSelection()
}
onBeforeUnmount(endDrag)
</script>
<template>
<div class="rd-canvas">
<div class="rd-canvas-corner">mm</div>
<ReportRuler
class="rd-canvas-ruler-h"
orientation="horizontal"
:length-mm="pageSize.width"
:zoom="zoom"
:offset="rulerOffsetH"
/>
<ReportRuler
class="rd-canvas-ruler-v"
orientation="vertical"
:length-mm="pageSize.height"
:zoom="zoom"
:offset="rulerOffsetV"
/>
<div
ref="viewport"
class="rd-canvas-viewport"
:class="{ 'rd-canvas-viewport--dragging': editor.state.dragging }"
@scroll="onScroll"
@mousedown="onBackgroundMouseDown"
>
<div class="rd-canvas-sizer" :style="sizerStyle">
<div ref="pageEl" class="rd-canvas-page" :style="pageStyle">
<div
v-for="element in editor.elements()"
:key="element.id"
class="rd-canvas-element"
:class="{
'rd-canvas-element--selected': isSelected(element),
'rd-canvas-element--text': element.type === 'text',
'rd-canvas-element--rect': element.type === 'rect',
}"
:style="elementStyle(element)"
@mousedown="onElementMouseDown($event, element)"
>
<span v-if="element.type === 'text'" class="rd-canvas-text">{{ elementText(element) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 轨道尺寸(20px)同时也是标尺容器的厚度,改这里需同步 ruler 的字号。 */
.rd-canvas {
flex: 1;
min-width: 0;
display: grid;
grid-template-columns: 20px 1fr;
grid-template-rows: 20px 1fr;
background: hsl(var(--rd-canvas));
}
.rd-canvas-corner {
grid-row: 1;
grid-column: 1;
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--rd-muted));
border-right: 1px solid hsl(var(--rd-border));
border-bottom: 1px solid hsl(var(--rd-border));
color: hsl(var(--rd-muted-foreground));
font-size: 9px;
user-select: none;
}
.rd-canvas-ruler-h {
grid-row: 1;
grid-column: 2;
}
.rd-canvas-ruler-v {
grid-row: 2;
grid-column: 1;
}
.rd-canvas-viewport {
grid-row: 2;
grid-column: 2;
position: relative;
overflow: auto;
}
.rd-canvas-viewport--dragging {
cursor: grabbing;
}
.rd-canvas-sizer {
position: relative;
}
.rd-canvas-page {
position: absolute;
background: hsl(var(--rd-paper));
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transform-origin: 0 0;
}
.rd-canvas-element {
position: absolute;
cursor: move;
outline: 1px solid transparent;
}
.rd-canvas-element:hover {
outline: 1px solid hsl(var(--rd-primary) / 0.5);
}
.rd-canvas-element--selected {
outline: 1px solid hsl(var(--rd-selection));
}
.rd-canvas-element--text {
display: flex;
align-items: center;
padding: 2px;
color: hsl(var(--rd-ink));
}
.rd-canvas-text {
overflow: hidden;
white-space: nowrap;
}
.rd-canvas-element--rect {
border: 1px solid hsl(var(--rd-ink) / 0.35);
background: hsl(var(--rd-ink) / 0.06);
}
</style>
@@ -0,0 +1,135 @@
<script setup lang="ts">
/**
* 右侧属性面板(对应设计文档 §14.8)。
*
* V1 骨架:显示选中元素的基本信息(ID、类型、位置、尺寸),
* 并允许修改文本内容。分组折叠、字段选择器等留到后续。
*/
import { computed } from "vue"
import type { ReportElement } from "@report/core"
import { useEditor } from "../use-editor.js"
const props = defineProps<{
editor: ReturnType<typeof useEditor>
elements: ReportElement[]
}>()
/** 当前选中的元素。 */
const selected = computed<ReportElement | null>(() => {
const id = props.editor.state.selectedIds[0]
if (!id) return null
return props.elements.find((e) => e.id === id) ?? null
})
/** 文本元素的文本内容(可编辑)。 */
const textValue = computed({
get: () => (selected.value?.props?.text as string) ?? "",
set: (value: string) => {
if (!selected.value) return
props.editor.editor.updateElement(selected.value.id, "props.text", value)
props.editor.sync()
},
})
</script>
<template>
<aside class="rd-properties" aria-label="属性面板">
<template v-if="selected">
<div class="rd-properties-title">属性</div>
<div class="rd-prop-row">
<span class="rd-prop-label">ID</span>
<span class="rd-prop-value">{{ selected.id }}</span>
</div>
<div class="rd-prop-row">
<span class="rd-prop-label">类型</span>
<span class="rd-prop-value">{{ selected.type }}</span>
</div>
<div class="rd-prop-row">
<span class="rd-prop-label">位置</span>
<span class="rd-prop-value">
x{{ selected.frame.x }} y{{ selected.frame.y }}
</span>
</div>
<div class="rd-prop-row">
<span class="rd-prop-label">尺寸</span>
<span class="rd-prop-value">
{{ selected.frame.width }} × {{ selected.frame.height }}
</span>
</div>
<div v-if="selected.type === 'text'" class="rd-prop-row">
<label class="rd-prop-label" for="rd-text-input">文本</label>
<input
id="rd-text-input"
v-model="textValue"
class="rd-prop-input"
type="text"
/>
</div>
</template>
<div v-else class="rd-properties-empty">未选中元素</div>
</aside>
</template>
<style scoped>
.rd-properties {
width: 320px;
flex-shrink: 0;
border-left: 1px solid hsl(var(--rd-border));
background: hsl(var(--rd-background));
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.rd-properties-title {
font-size: 12px;
color: hsl(var(--rd-muted-foreground));
padding: 0 4px;
}
.rd-prop-row {
display: flex;
align-items: center;
gap: 12px;
min-height: 32px;
}
.rd-prop-label {
width: 48px;
flex-shrink: 0;
color: hsl(var(--rd-muted-foreground));
font-size: 12px;
}
.rd-prop-value {
color: hsl(var(--rd-foreground));
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rd-prop-input {
flex: 1;
height: 32px;
padding: 0 8px;
border: 1px solid hsl(var(--rd-border));
border-radius: var(--rd-radius);
background: hsl(var(--rd-input));
color: hsl(var(--rd-foreground));
font-size: 13px;
}
.rd-prop-input:focus {
outline: 2px solid hsl(var(--rd-primary));
outline-offset: 1px;
}
.rd-properties-empty {
color: hsl(var(--rd-muted-foreground));
font-size: 13px;
text-align: center;
padding: 24px 0;
}
</style>
@@ -0,0 +1,141 @@
<script setup lang="ts">
/**
* 画布标尺(对应设计文档 §14.7 画布规范)。
*
* 只负责画刻度,不参与命中测试,因此对辅助技术隐藏。
* 刻度位置与纸张原点对齐:轨道偏移由画布传入(纸张起点 − 滚动量)。
*
* 刻度步长随缩放自适应,见 ruler.ts。
*/
import { computed } from "vue"
import { buildRulerTicks } from "../ruler.js"
const props = defineProps<{
/** 标尺方向。 */
orientation: "horizontal" | "vertical"
/** 标尺覆盖长度(mm),通常是纸张宽/高。 */
lengthMm: number
/** 当前缩放比例。 */
zoom: number
/** 轨道相对本容器的偏移(px),= 纸张起点 − 滚动量。 */
offset: number
}>()
const isHorizontal = computed(() => props.orientation === "horizontal")
const ticks = computed(() => buildRulerTicks(props.lengthMm, props.zoom))
/** 轨道定位:横向用 left,纵向用 top。 */
const trackStyle = computed(() =>
isHorizontal.value ? { left: `${props.offset}px` } : { top: `${props.offset}px` },
)
/** 刻度定位:同样按方向走主轴。 */
function tickStyle(px: number): Record<string, string> {
return isHorizontal.value ? { left: `${px}px` } : { top: `${px}px` }
}
</script>
<template>
<div
class="rd-ruler"
:class="`rd-ruler--${orientation}`"
aria-hidden="true"
>
<div class="rd-ruler-track" :style="trackStyle">
<div
v-for="tick in ticks"
:key="tick.mm"
class="rd-ruler-tick"
:class="{ 'rd-ruler-tick--major': tick.label !== undefined }"
:style="tickStyle(tick.px)"
>
<span v-if="tick.label" class="rd-ruler-label">{{ tick.label }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.rd-ruler {
position: relative;
overflow: hidden;
background: hsl(var(--rd-muted));
user-select: none;
}
.rd-ruler--horizontal {
border-bottom: 1px solid hsl(var(--rd-border));
}
.rd-ruler--vertical {
border-right: 1px solid hsl(var(--rd-border));
}
/* 轨道本身不占尺寸,只作为刻度定位的坐标原点。 */
.rd-ruler-track {
position: absolute;
}
.rd-ruler--horizontal .rd-ruler-track {
top: 0;
width: 0;
height: 100%;
}
.rd-ruler--vertical .rd-ruler-track {
left: 0;
width: 100%;
height: 0;
}
.rd-ruler-tick {
position: absolute;
background: hsl(var(--rd-muted-foreground));
}
.rd-ruler--horizontal .rd-ruler-tick {
top: 0;
width: 1px;
height: 5px;
}
.rd-ruler--vertical .rd-ruler-tick {
left: 0;
width: 5px;
height: 1px;
}
.rd-ruler-tick--major {
background: hsl(var(--rd-foreground));
}
.rd-ruler--horizontal .rd-ruler-tick--major {
height: 9px;
}
.rd-ruler--vertical .rd-ruler-tick--major {
width: 9px;
}
.rd-ruler-label {
position: absolute;
color: hsl(var(--rd-foreground));
font-size: 9px;
line-height: 1;
white-space: nowrap;
}
.rd-ruler--horizontal .rd-ruler-label {
left: 2px;
top: 10px;
}
/* 纵向刻度逆时针旋转 90°,读数自下而上,与常见排版软件一致。 */
.rd-ruler--vertical .rd-ruler-label {
left: 10px;
top: 0;
transform: rotate(-90deg);
transform-origin: left top;
}
</style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
/**
* 底部状态栏(对应设计文档 §14.3)。
*
* 显示缩放、单位与布局诊断提示。
*/
import { computed } from "vue"
import { useEditor } from "../use-editor.js"
const props = defineProps<{
editor: ReturnType<typeof useEditor>
}>()
const zoomPercent = computed(() => Math.round(props.editor.state.zoom * 100) + "%")
const elementCount = computed(() => props.editor.elements().length)
</script>
<template>
<div class="rd-statusbar" role="status">
<span class="rd-statusbar-item">缩放 {{ zoomPercent }}</span>
<span class="rd-statusbar-item">单位 mm</span>
<span class="rd-statusbar-item">元素 {{ elementCount }}</span>
</div>
</template>
<style scoped>
.rd-statusbar {
display: flex;
align-items: center;
gap: 16px;
height: 28px;
padding: 0 16px;
border-top: 1px solid hsl(var(--rd-border));
background: hsl(var(--rd-background));
font-size: 12px;
color: hsl(var(--rd-muted-foreground));
}
.rd-statusbar-item {
white-space: nowrap;
}
</style>
@@ -0,0 +1,137 @@
<script setup lang="ts">
/**
* 顶部工具栏(对应设计文档 §14.3、§14.9)。
*
* 提供:撤销、重做、缩放(放大/缩小/百分比)、预览占位。
* 按钮带可访问名称与禁用态。
*/
import { computed } from "vue"
import { useEditor } from "../use-editor.js"
const props = defineProps<{
editor: ReturnType<typeof useEditor>
}>()
const zoomPercent = computed(() => Math.round(props.editor.state.zoom * 100) + "%")
function undo(): void {
props.editor.undo()
}
function redo(): void {
props.editor.redo()
}
function zoomIn(): void {
props.editor.setZoom(props.editor.state.zoom * 1.25)
}
function zoomOut(): void {
props.editor.setZoom(props.editor.state.zoom / 1.25)
}
</script>
<template>
<div class="rd-toolbar" role="toolbar" aria-label="工具栏">
<div class="rd-toolbar-group">
<button
class="rd-btn"
type="button"
:disabled="!editor.canUndo.value"
title="撤销 (Ctrl+Z)"
aria-label="撤销"
@click="undo"
>
撤销
</button>
<button
class="rd-btn"
type="button"
:disabled="!editor.canRedo.value"
title="重做 (Ctrl+Shift+Z)"
aria-label="重做"
@click="redo"
>
重做
</button>
</div>
<div class="rd-toolbar-group">
<button class="rd-btn" type="button" title="缩小" aria-label="缩小" @click="zoomOut">−</button>
<span class="rd-toolbar-zoom" aria-live="polite">{{ zoomPercent }}</span>
<button class="rd-btn" type="button" title="放大" aria-label="放大" @click="zoomIn">+</button>
</div>
<div class="rd-toolbar-group rd-toolbar-spacer">
<button class="rd-btn rd-btn--primary" type="button" title="预览" aria-label="预览">预览</button>
</div>
</div>
</template>
<style scoped>
.rd-toolbar {
display: flex;
align-items: center;
gap: 12px;
height: 48px;
padding: 0 16px;
border-bottom: 1px solid hsl(var(--rd-border));
background: hsl(var(--rd-background));
}
.rd-toolbar-group {
display: flex;
align-items: center;
gap: 6px;
}
.rd-toolbar-spacer {
margin-left: auto;
}
.rd-toolbar-zoom {
min-width: 48px;
text-align: center;
font-size: 12px;
color: hsl(var(--rd-muted-foreground));
}
.rd-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
min-width: 32px;
padding: 0 12px;
border: 1px solid hsl(var(--rd-border));
border-radius: var(--rd-radius);
background: hsl(var(--rd-background));
color: hsl(var(--rd-foreground));
font-size: 13px;
cursor: pointer;
}
.rd-btn:hover:not(:disabled) {
background: hsl(var(--rd-muted));
}
.rd-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.rd-btn:focus-visible {
outline: 2px solid hsl(var(--rd-primary));
outline-offset: 2px;
}
.rd-btn--primary {
background: hsl(var(--rd-primary));
color: hsl(var(--rd-primary-foreground));
border-color: hsl(var(--rd-primary));
}
.rd-btn--primary:hover:not(:disabled) {
background: hsl(var(--rd-primary) / 0.9);
}
</style>
@@ -0,0 +1,71 @@
<script setup lang="ts">
/**
* 左侧工具箱(对应设计文档 §14.3)。
*
* V1 骨架只提供「文本 / 矩形」两类元素的新增入口,
* 图层与数据面板留到后续。
*/
const emit = defineEmits<{
add: [type: "text" | "rect"]
}>()
const tools = [
{ type: "text" as const, label: "文本", desc: "添加文本元素" },
{ type: "rect" as const, label: "矩形", desc: "添加矩形元素" },
]
</script>
<template>
<aside class="rd-toolbox" aria-label="工具箱">
<div class="rd-toolbox-title">元素</div>
<button
v-for="tool in tools"
:key="tool.type"
class="rd-toolbox-item"
type="button"
:title="tool.desc"
@click="emit('add', tool.type)"
>
{{ tool.label }}
</button>
</aside>
</template>
<style scoped>
.rd-toolbox {
width: 240px;
flex-shrink: 0;
border-right: 1px solid hsl(var(--rd-border));
background: hsl(var(--rd-background));
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.rd-toolbox-title {
font-size: 12px;
color: hsl(var(--rd-muted-foreground));
padding: 0 4px;
}
.rd-toolbox-item {
text-align: left;
height: 32px;
padding: 0 12px;
border: 1px solid hsl(var(--rd-border));
border-radius: var(--rd-radius);
background: hsl(var(--rd-background));
color: hsl(var(--rd-foreground));
cursor: pointer;
}
.rd-toolbox-item:hover {
background: hsl(var(--rd-muted));
}
.rd-toolbox-item:focus-visible {
outline: 2px solid hsl(var(--rd-primary));
outline-offset: 2px;
}
</style>
@@ -0,0 +1,27 @@
/**
* @report/designer-vue 公共入口(对应设计文档 §4.6、§14)。
*
* 只负责 Vue 组件、面板和样式实现,不向 Core 泄漏 Vue 类型。
* 组件采用 Headless 思路,宿主可覆写主题(通过 CSS 变量)。
*/
import ReportDesigner from "./ReportDesigner.vue"
import ReportToolbar from "./components/ReportToolbar.vue"
import ReportToolbox from "./components/ReportToolbox.vue"
import ReportCanvas from "./components/ReportCanvas.vue"
import ReportRuler from "./components/ReportRuler.vue"
import ReportProperties from "./components/ReportProperties.vue"
import ReportStatusbar from "./components/ReportStatusbar.vue"
export {
ReportDesigner,
ReportToolbar,
ReportToolbox,
ReportCanvas,
ReportRuler,
ReportProperties,
ReportStatusbar,
}
export { themeCss, injectThemeStyle } from "./theme.js"
export { useEditor } from "./use-editor.js"
export { useShortcuts } from "./use-shortcuts.js"
@@ -0,0 +1,88 @@
/**
* 标尺刻度计算(对应设计文档 §14.7 画布规范)。
*
* 抽成纯函数的原因:步长选择、标签密度这类逻辑很容易在缩放时出边界问题,
* 放在组件里只能靠渲染结果间接验证,单独拿出来可以直接断言。
*
* 坐标约定:刻度位置 `px` 是**距页面原点的显示像素**,已含缩放;
* `mm` 是距页面原点的毫米值(即文档坐标)。
*/
import { mmToPx } from "@report/layout"
/** 主刻度候选步长(mm)。 */
const TICK_CANDIDATES_MM = [1, 2, 5, 10, 20, 50] as const
/** 标签候选步长(mm),需比主刻度更稀疏。 */
const LABEL_CANDIDATES_MM = [1, 2, 5, 10, 20, 50, 100] as const
/** 主刻度最小视觉间距(px),低于此值刻度会糊成一片。 */
const MIN_TICK_SPACING_PX = 8
/** 标签最小视觉间距(px),低于此值数字会重叠。 */
const MIN_LABEL_SPACING_PX = 40
/** 刻度数量上限,防止自定义纸张尺寸过大时生成海量 DOM。 */
const MAX_TICKS = 1000
/** 一根刻度。 */
export interface RulerTick {
/** 距页面原点的毫米值。 */
mm: number
/** 距页面原点的显示像素(已含缩放)。 */
px: number
/** 需要显示的数字标签;次刻度为 undefined。 */
label?: string
}
/** 从候选步长里取第一个在给定缩放下间距达标的。 */
function firstFitting(
candidates: readonly number[],
zoom: number,
minSpacingPx: number,
atLeastMm = 0,
): number {
for (const candidate of candidates) {
if (candidate < atLeastMm) continue
if (mmToPx(candidate) * zoom >= minSpacingPx) return candidate
}
return candidates[candidates.length - 1]!
}
/** 按缩放选择主刻度步长(mm)。 */
export function chooseRulerStepMm(zoom: number): number {
return firstFitting(TICK_CANDIDATES_MM, zoom, MIN_TICK_SPACING_PX)
}
/**
* 生成标尺刻度。
*
* @param lengthMm 标尺覆盖的长度(mm),通常是纸张宽/高。
* @param zoom 当前缩放比例。
*/
export function buildRulerTicks(lengthMm: number, zoom: number): RulerTick[] {
if (!(lengthMm > 0) || !(zoom > 0)) return []
const stepMm = chooseRulerStepMm(zoom)
// 标签步长与主刻度步长都取自同一套候选值,因此 labelEvery 一定是正整数。
const labelStepMm = firstFitting(
LABEL_CANDIDATES_MM,
zoom,
MIN_LABEL_SPACING_PX,
stepMm,
)
const labelEvery = Math.max(1, Math.round(labelStepMm / stepMm))
const ticks: RulerTick[] = []
// 加 1e-9 防止 210 / 5 这类整除因浮点误差少算一根。
const count = Math.min(MAX_TICKS - 1, Math.floor(lengthMm / stepMm + 1e-9))
for (let i = 0; i <= count; i += 1) {
const mm = i * stepMm
const px = mmToPx(mm) * zoom
if (i % labelEvery === 0) {
ticks.push({ mm, px, label: String(Math.round(mm)) })
} else {
ticks.push({ mm, px })
}
}
return ticks
}
@@ -0,0 +1,68 @@
/**
* 设计令牌(对应设计文档 §14.4)。
*
* 使用 CSS 自定义属性作为主题契约,提供亮色与暗色两套主题。
* 宿主应用可通过覆写这些变量来定制品牌色,但必须保持语义:
* background / canvas / foreground / muted / border / primary /
* destructive / selection。
*
* 颜色使用 HSL 空间(h s% l% 三元组),便于组件里用 hsl(var(--x)) 拼装。
*/
export const themeCss = /* css */ `
:root {
--rd-background: 0 0% 100%;
--rd-foreground: 222 47% 11%;
--rd-muted: 210 40% 96%;
--rd-muted-foreground: 215 16% 47%;
--rd-border: 214 32% 91%;
--rd-input: 214 32% 91%;
--rd-primary: 221 83% 53%;
--rd-primary-foreground: 210 40% 98%;
--rd-destructive: 0 72% 51%;
--rd-canvas: 220 14% 96%;
--rd-selection: 221 83% 53%;
--rd-radius: 8px;
/* 纸张与墨色:属于文档表面,不随亮/暗主题切换(§14.7)。 */
--rd-paper: 0 0% 100%;
--rd-ink: 222 47% 11%;
}
.dark {
--rd-background: 222 47% 11%;
--rd-foreground: 210 40% 98%;
--rd-muted: 217 33% 17%;
--rd-muted-foreground: 215 20% 65%;
--rd-border: 217 33% 17%;
--rd-input: 217 33% 17%;
--rd-primary: 217 91% 60%;
--rd-primary-foreground: 222 47% 11%;
--rd-destructive: 0 63% 31%;
--rd-canvas: 222 47% 8%;
--rd-selection: 217 91% 60%;
}
.rd-designer {
color: hsl(var(--rd-foreground));
background: hsl(var(--rd-background));
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI",
"PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 13px;
line-height: 1.4;
}
.rd-designer * {
box-sizing: border-box;
}
`
/** 注入主题样式到文档 head(幂等)。 */
export function injectThemeStyle(): void {
if (typeof document === "undefined") return
const id = "rd-designer-theme"
if (document.getElementById(id)) return
const style = document.createElement("style")
style.id = id
style.textContent = themeCss
document.head.appendChild(style)
}
@@ -0,0 +1,185 @@
/**
* 编辑器响应式绑定(composable)。
*
* 把 designer 包的 EditorSession(非响应式类)包装成 Vue 响应式状态,
* 供组件消费。文档与状态变化时触发重渲染。
*
* 这里只暴露组件真正用到的能力。designer 包里还有 pan / hoveredId 等
* 状态,但当前画布用滚动容器承载平移、也没有 hover 高亮,故暂不暴露,
* 避免出现「有 API 没人用」的死代码。
*/
import { reactive, readonly, computed } from "vue"
import type { ReportDocument, ReportElement, Rect } from "@report/core"
import type { EditorSession } from "@report/designer"
/** 把 EditorSession 包装成响应式视图。 */
export function useEditor(editor: EditorSession) {
// 用一个自增版本号驱动重渲染:EditorSession 内部是不可变替换,
// 没有 Vue 的响应式钩子,这里在每次操作后手动 bump。
const state = reactive({
version: 0,
document: editor.getDocument() as ReportDocument,
selectedIds: [] as string[],
zoom: editor.getState().zoom,
/** 是否正在拖拽(用于切换光标)。 */
dragging: false,
})
/** 同步编辑器最新状态到响应式视图。 */
function sync(): void {
state.version += 1
state.document = editor.getDocument()
state.selectedIds = editor.getSelectedIds()
state.zoom = editor.getState().zoom
state.dragging = editor.isDragging()
}
// -------------------------------------------------------------------------
// 选择
// -------------------------------------------------------------------------
/** 选中单个元素。 */
function select(id: string): void {
editor.select(id)
sync()
}
/** 多选。 */
function selectMany(ids: string[]): void {
editor.selectMany(ids)
sync()
}
/** 切换单个元素的选中态(Ctrl/Cmd + 点击)。 */
function toggleSelect(id: string): void {
editor.toggleSelect(id)
sync()
}
/** 清空选中。 */
function clearSelection(): void {
editor.clearSelection()
sync()
}
// -------------------------------------------------------------------------
// 编辑操作
// -------------------------------------------------------------------------
/** 移动元素(frame 为文档绝对坐标 mm)。 */
function move(frames: Record<string, Rect>): void {
editor.moveElements(frames)
sync()
}
/** 删除当前选中的元素。 */
function deleteSelected(): void {
editor.deleteSelected()
sync()
}
/** 复制当前选中元素到剪贴板。 */
function copy(): void {
editor.copy()
}
/** 粘贴剪贴板元素到指定区域。 */
function paste(sectionId: string): void {
editor.paste(sectionId)
sync()
}
// -------------------------------------------------------------------------
// 拖拽
// -------------------------------------------------------------------------
/**
* 开始拖拽。
*
* startX / startY 为指针对应的文档坐标(mm),仅用于记录起点。
*/
function beginDrag(startX: number, startY: number): void {
editor.beginDrag(startX, startY)
sync()
}
/**
* 拖拽更新。
*
* dx / dy 必须是**文档坐标(mm)**位移量——调用方负责把屏幕像素
* 除以缩放比例再换算成 mm。吸附由 EditorSession 内部处理。
*/
function updateDrag(dx: number, dy: number): void {
editor.updateDrag(dx, dy)
sync()
}
/** 结束拖拽。 */
function endDrag(): void {
editor.endDrag()
sync()
}
/** 是否正在拖拽。 */
function isDragging(): boolean {
return editor.isDragging()
}
// -------------------------------------------------------------------------
// 撤销 / 重做 / 视图
// -------------------------------------------------------------------------
/** 撤销。 */
function undo(): void {
editor.undo()
sync()
}
/** 重做。 */
function redo(): void {
editor.redo()
sync()
}
/** 缩放。 */
function setZoom(zoom: number): void {
editor.setZoom(zoom)
sync()
}
/** 收集文档所有顶层元素(供画布渲染)。 */
function elements(): ReportElement[] {
const doc = state.document
const detail = doc.sections.find((s) => s.kind === "detail")
if (!detail) return []
return detail.children
}
return {
state: readonly(state),
editor,
sync,
select,
selectMany,
toggleSelect,
clearSelection,
move,
deleteSelected,
copy,
paste,
beginDrag,
updateDrag,
endDrag,
isDragging,
undo,
redo,
setZoom,
elements,
// canUndo/canRedo 必须依赖响应式的 version 计数器:
// EditorSession 是普通类,其内部变化 Vue 无法追踪,
// 若只调用 editor.canUndo(),computed 将永远停留在初始值 false,
// 撤销/重做按钮永远禁用。读一下 state.version 让 sync() 后重新求值。
canUndo: computed(() => (state.version >= 0, editor.canUndo())),
canRedo: computed(() => (state.version >= 0, editor.canRedo())),
}
}
@@ -0,0 +1,69 @@
/**
* 编辑器快捷键(对应设计文档 §14.9)。
*
* 监听挂在 window 上:画布本身不可聚焦,挂在组件根上会因焦点不在其中而失效。
*
* 关键约束:属性面板里有文本输入框,若不过滤输入态,用户在输入框里按
* Delete / Backspace 会连带删除画布上选中的元素。
*/
import { onBeforeUnmount, onMounted } from "vue"
import { useEditor } from "./use-editor.js"
/** 事件目标是否处于文本编辑态。 */
function isEditingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
const tag = target.tagName
return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable
}
/** 注册编辑器快捷键,组件卸载时自动解绑。 */
export function useShortcuts(editor: ReturnType<typeof useEditor>): void {
function onKeyDown(event: KeyboardEvent): void {
if (isEditingTarget(event.target)) return
const mod = event.ctrlKey || event.metaKey
const key = event.key.toLowerCase()
if (mod && key === "z") {
event.preventDefault()
// Ctrl+Shift+Z 是重做,与 Ctrl+Z 对称。
if (event.shiftKey) editor.redo()
else editor.undo()
return
}
// Ctrl+Y:Windows 习惯的重做键。
if (mod && key === "y") {
event.preventDefault()
editor.redo()
return
}
if (mod && key === "c") {
event.preventDefault()
editor.copy()
return
}
if (mod && key === "v") {
event.preventDefault()
// 粘贴目标固定为明细区,与工具箱新增元素的口径一致。
const detail = editor.state.document.sections.find((s) => s.kind === "detail")
if (detail) editor.paste(detail.id)
return
}
if (event.key === "Delete" || event.key === "Backspace") {
event.preventDefault()
editor.deleteSelected()
return
}
if (event.key === "Escape") {
editor.clearSelection()
}
}
onMounted(() => window.addEventListener("keydown", onKeyDown))
onBeforeUnmount(() => window.removeEventListener("keydown", onKeyDown))
}
@@ -0,0 +1,404 @@
/**
* report-designer-vue 组件测试。
*
* 验证:
* - 主布局挂载后渲染三栏结构(工具栏/工具箱/属性面板/状态栏/画布)。
* - 工具箱可新增元素并反映到画布。
* - 点击画布元素可选中,属性面板显示其信息;点击空白取消选中。
* - 工具栏撤销可用。
* - 画布缩放、页面尺寸、标尺刻度。
* - 拖拽移动元素,且连续拖拽合并为一步撤销。
* - 快捷键(Delete / Ctrl+Z),输入态下不触发。
* - 主题样式注入是幂等的。
*/
import { describe, expect, it, beforeEach } from "vitest"
import { mount, flushPromises } from "@vue/test-utils"
import { createDocument, type ReportDocument } from "@report/core"
import { mmToPx } from "@report/layout"
import ReportDesigner from "../src/ReportDesigner.vue"
import ReportToolbar from "../src/components/ReportToolbar.vue"
import ReportToolbox from "../src/components/ReportToolbox.vue"
import ReportCanvas from "../src/components/ReportCanvas.vue"
import ReportProperties from "../src/components/ReportProperties.vue"
import ReportStatusbar from "../src/components/ReportStatusbar.vue"
import { useEditor } from "../src/use-editor.js"
import { injectThemeStyle } from "../src/theme.js"
import { buildRulerTicks, chooseRulerStepMm } from "../src/ruler.js"
import { createEditorSession } from "@report/designer"
function sampleDoc(): ReportDocument {
const doc = createDocument({ name: "UI 测试" })
const detail = doc.sections.find((s) => s.kind === "detail")!
detail.children.push({
id: "t1",
type: "text",
frame: { x: 0, y: 0, width: 50, height: 8 },
props: { text: "标题" },
})
return doc
}
/** 取画布页面的实际宽度(px),从内联样式解析。 */
function pageWidthPx(wrapper: ReturnType<typeof mount>): number {
const style = wrapper.find(".rd-canvas-page").attributes("style") ?? ""
const match = /width:\s*([\d.]+)px/.exec(style)
return match ? Number(match[1]) : Number.NaN
}
/** 取画布 sizer 的宽度(px)。 */
function sizerWidthPx(wrapper: ReturnType<typeof mount>): number {
const style = wrapper.find(".rd-canvas-sizer").attributes("style") ?? ""
const match = /width:\s*([\d.]+)px/.exec(style)
return match ? Number(match[1]) : Number.NaN
}
/** 在工具栏里按 aria-label 找按钮。 */
function toolbarButton(wrapper: ReturnType<typeof mount>, label: string) {
return wrapper
.findComponent(ReportToolbar)
.findAll("button")
.find((b) => b.attributes("aria-label") === label)!
}
describe("ReportDesigner 主布局", () => {
it("挂载后渲染三栏结构与五个区域", () => {
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
expect(wrapper.findComponent(ReportToolbar).exists()).toBe(true)
expect(wrapper.findComponent(ReportToolbox).exists()).toBe(true)
expect(wrapper.findComponent(ReportCanvas).exists()).toBe(true)
expect(wrapper.findComponent(ReportProperties).exists()).toBe(true)
expect(wrapper.findComponent(ReportStatusbar).exists()).toBe(true)
})
it("画布渲染文档中的元素", () => {
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
const canvas = wrapper.findComponent(ReportCanvas)
expect(canvas.findAll(".rd-canvas-element").length).toBe(1)
expect(canvas.text()).toContain("标题")
})
it("点击画布元素后属性面板显示信息", async () => {
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
const element = wrapper.find(".rd-canvas-element")
await element.trigger("mousedown")
await flushPromises()
const properties = wrapper.findComponent(ReportProperties)
expect(properties.text()).toContain("t1")
expect(properties.text()).toContain("text")
// mousedown 会挂上全局拖拽监听,测试结束必须卸载以解绑。
wrapper.unmount()
})
it("属性面板可编辑文本内容", async () => {
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
await wrapper.find(".rd-canvas-element").trigger("mousedown")
await flushPromises()
const input = wrapper.find(".rd-prop-input")
expect(input.exists()).toBe(true)
await input.setValue("改后的标题")
await flushPromises()
// 画布同步显示新文本
expect(wrapper.findComponent(ReportCanvas).text()).toContain("改后的标题")
wrapper.unmount()
})
it("工具箱新增文本元素并出现在画布", async () => {
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
const before = wrapper.findAll(".rd-canvas-element").length
const toolbox = wrapper.findComponent(ReportToolbox)
await toolbox.find("button").trigger("click")
await flushPromises()
const after = wrapper.findAll(".rd-canvas-element").length
expect(after).toBe(before + 1)
})
it("工具栏撤销新增操作", async () => {
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
const before = wrapper.findAll(".rd-canvas-element").length
await wrapper.findComponent(ReportToolbox).find("button").trigger("click")
await flushPromises()
expect(wrapper.findAll(".rd-canvas-element").length).toBe(before + 1)
const toolbar = wrapper.findComponent(ReportToolbar)
const undoBtn = toolbar.findAll("button").find((b) => b.attributes("aria-label") === "撤销")!
await undoBtn.trigger("click")
await flushPromises()
expect(wrapper.findAll(".rd-canvas-element").length).toBe(before)
})
it("无选中时属性面板显示空态", () => {
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
const properties = wrapper.findComponent(ReportProperties)
expect(properties.text()).toContain("未选中元素")
})
})
describe("useEditor composable", () => {
it("select 与 move 反映到状态", () => {
const doc = sampleDoc()
const session = createEditorSession(doc)
const editor = useEditor(session)
editor.select("t1")
expect(editor.state.selectedIds).toContain("t1")
editor.move({ t1: { x: 20, y: 30, width: 50, height: 8 } })
const moved = editor.state.document.sections
.find((s) => s.kind === "detail")!.children[0]!.frame
expect(moved.x).toBe(20)
expect(moved.y).toBe(30)
})
it("undo 后文档恢复", () => {
const doc = sampleDoc()
const session = createEditorSession(doc)
const editor = useEditor(session)
editor.move({ t1: { x: 99, y: 99, width: 50, height: 8 } })
editor.undo()
const restored = editor.state.document.sections
.find((s) => s.kind === "detail")!.children[0]!.frame
expect(restored.x).toBe(0)
})
})
describe("主题注入", () => {
beforeEach(() => {
document.head.innerHTML = ""
})
it("注入主题样式到 head", () => {
injectThemeStyle()
const style = document.getElementById("rd-designer-theme")
expect(style).not.toBeNull()
expect(style!.textContent).toContain("--rd-background")
expect(style!.textContent).toContain(".dark")
})
it("重复注入是幂等的", () => {
injectThemeStyle()
injectThemeStyle()
const styles = document.querySelectorAll("#rd-designer-theme")
expect(styles.length).toBe(1)
})
})
describe("标尺刻度计算", () => {
it("步长随缩放自适应,保证刻度不会挤在一起", () => {
// mmToPx(1)=3.78px、2=7.56px、5=18.9px,阈值 8px
expect(chooseRulerStepMm(1)).toBe(5)
expect(chooseRulerStepMm(2)).toBe(2)
expect(chooseRulerStepMm(0.5)).toBe(5)
})
it("刻度从页面原点开始且不超出纸张长度", () => {
const ticks = buildRulerTicks(210, 1)
expect(ticks.length).toBeGreaterThan(0)
expect(ticks[0]!.mm).toBe(0)
expect(ticks[0]!.px).toBe(0)
const last = ticks[ticks.length - 1]!
expect(last.mm).toBeLessThanOrEqual(210)
// px 必须是 mm 换算后乘缩放的结果
expect(last.px).toBeCloseTo(mmToPx(last.mm) * 1, 6)
})
it("标签间距不小于 40px,避免数字重叠", () => {
const labeled = buildRulerTicks(210, 1).filter((t) => t.label !== undefined)
expect(labeled.length).toBeGreaterThan(1)
for (let i = 1; i < labeled.length; i += 1) {
expect(labeled[i]!.px - labeled[i - 1]!.px).toBeGreaterThanOrEqual(40)
}
})
it("放大后刻度更密", () => {
expect(buildRulerTicks(210, 2).length).toBeGreaterThan(
buildRulerTicks(210, 1).length,
)
})
it("非法输入返回空数组", () => {
expect(buildRulerTicks(0, 1)).toEqual([])
expect(buildRulerTicks(210, 0)).toEqual([])
})
})
describe("标尺渲染", () => {
it("画布渲染水平与垂直标尺及刻度", () => {
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
expect(wrapper.find(".rd-canvas-ruler-h").exists()).toBe(true)
expect(wrapper.find(".rd-canvas-ruler-v").exists()).toBe(true)
expect(wrapper.findAll(".rd-ruler-tick").length).toBeGreaterThan(0)
expect(wrapper.findAll(".rd-ruler-label").length).toBeGreaterThan(0)
})
it("标尺随滚动同步偏移", async () => {
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
const viewport = wrapper.find(".rd-canvas-viewport")
// jsdom 没有真实布局,直接造一个滚动量再派发 scroll。
Object.defineProperty(viewport.element, "scrollLeft", {
value: 40,
configurable: true,
})
await viewport.trigger("scroll")
// 轨道偏移 = 纸张起点(24) − 滚动量(40) = −16
const track = wrapper.find(".rd-canvas-ruler-h .rd-ruler-track")
expect(track.attributes("style")).toContain("left: -16px")
})
})
describe("画布页面尺寸与缩放", () => {
it("页面尺寸取自文档页面设置(A4 竖版)", () => {
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
expect(pageWidthPx(wrapper)).toBeCloseTo(mmToPx(210), 3)
})
it("换成 A5 后页面尺寸随之变化", () => {
const doc = sampleDoc()
doc.page.paper = "A5"
const wrapper = mount(ReportDesigner, { props: { document: doc } })
expect(pageWidthPx(wrapper)).toBeCloseTo(mmToPx(148), 3)
})
it("缩放真实作用于页面(transform),并撑开滚动区域", async () => {
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
expect(wrapper.find(".rd-canvas-page").attributes("style")).toContain("scale(1)")
const beforeSizer = sizerWidthPx(wrapper)
await toolbarButton(wrapper, "放大").trigger("click")
await flushPromises()
// 回归:旧实现只改状态栏数字,页面 transform 不变
expect(wrapper.find(".rd-canvas-page").attributes("style")).toContain("scale(1.25)")
expect(sizerWidthPx(wrapper)).toBeGreaterThan(beforeSizer)
})
})
describe("画布拖拽", () => {
/** 直接挂 ReportCanvas,便于从 session 读取拖拽结果。 */
function mountCanvas() {
const doc = sampleDoc()
const session = createEditorSession(doc)
const editor = useEditor(session)
const wrapper = mount(ReportCanvas, { props: { editor } })
return { wrapper, session, editor }
}
it("拖拽改变元素 frame,结束后退出拖拽态", async () => {
const { wrapper, session, editor } = mountCanvas()
await wrapper.find(".rd-canvas-element").trigger("mousedown", {
clientX: 100,
clientY: 100,
})
expect(editor.isDragging()).toBe(true)
// 屏幕位移 96px = 25.4mm,吸附到整数 mm → 25
document.dispatchEvent(new MouseEvent("mousemove", { clientX: 196, clientY: 100 }))
await flushPromises()
const frame = session
.getDocument()
.sections.find((s) => s.kind === "detail")!.children[0]!.frame
expect(frame.x).toBe(25)
expect(frame.y).toBe(0)
document.dispatchEvent(new MouseEvent("mouseup"))
await flushPromises()
expect(editor.isDragging()).toBe(false)
wrapper.unmount()
})
it("一次拖拽的多次位移合并为一步撤销", async () => {
const { wrapper, session } = mountCanvas()
const detail = () => session.getDocument().sections.find((s) => s.kind === "detail")!
await wrapper.find(".rd-canvas-element").trigger("mousedown", {
clientX: 100,
clientY: 100,
})
// 模拟拖拽过程中的多次 mousemove
for (const x of [130, 160, 196]) {
document.dispatchEvent(new MouseEvent("mousemove", { clientX: x, clientY: 100 }))
}
document.dispatchEvent(new MouseEvent("mouseup"))
await flushPromises()
expect(detail().children[0]!.frame.x).toBe(25)
// 依赖 moveElementsCommand 的 mergeKey,一次撤销即回到原位
session.undo()
expect(detail().children[0]!.frame.x).toBe(0)
wrapper.unmount()
})
it("点击画布空白取消选中", async () => {
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
await wrapper.find(".rd-canvas-element").trigger("mousedown")
await flushPromises()
expect(wrapper.findComponent(ReportProperties).text()).toContain("t1")
await wrapper.find(".rd-canvas-viewport").trigger("mousedown")
await flushPromises()
expect(wrapper.findComponent(ReportProperties).text()).toContain("未选中元素")
wrapper.unmount()
})
})
describe("快捷键", () => {
it("Delete 删除选中元素", async () => {
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
await wrapper.find(".rd-canvas-element").trigger("mousedown")
await flushPromises()
expect(wrapper.findAll(".rd-canvas-element").length).toBe(1)
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Delete" }))
await flushPromises()
expect(wrapper.findAll(".rd-canvas-element").length).toBe(0)
wrapper.unmount()
})
it("Ctrl+Z 撤销", async () => {
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
await wrapper.findComponent(ReportToolbox).find("button").trigger("click")
await flushPromises()
expect(wrapper.findAll(".rd-canvas-element").length).toBe(2)
window.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true }))
await flushPromises()
expect(wrapper.findAll(".rd-canvas-element").length).toBe(1)
wrapper.unmount()
})
it("输入框内按 Delete 不删除画布元素", async () => {
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
await wrapper.find(".rd-canvas-element").trigger("mousedown")
await flushPromises()
const input = wrapper.find(".rd-prop-input")
expect(input.exists()).toBe(true)
// 以输入框为 target 冒泡到 window,快捷键必须让位给打字
input.element.dispatchEvent(
new KeyboardEvent("keydown", { key: "Delete", bubbles: true }),
)
await flushPromises()
expect(wrapper.findAll(".rd-canvas-element").length).toBe(1)
wrapper.unmount()
})
})
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"],
"jsx": "preserve",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"exclude": ["node_modules", "dist", "tests"]
}
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"],
"jsx": "preserve",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "tests/**/*.ts", "*.config.ts"]
}
@@ -0,0 +1,25 @@
import { defineConfig } from "vite"
import vue from "@vitejs/plugin-vue"
import { resolve } from "node:path"
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: resolve(__dirname, "src/index.ts"),
// 显式指定 es + cjs:缺省的 umd 需要 output.globals,
// 库场景下宿主走打包器消费,不需要 UMD。
formats: ["es", "cjs"],
fileName: (format) => (format === "es" ? "index.js" : "index.cjs"),
},
rollupOptions: {
// 工作区兄弟包与 vue 保持 external,由宿主提供。
external: ["vue", "@report/core", "@report/layout", "@report/designer"],
output: {
// 只用具名导出,避免 ESM/CJS 互操作时 .default 歧义。
exports: "named",
},
},
sourcemap: true,
},
})
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config"
import vue from "@vitejs/plugin-vue"
export default defineConfig({
plugins: [vue()],
test: {
include: ["tests/**/*.test.ts"],
environment: "jsdom",
},
})