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,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))
}