feat: i18n control, Gitea auto-update CI, license-gated updater, fixes
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
@@ -50,11 +50,53 @@ export const DND_SCENE_ID_MIME = 'application/x-dnd-scene-id';
|
||||
const SCENE_CARD_W = 220;
|
||||
const SCENE_CARD_H = 248;
|
||||
|
||||
/** UI strings for the scene graph (passed from editor i18n). */
|
||||
export type SceneGraphUiStrings = {
|
||||
badgeStart: string;
|
||||
untitled: string;
|
||||
videoBadge: string;
|
||||
audioBadge: string;
|
||||
loop: string;
|
||||
autoplay: string;
|
||||
previewAutostart: string;
|
||||
videoLoop: string;
|
||||
zoomBar: string;
|
||||
zoomIn: string;
|
||||
zoomOut: string;
|
||||
fitAll: string;
|
||||
closeMenu: string;
|
||||
startScene: string;
|
||||
unsetStartScene: string;
|
||||
delete: string;
|
||||
};
|
||||
|
||||
const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
|
||||
badgeStart: 'НАЧАЛО',
|
||||
untitled: 'Без названия',
|
||||
videoBadge: 'Видео',
|
||||
audioBadge: 'Аудио',
|
||||
loop: 'Цикл',
|
||||
autoplay: 'Автостарт',
|
||||
previewAutostart: 'Авто превью',
|
||||
videoLoop: 'Цикл видео',
|
||||
zoomBar: 'Масштаб графа',
|
||||
zoomIn: 'Увеличить',
|
||||
zoomOut: 'Уменьшить',
|
||||
fitAll: 'Показать всё',
|
||||
closeMenu: 'Закрыть меню',
|
||||
startScene: 'Начальная сцена',
|
||||
unsetStartScene: 'Снять метку «Начальная сцена»',
|
||||
delete: 'Удалить',
|
||||
};
|
||||
|
||||
const GraphUiContext = createContext<SceneGraphUiStrings>(DEFAULT_SCENE_GRAPH_UI);
|
||||
|
||||
export type SceneGraphProps = {
|
||||
sceneGraphNodes: SceneGraphNode[];
|
||||
sceneGraphEdges: SceneGraphEdge[];
|
||||
sceneCardById: Record<SceneId, SceneGraphSceneCard>;
|
||||
currentSceneId: SceneId | null;
|
||||
graphUi?: SceneGraphUiStrings;
|
||||
onCurrentSceneChange: (id: SceneId) => void;
|
||||
onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void;
|
||||
onDisconnect: (edgeId: string) => void;
|
||||
@@ -131,6 +173,7 @@ function IconVideoPreviewAutostart() {
|
||||
}
|
||||
|
||||
function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
const ui = useContext(GraphUiContext);
|
||||
const thumbUrl = useAssetUrl(data.previewThumbAssetId);
|
||||
const previewUrl = useAssetUrl(data.previewAssetId);
|
||||
const cardClass = [styles.card, data.active ? styles.cardActive : ''].filter(Boolean).join(' ');
|
||||
@@ -141,7 +184,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
<Handle type="target" position={Position.Top} className={styles.handle} />
|
||||
<div className={cardClass}>
|
||||
<div className={styles.previewShell}>
|
||||
{data.isStartScene ? <div className={styles.badgeStart}>НАЧАЛО</div> : null}
|
||||
{data.isStartScene ? <div className={styles.badgeStart}>{ui.badgeStart}</div> : null}
|
||||
{thumbUrl ? (
|
||||
<div className={styles.previewFill}>
|
||||
{data.previewRotationDeg === 0 ? (
|
||||
@@ -209,12 +252,12 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
{showCornerVideo || showCornerAudio ? (
|
||||
<div className={styles.cornerBadges}>
|
||||
{showCornerVideo ? (
|
||||
<span className={styles.mediaBadge} title="Видео">
|
||||
<span className={styles.mediaBadge} title={ui.videoBadge}>
|
||||
<IconVideoBadge />
|
||||
</span>
|
||||
) : null}
|
||||
{showCornerAudio ? (
|
||||
<span className={styles.mediaBadge} title="Аудио">
|
||||
<span className={styles.mediaBadge} title={ui.audioBadge}>
|
||||
<IconAudioBadge />
|
||||
</span>
|
||||
) : null}
|
||||
@@ -222,19 +265,19 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.nodeBody}>
|
||||
<div className={styles.title}>{data.title || 'Без названия'}</div>
|
||||
<div className={styles.title}>{data.title || ui.untitled}</div>
|
||||
{data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? (
|
||||
<div className={styles.musicParams}>
|
||||
{data.hasAnyAudioLoop ? (
|
||||
<div className={styles.musicParam}>
|
||||
<IconLoopParam />
|
||||
<span>Цикл</span>
|
||||
<span>{ui.loop}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{data.hasAnyAudioAutoplay ? (
|
||||
<div className={styles.musicParam}>
|
||||
<IconAutoplayParam />
|
||||
<span>Автостарт</span>
|
||||
<span>{ui.autoplay}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -244,13 +287,13 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
{data.showPreviewVideoAutostart ? (
|
||||
<div className={styles.musicParam}>
|
||||
<IconVideoPreviewAutostart />
|
||||
<span>Авто превью</span>
|
||||
<span>{ui.previewAutostart}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{data.showPreviewVideoLoop ? (
|
||||
<div className={styles.musicParam}>
|
||||
<IconLoopParam />
|
||||
<span>Цикл видео</span>
|
||||
<span>{ui.videoLoop}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -265,18 +308,19 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
const nodeTypes = { sceneCard: SceneCardNode };
|
||||
|
||||
function GraphZoomToolbar() {
|
||||
const ui = useContext(GraphUiContext);
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
const zoom = useStore((s) => s.transform[2]);
|
||||
const pct = Math.max(1, Math.round(zoom * 100));
|
||||
|
||||
return (
|
||||
<Panel position="bottom-center" className={styles.zoomPanel}>
|
||||
<div className={styles.zoomBar} role="toolbar" aria-label="Масштаб графа">
|
||||
<button type="button" className={styles.zoomBtn} onClick={() => zoomIn()} aria-label="Увеличить">
|
||||
<div className={styles.zoomBar} role="toolbar" aria-label={ui.zoomBar}>
|
||||
<button type="button" className={styles.zoomBtn} onClick={() => zoomIn()} aria-label={ui.zoomIn}>
|
||||
+
|
||||
</button>
|
||||
<span className={styles.zoomPct}>{pct}%</span>
|
||||
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label="Уменьшить">
|
||||
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label={ui.zoomOut}>
|
||||
−
|
||||
</button>
|
||||
<span className={styles.zoomDivider} aria-hidden />
|
||||
@@ -284,8 +328,8 @@ function GraphZoomToolbar() {
|
||||
type="button"
|
||||
className={styles.zoomBtn}
|
||||
onClick={() => fitView({ padding: 0.25 })}
|
||||
aria-label="Показать всё"
|
||||
title="Показать всё"
|
||||
aria-label={ui.fitAll}
|
||||
title={ui.fitAll}
|
||||
>
|
||||
<svg className={styles.zoomFitIcon} viewBox="0 0 24 24" width={18} height={18} aria-hidden>
|
||||
<path
|
||||
@@ -307,6 +351,7 @@ function SceneGraphCanvas({
|
||||
sceneGraphEdges,
|
||||
sceneCardById,
|
||||
currentSceneId,
|
||||
graphUi,
|
||||
onCurrentSceneChange,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
@@ -316,6 +361,7 @@ function SceneGraphCanvas({
|
||||
onSetGraphNodeStart,
|
||||
onDropSceneFromList,
|
||||
}: SceneGraphProps) {
|
||||
const ui = graphUi ?? DEFAULT_SCENE_GRAPH_UI;
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; graphNodeId: GraphNodeId } | null>(null);
|
||||
|
||||
@@ -446,109 +492,111 @@ function SceneGraphCanvas({
|
||||
}, [menu]);
|
||||
|
||||
return (
|
||||
<div className={styles.canvasWrap}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onNodeDragStop={(_, node) => {
|
||||
onNodePositionCommit(node.id as GraphNodeId, node.position.x, node.position.y);
|
||||
}}
|
||||
onEdgesChange={onEdgesChange}
|
||||
isValidConnection={isValidConnection}
|
||||
onConnect={onConnectInternal}
|
||||
onEdgesDelete={(eds) => {
|
||||
for (const ed of eds) {
|
||||
onDisconnect(ed.id);
|
||||
}
|
||||
}}
|
||||
onEdgeClick={(_, edge) => {
|
||||
onDisconnect(edge.id);
|
||||
}}
|
||||
onNodesDelete={(nds) => {
|
||||
onRemoveGraphNodes(nds.map((n) => n.id as GraphNodeId));
|
||||
}}
|
||||
onNodeClick={(_, node) => {
|
||||
setMenu(null);
|
||||
const d = node.data as SceneCardData;
|
||||
onCurrentSceneChange(d.sceneId);
|
||||
}}
|
||||
onNodeContextMenu={(e, node) => {
|
||||
e.preventDefault();
|
||||
setMenu({ x: e.clientX, y: e.clientY, graphNodeId: node.id as GraphNodeId });
|
||||
}}
|
||||
onPaneClick={() => {
|
||||
setMenu(null);
|
||||
}}
|
||||
onPaneContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu(null);
|
||||
}}
|
||||
onInit={(instance) => {
|
||||
instance.fitView({ padding: 0.25 });
|
||||
}}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
panOnScroll
|
||||
selectionOnDrag={false}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background gap={18} size={1} color="rgba(255,255,255,0.06)" />
|
||||
<GraphZoomToolbar />
|
||||
</ReactFlow>
|
||||
{menu && menuPosition
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Закрыть меню"
|
||||
className={styles.menuBackdrop}
|
||||
onClick={() => setMenu(null)}
|
||||
/>
|
||||
<div
|
||||
role="menu"
|
||||
tabIndex={-1}
|
||||
className={styles.ctxMenu}
|
||||
style={{ left: menuPosition.x, top: menuPosition.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setMenu(null);
|
||||
}}
|
||||
>
|
||||
<GraphUiContext.Provider value={ui}>
|
||||
<div className={styles.canvasWrap}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onNodeDragStop={(_, node) => {
|
||||
onNodePositionCommit(node.id as GraphNodeId, node.position.x, node.position.y);
|
||||
}}
|
||||
onEdgesChange={onEdgesChange}
|
||||
isValidConnection={isValidConnection}
|
||||
onConnect={onConnectInternal}
|
||||
onEdgesDelete={(eds) => {
|
||||
for (const ed of eds) {
|
||||
onDisconnect(ed.id);
|
||||
}
|
||||
}}
|
||||
onEdgeClick={(_, edge) => {
|
||||
onDisconnect(edge.id);
|
||||
}}
|
||||
onNodesDelete={(nds) => {
|
||||
onRemoveGraphNodes(nds.map((n) => n.id as GraphNodeId));
|
||||
}}
|
||||
onNodeClick={(_, node) => {
|
||||
setMenu(null);
|
||||
const d = node.data as SceneCardData;
|
||||
onCurrentSceneChange(d.sceneId);
|
||||
}}
|
||||
onNodeContextMenu={(e, node) => {
|
||||
e.preventDefault();
|
||||
setMenu({ x: e.clientX, y: e.clientY, graphNodeId: node.id as GraphNodeId });
|
||||
}}
|
||||
onPaneClick={() => {
|
||||
setMenu(null);
|
||||
}}
|
||||
onPaneContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu(null);
|
||||
}}
|
||||
onInit={(instance) => {
|
||||
instance.fitView({ padding: 0.25 });
|
||||
}}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
panOnScroll
|
||||
selectionOnDrag={false}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background gap={18} size={1} color="rgba(255,255,255,0.06)" />
|
||||
<GraphZoomToolbar />
|
||||
</ReactFlow>
|
||||
{menu && menuPosition
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItem}
|
||||
onClick={() => {
|
||||
if (menuNodeIsStart) {
|
||||
onSetGraphNodeStart(null);
|
||||
} else {
|
||||
onSetGraphNodeStart(menu.graphNodeId);
|
||||
}
|
||||
setMenu(null);
|
||||
aria-label={ui.closeMenu}
|
||||
className={styles.menuBackdrop}
|
||||
onClick={() => setMenu(null)}
|
||||
/>
|
||||
<div
|
||||
role="menu"
|
||||
tabIndex={-1}
|
||||
className={styles.ctxMenu}
|
||||
style={{ left: menuPosition.x, top: menuPosition.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setMenu(null);
|
||||
}}
|
||||
>
|
||||
{menuNodeIsStart ? 'Снять метку «Начальная сцена»' : 'Начальная сцена'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItemDanger}
|
||||
onClick={() => {
|
||||
onRemoveGraphNode(menu.graphNodeId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItem}
|
||||
onClick={() => {
|
||||
if (menuNodeIsStart) {
|
||||
onSetGraphNodeStart(null);
|
||||
} else {
|
||||
onSetGraphNodeStart(menu.graphNodeId);
|
||||
}
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{menuNodeIsStart ? ui.unsetStartScene : ui.startScene}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItemDanger}
|
||||
onClick={() => {
|
||||
onRemoveGraphNode(menu.graphNodeId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{ui.delete}
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</GraphUiContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user