新增组件编辑相关功能:
- 实现 `ComponentPanel`,支持组件的新增、删除、移动和更新操作。 - 添加组件字段编辑表单 (`ComponentField`) 及组件类型编辑接口。 - 引入支持图片和文本组件的渲染逻辑 (`ImagePanel`, `TextPanel`)。 - 添加组件到 CSS 转换的工具函数 (`componentToCss.ts`),支持图片和文本样式生成。 - 优化 Inspector,支持多层级组件的编辑和操作。
This commit is contained in:
+19
-8
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
+95
@@ -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>
|
||||
);
|
||||
}
|
||||
+309
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
+442
@@ -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
|
||||
);
|
||||
}
|
||||
+8
@@ -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 } },
|
||||
};
|
||||
}
|
||||
+259
@@ -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 };
|
||||
}
|
||||
+15
@@ -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,
|
||||
};
|
||||
}
|
||||
+39
@@ -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} />;
|
||||
}
|
||||
+27
@@ -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="未知组件类型" />;
|
||||
}
|
||||
+45
@@ -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>
|
||||
);
|
||||
}
|
||||
+11
@@ -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>
|
||||
);
|
||||
}
|
||||
+30
@@ -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>;
|
||||
};
|
||||
+133
@@ -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;
|
||||
}
|
||||
+227
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user