新增组件编辑相关功能:
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Failing after 50s
Project CI / Native shell tests (pull_request) Failing after 4m53s

- 实现 `ComponentPanel`,支持组件的新增、删除、移动和更新操作。
- 添加组件字段编辑表单 (`ComponentField`) 及组件类型编辑接口。
- 引入支持图片和文本组件的渲染逻辑 (`ImagePanel`, `TextPanel`)。
- 添加组件到 CSS 转换的工具函数 (`componentToCss.ts`),支持图片和文本样式生成。
- 优化 Inspector,支持多层级组件的编辑和操作。
This commit is contained in:
2026-08-14 20:27:11 +08:00
parent 0dddec7b50
commit de966ead19
18 changed files with 2117 additions and 8 deletions
+19 -8
View File
@@ -21,6 +21,7 @@
"lucide-react": "^0.546.0",
"react": "^19.0.0",
"react-arborist": "^3.16.0",
"react-colorful": "^5.8.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
@@ -2273,19 +2274,19 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-http": {
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz",
"integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==",
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
"node_modules/@tauri-apps/plugin-http": {
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz",
"integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
@@ -4358,6 +4359,16 @@
"react-dom": "^16.9.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-colorful": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.0.tgz",
"integrity": "sha512-Wy9OzPfjSN9bF12OB8N7UQvlsZ0I+7wHxpN+bV5BjNQGxOj6IiwkRjevJK9yOBjJWGQvAaf1OXtn8rUeEatAng==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-dnd": {
"version": "14.0.5",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz",
+1
View File
@@ -47,6 +47,7 @@
"lucide-react": "^0.546.0",
"react": "^19.0.0",
"react-arborist": "^3.16.0",
"react-colorful": "^5.8.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
@@ -0,0 +1,279 @@
import type { CSSProperties } from 'react';
import type { FillMethod } from '../types/FillMethod';
import type { FontSizing } from '../types/FontSizing';
import type { FontStyle } from '../types/FontStyle';
import type { HorizontalTextOverflow } from '../types/HorizontalTextOverflow';
import type { ImageComponent } from '../types/ImageComponent';
import type { ImageType } from '../types/ImageType';
import type { SpriteAsset } from '../types/SpriteAsset';
import type { TextAlignment } from '../types/TextAlignment';
import type { TextComponent } from '../types/TextComponent';
import type { VerticalTextOverflow } from '../types/VerticalTextOverflow';
export const SYSTEM_UI_FONT_STACK =
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
export type ImageRenderModel = {
kind: 'image' | 'background' | 'filled';
src: string;
style: CSSProperties;
imageStyle?: CSSProperties;
diagnostic?: string;
};
function finite(value: number, field: string): number {
if (!Number.isFinite(value)) {
throw new RangeError(`${field} must be finite`);
}
return value;
}
function positive(value: number, field: string): number {
finite(value, field);
if (value <= 0) throw new RangeError(`${field} must be positive`);
return value;
}
function clamp01(value: number): number {
return Math.min(1, Math.max(0, finite(value, 'amount')));
}
function logicalSize(
sprite: SpriteAsset,
multiplier = 1,
): { width: number; height: number } {
const pixelsPerUnit = positive(sprite.pixels_per_unit, 'pixels_per_unit');
const scale = positive(multiplier, 'pixels_per_unit_multiplier');
return {
width:
positive(sprite.pixel_size[0], 'pixel_size[0]') / (pixelsPerUnit * scale),
height:
positive(sprite.pixel_size[1], 'pixel_size[1]') / (pixelsPerUnit * scale),
};
}
function fillClipPath(method: FillMethod, amount: number): string | undefined {
if (amount >= 1) return undefined;
if (amount <= 0) return 'inset(0 0 0 100%)';
if ('Horizontal' in method) {
return method.Horizontal === 'Left'
? `inset(0 ${(1 - amount) * 100}% 0 0)`
: `inset(0 0 0 ${(1 - amount) * 100}%)`;
}
if ('Vertical' in method) {
return method.Vertical === 'Top'
? `inset(0 0 ${(1 - amount) * 100}% 0)`
: `inset(${(1 - amount) * 100}% 0 0 0)`;
}
const [kind, config] =
'Radial90' in method
? (['Radial90', method.Radial90] as const)
: 'Radial180' in method
? (['Radial180', method.Radial180] as const)
: (['Radial360', method.Radial360] as const);
const maxSweep = kind === 'Radial90' ? 90 : kind === 'Radial180' ? 180 : 360;
const sweep = amount * maxSweep;
const origin = config.origin;
const originAngle =
origin === 'Top' || origin === 'TopLeft' || origin === 'TopRight'
? 0
: origin === 'Right' || origin === 'BottomRight'
? 90
: origin === 'Bottom' || origin === 'BottomLeft'
? 180
: 270;
const start = config.clockwise ? originAngle : originAngle - sweep;
// CSS conic gradients provide a deterministic browser preview for radial
// fills. Exact engine parity is intentionally deferred.
return `conic-gradient(from ${start}deg, #000 0deg ${sweep}deg, transparent ${sweep}deg 360deg)`;
}
function imageFitStyle(preserveAspect: boolean): CSSProperties {
return {
width: '100%',
height: '100%',
objectFit: preserveAspect ? 'contain' : 'fill',
};
}
function imageTypeModel(
imageType: ImageType,
sprite: SpriteAsset,
src: string,
): ImageRenderModel {
if ('Simple' in imageType) {
return {
kind: 'image',
src,
style: { overflow: 'hidden' },
imageStyle: imageFitStyle(imageType.Simple.preserve_aspect),
};
}
if ('Filled' in imageType) {
const amount = clamp01(imageType.Filled.amount);
const clip = fillClipPath(imageType.Filled.method, amount);
const imageStyle = imageFitStyle(imageType.Filled.preserve_aspect);
if (clip?.startsWith('conic-gradient')) {
imageStyle.maskImage = clip;
imageStyle.WebkitMaskImage = clip;
imageStyle.clipPath = undefined;
} else {
imageStyle.clipPath = clip;
}
return {
kind: 'filled',
src,
style: { overflow: 'hidden' },
imageStyle,
};
}
const multiplier =
'Sliced' in imageType
? imageType.Sliced.pixels_per_unit_multiplier
: imageType.Tiled.pixels_per_unit_multiplier;
const size = logicalSize(sprite, multiplier);
if ('Tiled' in imageType) {
return {
kind: 'background',
src,
style: {
backgroundImage: `url("${src}")`,
backgroundRepeat: 'repeat',
backgroundPosition: 'top left',
backgroundSize: `${size.width}px ${size.height}px`,
overflow: 'hidden',
},
diagnostic: imageType.Tiled.fill_center
? undefined
: 'Tiled fill_center=false uses the browser repeat fallback.',
};
}
const border = sprite.border;
const ppu = positive(sprite.pixels_per_unit, 'pixels_per_unit');
const logicalBorderScale =
ppu *
positive(
imageType.Sliced.pixels_per_unit_multiplier,
'pixels_per_unit_multiplier',
);
const borderWidths = {
borderTopWidth: `${border.top / logicalBorderScale}px`,
borderRightWidth: `${border.right / logicalBorderScale}px`,
borderBottomWidth: `${border.bottom / logicalBorderScale}px`,
borderLeftWidth: `${border.left / logicalBorderScale}px`,
};
return {
kind: 'background',
src,
style: {
...borderWidths,
borderStyle: 'solid',
borderImageSource: `url("${src}")`,
borderImageSlice: `${border.top} ${border.right} ${border.bottom} ${border.left}${imageType.Sliced.fill_center ? ' fill' : ''}`,
borderImageWidth: `${border.top / logicalBorderScale}px ${border.right / logicalBorderScale}px ${border.bottom / logicalBorderScale}px ${border.left / logicalBorderScale}px`,
borderImageRepeat: 'stretch',
overflow: 'hidden',
},
diagnostic:
border.left + border.right >= sprite.pixel_size[0] ||
border.top + border.bottom >= sprite.pixel_size[1]
? 'Sprite border leaves no center pixel; using best-effort CSS slicing.'
: undefined,
};
}
export function imageComponentToRenderModel(
component: ImageComponent,
sprite: SpriteAsset | null,
src: string | null,
): ImageRenderModel | null {
if (!src) return null;
if (!sprite) return null;
try {
return imageTypeModel(component.image_type, sprite, src);
} catch {
return {
kind: 'image',
src,
style: { overflow: 'hidden' },
imageStyle: imageFitStyle(true),
diagnostic: '图片组件参数无效,已回退为等比预览。',
};
}
}
function fontSize(sizing: FontSizing): number {
if ('Fixed' in sizing) return Math.max(1, finite(sizing.Fixed, 'font size'));
// TODO: implement measured BestFit using node dimensions and text metrics.
return Math.max(1, finite(sizing.BestFit.max, 'BestFit.max'));
}
function fontStyle(
style: FontStyle,
): Pick<CSSProperties, 'fontStyle' | 'fontWeight'> {
switch (style) {
case 'Bold':
return { fontStyle: 'normal', fontWeight: 700 };
case 'Italic':
return { fontStyle: 'italic', fontWeight: 400 };
case 'BoldItalic':
return { fontStyle: 'italic', fontWeight: 700 };
case 'Normal':
return { fontStyle: 'normal', fontWeight: 400 };
}
}
function alignment(
alignmentValue: TextAlignment,
): Pick<CSSProperties, 'alignItems' | 'justifyContent' | 'textAlign'> {
const vertical = alignmentValue.startsWith('Upper')
? 'flex-start'
: alignmentValue.startsWith('Middle')
? 'center'
: 'flex-end';
const horizontal = alignmentValue.endsWith('Left')
? 'flex-start'
: alignmentValue.endsWith('Center')
? 'center'
: 'flex-end';
return {
alignItems: vertical,
justifyContent: horizontal,
textAlign:
horizontal === 'flex-start'
? 'left'
: horizontal === 'center'
? 'center'
: 'right',
};
}
export function textComponentToCss(component: TextComponent): CSSProperties {
const [red, green, blue, alpha] = component.color;
const horizontalOverflow: HorizontalTextOverflow =
component.horizontal_overflow;
const verticalOverflow: VerticalTextOverflow = component.vertical_overflow;
return {
display: 'flex',
width: '100%',
height: '100%',
boxSizing: 'border-box',
padding: 0,
color: `rgba(${red}, ${green}, ${blue}, ${Math.min(255, Math.max(0, alpha)) / 255})`,
fontFamily: SYSTEM_UI_FONT_STACK,
fontSize: `${fontSize(component.font_sizing)}px`,
...fontStyle(component.font_style),
...alignment(component.alignment),
lineHeight: finite(component.line_spacing, 'line_spacing'),
whiteSpace: horizontalOverflow === 'Wrap' ? 'normal' : 'nowrap',
overflow: verticalOverflow === 'Truncate' ? 'hidden' : 'visible',
overflowWrap: horizontalOverflow === 'Wrap' ? 'anywhere' : 'normal',
};
}
@@ -0,0 +1,95 @@
import type {
InputHTMLAttributes,
ReactNode,
SelectHTMLAttributes,
TextareaHTMLAttributes,
} from 'react';
const CONTROL_BASE =
'mt-1 w-full rounded-lg border border-(--platform-subpanel-border) bg-white/65 text-xs text-(--platform-text-strong)';
const INPUT_CLASS = `${CONTROL_BASE} h-9 px-2`;
const TEXTAREA_CLASS = `${CONTROL_BASE} min-h-20 resize-y px-2 py-2 leading-5`;
export function ComponentField({
label,
error,
children,
}: {
label: string;
error?: string;
children: ReactNode;
}) {
return (
<label className="block text-[11px] font-semibold text-(--platform-text-soft)">
{label}
{children}
{error ? (
<span className="mt-1 block text-[10px] font-normal text-red-600">
{error}
</span>
) : null}
</label>
);
}
export function ComponentInput({
label,
error,
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> & {
label: string;
error?: string;
}) {
return (
<ComponentField label={label} error={error}>
<input {...props} className={INPUT_CLASS} />
</ComponentField>
);
}
export function ComponentNumberInput({
label,
error,
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'className' | 'type'> & {
label: string;
error?: string;
}) {
return (
<ComponentInput label={label} error={error} type="number" {...props} />
);
}
export function ComponentTextarea({
label,
error,
...props
}: Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'className'> & {
label: string;
error?: string;
}) {
return (
<ComponentField label={label} error={error}>
<textarea {...props} className={TEXTAREA_CLASS} />
</ComponentField>
);
}
export function ComponentSelect({
label,
error,
children,
...props
}: Omit<SelectHTMLAttributes<HTMLSelectElement>, 'className'> & {
label: string;
error?: string;
children: ReactNode;
}) {
return (
<ComponentField label={label} error={error}>
<select {...props} className={INPUT_CLASS}>
{children}
</select>
</ComponentField>
);
}
@@ -0,0 +1,309 @@
import {
ArrowDown,
ArrowUp,
ChevronDown,
ChevronRight,
Plus,
Trash2,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import type { Component } from '../../../../../features/ui-editor/types/Component';
import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState';
import type { ComponentPanelProps } from './componentEditorTypes';
import { ImagePanel } from './ImagePanel';
import { createDefaultImageComponent } from './ImagePanelDefaults';
import { TextPanel } from './TextPanel';
import { createDefaultTextComponent } from './TextPanelDefaults';
export function ComponentPanel(props: ComponentPanelProps) {
const {
components,
readOnly,
onSetComponents,
onInsertComponent,
onDeleteComponent,
onMoveComponent,
} = props;
const [addKind, setAddKind] = useState<'Image' | 'Text'>('Image');
const [expandedIndexes, setExpandedIndexes] = useState<Set<number>>(
() => new Set(),
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setExpandedIndexes((current) => {
const next = new Set(
[...current].filter((index) => index >= 0 && index < components.length),
);
if (next.size === current.size) return current;
return next;
});
}, [components.length]);
const updateComponent = (index: number, next: Component) => {
const nextComponents = components.slice();
nextComponents[index] = next;
const result = onSetComponents(nextComponents);
if (result && !result.ok) setError('组件字段无效,更新未应用。');
else setError(null);
return result;
};
function addComponent() {
let component: Component;
switch (addKind) {
case 'Image':
component = { Image: createDefaultImageComponent() };
break;
case 'Text':
component = { Text: createDefaultTextComponent() };
break;
}
const result = onInsertComponent(components.length, component);
if (result?.ok) {
setExpandedIndexes((current) => new Set(current).add(components.length));
setError(null);
} else if (result) {
setError('组件新增失败。');
}
}
function deleteComponentAt(index: number) {
const result = onDeleteComponent(index);
if (result?.ok) {
setExpandedIndexes((current) => {
const next = new Set<number>();
for (const expanded of current) {
if (expanded === index) continue;
if (expanded > index) next.add(expanded - 1);
else next.add(expanded);
}
return next;
});
setError(null);
} else if (result) {
setError('组件删除失败。');
}
}
function moveComponent(index: number, direction: 'up' | 'down') {
// Components are rendered in array order. The last item therefore sits
// visually at the top of the stack.
let nextIndex: number;
switch (direction) {
case 'up':
nextIndex = index + 1;
break;
case 'down':
nextIndex = index - 1;
break;
}
if (nextIndex < 0 || nextIndex >= components.length) return;
const result = onMoveComponent(index, nextIndex);
if (result?.ok) {
setExpandedIndexes((current) => {
const next = new Set(current);
const wasCurrentExpanded = next.has(index);
const wasTargetExpanded = next.has(nextIndex);
next.delete(index);
next.delete(nextIndex);
if (wasCurrentExpanded) next.add(nextIndex);
if (wasTargetExpanded) next.add(index);
return next;
});
setError(null);
} else if (result) {
setError('组件顺序更新失败。');
}
}
return (
<section className="space-y-3 rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3">
<div className="flex items-center justify-between gap-2">
<h3 className="m-0 text-xs font-semibold"></h3>
<span className="text-[10px] text-(--platform-text-soft)">
{components.length}
</span>
</div>
<div className="flex gap-2 rounded-xl border border-dashed border-(--platform-subpanel-border) bg-white/30 p-2">
<select
className="h-8 min-w-0 flex-1 rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 text-xs"
value={addKind}
disabled={readOnly}
onChange={(event) =>
setAddKind(event.target.value as 'Image' | 'Text')
}
aria-label="新增组件类型"
>
<option value="Image"></option>
<option value="Text"></option>
</select>
<button
type="button"
className="flex h-8 items-center gap-1 rounded-lg bg-blue-600 px-3 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
disabled={readOnly}
onClick={addComponent}
aria-label="新增组件"
title="新增组件"
>
{componentKindLabel(addKind)}
<Plus size={14} />
</button>
</div>
{components.length > 0 && (
<div className="space-y-2">
{[...components].reverse().map((component, reverseIndex) => {
const index = components.length - reverseIndex - 1;
const expanded = expandedIndexes.has(index);
return (
<div
key={`${index}-${componentKind(component)}`}
className="overflow-hidden rounded-xl border border-(--platform-subpanel-border) bg-white/35"
>
<div className="flex items-center gap-1 p-1">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-2 text-left text-xs font-semibold hover:bg-white/50"
onClick={() =>
setExpandedIndexes((current) => {
const next = new Set(current);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
})
}
aria-expanded={expanded}
>
<ExpandIcon expanded={expanded} />
<span>{componentKind(component)}</span>
<span className="ml-auto text-[10px] font-normal text-(--platform-text-soft)">
{componentLayerLabel(index, components.length)}
</span>
</button>
<button
type="button"
className="grid size-7 place-items-center rounded-lg border border-(--platform-subpanel-border) disabled:cursor-not-allowed disabled:opacity-35"
disabled={readOnly || index === components.length - 1}
onClick={() => moveComponent(index, 'up')}
aria-label="组件上移"
title="上移(渲染到更上层)"
>
<ArrowUp size={13} />
</button>
<button
type="button"
className="grid size-7 place-items-center rounded-lg border border-(--platform-subpanel-border) disabled:cursor-not-allowed disabled:opacity-35"
disabled={readOnly || index === 0}
onClick={() => moveComponent(index, 'down')}
aria-label="组件下移"
title="下移(渲染到更下层)"
>
<ArrowDown size={13} />
</button>
<button
type="button"
className="grid size-7 place-items-center rounded-lg border border-red-200 bg-red-50 text-red-700 disabled:cursor-not-allowed disabled:opacity-35"
disabled={readOnly}
onClick={() => deleteComponentAt(index)}
aria-label="删除组件"
title="删除组件"
>
<Trash2 size={13} />
</button>
</div>
{expanded && (
<div className="border-t border-(--platform-subpanel-border) p-3">
{renderComponentEditor(
component,
index,
props,
readOnly,
updateComponent,
)}
</div>
)}
</div>
);
})}
</div>
)}
{components.length === 0 && (
<p className="m-0 text-xs text-(--platform-text-soft)">
</p>
)}
{error && <p className="m-0 text-[10px] text-red-600">{error}</p>}
</section>
);
}
function componentKind(component: Component): string {
switch (true) {
case 'Image' in component:
return '图片';
case 'Text' in component:
return '文本';
default:
return '未知';
}
}
function componentKindLabel(kind: 'Image' | 'Text'): string {
switch (kind) {
case 'Image':
return '图片';
case 'Text':
return '文本';
}
}
function componentLayerLabel(index: number, count: number): string {
if (index === count - 1) return '顶部';
return `层级 ${index + 1}`;
}
function ExpandIcon({ expanded }: { expanded: boolean }) {
if (expanded) return <ChevronDown size={14} />;
return <ChevronRight size={14} />;
}
function renderComponentEditor(
component: Component,
index: number,
props: ComponentPanelProps,
readOnly: boolean,
updateComponent: (
index: number,
next: Component,
) => UiEditorOperationResult | undefined,
) {
switch (true) {
case 'Image' in component:
return (
<ImagePanel
component={component.Image}
sprites={props.sprites}
readOnly={readOnly}
onChange={(next) => updateComponent(index, { Image: next })}
/>
);
case 'Text' in component:
return (
<TextPanel
component={component.Text}
fonts={props.fonts}
readOnly={readOnly}
onChange={(next) => updateComponent(index, { Text: next })}
/>
);
default:
return (
<p className="m-0 text-xs text-(--platform-text-soft)">
</p>
);
}
}
@@ -0,0 +1,442 @@
import type { FillMethod } from '../../../../../features/ui-editor/types/FillMethod';
import type { ImageComponent } from '../../../../../features/ui-editor/types/ImageComponent';
import type { ImageType } from '../../../../../features/ui-editor/types/ImageType';
import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState';
import type { ImageEditorProps } from './componentEditorTypes';
import { ComponentNumberInput, ComponentSelect } from './ComponentField';
export function ImagePanel({
component,
sprites,
readOnly,
onChange,
}: ImageEditorProps) {
const update = (next: ImageComponent): UiEditorOperationResult | undefined =>
onChange(next);
const imageType = component.image_type;
return (
<div className="space-y-3">
<ComponentSelect
label="目标素材"
value={component.target_graphic ?? ''}
disabled={readOnly}
onChange={(event) =>
update({
...component,
target_graphic: event.target.value || null,
})
}
>
<option value=""></option>
{Object.entries(sprites).map(([id, sprite]) => (
<option key={id} value={id}>
{sprite.metadata.name || id}
</option>
))}
</ComponentSelect>
<ComponentSelect
label="图片类型"
value={imageTypeVariant(imageType)}
disabled={readOnly}
onChange={(event) =>
update({
...component,
image_type: defaultImageType(event.target.value),
})
}
>
<option value="Simple"></option>
<option value="Sliced"></option>
<option value="Tiled"></option>
<option value="Filled"></option>
</ComponentSelect>
{renderImageTypeFields(imageType, component, readOnly, update)}
</div>
);
}
function renderImageTypeFields(
imageType: ImageType,
component: ImageComponent,
readOnly: boolean,
update: (next: ImageComponent) => UiEditorOperationResult | undefined,
) {
switch (imageTypeVariant(imageType)) {
case 'Simple':
if (!('Simple' in imageType)) return null;
return (
<BooleanField
label="保持宽高比"
checked={imageType.Simple.preserve_aspect}
disabled={readOnly}
onChange={(preserve_aspect) =>
update({
...component,
image_type: { Simple: { preserve_aspect } },
})
}
/>
);
case 'Sliced':
if (!('Sliced' in imageType)) return null;
return (
<>
<BooleanField
label="填充中心"
checked={imageType.Sliced.fill_center}
disabled={readOnly}
onChange={(fill_center) =>
update({
...component,
image_type: { Sliced: { ...imageType.Sliced, fill_center } },
})
}
/>
<ComponentNumberInput
label="单位倍率"
min={0.01}
step={0.01}
value={imageType.Sliced.pixels_per_unit_multiplier}
disabled={readOnly}
onChange={(event) =>
update({
...component,
image_type: {
Sliced: {
...imageType.Sliced,
pixels_per_unit_multiplier: Number(event.target.value),
},
},
})
}
/>
</>
);
case 'Tiled':
if (!('Tiled' in imageType)) return null;
return (
<>
<BooleanField
label="填充中心"
checked={imageType.Tiled.fill_center}
disabled={readOnly}
onChange={(fill_center) =>
update({
...component,
image_type: { Tiled: { ...imageType.Tiled, fill_center } },
})
}
/>
<ComponentNumberInput
label="单位倍率"
min={0.01}
step={0.01}
value={imageType.Tiled.pixels_per_unit_multiplier}
disabled={readOnly}
onChange={(event) =>
update({
...component,
image_type: {
Tiled: {
...imageType.Tiled,
pixels_per_unit_multiplier: Number(event.target.value),
},
},
})
}
/>
</>
);
case 'Filled':
if (!('Filled' in imageType)) return null;
return (
<>
<BooleanField
label="保持宽高比"
checked={imageType.Filled.preserve_aspect}
disabled={readOnly}
onChange={(preserve_aspect) =>
update({
...component,
image_type: {
Filled: { ...imageType.Filled, preserve_aspect },
},
})
}
/>
<ComponentNumberInput
label="填充量"
min={0}
max={1}
step={0.01}
value={imageType.Filled.amount}
disabled={readOnly}
onChange={(event) =>
update({
...component,
image_type: {
Filled: {
...imageType.Filled,
amount: Number(event.target.value),
},
},
})
}
/>
<FillMethodPanel
method={imageType.Filled.method}
readOnly={readOnly}
onChange={(method) =>
update({
...component,
image_type: { Filled: { ...imageType.Filled, method } },
})
}
/>
</>
);
}
}
function imageTypeVariant(imageType: ImageType): string {
if ('Simple' in imageType) return 'Simple';
if ('Sliced' in imageType) return 'Sliced';
if ('Tiled' in imageType) return 'Tiled';
return 'Filled';
}
function defaultImageType(kind: string): ImageType {
switch (kind) {
case 'Sliced':
return { Sliced: { fill_center: true, pixels_per_unit_multiplier: 1 } };
case 'Tiled':
return { Tiled: { fill_center: true, pixels_per_unit_multiplier: 1 } };
case 'Filled':
return {
Filled: {
preserve_aspect: false,
amount: 1,
method: { Horizontal: 'Left' },
},
};
default:
return { Simple: { preserve_aspect: false } };
}
}
function BooleanField({
label,
checked,
disabled,
onChange,
}: {
label: string;
checked: boolean;
disabled: boolean;
onChange: (value: boolean) => void;
}) {
return (
<div className="block text-[11px] font-semibold text-(--platform-text-soft)">
{label}
<label className="mt-1 flex h-9 items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 text-xs font-normal">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
/>
</label>
</div>
);
}
function FillMethodPanel({
method,
readOnly,
onChange,
}: {
method: FillMethod;
readOnly: boolean;
onChange: (method: FillMethod) => void;
}) {
const variant = fillMethodVariant(method);
return (
<div className="space-y-3 rounded-lg border border-(--platform-subpanel-border) bg-black/3 p-2">
<ComponentSelect
label="填充方式"
value={variant}
disabled={readOnly}
onChange={(event) => onChange(defaultFillMethod(event.target.value))}
>
<option value="Horizontal"></option>
<option value="Vertical"></option>
<option value="Radial90"> 90°</option>
<option value="Radial180"> 180°</option>
<option value="Radial360"> 360°</option>
</ComponentSelect>
{renderFillMethodFields(method, readOnly, onChange)}
</div>
);
}
function renderFillMethodFields(
method: FillMethod,
readOnly: boolean,
onChange: (method: FillMethod) => void,
) {
switch (fillMethodVariant(method)) {
case 'Horizontal':
if (!('Horizontal' in method)) return null;
return (
<ComponentSelect
label="起点"
value={method.Horizontal}
disabled={readOnly}
onChange={(event) =>
onChange({ Horizontal: event.target.value as 'Left' | 'Right' })
}
>
<option value="Left"></option>
<option value="Right"></option>
</ComponentSelect>
);
case 'Vertical':
if (!('Vertical' in method)) return null;
return (
<ComponentSelect
label="起点"
value={method.Vertical}
disabled={readOnly}
onChange={(event) =>
onChange({ Vertical: event.target.value as 'Bottom' | 'Top' })
}
>
<option value="Bottom"></option>
<option value="Top"></option>
</ComponentSelect>
);
case 'Radial90':
if (!('Radial90' in method)) return null;
return (
<RadialMethodPanel
method={method.Radial90}
readOnly={readOnly}
onChange={(value) => onChange({ Radial90: value })}
/>
);
case 'Radial180':
if (!('Radial180' in method)) return null;
return (
<RadialMethodPanel
method={method.Radial180}
readOnly={readOnly}
onChange={(value) => onChange({ Radial180: value })}
/>
);
case 'Radial360':
if (!('Radial360' in method)) return null;
return (
<RadialMethodPanel
method={method.Radial360}
readOnly={readOnly}
onChange={(value) => onChange({ Radial360: value })}
/>
);
}
}
function fillMethodVariant(method: FillMethod): string {
if ('Horizontal' in method) return 'Horizontal';
if ('Vertical' in method) return 'Vertical';
if ('Radial90' in method) return 'Radial90';
if ('Radial180' in method) return 'Radial180';
return 'Radial360';
}
function defaultFillMethod(kind: string): FillMethod {
if (kind === 'Vertical') return { Vertical: 'Top' };
if (kind === 'Radial90')
return { Radial90: { origin: 'TopLeft', clockwise: true } };
if (kind === 'Radial180')
return { Radial180: { origin: 'Top', clockwise: true } };
if (kind === 'Radial360')
return { Radial360: { origin: 'Top', clockwise: true } };
return { Horizontal: 'Left' };
}
type RadialMethod =
| Extract<FillMethod, { Radial90: unknown }>['Radial90']
| Extract<FillMethod, { Radial180: unknown }>['Radial180']
| Extract<FillMethod, { Radial360: unknown }>['Radial360'];
function RadialMethodPanel<T extends RadialMethod>({
method,
readOnly,
onChange,
}: {
method: T;
readOnly: boolean;
onChange: (method: T) => void;
}) {
const originOptions = radialOriginOptions(method.origin);
return (
<>
<ComponentSelect
label="起点"
value={method.origin}
disabled={readOnly}
onChange={(event) =>
onChange({
...method,
origin: event.target.value as T['origin'],
} as T)
}
>
{originOptions.map((origin) => (
<option key={origin} value={origin}>
{radialOriginLabel(origin)}
</option>
))}
</ComponentSelect>
<BooleanField
label="顺时针"
checked={method.clockwise}
disabled={readOnly}
onChange={(clockwise) => onChange({ ...method, clockwise } as T)}
/>
</>
);
}
function radialOriginOptions(origin: string): string[] {
switch (origin) {
case 'BottomLeft':
case 'TopLeft':
case 'TopRight':
case 'BottomRight':
return ['BottomLeft', 'TopLeft', 'TopRight', 'BottomRight'];
default:
return ['Bottom', 'Left', 'Top', 'Right'];
}
}
function radialOriginLabel(origin: string): string {
return (
(
{
BottomLeft: '左下',
TopLeft: '左上',
TopRight: '右上',
BottomRight: '右下',
Bottom: '下',
Left: '左',
Top: '上',
Right: '右',
} satisfies Record<string, string>
)[origin] ?? origin
);
}
@@ -0,0 +1,8 @@
import type { ImageComponent } from '../../../../../features/ui-editor/types/ImageComponent';
export function createDefaultImageComponent(): ImageComponent {
return {
target_graphic: null,
image_type: { Simple: { preserve_aspect: false } },
};
}
@@ -0,0 +1,259 @@
import { useState } from 'react';
import { type RgbaColor, RgbaColorPicker } from 'react-colorful';
import type { FontSizing } from '../../../../../features/ui-editor/types/FontSizing';
import type { FontStyle } from '../../../../../features/ui-editor/types/FontStyle';
import type { HorizontalTextOverflow } from '../../../../../features/ui-editor/types/HorizontalTextOverflow';
import type { TextAlignment } from '../../../../../features/ui-editor/types/TextAlignment';
import type { VerticalTextOverflow } from '../../../../../features/ui-editor/types/VerticalTextOverflow';
import type { TextEditorProps } from './componentEditorTypes';
import {
ComponentField,
ComponentNumberInput,
ComponentSelect,
ComponentTextarea,
} from './ComponentField';
export function TextPanel({
component,
fonts,
readOnly,
onChange,
}: TextEditorProps) {
const [colorOpen, setColorOpen] = useState(false);
const rgba: RgbaColor = {
r: component.color[0] ?? 255,
g: component.color[1] ?? 255,
b: component.color[2] ?? 255,
a: (component.color[3] ?? 255) / 255,
};
const bestFit =
'BestFit' in component.font_sizing ? component.font_sizing.BestFit : null;
const updateColor = (next: RgbaColor) =>
onChange({
...component,
color: [next.r, next.g, next.b, Math.round(next.a * 255)],
});
return (
<div className="space-y-3">
<ComponentTextarea
label="内容"
value={component.content}
disabled={readOnly}
onChange={(event) =>
onChange({ ...component, content: event.target.value })
}
/>
<ComponentField label="字体资源">
{/* TODO: expose editable imported font assets after FontAsset gets a safe browser resource. */}
<div className="mt-1 rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 py-2 text-xs font-normal">
{component.font
? (fonts[component.font]?.asset_id ?? component.font)
: '未绑定(使用系统字体)'}
</div>
</ComponentField>
<ComponentSelect
label="字体样式"
value={component.font_style}
disabled={readOnly}
onChange={(event) =>
onChange({
...component,
font_style: event.target.value as FontStyle,
})
}
>
{(['Normal', 'Bold', 'Italic', 'BoldItalic'] as const).map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</ComponentSelect>
<ComponentSelect
label="字号策略"
value={fontSizingVariant(component.font_sizing)}
disabled={readOnly}
onChange={(event) =>
onChange({
...component,
font_sizing: defaultFontSizing(event.target.value),
})
}
>
<option value="Fixed">Fixed</option>
<option value="BestFit">BestFit</option>
</ComponentSelect>
{'Fixed' in component.font_sizing ? (
<ComponentNumberInput
label="字号"
min={1}
step={1}
value={component.font_sizing.Fixed}
disabled={readOnly}
onChange={(event) =>
onChange({
...component,
font_sizing: { Fixed: Number(event.target.value) },
})
}
/>
) : (
<div className="grid grid-cols-2 gap-2">
<ComponentNumberInput
label="最小字号"
min={1}
step={1}
value={bestFit?.min ?? 8}
disabled={readOnly}
onChange={(event) =>
onChange({
...component,
font_sizing: {
BestFit: {
...(bestFit ?? { min: 8, max: 32 }),
min: Number(event.target.value),
},
},
})
}
/>
<ComponentNumberInput
label="最大字号"
min={1}
step={1}
value={bestFit?.max ?? 32}
disabled={readOnly}
onChange={(event) =>
onChange({
...component,
font_sizing: {
BestFit: {
...(bestFit ?? { min: 8, max: 32 }),
max: Number(event.target.value),
},
},
})
}
/>
</div>
)}
<div className="relative">
<ComponentField label="颜色">
<button
type="button"
className="mt-1 flex h-9 w-full items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 text-left text-xs disabled:opacity-40"
disabled={readOnly}
onClick={() => setColorOpen((open) => !open)}
>
<span
className="size-5 rounded border border-black/15"
style={{
backgroundColor: `rgba(${component.color[0]}, ${component.color[1]}, ${component.color[2]}, ${(component.color[3] ?? 255) / 255})`,
}}
/>
rgba({component.color.join(', ')})
</button>
</ComponentField>
{colorOpen && !readOnly ? (
<div className="absolute right-0 top-full z-20 mt-2 w-64 rounded-xl border border-(--platform-subpanel-border) bg-white p-3 shadow-xl">
<RgbaColorPicker color={rgba} onChange={updateColor} />
<ComponentNumberInput
label="Alpha"
min={0}
max={255}
step={1}
value={component.color[3]}
onChange={(event) =>
onChange({
...component,
color: [
component.color[0],
component.color[1],
component.color[2],
Number(event.target.value),
],
})
}
/>
</div>
) : null}
</div>
<ComponentSelect
label="对齐"
value={component.alignment}
disabled={readOnly}
onChange={(event) =>
onChange({
...component,
alignment: event.target.value as TextAlignment,
})
}
>
{(
[
'UpperLeft',
'UpperCenter',
'UpperRight',
'MiddleLeft',
'MiddleCenter',
'MiddleRight',
'LowerLeft',
'LowerCenter',
'LowerRight',
] as const
).map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</ComponentSelect>
<ComponentSelect
label="水平溢出"
value={component.horizontal_overflow}
disabled={readOnly}
onChange={(event) =>
onChange({
...component,
horizontal_overflow: event.target.value as HorizontalTextOverflow,
})
}
>
<option value="Wrap">Wrap</option>
<option value="Overflow">Overflow</option>
</ComponentSelect>
<ComponentSelect
label="垂直溢出"
value={component.vertical_overflow}
disabled={readOnly}
onChange={(event) =>
onChange({
...component,
vertical_overflow: event.target.value as VerticalTextOverflow,
})
}
>
<option value="Truncate">Truncate</option>
<option value="Overflow">Overflow</option>
</ComponentSelect>
<ComponentNumberInput
label="行距"
min={0.01}
step={0.01}
value={component.line_spacing}
disabled={readOnly}
onChange={(event) =>
onChange({ ...component, line_spacing: Number(event.target.value) })
}
/>
</div>
);
}
function fontSizingVariant(sizing: FontSizing): string {
return 'Fixed' in sizing ? 'Fixed' : 'BestFit';
}
function defaultFontSizing(kind: string): FontSizing {
return kind === 'BestFit' ? { BestFit: { min: 8, max: 32 } } : { Fixed: 14 };
}
@@ -0,0 +1,15 @@
import type { TextComponent } from '../../../../../features/ui-editor/types/TextComponent';
export function createDefaultTextComponent(): TextComponent {
return {
content: '',
font: null,
font_style: 'Normal',
font_sizing: { Fixed: 14 },
color: [255, 255, 255, 255],
alignment: 'UpperLeft',
horizontal_overflow: 'Wrap',
vertical_overflow: 'Truncate',
line_spacing: 1,
};
}
@@ -0,0 +1,39 @@
import type { Component } from '../../../../../features/ui-editor/types/Component';
import type { FontAsset } from '../../../../../features/ui-editor/types/FontAsset';
import type { ImageComponent } from '../../../../../features/ui-editor/types/ImageComponent';
import type { SpriteAsset } from '../../../../../features/ui-editor/types/SpriteAsset';
import type { TextComponent } from '../../../../../features/ui-editor/types/TextComponent';
import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState';
export type ComponentPanelProps = {
components: Component[];
sprites: Record<string, SpriteAsset>;
fonts: Record<string, FontAsset>;
readOnly: boolean;
onSetComponents: (
components: Component[],
) => UiEditorOperationResult | undefined;
onInsertComponent: (
index: number,
component: Component,
) => UiEditorOperationResult | undefined;
onDeleteComponent: (index: number) => UiEditorOperationResult | undefined;
onMoveComponent: (
fromIndex: number,
toIndex: number,
) => UiEditorOperationResult | undefined;
};
export type ComponentEditorProps<T> = {
component: T;
readOnly: boolean;
onChange: (component: T) => UiEditorOperationResult | undefined;
};
export type ImageEditorProps = ComponentEditorProps<ImageComponent> & {
sprites: Record<string, SpriteAsset>;
};
export type TextEditorProps = ComponentEditorProps<TextComponent> & {
fonts: Record<string, FontAsset>;
};
@@ -0,0 +1,170 @@
import type { PointerEvent as ReactPointerEvent } from 'react';
import type { Node as UiNode } from '../../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
import type { UITree } from '../../../../features/ui-editor/types/UITree';
import { tf2css } from '../../../../features/ui-editor/utils/transform/tf2css';
import { ComponentView } from './components/ComponentView';
import type { PreviewComponentResources } from './components/types';
import type { ResizeHandle } from './nodeTransformGeometry';
export type { ResizeHandle } from './nodeTransformGeometry';
export type UiEditorRenderMode = 'editor-overlay' | 'final-preview';
type NodePointerDown = (
event: ReactPointerEvent<HTMLDivElement>,
node: UiNode,
) => void;
type UiTreeRendererProps = {
tree: UITree | null;
renderMode: UiEditorRenderMode;
selectedNodeId: NodeId | null;
resources: PreviewComponentResources;
onSelectNode: (id: NodeId) => void;
onNodePointerDown: NodePointerDown;
onNodePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodeResizePointerDown: (
event: ReactPointerEvent<HTMLDivElement>,
node: UiNode,
handle: ResizeHandle,
) => void;
onNodeResizePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodeResizePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
viewportScale: number;
};
const RESIZE_HANDLES: ReadonlyArray<{
id: ResizeHandle;
left: string;
top: string;
cursor: string;
}> = [
{ id: 'nw', left: '0%', top: '0%', cursor: 'nwse-resize' },
{ id: 'n', left: '50%', top: '0%', cursor: 'ns-resize' },
{ id: 'ne', left: '100%', top: '0%', cursor: 'nesw-resize' },
{ id: 'e', left: '100%', top: '50%', cursor: 'ew-resize' },
{ id: 'se', left: '100%', top: '100%', cursor: 'nwse-resize' },
{ id: 's', left: '50%', top: '100%', cursor: 'ns-resize' },
{ id: 'sw', left: '0%', top: '100%', cursor: 'nesw-resize' },
{ id: 'w', left: '0%', top: '50%', cursor: 'ew-resize' },
];
function RenderNode({
node,
isRoot,
renderMode,
selectedNodeId,
resources,
onSelectNode,
onNodePointerDown,
onNodePointerMove,
onNodePointerUp,
onNodeResizePointerDown,
onNodeResizePointerMove,
onNodeResizePointerUp,
viewportScale,
}: Omit<UiTreeRendererProps, 'tree'> & { node: UiNode; isRoot?: boolean }) {
let geometry;
try {
geometry = tf2css(node.transform);
} catch {
// Keep malformed nodes isolated from the rest of the tree.
return null;
}
const isEditorOverlay = renderMode === 'editor-overlay';
return (
<div
data-node-id={node.id}
className={`absolute min-h-0 min-w-0 ${isRoot ? 'cursor-default' : 'cursor-move'}`}
style={{
...geometry,
...(isEditorOverlay
? {
outline:
selectedNodeId === node.id
? '2px solid rgb(249 115 22)'
: '1px solid rgb(59 130 246 / 70%)',
outlineOffset: selectedNodeId === node.id ? 0 : -1,
backgroundColor:
selectedNodeId === node.id
? 'rgb(249 115 22 / 10%)'
: 'rgb(59 130 246 / 4%)',
}
: {}),
}}
onClick={(event) => {
event.stopPropagation();
onSelectNode(node.id);
}}
onPointerDown={(event) => onNodePointerDown(event, node)}
onPointerMove={onNodePointerMove}
onPointerUp={onNodePointerUp}
onPointerCancel={onNodePointerUp}
title={isEditorOverlay ? node.metadata.name || undefined : undefined}
>
{isEditorOverlay && node.metadata.name ? (
<span className="pointer-events-none absolute left-0 top-0 max-w-full truncate bg-blue-600/80 px-1 text-[9px] leading-4 text-white">
{node.metadata.name}
</span>
) : null}
{node.components.map((component, index) => (
<ComponentView
key={`${node.id}:component:${index}`}
component={component}
resources={resources}
/>
))}
{node.children.map((child) => (
<RenderNode
key={child.id}
node={child}
renderMode={renderMode}
selectedNodeId={selectedNodeId}
resources={resources}
onSelectNode={onSelectNode}
onNodePointerDown={onNodePointerDown}
onNodePointerMove={onNodePointerMove}
onNodePointerUp={onNodePointerUp}
onNodeResizePointerDown={onNodeResizePointerDown}
onNodeResizePointerMove={onNodeResizePointerMove}
onNodeResizePointerUp={onNodeResizePointerUp}
viewportScale={viewportScale}
/>
))}
{isEditorOverlay && !isRoot && selectedNodeId === node.id
? RESIZE_HANDLES.map((handle) => (
<div
key={handle.id}
aria-label={`${handle.id} 调整控制点`}
className="absolute z-30 rounded-sm border border-orange-600 bg-white shadow-sm"
style={{
left: handle.left,
top: handle.top,
width: `${16 / Math.max(viewportScale, 0.025)}px`,
height: `${16 / Math.max(viewportScale, 0.025)}px`,
borderWidth: `${Math.max(1 / Math.max(viewportScale, 0.025), 0.5)}px`,
transform: 'translate(-50%, -50%)',
cursor: handle.cursor,
touchAction: 'none',
}}
onPointerDown={(event) =>
onNodeResizePointerDown(event, node, handle.id)
}
onPointerMove={onNodeResizePointerMove}
onPointerUp={onNodeResizePointerUp}
onPointerCancel={onNodeResizePointerUp}
/>
))
: null}
</div>
);
}
export function UiTreeRenderer({ tree, ...props }: UiTreeRendererProps) {
if (!tree?.root) return null;
return <RenderNode node={tree.root} isRoot {...props} />;
}
@@ -0,0 +1,27 @@
import type { Component } from '../../../../../features/ui-editor/types/Component';
import { ImageComponentView } from './ImageComponentView';
import { MissingComponent } from './MissingComponent';
import { TextComponentView } from './TextComponentView';
import type { PreviewComponentResources } from './types';
export function ComponentView({
component,
resources,
}: {
component: Component;
resources: PreviewComponentResources;
}) {
if ('Image' in component) {
return (
<ImageComponentView component={component.Image} resources={resources} />
);
}
if ('Text' in component) {
return (
<TextComponentView component={component.Text} resources={resources} />
);
}
return <MissingComponent message="未知组件类型" />;
}
@@ -0,0 +1,45 @@
import type { ImageComponent } from '../../../../../features/ui-editor/types/ImageComponent';
import { imageComponentToRenderModel } from '../../../../../features/ui-editor/utils/componentToCss';
import { MissingComponent } from './MissingComponent';
import type { PreviewComponentResources } from './types';
export function ImageComponentView({
component,
resources,
}: {
component: ImageComponent;
resources: PreviewComponentResources;
}) {
const targetGraphic = component.target_graphic;
const model = imageComponentToRenderModel(
component,
targetGraphic ? (resources.sprites[targetGraphic] ?? null) : null,
targetGraphic ? (resources.previewUrls[targetGraphic] ?? null) : null,
);
if (!model) {
return <MissingComponent message="Image 缺少可预览素材" />;
}
return (
<div
className="pointer-events-none absolute inset-0"
style={model.style}
data-component-kind="Image"
>
{model.kind === 'background' ? null : (
<img
src={model.src}
alt=""
aria-hidden="true"
className="pointer-events-none block"
style={model.imageStyle}
draggable={false}
/>
)}
{model.diagnostic ? (
<MissingComponent message={model.diagnostic} />
) : null}
</div>
);
}
@@ -0,0 +1,11 @@
export function MissingComponent({ message }: { message: string }) {
return (
<span
className="pointer-events-none absolute inset-0 grid place-items-center bg-red-500/10 text-[9px] text-red-700"
title={message}
aria-label={message}
>
!
</span>
);
}
@@ -0,0 +1,30 @@
import type { TextComponent } from '../../../../../features/ui-editor/types/TextComponent';
import { textComponentToCss } from '../../../../../features/ui-editor/utils/componentToCss';
import { MissingComponent } from './MissingComponent';
import type { PreviewComponentResources } from './types';
export function TextComponentView({
component,
resources,
}: {
component: TextComponent;
resources: PreviewComponentResources;
}) {
// TODO: FontAsset currently has no browser URL; keep the resource lookup
// explicit so a future @font-face implementation has one integration point.
if (component.font) void resources.fonts[component.font];
try {
return (
<div
className="pointer-events-none absolute inset-0"
style={textComponentToCss(component)}
data-component-kind="Text"
>
{component.content}
</div>
);
} catch {
return <MissingComponent message="Text 参数无效" />;
}
}
@@ -0,0 +1,8 @@
import type { FontAsset } from '../../../../../features/ui-editor/types/FontAsset';
import type { SpriteAsset } from '../../../../../features/ui-editor/types/SpriteAsset';
export type PreviewComponentResources = {
previewUrls: Record<string, string>;
sprites: Record<string, SpriteAsset>;
fonts: Record<string, FontAsset>;
};
@@ -0,0 +1,133 @@
import type { Node as UiNode } from '../../../../features/ui-editor/types/Node';
export type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
export type PageRect = {
min: [number, number];
max: [number, number];
};
export type NodePageContext = {
rect: PageRect;
parentRect: PageRect;
};
const MIN_NODE_SIZE = 1;
export function resolvePageRect(
transform: UiNode['transform'],
parentRect: PageRect,
): PageRect {
return {
min: [
parentRect.min[0] +
(parentRect.max[0] - parentRect.min[0]) * transform.anchor_min[0] +
transform.offset_min[0],
parentRect.min[1] +
(parentRect.max[1] - parentRect.min[1]) * transform.anchor_min[1] +
transform.offset_min[1],
],
max: [
parentRect.min[0] +
(parentRect.max[0] - parentRect.min[0]) * transform.anchor_max[0] +
transform.offset_max[0],
parentRect.min[1] +
(parentRect.max[1] - parentRect.min[1]) * transform.anchor_max[1] +
transform.offset_max[1],
],
};
}
export function findNodePageContext(
node: UiNode,
nodeId: string,
parentRect: PageRect,
): NodePageContext | null {
const rect = resolvePageRect(node.transform, parentRect);
if (node.id === nodeId) return { rect, parentRect };
for (const child of node.children) {
const found = findNodePageContext(child, nodeId, rect);
if (found) return found;
}
return null;
}
export function setOffsetsForPageRect(
transform: UiNode['transform'],
rect: PageRect,
parentRect: PageRect,
): UiNode['transform'] {
const parentWidth = parentRect.max[0] - parentRect.min[0];
const parentHeight = parentRect.max[1] - parentRect.min[1];
const next = structuredClone(transform);
next.offset_min = [
rect.min[0] - parentRect.min[0] - parentWidth * next.anchor_min[0],
rect.min[1] - parentRect.min[1] - parentHeight * next.anchor_min[1],
];
next.offset_max = [
rect.max[0] - parentRect.min[0] - parentWidth * next.anchor_max[0],
rect.max[1] - parentRect.min[1] - parentHeight * next.anchor_max[1],
];
return next;
}
export function resizePageRect(
startRect: PageRect,
handle: ResizeHandle,
delta: [number, number],
keepRatio: boolean,
): PageRect {
const next: PageRect = {
min: [startRect.min[0], startRect.min[1]],
max: [startRect.max[0], startRect.max[1]],
};
if (handle.includes('w')) {
next.min[0] = Math.min(
startRect.max[0] - MIN_NODE_SIZE,
startRect.min[0] + delta[0],
);
} else if (handle.includes('e')) {
next.max[0] = Math.max(
startRect.min[0] + MIN_NODE_SIZE,
startRect.max[0] + delta[0],
);
}
if (handle.includes('n')) {
next.min[1] = Math.min(
startRect.max[1] - MIN_NODE_SIZE,
startRect.min[1] + delta[1],
);
} else if (handle.includes('s')) {
next.max[1] = Math.max(
startRect.min[1] + MIN_NODE_SIZE,
startRect.max[1] + delta[1],
);
}
if (keepRatio && handle.length === 2) {
const startWidth = Math.max(
MIN_NODE_SIZE,
startRect.max[0] - startRect.min[0],
);
const startHeight = Math.max(
MIN_NODE_SIZE,
startRect.max[1] - startRect.min[1],
);
const ratio = startWidth / startHeight;
const rawWidth = Math.max(
MIN_NODE_SIZE,
Math.abs(next.max[0] - next.min[0]),
);
const rawHeight = Math.max(
MIN_NODE_SIZE,
Math.abs(next.max[1] - next.min[1]),
);
const width = Math.max(MIN_NODE_SIZE, rawWidth, rawHeight * ratio);
const height = Math.max(MIN_NODE_SIZE, width / ratio);
if (handle.includes('w')) next.min[0] = startRect.max[0] - width;
else next.max[0] = startRect.min[0] + width;
if (handle.includes('n')) next.min[1] = startRect.max[1] - height;
else next.max[1] = startRect.min[1] + height;
}
return next;
}
@@ -0,0 +1,227 @@
import {
type PointerEvent as ReactPointerEvent,
type RefObject,
useCallback,
useRef,
} from 'react';
import type { Node as UiNode } from '../../../../features/ui-editor/types/Node';
import type { UITree } from '../../../../features/ui-editor/types/UITree';
import type { UiEditorPageController } from '../../useUiEditorPage';
import {
findNodePageContext,
type PageRect,
type ResizeHandle,
resizePageRect,
setOffsetsForPageRect,
} from './nodeTransformGeometry';
type ViewportScale = { scale: number };
type NodeDragState = {
nodeId: string;
pointerId: number;
startClientX: number;
startClientY: number;
startTransform: UiNode['transform'];
hasMoved: boolean;
};
type NodeResizeState = {
nodeId: string;
pointerId: number;
handle: ResizeHandle;
startClientX: number;
startClientY: number;
startTransform: UiNode['transform'];
startRect: PageRect;
parentRect: PageRect;
hasMoved: boolean;
};
const DRAG_THRESHOLD_SCREEN_PX = 2;
function passedDragThreshold(
startClientX: number,
startClientY: number,
event: ReactPointerEvent<HTMLDivElement>,
) {
return (
Math.hypot(event.clientX - startClientX, event.clientY - startClientY) >=
DRAG_THRESHOLD_SCREEN_PX
);
}
function releasePointer(event: ReactPointerEvent<HTMLDivElement>) {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
}
export function useNodeTransformInteraction({
activeImageId,
controller,
logicalSize,
spaceHeld,
tree,
viewportRef,
}: {
activeImageId: UiEditorPageController['activeImageId'];
controller: UiEditorPageController;
logicalSize: { width: number; height: number } | null;
spaceHeld: boolean;
tree: UITree | null;
viewportRef: RefObject<ViewportScale>;
}) {
const nodeDragRef = useRef<NodeDragState | null>(null);
const nodeResizeRef = useRef<NodeResizeState | null>(null);
const onNodePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>, node: UiNode) => {
if (event.button !== 0 || spaceHeld || node.id === tree?.root.id) return;
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
controller.selectNode(node.id);
nodeDragRef.current = {
nodeId: node.id,
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startTransform: structuredClone(node.transform),
hasMoved: false,
};
},
[controller, spaceHeld, tree?.root.id],
);
const onNodePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation();
const drag = nodeDragRef.current;
if (!drag || drag.pointerId !== event.pointerId || !activeImageId) return;
if (
!drag.hasMoved &&
!passedDragThreshold(drag.startClientX, drag.startClientY, event)
) {
return;
}
drag.hasMoved = true;
const nextTransform = structuredClone(drag.startTransform);
const scale = viewportRef.current?.scale ?? 1;
const logicalDeltaX = (event.clientX - drag.startClientX) / scale;
const logicalDeltaY = (event.clientY - drag.startClientY) / scale;
nextTransform.offset_min[0] += logicalDeltaX;
nextTransform.offset_min[1] += logicalDeltaY;
nextTransform.offset_max[0] += logicalDeltaX;
nextTransform.offset_max[1] += logicalDeltaY;
controller.updateNodeTransform(activeImageId, drag.nodeId, nextTransform);
},
[activeImageId, controller, viewportRef],
);
const onNodePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation();
if (nodeDragRef.current?.pointerId !== event.pointerId) return;
nodeDragRef.current = null;
releasePointer(event);
},
[],
);
const onNodeResizePointerDown = useCallback(
(
event: ReactPointerEvent<HTMLDivElement>,
node: UiNode,
handle: ResizeHandle,
) => {
if (
event.button !== 0 ||
spaceHeld ||
node.id === tree?.root.id ||
!tree ||
!logicalSize
) {
return;
}
const pageRect: PageRect = {
min: [0, 0],
max: [logicalSize.width, logicalSize.height],
};
const context = findNodePageContext(tree.root, node.id, pageRect);
if (!context) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
controller.selectNode(node.id);
nodeResizeRef.current = {
nodeId: node.id,
pointerId: event.pointerId,
handle,
startClientX: event.clientX,
startClientY: event.clientY,
startTransform: structuredClone(node.transform),
startRect: context.rect,
parentRect: context.parentRect,
hasMoved: false,
};
},
[controller, logicalSize, spaceHeld, tree],
);
const onNodeResizePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation();
const resize = nodeResizeRef.current;
if (!resize || resize.pointerId !== event.pointerId || !activeImageId) {
return;
}
if (
!resize.hasMoved &&
!passedDragThreshold(resize.startClientX, resize.startClientY, event)
) {
return;
}
resize.hasMoved = true;
const scale = viewportRef.current?.scale ?? 1;
const nextRect = resizePageRect(
resize.startRect,
resize.handle,
[
(event.clientX - resize.startClientX) / scale,
(event.clientY - resize.startClientY) / scale,
],
event.shiftKey,
);
controller.updateNodeTransform(
activeImageId,
resize.nodeId,
setOffsetsForPageRect(
resize.startTransform,
nextRect,
resize.parentRect,
),
);
},
[activeImageId, controller, viewportRef],
);
const onNodeResizePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation();
if (nodeResizeRef.current?.pointerId !== event.pointerId) return;
nodeResizeRef.current = null;
releasePointer(event);
},
[],
);
return {
onNodePointerDown,
onNodePointerMove,
onNodePointerUp,
onNodeResizePointerDown,
onNodeResizePointerMove,
onNodeResizePointerUp,
};
}