2026-03-30 12:01:08 +08:00
|
|
|
import { createMemo, For, Show } from 'solid-js';
|
|
|
|
|
import { parseMarkdown } from '../../markdown';
|
2026-02-27 21:12:23 +08:00
|
|
|
import { getLayerStyle } from './hooks/dimensions';
|
2026-03-30 12:01:08 +08:00
|
|
|
import type { CardData, CardSide, LayerConfig } from './types';
|
|
|
|
|
import { DeckStore } from "./hooks/deckStore";
|
|
|
|
|
import { processVariables } from "../utils/csv-loader";
|
|
|
|
|
import { resolvePath } from "../utils/path";
|
|
|
|
|
import type { LayerInteractionHandlers } from './hooks/useLayerInteraction';
|
2026-02-27 21:12:23 +08:00
|
|
|
|
|
|
|
|
export interface CardLayerProps {
|
|
|
|
|
cardData: CardData;
|
2026-02-28 13:27:15 +08:00
|
|
|
store: DeckStore;
|
2026-03-13 17:26:00 +08:00
|
|
|
side?: CardSide;
|
2026-03-30 12:01:08 +08:00
|
|
|
interaction?: LayerInteractionHandlers;
|
2026-02-27 21:12:23 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function CardLayer(props: CardLayerProps) {
|
2026-03-13 17:26:00 +08:00
|
|
|
const side = () => props.side || 'front';
|
|
|
|
|
const layers = createMemo(() =>
|
|
|
|
|
side() === 'front'
|
|
|
|
|
? props.store.state.frontLayerConfigs.filter((l) => l.visible)
|
|
|
|
|
: props.store.state.backLayerConfigs.filter((l) => l.visible)
|
|
|
|
|
);
|
2026-02-28 13:27:15 +08:00
|
|
|
const dimensions = () => props.store.state.dimensions!;
|
|
|
|
|
const showBounds = () => props.store.state.isEditing;
|
2026-03-30 12:01:08 +08:00
|
|
|
const selectedLayer = () => props.store.state.selectedLayer;
|
|
|
|
|
const draggingState = () => props.store.state.draggingState;
|
2026-03-13 17:26:00 +08:00
|
|
|
|
2026-02-28 13:27:15 +08:00
|
|
|
function renderLayerContent(content: string) {
|
2026-03-25 17:29:56 +08:00
|
|
|
const iconPath = resolvePath(props.store.state.cards.sourcePath, props.cardData.iconPath ?? "./assets");
|
2026-03-13 11:46:18 +08:00
|
|
|
return parseMarkdown(processVariables(content, props.cardData, props.store.state.cards), iconPath) as string;
|
2026-02-28 13:27:15 +08:00
|
|
|
}
|
2026-03-27 15:17:25 +08:00
|
|
|
|
|
|
|
|
const getAlignStyle = (align?: 'l' | 'c' | 'r') => {
|
|
|
|
|
if (align === 'l') return 'left';
|
|
|
|
|
if (align === 'r') return 'right';
|
|
|
|
|
return 'center';
|
|
|
|
|
};
|
2026-03-30 12:01:08 +08:00
|
|
|
|
|
|
|
|
const isLayerSelected = (layer: LayerConfig) => selectedLayer() === layer.prop;
|
|
|
|
|
|
|
|
|
|
const getFrameBounds = (layer: LayerConfig) => {
|
|
|
|
|
const dims = dimensions();
|
|
|
|
|
const left = (layer.x1 - 1) * dims.cellWidth;
|
|
|
|
|
const top = (layer.y1 - 1) * dims.cellHeight;
|
|
|
|
|
const width = (layer.x2 - layer.x1 + 1) * dims.cellWidth;
|
|
|
|
|
const height = (layer.y2 - layer.y1 + 1) * dims.cellHeight;
|
|
|
|
|
return { left, top, width, height };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleLayerClick = (layerProp: string, e: MouseEvent) => {
|
|
|
|
|
if (props.interaction) {
|
|
|
|
|
props.interaction.onLayerClick(layerProp, e);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleFrameMouseDown = (
|
|
|
|
|
action: 'drag' | 'resize-corner' | 'resize-edge',
|
|
|
|
|
anchor?: 'nw' | 'ne' | 'sw' | 'se',
|
|
|
|
|
edge?: 'n' | 's' | 'e' | 'w',
|
|
|
|
|
e?: MouseEvent
|
|
|
|
|
) => {
|
|
|
|
|
if (props.interaction) {
|
|
|
|
|
props.interaction.onFrameMouseDown(action, anchor, edge, e);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-27 21:12:23 +08:00
|
|
|
return (
|
2026-02-28 13:27:15 +08:00
|
|
|
<For each={layers()}>
|
2026-02-27 22:56:06 +08:00
|
|
|
{(layer) => {
|
2026-03-30 12:01:08 +08:00
|
|
|
const bounds = () => getFrameBounds(layer);
|
|
|
|
|
const isSelected = () => isLayerSelected(layer);
|
|
|
|
|
|
2026-02-27 22:56:06 +08:00
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<article
|
2026-03-30 12:01:08 +08:00
|
|
|
class="absolute flex flex-col items-stretch justify-center prose prose-sm cursor-pointer"
|
|
|
|
|
classList={{
|
|
|
|
|
'ring-2 ring-blue-500 ring-offset-1': isSelected() && !draggingState()
|
|
|
|
|
}}
|
2026-02-27 22:37:11 +08:00
|
|
|
style={{
|
2026-02-28 13:27:15 +08:00
|
|
|
...getLayerStyle(layer, dimensions()),
|
2026-03-27 15:17:25 +08:00
|
|
|
'font-size': `${layer.fontSize || 3}mm`,
|
|
|
|
|
'text-align': getAlignStyle(layer.align)
|
2026-02-27 22:37:11 +08:00
|
|
|
}}
|
2026-02-28 13:27:15 +08:00
|
|
|
innerHTML={renderLayerContent(props.cardData[layer.prop])}
|
2026-03-30 12:01:08 +08:00
|
|
|
onClick={(e) => handleLayerClick(layer.prop, e)}
|
2026-02-27 22:37:11 +08:00
|
|
|
/>
|
2026-03-30 12:01:08 +08:00
|
|
|
<Show when={showBounds() && !isSelected()}>
|
2026-02-27 22:56:06 +08:00
|
|
|
<div
|
|
|
|
|
class="absolute border-2 border-blue-500/50 pointer-events-none select-none"
|
|
|
|
|
style={{
|
2026-02-28 13:27:15 +08:00
|
|
|
left: `${(layer.x1 - 1) * dimensions().cellWidth}mm`,
|
|
|
|
|
top: `${(layer.y1 - 1) * dimensions().cellHeight}mm`,
|
|
|
|
|
width: `${(layer.x2 - layer.x1 + 1) * dimensions().cellWidth}mm`,
|
|
|
|
|
height: `${(layer.y2 - layer.y1 + 1) * dimensions().cellHeight}mm`
|
2026-02-27 22:56:06 +08:00
|
|
|
}}
|
|
|
|
|
/>
|
2026-03-30 12:01:08 +08:00
|
|
|
</Show>
|
|
|
|
|
<Show when={isSelected()}>
|
|
|
|
|
<div
|
|
|
|
|
class="absolute border-2 border-blue-500 pointer-events-none"
|
|
|
|
|
style={{
|
|
|
|
|
left: `${bounds().left}mm`,
|
|
|
|
|
top: `${bounds().top}mm`,
|
|
|
|
|
width: `${bounds().width}mm`,
|
|
|
|
|
height: `${bounds().height}mm`
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
class="absolute pointer-events-auto"
|
|
|
|
|
style={{
|
|
|
|
|
left: `${bounds().left}mm`,
|
|
|
|
|
top: `${bounds().top}mm`,
|
|
|
|
|
width: `${bounds().width}mm`,
|
|
|
|
|
height: `${bounds().height}mm`
|
|
|
|
|
}}
|
|
|
|
|
onMouseDown={(e) => handleFrameMouseDown('drag', undefined, undefined, e)}
|
|
|
|
|
/>
|
|
|
|
|
<Show when={!draggingState() || draggingState()?.action !== 'drag'}>
|
|
|
|
|
<div
|
|
|
|
|
class="absolute w-4 h-4 bg-white border-2 border-blue-500 rounded-sm cursor-nwse-resize pointer-events-auto z-10"
|
|
|
|
|
style={{
|
|
|
|
|
left: `${bounds().left - 2}mm`,
|
|
|
|
|
top: `${bounds().top - 2}mm`
|
|
|
|
|
}}
|
|
|
|
|
onMouseDown={(e) => handleFrameMouseDown('resize-corner', 'nw', undefined, e)}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
class="absolute w-4 h-4 bg-white border-2 border-blue-500 rounded-sm cursor-nesw-resize pointer-events-auto z-10"
|
|
|
|
|
style={{
|
|
|
|
|
left: `${bounds().left + bounds().width - 2}mm`,
|
|
|
|
|
top: `${bounds().top - 2}mm`
|
|
|
|
|
}}
|
|
|
|
|
onMouseDown={(e) => handleFrameMouseDown('resize-corner', 'ne', undefined, e)}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
class="absolute w-4 h-4 bg-white border-2 border-blue-500 rounded-sm cursor-nesw-resize pointer-events-auto z-10"
|
|
|
|
|
style={{
|
|
|
|
|
left: `${bounds().left - 2}mm`,
|
|
|
|
|
top: `${bounds().top + bounds().height - 2}mm`
|
|
|
|
|
}}
|
|
|
|
|
onMouseDown={(e) => handleFrameMouseDown('resize-corner', 'sw', undefined, e)}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
class="absolute w-4 h-4 bg-white border-2 border-blue-500 rounded-sm cursor-nwse-resize pointer-events-auto z-10"
|
|
|
|
|
style={{
|
|
|
|
|
left: `${bounds().left + bounds().width - 2}mm`,
|
|
|
|
|
top: `${bounds().top + bounds().height - 2}mm`
|
|
|
|
|
}}
|
|
|
|
|
onMouseDown={(e) => handleFrameMouseDown('resize-corner', 'se', undefined, e)}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
class="absolute h-2 cursor-ns-resize pointer-events-auto"
|
|
|
|
|
style={{
|
|
|
|
|
left: `${bounds().left}mm`,
|
|
|
|
|
top: `${bounds().top - 1}mm`,
|
|
|
|
|
width: `${bounds().width}mm`
|
|
|
|
|
}}
|
|
|
|
|
onMouseDown={(e) => handleFrameMouseDown('resize-edge', undefined, 'n', e)}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
class="absolute h-2 cursor-ns-resize pointer-events-auto"
|
|
|
|
|
style={{
|
|
|
|
|
left: `${bounds().left}mm`,
|
|
|
|
|
top: `${bounds().top + bounds().height - 1}mm`,
|
|
|
|
|
width: `${bounds().width}mm`
|
|
|
|
|
}}
|
|
|
|
|
onMouseDown={(e) => handleFrameMouseDown('resize-edge', undefined, 's', e)}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
class="absolute w-2 cursor-ew-resize pointer-events-auto"
|
|
|
|
|
style={{
|
|
|
|
|
left: `${bounds().left - 1}mm`,
|
|
|
|
|
top: `${bounds().top}mm`,
|
|
|
|
|
height: `${bounds().height}mm`
|
|
|
|
|
}}
|
|
|
|
|
onMouseDown={(e) => handleFrameMouseDown('resize-edge', undefined, 'w', e)}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
class="absolute w-2 cursor-ew-resize pointer-events-auto"
|
|
|
|
|
style={{
|
|
|
|
|
left: `${bounds().left + bounds().width - 1}mm`,
|
|
|
|
|
top: `${bounds().top}mm`,
|
|
|
|
|
height: `${bounds().height}mm`
|
|
|
|
|
}}
|
|
|
|
|
onMouseDown={(e) => handleFrameMouseDown('resize-edge', undefined, 'e', e)}
|
|
|
|
|
/>
|
|
|
|
|
</Show>
|
|
|
|
|
</Show>
|
2026-02-27 22:56:06 +08:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}}
|
2026-02-27 21:12:23 +08:00
|
|
|
</For>
|
|
|
|
|
);
|
2026-03-30 12:01:08 +08:00
|
|
|
}
|