Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 62 additions & 62 deletions packages/vue-xlsx/src/components/XlsxChartOverlay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,45 +4,54 @@
data-testid="xlsx-chart-overlay"
:data-state="controller.isChartsLoading ? 'loading' : 'ready'"
:data-chart-count="chartItems.length"
:style="layerStyle"
>
<div
v-for="item in chartItems"
:key="item.chart.id"
class="xlsx-chart-overlay__item"
data-testid="xlsx-chart-item"
:class="{
'xlsx-chart-overlay__item--selected': item.chart.id === selectedChartId,
}"
:style="item.style"
@pointerdown.stop="onChartPointerDown(item.chart, $event)"
v-for="pane in panes"
:key="pane.key"
class="xlsx-chart-overlay__pane"
:data-grid-pane="pane.key"
:style="pane.style"
>
<MemoChartSvg
v-if="item.chart.chartType !== 'Unsupported'"
:chart="item.chart"
:rect="item.rect"
:palette="chartPalette"
:selected-chart-element="
selectedChartId === item.chart.id ? controller.selectedChartElement : null
"
:on-chart-element-pointer-down="
selectedChartId === item.chart.id ? onChartElementPointerDown : undefined
"
:on-chart-element-double-click="
selectedChartId === item.chart.id ? onChartElementDoubleClick : undefined
"
/>
<div v-else class="xlsx-chart-overlay__unsupported">
📊 不支持的图表类型
<div
v-for="item in pane.items"
:key="item.chart.id"
class="xlsx-chart-overlay__item"
data-testid="xlsx-chart-item"
:class="{
'xlsx-chart-overlay__item--selected': item.chart.id === selectedChartId,
}"
:style="item.style"
@pointerdown.stop="onChartPointerDown(item.chart, $event)"
>
<MemoChartSvg
v-if="item.chart.chartType !== 'Unsupported'"
:chart="item.chart"
:rect="item.rect"
:palette="chartPalette"
:selected-chart-element="
selectedChartId === item.chart.id ? controller.selectedChartElement : null
"
:on-chart-element-pointer-down="
selectedChartId === item.chart.id ? onChartElementPointerDown : undefined
"
:on-chart-element-double-click="
selectedChartId === item.chart.id ? onChartElementDoubleClick : undefined
"
/>
<div v-else class="xlsx-chart-overlay__unsupported">
📊 不支持的图表类型
</div>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { computed, type CSSProperties } from "vue";
import type { XlsxViewerController, XlsxChart, XlsxChartElementSelection, XlsxImageRect, XlsxImageAnchor, XlsxSheetData } from "@arcships/xlsx-core";
import { anchorToAbsoluteRect, emuToPixels } from "@arcships/xlsx-core";
import type { XlsxViewerController, XlsxChart, XlsxChartElementSelection, XlsxImageRect } from "@arcships/xlsx-core";
import { MemoChartSvg } from "../optional/lazy-renderers";
import { createGridPaneLayout, gridBodyLayerStyle, resolveAnchorRect, type GridPaneKey } from "./grid-metrics";
import type { ChartRendererPalette } from "../render/chart-types";

