Compare commits

...

2 Commits

8 changed files with 298 additions and 63 deletions

View File

@ -20,7 +20,7 @@ export interface CanvasProps {
onBeginMove(e: React.PointerEvent): void;
onUpdateMove(e: PointerEvent): void;
onEndMove(): void;
onBeginBoxSelect(e: React.PointerEvent, offsetX: number, offsetY: number): void;
onBeginBoxSelect(e: React.PointerEvent, offsetX: number, offsetY: number, additive: boolean): void;
onUpdateBoxSelect(e: PointerEvent, offsetX: number, offsetY: number): void;
onEndBoxSelect(): void;
onBeginResize(e: React.PointerEvent, id: string, handle: ResizeHandle): void;
@ -31,6 +31,10 @@ export interface CanvasProps {
onZoomAt(scale: number, anchorX: number, anchorY: number): void;
onDeleteSelected?(): void;
onDuplicateSelected?(): void;
onToggleLockSelected?(): void;
onToggleHideSelected?(): void;
onBringToFrontSelected?(): void;
onSendToBackSelected?(): void;
}
function isPrimaryButton(e: React.PointerEvent | PointerEvent): boolean {
@ -53,6 +57,14 @@ export function Canvas(props: CanvasProps) {
const bounds = useMemo(() => ({ w: props.screen.width, h: props.screen.height }), [props.screen.width, props.screen.height]);
const selection = useMemo(() => {
const ids = new Set(props.selectionIds);
return props.screen.nodes.filter((n) => ids.has(n.id));
}, [props.screen.nodes, props.selectionIds]);
const selectionAllLocked = selection.length > 0 && selection.every((n) => n.locked);
const selectionAllHidden = selection.length > 0 && selection.every((n) => n.hidden);
const clientToCanvas = useCallback((clientX: number, clientY: number) => {
const el = ref.current;
if (!el) return null;
@ -165,7 +177,12 @@ export function Canvas(props: CanvasProps) {
if (targetId && !props.selectionIds.includes(targetId)) {
// goView-ish: right click selects the item.
props.onSelectSingle(targetId);
// Ctrl/Cmd keeps multi-select parity (add to selection instead of replacing).
if ((e as React.MouseEvent).ctrlKey || (e as React.MouseEvent).metaKey) {
props.onToggleSelect(targetId);
} else {
props.onSelectSingle(targetId);
}
}
setCtx({ clientX: e.clientX, clientY: e.clientY, worldX: p.x, worldY: p.y, targetId });
@ -190,8 +207,9 @@ export function Canvas(props: CanvasProps) {
const p = clientToWorld(e.clientX, e.clientY);
if (!p) return;
props.onSelectSingle(undefined);
props.onBeginBoxSelect(e, p.x, p.y);
const additive = e.ctrlKey || e.metaKey;
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 }));
};
@ -283,6 +301,43 @@ export function Canvas(props: CanvasProps) {
setCtx(null);
}}
/>
<MenuItem
label={selectionAllLocked ? 'Unlock' : 'Lock'}
disabled={!props.selectionIds.length || !props.onToggleLockSelected}
onClick={() => {
props.onToggleLockSelected?.();
setCtx(null);
}}
/>
<MenuItem
label={selectionAllHidden ? 'Show' : 'Hide'}
disabled={!props.selectionIds.length || !props.onToggleHideSelected}
onClick={() => {
props.onToggleHideSelected?.();
setCtx(null);
}}
/>
<div style={{ height: 1, margin: '6px 0', background: 'rgba(255,255,255,0.08)' }} />
<MenuItem
label="Bring To Front"
disabled={!props.selectionIds.length || !props.onBringToFrontSelected}
onClick={() => {
props.onBringToFrontSelected?.();
setCtx(null);
}}
/>
<MenuItem
label="Send To Back"
disabled={!props.selectionIds.length || !props.onSendToBackSelected}
onClick={() => {
props.onSendToBackSelected?.();
setCtx(null);
}}
/>
<MenuItem
label="Delete"
danger

View File

@ -126,6 +126,10 @@ export function EditorApp() {
onToggleSelect={(id) => dispatch({ type: 'toggleSelect', id })}
onDeleteSelected={() => dispatch({ type: 'deleteSelected' })}
onDuplicateSelected={() => dispatch({ type: 'duplicateSelected' })}
onToggleLockSelected={() => dispatch({ type: 'toggleLockSelected' })}
onToggleHideSelected={() => dispatch({ type: 'toggleHideSelected' })}
onBringToFrontSelected={() => dispatch({ type: 'bringToFrontSelected' })}
onSendToBackSelected={() => dispatch({ type: 'sendToBackSelected' })}
onBeginPan={(e) => {
dispatch({ type: 'beginPan', start: { screenX: e.screenX, screenY: e.screenY } });
}}
@ -140,8 +144,12 @@ export function EditorApp() {
dispatch({ type: 'updateMove', current: { screenX: e.screenX, screenY: e.screenY }, bounds });
}}
onEndMove={() => dispatch({ type: 'endMove' })}
onBeginBoxSelect={(e, offsetX, offsetY) => {
dispatch({ type: 'beginBoxSelect', start: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY } });
onBeginBoxSelect={(e, offsetX, offsetY, additive) => {
dispatch({
type: 'beginBoxSelect',
additive,
start: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY },
});
}}
onUpdateBoxSelect={(e: PointerEvent, offsetX, offsetY) => {
dispatch({ type: 'updateBoxSelect', current: { offsetX, offsetY, screenX: e.screenX, screenY: e.screenY } });

View File

@ -28,7 +28,11 @@ export type EditorAction =
| { type: 'endPan' }
| { type: 'selectSingle'; id?: string }
| { type: 'toggleSelect'; id: string }
| { type: 'beginBoxSelect'; start: { offsetX: number; offsetY: number; screenX: number; screenY: number } }
| {
type: 'beginBoxSelect';
additive: boolean;
start: { offsetX: number; offsetY: number; screenX: number; screenY: number };
}
| { type: 'updateBoxSelect'; current: { offsetX: number; offsetY: number; screenX: number; screenY: number } }
| { type: 'endBoxSelect' }
| { type: 'beginMove'; start: { screenX: number; screenY: number }; bounds: { w: number; h: number } }
@ -42,6 +46,10 @@ export type EditorAction =
| { type: 'deleteSelected' }
| { type: 'nudgeSelected'; dx: number; dy: number }
| { type: 'duplicateSelected' }
| { type: 'toggleLockSelected' }
| { type: 'toggleHideSelected' }
| { type: 'bringToFrontSelected' }
| { type: 'sendToBackSelected' }
| { type: 'updateTextProps'; id: string; props: Partial<TextWidgetNode['props']> }
| { type: 'updateImageProps'; id: string; props: Partial<ImageWidgetNode['props']> }
| { type: 'updateIframeProps'; id: string; props: Partial<IframeWidgetNode['props']> }
@ -316,6 +324,101 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
};
}
case 'toggleLockSelected': {
if (!state.selection.ids.length) return state;
const ids = new Set(state.selection.ids);
const selected = state.doc.screen.nodes.filter((n) => ids.has(n.id));
if (!selected.length) return state;
const shouldLock = selected.some((n) => !n.locked);
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: state.doc.screen.nodes.map((n) => (ids.has(n.id) ? { ...n, locked: shouldLock } : n)),
},
},
};
}
case 'toggleHideSelected': {
if (!state.selection.ids.length) return state;
const ids = new Set(state.selection.ids);
const selected = state.doc.screen.nodes.filter((n) => ids.has(n.id));
if (!selected.length) return state;
const shouldHide = selected.some((n) => !n.hidden);
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: state.doc.screen.nodes.map((n) => (ids.has(n.id) ? { ...n, hidden: shouldHide } : n)),
},
},
selection: shouldHide ? { ids: [] } : state.selection,
};
}
case 'bringToFrontSelected': {
if (!state.selection.ids.length) return state;
const ids = new Set(state.selection.ids);
const nodes = state.doc.screen.nodes;
let maxZ = 0;
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i]!;
const z = n.zIndex ?? i;
if (z > maxZ) maxZ = z;
}
let bump = 0;
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: nodes.map((n) => {
if (!ids.has(n.id)) return n;
bump += 1;
return { ...n, zIndex: maxZ + bump };
}),
},
},
};
}
case 'sendToBackSelected': {
if (!state.selection.ids.length) return state;
const ids = new Set(state.selection.ids);
const nodes = state.doc.screen.nodes;
let minZ = 0;
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i]!;
const z = n.zIndex ?? i;
if (z < minZ) minZ = z;
}
let bump = 0;
return {
...historyPush(state),
doc: {
screen: {
...state.doc.screen,
nodes: nodes.map((n) => {
if (!ids.has(n.id)) return n;
bump += 1;
return { ...n, zIndex: minZ - bump };
}),
},
},
};
}
case 'beginPan': {
return {
...state,
@ -376,6 +479,9 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
case 'beginBoxSelect': {
if (action.start.screenX === 0 && action.start.screenY === 0) return state;
const baseIds = action.additive ? state.selection.ids : [];
return {
...state,
canvas: {
@ -393,8 +499,9 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
screenY: action.start.screenY,
},
},
selection: { ids: [] },
};
selection: { ids: baseIds },
__boxSelect: { additive: action.additive, baseIds },
} as EditorState & { __boxSelect: { additive: boolean; baseIds: string[] } };
}
case 'updateBoxSelect': {
@ -415,9 +522,12 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
if (rectContains(box, node.rect)) selected.push(node.id);
}
const boxState = (state as EditorState & { __boxSelect?: { additive: boolean; baseIds: string[] } }).__boxSelect;
const ids = boxState?.additive ? Array.from(new Set([...(boxState.baseIds ?? []), ...selected])) : selected;
return {
...state,
selection: { ids: selected },
selection: { ids },
canvas: {
...state.canvas,
mouse: {
@ -433,8 +543,10 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
case 'endBoxSelect': {
if (!state.canvas.isBoxSelecting) return state;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { __boxSelect: _box, ...rest } = state as EditorState & { __boxSelect?: unknown };
return {
...state,
...rest,
canvas: {
...state.canvas,
isBoxSelecting: false,

View File

@ -17,7 +17,14 @@ export interface GoViewComponentLike {
// component identity
key?: string; // e.g. "TextCommon" (sometimes)
chartConfig?: { key?: string };
componentKey?: string;
chartConfig?: {
key?: string;
chartKey?: string;
type?: string;
option?: unknown;
data?: unknown;
};
// geometry
attr?: { x: number; y: number; w: number; h: number; zIndex?: number };
@ -52,7 +59,14 @@ export interface GoViewProjectLike {
}
function keyOf(c: GoViewComponentLike): string {
return (c.chartConfig?.key ?? c.key ?? '').toLowerCase();
return (
c.chartConfig?.key ??
c.chartConfig?.chartKey ??
c.chartConfig?.type ??
c.componentKey ??
c.key ??
''
).toLowerCase();
}
function isTextCommon(c: GoViewComponentLike): boolean {
@ -92,6 +106,28 @@ function pick<T>(...values: Array<T | undefined | null>): T | undefined {
return undefined;
}
function optionOf(c: GoViewComponentLike): unknown {
const chartData = c.chartConfig?.data as Record<string, unknown> | undefined;
return (
c.option ??
c.chartConfig?.option ??
chartData?.option ??
chartData?.options ??
chartData?.config ??
chartData ??
{}
);
}
function toNumber(v: unknown, fallback: number): number {
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string') {
const n = Number(v);
if (Number.isFinite(n)) return n;
}
return fallback;
}
export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewStorageLike): Screen {
// goView exports vary a lot; attempt a few common nesting shapes.
const root = input as unknown as Record<string, unknown>;
@ -144,16 +180,21 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
for (const c of componentList) {
const rect = c.attr
? { x: c.attr.x, y: c.attr.y, w: c.attr.w, h: c.attr.h }
? {
x: toNumber((c.attr as unknown as Record<string, unknown>).x, 0),
y: toNumber((c.attr as unknown as Record<string, unknown>).y, 0),
w: toNumber((c.attr as unknown as Record<string, unknown>).w, 320),
h: toNumber((c.attr as unknown as Record<string, unknown>).h, 60),
}
: { x: 0, y: 0, w: 320, h: 60 };
if (isTextCommon(c)) {
const props = convertGoViewTextOptionToNodeProps((c.option ?? {}) as GoViewTextOption);
const props = convertGoViewTextOptionToNodeProps(optionOf(c) as GoViewTextOption);
nodes.push({
id: c.id ?? `import_text_${Math.random().toString(16).slice(2)}`,
type: 'text',
rect,
zIndex: c.attr?.zIndex,
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false,
props,
@ -162,12 +203,12 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
}
if (isImage(c)) {
const props = convertGoViewImageOptionToNodeProps((c.option ?? {}) as GoViewImageOption);
const props = convertGoViewImageOptionToNodeProps(optionOf(c) as GoViewImageOption);
nodes.push({
id: c.id ?? `import_image_${Math.random().toString(16).slice(2)}`,
type: 'image',
rect,
zIndex: c.attr?.zIndex,
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false,
props,
@ -176,12 +217,12 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
}
if (isIframe(c)) {
const props = convertGoViewIframeOptionToNodeProps((c.option ?? {}) as GoViewIframeOption);
const props = convertGoViewIframeOptionToNodeProps(optionOf(c) as GoViewIframeOption);
nodes.push({
id: c.id ?? `import_iframe_${Math.random().toString(16).slice(2)}`,
type: 'iframe',
rect,
zIndex: c.attr?.zIndex,
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false,
props,
@ -190,12 +231,12 @@ export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewSt
}
if (isVideo(c)) {
const props = convertGoViewVideoOptionToNodeProps((c.option ?? {}) as GoViewVideoOption);
const props = convertGoViewVideoOptionToNodeProps(optionOf(c) as GoViewVideoOption);
nodes.push({
id: c.id ?? `import_video_${Math.random().toString(16).slice(2)}`,
type: 'video',
rect,
zIndex: c.attr?.zIndex,
zIndex: c.attr?.zIndex === undefined ? undefined : toNumber((c.attr as unknown as Record<string, unknown>).zIndex, 0),
locked: c.status?.lock ?? false,
hidden: c.status?.hide ?? false,
props,

View File

@ -1,4 +1,5 @@
import type { IframeWidgetNode } from '../schema';
import { pickUrlLike } from './urlLike';
/**
* goView iframe option shape varies across versions.
@ -16,23 +17,8 @@ export interface GoViewIframeOption {
*/
export type LegacyIframeOption = GoViewIframeOption;
function asString(v: unknown): string {
if (typeof v === 'string') return v;
if (!v) return '';
// Some goView widgets store data as { value: '...' } or { url: '...' }.
if (typeof v === 'object') {
const obj = v as Record<string, unknown>;
if (typeof obj.value === 'string') return obj.value;
if (typeof obj.url === 'string') return obj.url;
if (typeof obj.src === 'string') return obj.src;
}
return '';
}
function pickSrc(option: GoViewIframeOption): string {
return asString(option.dataset) || asString(option.src) || asString(option.url);
return pickUrlLike(option.dataset) || pickUrlLike(option.src) || pickUrlLike(option.url);
}
export function convertGoViewIframeOptionToNodeProps(option: GoViewIframeOption): IframeWidgetNode['props'] {

View File

@ -1,4 +1,5 @@
import type { ImageWidgetNode } from '../schema';
import { pickUrlLike } from './urlLike';
/**
* goView Image option shape varies across versions. We keep this intentionally
@ -24,25 +25,16 @@ export interface GoViewImageOption {
borderRadius?: number;
}
function pickSrc(option: GoViewImageOption): string {
return pickUrlLike(option.dataset) || pickUrlLike(option.src) || pickUrlLike(option.url);
}
function asString(v: unknown): string {
if (typeof v === 'string') return v;
if (!v) return '';
// Some goView widgets store data as { value: '...' } or { url: '...' }.
if (typeof v === 'object') {
const obj = v as Record<string, unknown>;
if (typeof obj.value === 'string') return obj.value;
if (typeof obj.url === 'string') return obj.url;
if (typeof obj.src === 'string') return obj.src;
}
return '';
}
function pickSrc(option: GoViewImageOption): string {
return asString(option.dataset) || asString(option.src) || asString(option.url);
}
function pickFit(option: GoViewImageOption): ImageWidgetNode['props']['fit'] | undefined {
const raw = asString(option.fit) || asString(option.objectFit);
if (!raw) return undefined;

View File

@ -0,0 +1,48 @@
/**
* Small helper for permissive imports (goView / low-code exports).
*
* Many widgets store their URL-ish source in slightly different shapes:
* - string
* - { value: string }
* - { url: string }
* - { src: string }
* - { dataset: string | { value/url/src } }
* - nested objects under `data` / `config` / `options`
*/
export function pickUrlLike(input: unknown, maxDepth = 3): string {
return pickUrlLikeInner(input, maxDepth);
}
function pickUrlLikeInner(input: unknown, depth: number): string {
if (typeof input === 'string') return input;
if (!input) return '';
if (Array.isArray(input)) {
for (const item of input) {
const v = pickUrlLikeInner(item, depth - 1);
if (v) return v;
}
return '';
}
if (typeof input !== 'object') return '';
const obj = input as Record<string, unknown>;
// Common direct keys.
for (const key of ['value', 'url', 'src', 'href', 'link', 'path', 'iframeUrl', 'videoUrl', 'mp4']) {
const v = obj[key];
if (typeof v === 'string' && v) return v;
}
if (depth <= 0) return '';
// Common nesting keys.
for (const key of ['dataset', 'data', 'config', 'option', 'options', 'props']) {
const v = obj[key];
const nested = pickUrlLikeInner(v, depth - 1);
if (nested) return nested;
}
return '';
}

View File

@ -1,4 +1,5 @@
import type { VideoWidgetNode } from '../schema';
import { pickUrlLike } from './urlLike';
/**
* goView video option shape varies across versions.
@ -23,24 +24,16 @@ export interface GoViewVideoOption {
*/
export type LegacyVideoOption = GoViewVideoOption;
function pickSrc(option: GoViewVideoOption): string {
return pickUrlLike(option.dataset) || pickUrlLike(option.src) || pickUrlLike(option.url);
}
function asString(v: unknown): string {
if (typeof v === 'string') return v;
if (!v) return '';
if (typeof v === 'object') {
const obj = v as Record<string, unknown>;
if (typeof obj.value === 'string') return obj.value;
if (typeof obj.url === 'string') return obj.url;
if (typeof obj.src === 'string') return obj.src;
}
return '';
}
function pickSrc(option: GoViewVideoOption): string {
return asString(option.dataset) || asString(option.src) || asString(option.url);
}
function pickFit(option: GoViewVideoOption): VideoWidgetNode['props']['fit'] | undefined {
const raw = asString(option.fit) || asString(option.objectFit);
if (!raw) return undefined;