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