const props = defineProps<{
Expand All @@ -52,6 +61,7 @@ const props = defineProps<{
scrollTop?: number;
}>();

const layerStyle = gridBodyLayerStyle;
const selectedChartId = computed(() => props.controller.selectedChartId);

const chartPalette = computed<ChartRendererPalette>(() => ({
Expand All @@ -64,51 +74,31 @@ const chartPalette = computed<ChartRendererPalette>(() => ({
interface ChartItem {
chart: XlsxChart;
rect: XlsxImageRect;
pane: GridPaneKey;
style: CSSProperties;
}

function frozenExtent(sheet: XlsxSheetData | null, axis: "column" | "row", zoomScale: number): number {
if (!sheet?.freezePanes) return 0;
const limit = axis === "column" ? sheet.freezePanes.col : sheet.freezePanes.row;
const indices = axis === "column" ? sheet.visibleCols : sheet.visibleRows;
const sizes = axis === "column" ? sheet.colWidths : sheet.rowHeights;
const fallback = axis === "column" ? sheet.defaultColWidthPx : sheet.defaultRowHeightPx;
const zoom = Math.max(0.1, zoomScale / 100);
return indices.reduce((total, actualIndex, displayIndex) =>
actualIndex < limit ? total + (sizes[displayIndex] || fallback) * zoom : total,
0);
}

function resolveAnchorRect(anchor: XlsxImageAnchor, sheet: XlsxSheetData | null, zoomScale: number): XlsxImageRect {
const rect = anchorToAbsoluteRect(anchor, sheet);
const zoom = Math.max(0.1, zoomScale / 100);
return {
left: emuToPixels(rect.x) * zoom,
top: emuToPixels(rect.y) * zoom,
width: Math.max(1, emuToPixels(rect.cx) * zoom),
height: Math.max(1, emuToPixels(rect.cy) * zoom),
};
}
const paneLayout = computed(() => createGridPaneLayout(
props.controller.activeSheet,
props.controller.zoomScale,
props.scrollLeft ?? 0,
props.scrollTop ?? 0,
));

const chartItems = computed<ChartItem[]>(() => {
const charts = props.controller.charts as XlsxChart[];
return charts
const layout = paneLayout.value;
return (props.controller.charts as XlsxChart[])
.filter((chart) => chart.anchor)
.map((chart) => {
const rect = resolveAnchorRect(chart.anchor, props.controller.activeSheet, props.controller.zoomScale);
const isSelected = chart.id === selectedChartId.value;
const frozenWidth = frozenExtent(props.controller.activeSheet, "column", props.controller.zoomScale);
const frozenHeight = frozenExtent(props.controller.activeSheet, "row", props.controller.zoomScale);
const scrollX = rect.left < frozenWidth ? 0 : props.scrollLeft ?? 0;
const scrollY = rect.top < frozenHeight ? 0 : props.scrollTop ?? 0;
const placement = layout.place(rect);
return {
chart,
rect,
pane: placement.pane,
style: {
left: `${rect.left + 48 - scrollX}px`,
top: `${rect.top + 24 - scrollY}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
...placement.style,
position: "absolute",
pointerEvents: "auto",
cursor: "pointer",
Expand All @@ -120,6 +110,11 @@ const chartItems = computed<ChartItem[]>(() => {
});
});

const panes = computed(() => paneLayout.value.panes.map((pane) => ({
...pane,
items: chartItems.value.filter((item) => item.pane === pane.key),
})));

function onChartPointerDown(chart: XlsxChart, _event: PointerEvent) {
props.controller.selectChart(chart.id);
}
Expand All @@ -134,9 +129,14 @@ function onChartElementDoubleClick(selection: XlsxChartElementSelection, _event:
</script>

<style scoped>
/* Geometry (inset past the headers) comes from `gridBodyLayerStyle`; each
freeze pane clips its own charts so a scrolled-away chart never paints over
frozen panes, the headers, or the host UI around the surface. */
.xlsx-chart-overlay {
height: 100%; left: 0; pointer-events: none;
position: absolute; top: 0; width: 100%;
pointer-events: none; position: absolute;
}
.xlsx-chart-overlay__pane {
overflow: hidden; pointer-events: none; position: absolute;
}
.xlsx-chart-overlay__item { background: transparent; overflow: hidden; }
.xlsx-chart-overlay__item--selected { box-shadow: 0 0 0 1px #2563eb; }
Expand Down
117 changes: 63 additions & 54 deletions packages/vue-xlsx/src/components/XlsxDrawingLayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,79 +4,78 @@
data-testid="xlsx-drawing-layer"
:data-shape-count="shapeItems.length"
:data-form-control-count="controlItems.length"
:style="layerStyle"
>
<div
v-for="item in shapeItems"
:key="item.shape.id"
class="xlsx-drawing-layer__shape"
data-testid="xlsx-shape"
:data-shape-geometry="item.shape.geometry"
:style="item.style"
v-for="pane in panes"
:key="pane.key"
class="xlsx-drawing-layer__pane"
:data-grid-pane="pane.key"
:style="pane.style"
>
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
<ellipse v-if="/ellipse|oval/i.test(item.shape.geometry)" cx="50" cy="50" rx="48" ry="48" />
<path v-else-if="item.shape.svgPath" :d="item.shape.svgPath" :viewBox="item.shape.svgViewBox ? `0 0 ${item.shape.svgViewBox.width} ${item.shape.svgViewBox.height}` : undefined" />
<rect v-else x="1" y="1" width="98" height="98" rx="4" />
</svg>
<div class="xlsx-drawing-layer__text">{{ shapeText(item.shape) }}</div>
</div>
<div
v-for="item in controlItems"
:key="item.control.id"
class="xlsx-drawing-layer__control"
data-testid="xlsx-form-control"
:data-control-kind="item.control.kind"
:style="item.style"
>
<span v-if="item.control.kind === 'checkbox'" class="xlsx-drawing-layer__box">{{ item.control.checked ? '✓' : '' }}</span>
<span v-else-if="item.control.kind === 'radio'" class="xlsx-drawing-layer__radio">{{ item.control.checked ? '●' : '' }}</span>
<button v-else-if="item.control.kind === 'button'" type="button" disabled>{{ item.control.label || item.control.name || '按钮' }}</button>
<span v-else>{{ item.control.label || item.control.name || controlLabel(item.control.kind) }}</span>
<span v-if="item.control.kind === 'checkbox' || item.control.kind === 'radio'">{{ item.control.label || item.control.name }}</span>
<div
v-for="item in pane.shapes"
:key="item.shape.id"
class="xlsx-drawing-layer__shape"
data-testid="xlsx-shape"
:data-shape-geometry="item.shape.geometry"
:style="item.style"
>
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
<ellipse v-if="/ellipse|oval/i.test(item.shape.geometry)" cx="50" cy="50" rx="48" ry="48" />
<path v-else-if="item.shape.svgPath" :d="item.shape.svgPath" :viewBox="item.shape.svgViewBox ? `0 0 ${item.shape.svgViewBox.width} ${item.shape.svgViewBox.height}` : undefined" />
<rect v-else x="1" y="1" width="98" height="98" rx="4" />
</svg>
<div class="xlsx-drawing-layer__text">{{ shapeText(item.shape) }}</div>
</div>
<div
v-for="item in pane.controls"
:key="item.control.id"
class="xlsx-drawing-layer__control"
data-testid="xlsx-form-control"
:data-control-kind="item.control.kind"
:style="item.style"
>
<span v-if="item.control.kind === 'checkbox'" class="xlsx-drawing-layer__box">{{ item.control.checked ? '✓' : '' }}</span>
<span v-else-if="item.control.kind === 'radio'" class="xlsx-drawing-layer__radio">{{ item.control.checked ? '●' : '' }}</span>
<button v-else-if="item.control.kind === 'button'" type="button" disabled>{{ item.control.label || item.control.name || '按钮' }}</button>
<span v-else>{{ item.control.label || item.control.name || controlLabel(item.control.kind) }}</span>
<span v-if="item.control.kind === 'checkbox' || item.control.kind === 'radio'">{{ item.control.label || item.control.name }}</span>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { computed, type CSSProperties } from "vue"
import type { XlsxFormControl, XlsxImageAnchor, XlsxShape, XlsxSheetData, XlsxViewerController } from "@arcships/xlsx-core"
import { anchorToAbsoluteRect, emuToPixels } from "@arcships/xlsx-core"
import type { XlsxFormControl, XlsxImageAnchor, XlsxShape, XlsxViewerController } from "@arcships/xlsx-core"
import { createGridPaneLayout, gridBodyLayerStyle, resolveAnchorRect, type GridPaneKey } from "./grid-metrics"

const props = defineProps<{ controller: XlsxViewerController; scrollLeft?: number; scrollTop?: number }>()

function frozenExtent(sheet: XlsxSheetData | null, axis: "column" | "row", zoomScale: number): number {
if (!sheet?.freezePanes) return 0
const limit = axis === "column" ? sheet.freezePanes.col : sheet.freezePanes.row
const indices = axis === "column" ? sheet.visibleCols : sheet.visibleRows
const sizes = axis === "column" ? sheet.colWidths : sheet.rowHeights
const fallback = axis === "column" ? sheet.defaultColWidthPx : sheet.defaultRowHeightPx
const zoom = Math.max(.1, zoomScale / 100)
return indices.reduce((sum, actual, display) => actual < limit ? sum + (sizes[display] || fallback) * zoom : sum, 0)
}
const layerStyle = gridBodyLayerStyle

const paneLayout = computed(() => createGridPaneLayout(
props.controller.activeSheet,
props.controller.zoomScale,
props.scrollLeft ?? 0,
props.scrollTop ?? 0,
))

function itemStyle(anchor: XlsxImageAnchor, zIndex: number, extra?: CSSProperties): CSSProperties {
const sheet = props.controller.activeSheet
const zoom = Math.max(.1, props.controller.zoomScale / 100)
const rect = anchorToAbsoluteRect(anchor, sheet)
const left = emuToPixels(rect.x) * zoom
const top = emuToPixels(rect.y) * zoom
const frozenWidth = frozenExtent(sheet, "column", props.controller.zoomScale)
const frozenHeight = frozenExtent(sheet, "row", props.controller.zoomScale)
function itemPlacement(anchor: XlsxImageAnchor, zIndex: number, extra?: CSSProperties): { pane: GridPaneKey; style: CSSProperties } {
const rect = resolveAnchorRect(anchor, props.controller.activeSheet, props.controller.zoomScale)
const placement = paneLayout.value.place(rect)
return {
left: `${left + 48 - (left < frozenWidth ? 0 : props.scrollLeft ?? 0)}px`,
top: `${top + 24 - (top < frozenHeight ? 0 : props.scrollTop ?? 0)}px`,
width: `${Math.max(1, emuToPixels(rect.cx) * zoom)}px`,
height: `${Math.max(1, emuToPixels(rect.cy) * zoom)}px`,
zIndex: Math.max(1, zIndex),
...extra,
pane: placement.pane,
style: { ...placement.style, zIndex: Math.max(1, zIndex), ...extra },
}
}

const shapeItems = computed(() => (props.controller.shapes as XlsxShape[])
.filter((shape) => !shape.hidden)
.map((shape) => ({
shape,
style: itemStyle(shape.anchor, shape.zIndex, {
...itemPlacement(shape.anchor, shape.zIndex, {
"--shape-fill": shape.fill?.none ? "transparent" : shape.fill?.color || "#dbeafe",
"--shape-stroke": shape.stroke?.none ? "transparent" : shape.stroke?.color || "#2563eb",
"--shape-stroke-width": `${shape.stroke?.widthPx || 1}px`,
Expand All @@ -86,14 +85,24 @@ const shapeItems = computed(() => (props.controller.shapes as XlsxShape[])

const controlItems = computed(() => (props.controller.formControls as XlsxFormControl[])
.filter((control) => !control.hidden)
.map((control) => ({ control, style: itemStyle(control.anchor, control.zIndex) })))
.map((control) => ({ control, ...itemPlacement(control.anchor, control.zIndex) })))

const panes = computed(() => paneLayout.value.panes.map((pane) => ({
...pane,
shapes: shapeItems.value.filter((item) => item.pane === pane.key),
controls: controlItems.value.filter((item) => item.pane === pane.key),
})))

function shapeText(shape: XlsxShape): string { return shape.paragraphs.map((p) => p.runs.map((r) => r.text).join("")).join("\n") }
function controlLabel(kind: XlsxFormControl["kind"]): string { return ({ dropdown: "下拉框", editbox: "输入框", listbox: "列表框", spinner: "微调框", scrollbar: "滚动条", label: "标签", "group-box": "分组框", unknown: "控件" } as Record<string, string>)[kind] || kind }
</script>

<style scoped>
.xlsx-drawing-layer { height: 100%; left: 0; pointer-events: none; position: absolute; top: 0; width: 100%; }
/* Geometry (inset past the headers) comes from `gridBodyLayerStyle`; each
freeze pane clips its own drawings so a scrolled-away shape never paints
over frozen panes, the headers, or the host UI around the surface. */
.xlsx-drawing-layer { pointer-events: none; position: absolute; }
.xlsx-drawing-layer__pane { overflow: hidden; pointer-events: none; position: absolute; }
.xlsx-drawing-layer__shape, .xlsx-drawing-layer__control { box-sizing: border-box; overflow: hidden; pointer-events: none; position: absolute; }
.xlsx-drawing-layer__shape svg { fill: var(--shape-fill); height: 100%; left: 0; position: absolute; stroke: var(--shape-stroke); stroke-width: var(--shape-stroke-width); top: 0; width: 100%; }
.xlsx-drawing-layer__text { align-items: center; display: flex; height: 100%; justify-content: center; padding: 6px; position: relative; text-align: center; white-space: pre-wrap; z-index: 1; }
Expand Down
5 changes: 3 additions & 2 deletions packages/vue-xlsx/src/components/XlsxGrid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import {
type XlsxResolvedCellStyle,
type XlsxSheetData,
} from "@arcships/xlsx-core";
import { GRID_HEADER_HEIGHT_PX, GRID_ROW_HEADER_WIDTH_PX } from "./grid-metrics";

const props = defineProps<{
controller: XlsxViewerController;
Expand All @@ -111,8 +112,8 @@ const emit = defineEmits<{
// ── Constants ──────────────────────────────────────────────────────────
const DEFAULT_ROW_HEIGHT = 24;
const DEFAULT_COL_WIDTH = 80;
const HEADER_HEIGHT = 24;
const ROW_HEADER_WIDTH = 48;
const HEADER_HEIGHT = GRID_HEADER_HEIGHT_PX;
const ROW_HEADER_WIDTH = GRID_ROW_HEADER_WIDTH_PX;
const OVERSCAN = 240;
const WORKER_ROW_BATCH_SIZE = 64;
const MIN_DISPLAY_ROWS = 200;
Expand Down
Loading
Loading