refactor(editor): improve selection parity and exhaustiveness

This commit is contained in:
clawdbot 2026-01-27 23:12:47 +08:00
parent 534516d17e
commit 2d032fe050

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Screen, WidgetNode } from '@astralview/sdk';
import { assertNever } from '@astralview/sdk';
import { Button, Space, Typography } from 'antd';
import type { ResizeHandle } from './types';
import { rectFromPoints } from './geometry';
@ -232,7 +233,7 @@ export function Canvas(props: CanvasProps) {
const p = clientToWorld(e.clientX, e.clientY);
if (!p) return;
const additive = e.ctrlKey || e.metaKey;
const additive = e.ctrlKey || e.metaKey || e.shiftKey;
if (!additive) props.onSelectSingle(undefined);
props.onBeginBoxSelect(e, p.x, p.y, additive);
setBox(rectFromPoints({ x: p.x, y: p.y }, { x: p.x, y: p.y }));
@ -555,120 +556,137 @@ function NodeView(props: {
borderStyle: node.hidden ? 'dashed' : 'solid',
}}
>
{node.type === 'text' ? (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent:
node.props.textAlign === 'left'
? 'flex-start'
: node.props.textAlign === 'right'
? 'flex-end'
: 'center',
backgroundColor: node.props.backgroundColor ?? 'transparent',
borderStyle: 'solid',
borderWidth: `${node.props.borderWidth ?? 0}px`,
borderColor: node.props.borderColor ?? 'transparent',
borderRadius: `${node.props.borderRadius ?? 0}px`,
boxSizing: 'border-box',
padding: `${node.props.paddingY ?? 0}px ${node.props.paddingX ?? 0}px`,
overflow: 'hidden',
}}
>
<span
style={{
whiteSpace: 'pre-wrap',
fontSize: node.props.fontSize ?? 24,
color: node.props.color ?? '#fff',
fontWeight: node.props.fontWeight ?? 400,
letterSpacing: `${node.props.letterSpacing ?? 0}px`,
writingMode: node.props.writingMode as unknown as React.CSSProperties['writingMode'],
cursor: node.props.link ? 'pointer' : 'default',
}}
onClick={() => {
if (!node.props.link) return;
const head = node.props.linkHead ?? 'http://';
window.open(`${head}${node.props.link}`);
}}
>
{node.props.text}
</span>
</div>
) : node.type === 'image' ? (
<div
style={{
width: '100%',
height: '100%',
overflow: 'hidden',
borderRadius: `${node.props.borderRadius ?? 0}px`,
}}
>
<img
src={node.props.src}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: node.props.fit ?? 'contain',
display: 'block',
// Editor parity: allow selecting/dragging the widget even when clicking the media.
pointerEvents: 'none',
}}
/>
</div>
) : node.type === 'iframe' ? (
<div
style={{
width: '100%',
height: '100%',
overflow: 'hidden',
borderRadius: `${node.props.borderRadius ?? 0}px`,
}}
>
<iframe
src={node.props.src}
width={rect.w}
height={rect.h}
style={{
border: 0,
display: 'block',
// Editor parity: iframes steal pointer events; disable so selection/context menu works.
pointerEvents: 'none',
}}
title={node.id}
/>
</div>
) : node.type === 'video' ? (
<div
style={{
width: '100%',
height: '100%',
overflow: 'hidden',
borderRadius: `${node.props.borderRadius ?? 0}px`,
}}
>
<video
src={node.props.src}
width={rect.w}
height={rect.h}
autoPlay
playsInline
loop={node.props.loop ?? false}
muted={node.props.muted ?? false}
style={{
display: 'block',
width: '100%',
height: '100%',
objectFit: node.props.fit ?? 'contain',
// Editor parity: allow selecting/dragging even when clicking the video surface.
pointerEvents: 'none',
}}
/>
</div>
) : null}
{(() => {
switch (node.type) {
case 'text':
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent:
node.props.textAlign === 'left'
? 'flex-start'
: node.props.textAlign === 'right'
? 'flex-end'
: 'center',
backgroundColor: node.props.backgroundColor ?? 'transparent',
borderStyle: 'solid',
borderWidth: `${node.props.borderWidth ?? 0}px`,
borderColor: node.props.borderColor ?? 'transparent',
borderRadius: `${node.props.borderRadius ?? 0}px`,
boxSizing: 'border-box',
padding: `${node.props.paddingY ?? 0}px ${node.props.paddingX ?? 0}px`,
overflow: 'hidden',
}}
>
<span
style={{
whiteSpace: 'pre-wrap',
fontSize: node.props.fontSize ?? 24,
color: node.props.color ?? '#fff',
fontWeight: node.props.fontWeight ?? 400,
letterSpacing: `${node.props.letterSpacing ?? 0}px`,
writingMode: node.props.writingMode as unknown as React.CSSProperties['writingMode'],
cursor: node.props.link ? 'pointer' : 'default',
}}
onClick={() => {
if (!node.props.link) return;
const head = node.props.linkHead ?? 'http://';
window.open(`${head}${node.props.link}`);
}}
>
{node.props.text}
</span>
</div>
);
case 'image':
return (
<div
style={{
width: '100%',
height: '100%',
overflow: 'hidden',
borderRadius: `${node.props.borderRadius ?? 0}px`,
}}
>
<img
src={node.props.src}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: node.props.fit ?? 'contain',
display: 'block',
// Editor parity: allow selecting/dragging the widget even when clicking the media.
pointerEvents: 'none',
}}
/>
</div>
);
case 'iframe':
return (
<div
style={{
width: '100%',
height: '100%',
overflow: 'hidden',
borderRadius: `${node.props.borderRadius ?? 0}px`,
}}
>
<iframe
src={node.props.src}
width={rect.w}
height={rect.h}
style={{
border: 0,
display: 'block',
// Editor parity: iframes steal pointer events; disable so selection/context menu works.
pointerEvents: 'none',
}}
title={node.id}
/>
</div>
);
case 'video':
return (
<div
style={{
width: '100%',
height: '100%',
overflow: 'hidden',
borderRadius: `${node.props.borderRadius ?? 0}px`,
}}
>
<video
src={node.props.src}
width={rect.w}
height={rect.h}
autoPlay
playsInline
loop={node.props.loop ?? false}
muted={node.props.muted ?? false}
style={{
display: 'block',
width: '100%',
height: '100%',
objectFit: node.props.fit ?? 'contain',
// Editor parity: allow selecting/dragging even when clicking the video surface.
pointerEvents: 'none',
}}
/>
</div>
);
default:
return assertNever(node);
}
})()}
{props.selected && !node.locked && !node.hidden && <ResizeHandles onPointerDown={props.onResizePointerDown} />}
</div>