diff --git a/docs/BOARD.md b/docs/BOARD.md index ec47138d..369387c2 100644 --- a/docs/BOARD.md +++ b/docs/BOARD.md @@ -239,3 +239,4 @@ | 导出历史进导出标签(画布重设计稿「页签去重」表:导出历史 → 导出标签,第二批) | 🅿️ | PR-6 页签去重撤掉画布底栏时,底栏的「导出历史」页签本来就只画了标签、没有内容,随底栏一起删了,没有搬。要做:在导出标签里列历次导出(文件、时间、镜数、用的是哪些 take),数据来源要先定——仓库眼下不记导出历史(导出服务只产出文件),得先有一张导出记录表与对应的前向迁移;交付目录里写出的 metadata.json(delivery_metadata.dart)能回读一部分,但只覆盖「交付」,不覆盖普通的「导出 mp4」 | | 检查器「历史」页的「固定」:稿上 take 行带图钉,固定的那张「往下游流」。现在没有固定这个数据模型——下游用哪张 take 由连线的源节点决定,所以历史页改画「在用」(有出边的 take:首帧 / 尾帧 / 参考),不画图钉 | 🅿️ | 2026-10-14 画布页去重 PR。要做得先定语义:固定 = 替换下游所有引用它兄弟 take 的边的源?还是节点级的「当前 take」字段(要加列 + 迁移)? | | 任务弹层的「耗时」:稿上行 meta 写「万相 2.2 · 已用 12 秒 / 完成 · 18 秒」。JobState 没有起止时间(原渲染队列同样因此去掉了耗时列),所以弹层只写 provider · 状态 | 🅿️ | 2026-10-14 画布页去重 PR。要做得给 JobState 加 startedAt / finishedAt(队列编排处打点),注意持久化的 jobs 表与启动孤儿回收 | +| 画布右键菜单的剪切 / 复制 / 粘贴、导入媒体(画布重设计稿 › 画布交互「右键菜单」:空白处「粘贴 Ctrl V」「导入媒体… Ctrl U」,节点「剪切 Ctrl X」「复制 Ctrl C」) | 🅿️ | 2026-10-15 新建菜单 PR(PR-8a)按「无后端不画」不放:画布没有剪贴板(节点 / 连线的序列化载荷、粘贴时的 id 重映射与落点规则都没有),也没有媒体导入(把本地图片 / 视频拷进项目目录、落成 result 节点的那条写入路径)。空白处右键菜单这一版只有新建… / 导入脚本… / 全选 / 适应视图。要做先定剪贴板载荷(可复用 LB-12 导入重映射器的 id 重映射)与粘贴落点,再补媒体导入的写入服务;菜单届时直接加项 | diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index aef77fb6..62f01715 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -236,8 +236,8 @@ lib/ │ ├── canvas/ # Node canvas │ │ ├── models/ │ │ ├── providers/ # Riverpod ViewModels; canvas_compact_cards.dart(缩放 < 0.6 时节点卡只画类型图标——只在跨阈值时通知,缩放时不逐帧重建卡片);left_rail_state.dart(PR-4 画布左栏:停在哪一页〔大纲 / 素材 / 角色〕+ 面板展开 / 收起,非 autoDispose——「管理」链接与 Ctrl+\ / ⌘\ 都是跨子树来开合 / 切页;取代 P4 的 project_panel_tab.dart)+ character_usage.dart(角色 id → 引用它的 config 节点,只数 config、跨全项目画布、按节点 id 去重;数据源复用 galleryGraphProvider,挂/摘角色后由 CharactersSection 定点 invalidate)+ canvas_bootstrap_controller.dart(短剧示例:单事务建项目/画布/泳道/config 节点,并把 assets/samples/ 的两张打包成片落盘 + 种成 result 节点——P7 把内置示例页并进来了;成片 best-effort,读不出来就少种两个节点)+ inspector_submit_controller.dart(提交四态 + type_config 单写入链 + 自动保存失败态;并实现 `PendingWriteFlushable`、在 build 里注册进 `pendingWriteRegistryProvider`——退出序列在关连接池之前冲一次挂起的防抖 patch 与 `_unsaved`,BOARD 第 201 行)+ canvas_tool_mode.dart(浮动工具条的工具态:选择 / 平移 + 按住空格临时平移,两个来源分开记、按画布 autoDispose;实际是否平移 = 二者取或,舞台指针闸 / 手形光标 / 工具条高亮共读 canvasPanActiveProvider)+ inspector_tab.dart(PR-6 检查器停在「属性」还是「历史」;autoDispose,换选中节点不重置) -│ │ ├── util/ # incl. narrative_order.dart (SB-5 chain ordering) + node_artifacts.dart (node → latest result) + camera_labels.dart (运镜 / 景别 / 机位角度 / 焦段 → ARB 文案 + parseCameraMovement + shotLanguageSummary) + batch_slot_view.dart (P4: slot 行 → 四种呈现态 promoted/success/error/generating 的唯一判据 + canPromote/aspectRatio) + ref_image_budget.dart (CH-2②: 参考图配额纯判据——边连 reference 入边 + 挂载角色图合计 vs maxRefImages,算出"谁被截掉几张";检查器提交前点名、生成侧落 warn 用同一口径。首/尾帧不占配额) + edge_style.dart(连线外观的唯一判据:叙事 / 首帧 / 尾帧 / 参考 / 产出五种线型、颜色槽、标签,选中只换色)+ lineage_edges.dart(产出线由 source_node_id 推导、不入库不可点不可删;端口占用判据)+ result_placement.dart(成片落点:config 右侧一列、第 k 张往下错开;与 config 同道) + prompt_bar_width.dart(提示词条宽 = 画布宽 × 0.6,夹在 480–880;窄于 512 贴边留 16)+ canvas_tool_actions.dart(浮动工具条与单键快捷键 V/I/G/T/F/L 共用的唯一实现:视口中心建节点 / 建泳道 / 切连线 / 选择工具)+ outline_model.dart(PR-4 左栏大纲的分组纯函数:链上配置节点按泳道分组〔组序 = 泳道 sortOrder、组内 = 叙事链序〕→ 未分泳道 → 未入链〔按位置、不编号〕,名称 / 提示词筛选;narrativeChainMembers / narrativeSequenceIndex 是「在链上」与链序号的唯一判据,⌘K 镜头组共用)+ viewport_focus.dart(保持缩放、把节点渲染矩形〔世界 + 分道位移〕移到视口中心的纯函数) -│ │ └── widgets/ # canvas_screen.dart(左栏 | 舞台 + 浮层 | 检查器;PR-6 页签去重撤掉了舞台上方的画布头与底部的渲染队列——画布名在面包屑、缩放比例在缩放条、任务进状态栏胶囊,舞台上沿贴外壳标签栏、下沿贴状态栏)+ canvas_toolbar.dart(舞台顶部居中的浮动工具条:选择 · 平移 | 图像 · 视频 · 文本 · 分镜 · 泳道 · 连线 | 更多〔导入脚本〕;取代原左侧 37px 工具条与右下角加号)+ canvas_zoom_bar.dart(舞台左下的浮动缩放条)+ canvas_inspector_panel.dart(右侧检查器,页签「属性 / 历史」,原「状态」页签已删)+ inspector_history_panel.dart(检查器「历史」页:选中节点的 take 列表——配置节点列名下全部产物、产物节点列同源兄弟并高亮自己;take 按时间正序编号;有 data 出边的标「在用」;点行 = 单选 + 居中)+ canvas_shortcuts.dart(删除 / Esc / 全选 / 缩放 + 工具单键 V/H/I/G/T/F/L,键位常量 CanvasToolKeys 与工具条 tooltip 同源;按住空格临时平移走 Focus.onKeyEvent,只在画布本身持焦时接管;Ctrl+\ / ⌘\ 开合左栏)。PR-4 左栏 left_rail/:canvas_left_rail.dart(43px 图标导航条:大纲 / 素材 / 角色三页键 + 快捷键〔开设置的快捷键页〕+ 收起;剧本 / 帮助无后端不画)/ left_rail_panel.dart(241 面板宿主,收起时不占宽)/ outline_panel.dart(泳道分组的镜头行 + 产物子行 + 生成中子行,状态走 node_card 的 nodeStatusOf;点行 = 单选 + 移到视口中心)/ canvas_switcher.dart(大纲面板头的画布切换胶囊,接替旧画布树;同项目 openCanvas 不带 withProject)/ assets_panel.dart + assets_model.dart(本画布 / 项目全部 + 类型 chip + 两列网格,数据复用画廊 galleryGraphProvider / galleryController;点格子 = 当前画布定位或走画廊「在画布中定位」)/ left_panel_parts.dart(面板头 + 筛选框 + 说明行)/ left_rail_actions.dart(focusNodeOnCanvas:单选 + 居中)。P4 批量:batch_results_grid.dart(检查器内联 2×N 网格,宿主 image_result_inspector.dart 只剩一层 s12 内边距——宽度跟面板走)/ batch_compare_overlay.dart(全屏并排对比;「叠加对比」是禁用标签,不做清单)/ batch_slot_parts.dart(BatchSlotImage + BatchTappable + runBatchSlotAction——slot 动作的统一执行口,只捕具体失败类型并落 toast)。P4 角色:character_library_panel.dart(左栏角色页,PR-4 起顶上是面板头 + 「搜索角色…」筛选;行 = 缩略图 + 名 + 「N 张参考图 · M 处引用」+ ⋯ 菜单)/ character_edit_dialog.dart(改名 + 描述 + 参考图增删拖排 + 「被引用」) +│ │ ├── util/ # incl. narrative_order.dart (SB-5 chain ordering) + node_artifacts.dart (node → latest result) + camera_labels.dart (运镜 / 景别 / 机位角度 / 焦段 → ARB 文案 + parseCameraMovement + shotLanguageSummary) + batch_slot_view.dart (P4: slot 行 → 四种呈现态 promoted/success/error/generating 的唯一判据 + canPromote/aspectRatio) + ref_image_budget.dart (CH-2②: 参考图配额纯判据——边连 reference 入边 + 挂载角色图合计 vs maxRefImages,算出"谁被截掉几张";检查器提交前点名、生成侧落 warn 用同一口径。首/尾帧不占配额) + edge_style.dart(连线外观的唯一判据:叙事 / 首帧 / 尾帧 / 参考 / 产出五种线型、颜色槽、标签,选中只换色)+ lineage_edges.dart(产出线由 source_node_id 推导、不入库不可点不可删;端口占用判据)+ result_placement.dart(成片落点:config 右侧一列、第 k 张往下错开;与 config 同道) + prompt_bar_width.dart(提示词条宽 = 画布宽 × 0.6,夹在 480–880;窄于 512 贴边留 16)+ canvas_tool_actions.dart(浮动工具条与单键快捷键 V/I/G/T/F/L 共用的唯一实现:视口中心建节点 / 建泳道 / 切连线 / 选择工具;PR-8a 起还有新建菜单的动作:在菜单锚点建节点并连好线〔先建节点再连线,边写失败回滚删节点 + 错误卡,成功挂定位请求单选〕、端口拖到卡上连线〔走 linkTo〕、全选、适应视图)+ create_menu_model.dart(PR-8a 新建菜单项判据:带连线上下文时源带图像产物出首帧 / 尾帧 / 参考三项、配置节点〔shot / image / video〕出「下一镜」;不带上下文出图像配置 / 视频配置 / 文本 / 镜头 / 泳道;搜索过滤)+ drop_placement.dart(落点换算:拖卡片落点与新建菜单落点共用的泳道归属判定 resolveLaneDrop、视口点 → 世界坐标、舞台上一点落在哪张卡上)+ outline_model.dart(PR-4 左栏大纲的分组纯函数:链上配置节点按泳道分组〔组序 = 泳道 sortOrder、组内 = 叙事链序〕→ 未分泳道 → 未入链〔按位置、不编号〕,名称 / 提示词筛选;narrativeChainMembers / narrativeSequenceIndex 是「在链上」与链序号的唯一判据,⌘K 镜头组共用)+ viewport_focus.dart(保持缩放、把节点渲染矩形〔世界 + 分道位移〕移到视口中心的纯函数) +│ │ └── widgets/ # canvas_screen.dart(左栏 | 舞台 + 浮层 | 检查器;PR-6 页签去重撤掉了舞台上方的画布头与底部的渲染队列——画布名在面包屑、缩放比例在缩放条、任务进状态栏胶囊,舞台上沿贴外壳标签栏、下沿贴状态栏)+ canvas_toolbar.dart(舞台顶部居中的浮动工具条:选择 · 平移 | 图像 · 视频 · 文本 · 分镜 · 泳道 · 连线 | 更多〔导入脚本〕;取代原左侧 37px 工具条与右下角加号)+ canvas_zoom_bar.dart(舞台左下的浮动缩放条)+ canvas_inspector_panel.dart(右侧检查器,页签「属性 / 历史」,原「状态」页签已删)+ inspector_history_panel.dart(检查器「历史」页:选中节点的 take 列表——配置节点列名下全部产物、产物节点列同源兄弟并高亮自己;take 按时间正序编号;有 data 出边的标「在用」;点行 = 单选 + 居中)+ canvas_shortcuts.dart(删除 / Esc / 全选 / 缩放 + 工具单键 V/H/I/G/T/F/L,键位常量 CanvasToolKeys 与工具条 tooltip 同源;按住空格临时平移走 Focus.onKeyEvent,只在画布本身持焦时接管;Ctrl+\ / ⌘\ 开合左栏;Tab = 新建…,同样只在画布本身持主焦点时接管、别处的 Tab 放行给焦点遍历)+ create_node_menu.dart(PR-8a 新建菜单:从出端口拖线到空白处 / 双击空白 / Tab / 空白处右键「新建…」开同一个带搜索的菜单;canvasMenuRequestProvider〔开着哪个菜单、锚点、源节点〕+ canvasWireDragProvider〔拖线实时状态,Esc 取消〕+ CanvasMenuHost〔OverlayPortal 浮层,点外面 / 失焦关〕+ CanvasMenuPanel〔↑↓ / ↵ / Esc〕;空白处右键菜单只有新建… / 导入脚本… / 全选 / 适应视图)。PR-4 左栏 left_rail/:canvas_left_rail.dart(43px 图标导航条:大纲 / 素材 / 角色三页键 + 快捷键〔开设置的快捷键页〕+ 收起;剧本 / 帮助无后端不画)/ left_rail_panel.dart(241 面板宿主,收起时不占宽)/ outline_panel.dart(泳道分组的镜头行 + 产物子行 + 生成中子行,状态走 node_card 的 nodeStatusOf;点行 = 单选 + 移到视口中心)/ canvas_switcher.dart(大纲面板头的画布切换胶囊,接替旧画布树;同项目 openCanvas 不带 withProject)/ assets_panel.dart + assets_model.dart(本画布 / 项目全部 + 类型 chip + 两列网格,数据复用画廊 galleryGraphProvider / galleryController;点格子 = 当前画布定位或走画廊「在画布中定位」)/ left_panel_parts.dart(面板头 + 筛选框 + 说明行)/ left_rail_actions.dart(focusNodeOnCanvas:单选 + 居中)。P4 批量:batch_results_grid.dart(检查器内联 2×N 网格,宿主 image_result_inspector.dart 只剩一层 s12 内边距——宽度跟面板走)/ batch_compare_overlay.dart(全屏并排对比;「叠加对比」是禁用标签,不做清单)/ batch_slot_parts.dart(BatchSlotImage + BatchTappable + runBatchSlotAction——slot 动作的统一执行口,只捕具体失败类型并落 toast)。P4 角色:character_library_panel.dart(左栏角色页,PR-4 起顶上是面板头 + 「搜索角色…」筛选;行 = 缩略图 + 名 + 「N 张参考图 · M 处引用」+ ⋯ 菜单)/ character_edit_dialog.dart(改名 + 描述 + 参考图增删拖排 + 「被引用」) │ ├── command_palette/ # ⌘K/Ctrl+K command palette (PL-1; app-level, wraps InkShell). Screens 稿第 4 屏右:跨实体搜索,三组「镜头 · 当前画布 / 产物 / 动作」 │ │ ├── palette_entry.dart # PaletteEntry / PaletteChoice(hand-written value objects; run = ↵ 打开, locate = ⌘↵ 在画布中定位) │ │ ├── palette_search.dart # buildPaletteEntries — 镜头组只搜当前画布已加载节点(不为搜索读库)、产物组复用 galleryController、动作组 = buildCommandActions diff --git a/lib/features/canvas/providers/canvas_focus_request.dart b/lib/features/canvas/providers/canvas_focus_request.dart index ae32953b..cdfb7d61 100644 --- a/lib/features/canvas/providers/canvas_focus_request.dart +++ b/lib/features/canvas/providers/canvas_focus_request.dart @@ -9,24 +9,31 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -/// 一条定位请求:在 [canvasId] 上单选 [nodeId] 并把它移到视口中心。 +/// 一条定位请求:在 [canvasId] 上单选 [nodeId];[center] 为真时再把它移到视口中心。 @immutable class CanvasFocusRequest { - const CanvasFocusRequest({required this.canvasId, required this.nodeId}); + const CanvasFocusRequest({required this.canvasId, required this.nodeId, this.center = true}); final String canvasId; final String nodeId; + /// 落地时要不要把节点移到视口中心。「定位」类入口要(节点可能在视口外);新建菜单建出来的 + /// 节点就落在用户刚点的地方,挪相机只会让人找不着北——那里传 false,只单选。 + final bool center; + @override bool operator ==(Object other) => identical(this, other) || - other is CanvasFocusRequest && other.canvasId == canvasId && other.nodeId == nodeId; + other is CanvasFocusRequest && + other.canvasId == canvasId && + other.nodeId == nodeId && + other.center == center; @override - int get hashCode => Object.hash(canvasId, nodeId); + int get hashCode => Object.hash(canvasId, nodeId, center); @override - String toString() => 'CanvasFocusRequest($canvasId, $nodeId)'; + String toString() => 'CanvasFocusRequest($canvasId, $nodeId, center: $center)'; } final canvasFocusRequestProvider = @@ -40,8 +47,8 @@ class CanvasFocusRequestController extends Notifier { CanvasFocusRequest? build() => null; /// 挂起一条请求;已有未消费的请求时以新的为准(用户最后一次点的那个)。 - void request({required String canvasId, required String nodeId}) => - state = CanvasFocusRequest(canvasId: canvasId, nodeId: nodeId); + void request({required String canvasId, required String nodeId, bool center = true}) => + state = CanvasFocusRequest(canvasId: canvasId, nodeId: nodeId, center: center); /// 消费 [request]:只清它自己——落地在帧后,期间可能已经挂起了新的一条。 void consume(CanvasFocusRequest request) { diff --git a/lib/features/canvas/providers/canvas_nodes_controller.dart b/lib/features/canvas/providers/canvas_nodes_controller.dart index 73a3f530..83683f27 100644 --- a/lib/features/canvas/providers/canvas_nodes_controller.dart +++ b/lib/features/canvas/providers/canvas_nodes_controller.dart @@ -72,6 +72,7 @@ class CanvasNodesController required CanvasNodeType type, NodeRole role = NodeRole.config, String? sourceNodeId, + String? laneId, Offset position = Offset.zero, Size? size, Map typeConfig = const {}, @@ -95,6 +96,7 @@ class CanvasNodesController nodeRole: role.name, label: label, sourceNodeId: sourceNodeId, + laneId: laneId, positionX: position.dx, positionY: position.dy, width: nodeSize.width, @@ -108,6 +110,7 @@ class CanvasNodesController role: role, canvasId: canvasId, sourceNodeId: sourceNodeId, + laneId: laneId, typeConfig: typeConfig, position: position, size: nodeSize, diff --git a/lib/features/canvas/util/canvas_tool_actions.dart b/lib/features/canvas/util/canvas_tool_actions.dart index 53fd0e18..124620ce 100644 --- a/lib/features/canvas/util/canvas_tool_actions.dart +++ b/lib/features/canvas/util/canvas_tool_actions.dart @@ -6,6 +6,9 @@ // // 与 canvas_node_delete.dart 同一类:要 BuildContext(弹框 / 出提示)的画布动作。 // 建泳道要开泳道编辑框,所以这里反向引了 widgets/lane_edit_dialog.dart。 +// +// 新建菜单(widgets/create_node_menu.dart)的动作也在这里:在菜单锚点建节点并连好线(addNodeFromMenu, +// 不留半截)、从出端口拖到另一张卡上连线(linkByPortDrop,走连线模式那一条 linkTo)、全选、适应视图。 import 'dart:math'; import 'package:flutter/widgets.dart'; @@ -14,14 +17,23 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/errors/ink_error.dart'; import '../../../l10n/l10n_x.dart'; import '../models/canvas_node.dart'; +import '../models/style_lane.dart'; +import '../providers/canvas_edges_controller.dart'; +import '../providers/canvas_focus_request.dart'; import '../providers/canvas_lanes_controller.dart'; import '../providers/canvas_nodes_controller.dart'; import '../providers/canvas_selection_controller.dart'; import '../providers/canvas_tool_mode.dart'; import '../providers/canvas_transform_controller.dart'; +import '../providers/lane_collapse_controller.dart'; +import '../providers/link_action_controller.dart'; import '../providers/link_mode_controller.dart'; import '../widgets/lane_edit_dialog.dart'; import 'canvas_snackbars.dart'; +import 'canvas_zoom.dart'; +import 'create_menu_model.dart'; +import 'drop_placement.dart'; +import 'lane_geometry.dart'; import 'node_position.dart'; /// 新节点(或导入脚本那条链的第一镜)的落点:视口中心(债150;视口未上报时回退旧 @@ -115,3 +127,123 @@ void activateSelectTool(WidgetRef ref, {required String canvasId}) { ref.read(canvasToolModeProvider(canvasId).notifier).select(); ref.read(linkModeControllerProvider(canvasId).notifier).cancel(); } + +/// 全选当前画布的全部节点(Ctrl+A / ⌘A 与空白处右键菜单「全选」共用)。节点还没载入时不动。 +void selectAllNodes(WidgetRef ref, {required String canvasId}) { + final List? nodes = + ref.read(canvasNodesControllerProvider(canvasId)).valueOrNull; + if (nodes == null) return; + ref + .read(canvasSelectionControllerProvider(canvasId).notifier) + .selectAll(nodes.map((CanvasNode n) => n.id)); +} + +/// 适应视图:回到初始相机——与左下缩放条的「适应」格同一个动作(canvas_zoom_bar.dart 那一格写的 +/// 也是 initialCanvasTransform)。 +void fitCanvasView(WidgetRef ref, {required String canvasId}) { + ref.read(canvasTransformControllerProvider(canvasId)).value = + initialCanvasTransform(); +} + +/// 从出端口拖到另一张卡上松手:等价于「连线模式里选源 → 点目标」。先以 [sourceNodeId] 进连线模式, +/// 再走 LinkActionController.linkTo——智能默认角色(image → video 默认首帧)、23505 去重、结果提示 +/// (画布 ref.listen 出通知卡)都是那一条路径的,不另写一套;linkTo 无论成败都会退出连线模式。 +Future linkByPortDrop( + WidgetRef ref, { + required String canvasId, + required String sourceNodeId, + required String targetNodeId, +}) { + ref.read(linkModeControllerProvider(canvasId).notifier).start(sourceNodeId); + return ref + .read(linkActionControllerProvider(canvasId).notifier) + .linkTo(targetNodeId); +} + +/// 新建菜单的落点:视口里的 [viewportPoint](菜单锚点)→ 新卡片左上角的世界坐标 + 泳道归属。 +/// 相机、泳道切片(有效厚度,折叠的道按轨宽算)与方向都取此刻的现值,判定本体见 drop_placement.dart。 +NodePlacement menuNodePlacement( + WidgetRef ref, { + required String canvasId, + required Offset viewportPoint, +}) { + final List lanes = + ref.read(canvasLanesControllerProvider(canvasId)).valueOrNull ?? + const []; + return createNodePlacement( + viewportPoint: viewportPoint, + transform: ref.read(canvasTransformControllerProvider(canvasId)).value, + laneSlices: collapseLaneSlices( + <({String id, double size})>[ + for (final StyleLane l in lanes) (id: l.id, size: l.size), + ], + ref.read(laneCollapseProvider(canvasId)), + ), + direction: ref.read(canvasLaneDirectionProvider(canvasId)).valueOrNull ?? + LaneDirection.horizontal, + ); +} + +/// 新建菜单建节点:在 [placement] 建一个 [type] 配置节点;给了 [edge] 就再从 [sourceNodeId] 连一条线到它。 +/// +/// 写入顺序 = 先建节点(落进泳道的再写泳道归属)、再连线。**不留半截**:节点建好之后任何一步失败, +/// 都把刚建的节点删掉(removeNode 同一个事务里级联软删它身上已有的线),再弹错误卡「添加节点失败」。 +/// 成功后挂一条定位请求——新节点在画布下一帧被单选(canvas_view 的 _PendingFocusApplier 落地, +/// 不在这里直接写选中)。 +Future addNodeFromMenu( + BuildContext context, + WidgetRef ref, { + required String canvasId, + required CanvasNodeType type, + required NodePlacement placement, + String? sourceNodeId, + CreateMenuEdge? edge, +}) async { + // 文案与控制器都在 await 之前取:await 之后 context 可能已卸载。 + final String label = context.l10n.canvasNodeDefaultLabel; + final String failed = context.l10n.canvasAddNodeFailed; + final CanvasNodesController nodes = + ref.read(canvasNodesControllerProvider(canvasId).notifier); + final CanvasEdgesController edges = + ref.read(canvasEdgesControllerProvider(canvasId).notifier); + final CanvasFocusRequestController focus = + ref.read(canvasFocusRequestProvider.notifier); + + final CanvasNode created; + try { + // 泳道归属随建节点一次写进去(落进道里的卡与拖卡片落点同一套判定,见 menuNodePlacement)。 + created = await nodes.addNode( + label: label, + type: type, + position: placement.world, + laneId: placement.laneId, + ); + } on InkError catch (_) { + if (context.mounted) showCanvasSnack(context, failed); + return; + } + + try { + final CreateMenuEdge? link = edge; + if (link != null && sourceNodeId != null) { + await edges.addEdge( + sourceNodeId: sourceNodeId, + targetNodeId: created.id, + edgeType: link.type, + role: link.role, + ); + } + } on InkError catch (_) { + try { + await nodes.removeNode(created.id); + } on InkError catch (_) { + // 回滚也没成:节点留在画布上——至少让它被选中,用户看得见、能手动删。 + focus.request(canvasId: canvasId, nodeId: created.id, center: false); + } + if (context.mounted) showCanvasSnack(context, failed); + return; + } + + // 只单选、不挪相机:新节点就落在用户刚点的地方(拖线松手点 / 双击点 / 右键点 / Tab 的视口中心)。 + focus.request(canvasId: canvasId, nodeId: created.id, center: false); +} diff --git a/lib/features/canvas/util/create_menu_model.dart b/lib/features/canvas/util/create_menu_model.dart new file mode 100644 index 00000000..b835913a --- /dev/null +++ b/lib/features/canvas/util/create_menu_model.dart @@ -0,0 +1,166 @@ +// 新建菜单(画布重设计稿 › 画布交互「拖线到空白处 → 建节点」)的菜单项判据——纯函数,不碰 widget / provider。 +// +// 四个入口(从出端口拖到空白处 / 双击空白 / Tab / 空白处右键「新建…」)开的是同一个菜单,区别只在有没有 +// 「从哪张卡拖出来」的连线上下文: +// - 带上下文:源带图像产物 ⇒ 当首帧 / 当尾帧(建视频配置)、当参考(建图像配置),各连一条对应角色的 +// data 边;源有叙事锚点 ⇒「下一镜」(建与锚点同类型的配置节点 + 锚点 → 新节点的 narrative 边)。 +// 两组之间一条分隔线。 +// 视频产物不出前三项——首帧 / 尾帧 / 参考是图像语义。 +// - 不带上下文:图像配置 · 视频配置 · 文本 · 镜头 · 泳道。 +// +// 【叙事边只连配置节点】仓库里建 narrative 边的只有两处:导入脚本(shot → shot)与 shot 检查器的 +// 「用本镜备注生成」(shot → image / video 配置)。读侧全把叙事链当成「配置节点的序列」:export_order +// 头注写明 result 节点「自己不在 narrative 链上」,sequence_builder 不让 result 独立成镜,左栏大纲只列链上的 +// 配置节点。text 节点进了链也成不了一镜(没有备注、没有产物,buildSequence 直接跳过)。所以「下一镜」的叙事 +// 边一律连在 shot / image / video 的**配置节点**上([narrativeAnchorOf]):从配置卡拖出来就是它自己;从产物 +// 卡拖出来(稿上的示例源正是「镜头 01 · 成片」)是它的来源配置节点。文本卡、来源已删的孤儿产物没有锚点, +// 不出这一项。 +import '../models/canvas_edge.dart'; +import '../models/canvas_node.dart'; +import 'node_artifacts.dart'; + +/// 新建菜单里的一项。 +enum CreateMenuAction { + /// 当首帧 · 新建视频配置:源 → 新节点一条 data 边,角色首帧。 + asFirstFrame, + + /// 当尾帧 · 新建视频配置:data 边,角色尾帧。 + asLastFrame, + + /// 当参考 · 新建图像配置:data 边,角色参考。 + asReference, + + /// 下一镜(叙事):与源同类型的配置节点 + narrative 边(源 → 新)。 + nextShot, + + /// 图像配置(I)。 + imageConfig, + + /// 视频配置(G)。 + videoConfig, + + /// 文本(T)。 + text, + + /// 镜头。 + shot, + + /// 泳道(F):不建节点,开「新建泳道」框。 + lane, +} + +/// 一项要连的线:源 → 新节点。 +typedef CreateMenuEdge = ({EdgeType type, EdgeRole role}); + +/// 不带连线上下文时的菜单项(与浮动工具条的建节点组同序)。 +const List kPlainCreateActions = [ + CreateMenuAction.imageConfig, + CreateMenuAction.videoConfig, + CreateMenuAction.text, + CreateMenuAction.shot, + CreateMenuAction.lane, +]; + +/// 带图像产物时的三项。 +const List kImageSourceActions = [ + CreateMenuAction.asFirstFrame, + CreateMenuAction.asLastFrame, + CreateMenuAction.asReference, +]; + +/// 源节点「带图像产物」:image 类型(配置或产物都算——图像配置连到视频上当首帧,是连线模式里 +/// 早就有的智能默认),或名下最新的产物是一张图。视频产物 / 视频配置不算。 +bool sourceCarriesImage(CanvasNode source, List nodes) { + if (source.type == CanvasNodeType.image) return true; + return latestResultFor(sourceNodeId: source.id, nodes: nodes)?.imageUrl != null; +} + +/// 「下一镜」的叙事锚点(narrative 边从它连出,新节点与它同类型):源自己是 shot / image / video 的配置 +/// 节点就是它;源是产物,就是它还在 [nodes] 里的来源配置节点(同样不能是文本)。没有锚点 ⇒ null, +/// 菜单不出「下一镜」(见头注)。 +CanvasNode? narrativeAnchorOf(CanvasNode source, List nodes) { + bool continuable(CanvasNode n) => n.role == NodeRole.config && n.type != CanvasNodeType.text; + if (continuable(source)) return source; + final String? origin = source.role == NodeRole.result ? source.sourceNodeId : null; + if (origin == null) return null; + for (final CanvasNode n in nodes) { + if (n.id == origin) return continuable(n) ? n : null; + } + return null; +} + +/// 菜单分组:组与组之间画分隔线,空组不出。[source] 为 null ⇒ 不带上下文的五项。 +List> createMenuGroups({ + CanvasNode? source, + List nodes = const [], +}) { + if (source == null) return const >[kPlainCreateActions]; + return >[ + if (sourceCarriesImage(source, nodes)) kImageSourceActions, + if (narrativeAnchorOf(source, nodes) != null) const [CreateMenuAction.nextShot], + ]; +} + +/// 搜索过滤:按 [labelOf] 给出的文字做不区分大小写的包含匹配;整组都没匹配上的组不出 +/// (不留孤零零的分隔线)。[query] 去首尾空白后为空 ⇒ 原样返回。 +List> filterMenuGroups( + List> groups, + String query, + String Function(E entry) labelOf, +) { + final String q = query.trim().toLowerCase(); + if (q.isEmpty) return groups; + final List> out = >[]; + for (final List group in groups) { + final List kept = [ + for (final E entry in group) + if (labelOf(entry).toLowerCase().contains(q)) entry, + ]; + if (kept.isNotEmpty) out.add(kept); + } + return out; +} + +/// 这一项要建的节点类型;泳道不建节点 ⇒ null。「下一镜」= 叙事锚点的类型(没有源 / 没有锚点也是 null)。 +CanvasNodeType? createdNodeType( + CreateMenuAction action, { + CanvasNode? source, + List nodes = const [], +}) => + switch (action) { + CreateMenuAction.asFirstFrame || + CreateMenuAction.asLastFrame || + CreateMenuAction.videoConfig => + CanvasNodeType.video, + CreateMenuAction.asReference || CreateMenuAction.imageConfig => CanvasNodeType.image, + CreateMenuAction.nextShot => source == null ? null : narrativeAnchorOf(source, nodes)?.type, + CreateMenuAction.text => CanvasNodeType.text, + CreateMenuAction.shot => CanvasNodeType.shot, + CreateMenuAction.lane => null, + }; + +/// 这一项的边从哪个节点连出:「下一镜」从叙事锚点(产物 ⇒ 它的来源配置节点),其余从源本身; +/// 没有源 ⇒ null。 +String? createdEdgeSourceId( + CreateMenuAction action, { + CanvasNode? source, + List nodes = const [], +}) { + if (source == null) return null; + if (action == CreateMenuAction.nextShot) return narrativeAnchorOf(source, nodes)?.id; + return source.id; +} + +/// 这一项要连的线(源 → 新节点);不连线的项 ⇒ null。 +CreateMenuEdge? createdEdge(CreateMenuAction action) => switch (action) { + CreateMenuAction.asFirstFrame => (type: EdgeType.data, role: EdgeRole.firstFrame), + CreateMenuAction.asLastFrame => (type: EdgeType.data, role: EdgeRole.lastFrame), + CreateMenuAction.asReference => (type: EdgeType.data, role: EdgeRole.reference), + CreateMenuAction.nextShot => (type: EdgeType.narrative, role: EdgeRole.reference), + CreateMenuAction.imageConfig || + CreateMenuAction.videoConfig || + CreateMenuAction.text || + CreateMenuAction.shot || + CreateMenuAction.lane => + null, + }; diff --git a/lib/features/canvas/util/drop_placement.dart b/lib/features/canvas/util/drop_placement.dart new file mode 100644 index 00000000..6e5bc836 --- /dev/null +++ b/lib/features/canvas/util/drop_placement.dart @@ -0,0 +1,92 @@ +// 落点换算(纯函数):拖卡片落点与新建菜单落点共用的泳道归属判定,外加「舞台上这一点落在哪张卡上」。 +// +// 泳道钉死模型(lane_pin_geometry.dart):泳道里的卡片以本道起始边为锚缩放,同一个世界坐标在不同的道里 +// 画在不同的屏幕位置。所以「落在哪条道」要先把卡片中心换到泳道栈坐标(屏幕坐标 − 泳道栈偏移)里判, +// 再按新道的锚定逆映射回世界坐标——换道前后卡片在屏幕上不动。这一段原来写在 canvas_view 的拖卡片落点 +// 里;新建菜单的落点要同一套判定(派工单:复用,不另写),所以抽到这里,两边都调 [resolveLaneDrop]。 +import 'package:flutter/widgets.dart'; + +import '../models/canvas_node.dart'; +import 'canvas_extent.dart'; +import 'canvas_zoom.dart'; +import 'lane_geometry.dart'; +import 'lane_pin_geometry.dart'; + +/// 新建节点的落点:左上角的世界坐标 + 归属泳道(null = 不在任何道里)。 +typedef NodePlacement = ({Offset world, String? laneId}); + +/// 卡片落点的泳道归属。 +/// +/// [centerCrossWorld]:卡片中心的世界横截坐标(横向泳道 = y,竖向 = x),按 [fromLaneId] 的锚定 +/// (null 或道已不存在 = 全局变换)。返回中心落进了哪条道,以及按那条道锚定后的中心横截坐标。 +/// 泳道栈坐标里锚定没有平移项(泳道栈随平移整体走),所以只用到缩放 [scale]。 +({String? laneId, double crossWorld}) resolveLaneDrop({ + required double centerCrossWorld, + required String? fromLaneId, + required List<({String id, double size})> laneSlices, + required LaneDirection direction, + required double scale, +}) { + final bool horizontal = direction == LaneDirection.horizontal; + final double crossScreen = crossToScreen( + laneStart: laneStartOf(fromLaneId, laneSlices), + world: centerCrossWorld, + scale: scale, + crossTranslation: 0, + ); + final String? laneId = laneIdAtPoint( + point: horizontal ? Offset(0, crossScreen) : Offset(crossScreen, 0), + lanes: laneSlices, + direction: direction, + ); + final double crossWorld = crossToWorld( + laneStart: laneStartOf(laneId, laneSlices), + screen: crossScreen, + scale: scale, + crossTranslation: 0, + ); + return (laneId: laneId, crossWorld: crossWorld); +} + +/// 视口里的一点 → 世界坐标,按不在任何道里的卡片算(全局变换的逆,再减舞台原点)。 +Offset viewportToWorld(Offset viewportPoint, Matrix4 transform) => stageToWorld( + MatrixUtils.transformPoint(Matrix4.inverted(transform), viewportPoint), + ); + +/// 新建菜单的落点:新卡片的左上角画在视口里的 [viewportPoint] 上。 +/// +/// 先当它是一张不在道里的卡(左上角 = 这一点的世界坐标),再按拖卡片落点那一套判它的中心落进哪条道、 +/// 换到那条道的锚定下——落进道里的卡片画出来,左上角仍在这一点上。世界坐标收进可达范围(±kWorldReach)。 +NodePlacement createNodePlacement({ + required Offset viewportPoint, + required Matrix4 transform, + required List<({String id, double size})> laneSlices, + required LaneDirection direction, +}) { + final Offset topLeft = viewportToWorld(viewportPoint, transform); + final bool horizontal = direction == LaneDirection.horizontal; + final double half = + horizontal ? kNodeCardSize.height / 2 : kNodeCardSize.width / 2; + final ({String? laneId, double crossWorld}) drop = resolveLaneDrop( + centerCrossWorld: (horizontal ? topLeft.dy : topLeft.dx) + half, + fromLaneId: null, + laneSlices: laneSlices, + direction: direction, + scale: scaleOf(transform), + ); + final Offset world = horizontal + ? Offset(topLeft.dx, drop.crossWorld - half) + : Offset(drop.crossWorld - half, topLeft.dy); + return (world: clampIntoWorldReach(world), laneId: drop.laneId); +} + +/// 舞台坐标里 [point] 落在哪张卡片上。 +/// +/// [drawn]:按绘制顺序排好、坐标已换成渲染位置(舞台坐标 + 分道位移,见 displacedNodes)的节点。 +/// 后画的压在上面,所以从后往前找。卡片的命中区就是 kNodeCardSize(与卡片的 SizedBox 同一个盒)。 +String? topmostNodeAt({required Offset point, required List drawn}) { + for (int i = drawn.length - 1; i >= 0; i--) { + if ((drawn[i].position & kNodeCardSize).contains(point)) return drawn[i].id; + } + return null; +} diff --git a/lib/features/canvas/widgets/canvas_shortcuts.dart b/lib/features/canvas/widgets/canvas_shortcuts.dart index f2cdfd52..853372d7 100644 --- a/lib/features/canvas/widgets/canvas_shortcuts.dart +++ b/lib/features/canvas/widgets/canvas_shortcuts.dart @@ -15,13 +15,18 @@ // // Ctrl+\(macOS ⌘\)开合左栏(PR-4):同样对文本编辑让位——在筛选框 / 提示词条里打字时 // 不该把左栏收起来。键位常量在 CanvasToolKeys,导航条「收起」键的 tooltip 与它同源。 +// +// Tab = 新建…(新建菜单,开在视口中心;create_node_menu.dart)。Tab 本来是焦点遍历键,所以它的门槛 +// 比别的单键更严:不只对文本编辑让位,而是**只在本层自己持主焦点时**才接管(_CanvasFocusedAction)。 +// 焦点落在本层子树里的任何控件上(工具条键、检查器按钮、缩放条格子、左栏行、菜单里的行),Tab 都原样 +// 放行给 WidgetsApp 的焦点遍历;本层之外的控件按 Tab 根本到不了这里。Shift+Tab 不绑。 +// Esc 多了两层,按序剥:拖线中先取消拖线,菜单开着先关菜单,再往下才是平移 / 连线 / 清选中。 import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/canvas_node.dart'; -import '../providers/canvas_nodes_controller.dart'; import '../providers/canvas_selection_controller.dart'; import '../providers/canvas_tool_mode.dart'; import '../providers/canvas_transform_controller.dart'; @@ -32,6 +37,7 @@ import '../providers/selected_edge_controller.dart'; import '../util/canvas_node_delete.dart'; import '../util/canvas_tool_actions.dart'; import '../util/canvas_zoom.dart'; +import 'create_node_menu.dart'; // ===== Intents ===== @@ -40,7 +46,8 @@ class DeleteSelectionIntent extends Intent { const DeleteSelectionIntent(); } -/// Esc:在平移工具则回到选择;否则连线模式激活则退出连线;否则清空节点 + 边选择。 +/// Esc:拖线中则取消拖线;菜单开着则关菜单;在平移工具则回到选择;否则连线模式激活则退出连线; +/// 否则清空节点 + 边选择。 class CanvasEscapeIntent extends Intent { const CanvasEscapeIntent(); } @@ -97,6 +104,11 @@ class CanvasToggleLeftRailIntent extends Intent { const CanvasToggleLeftRailIntent(); } +/// Tab:在视口中心开「新建」菜单(与双击空白、空白处右键「新建…」同一个菜单)。 +class CanvasOpenCreateMenuIntent extends Intent { + const CanvasOpenCreateMenuIntent(); +} + /// 画布工具的键位。工具条 tooltip 与下面的 [kCanvasShortcuts] 取同一组常量——改键位 /// 只改这里,tooltip 不会还挂着旧键。 class CanvasToolKeys { @@ -109,6 +121,9 @@ class CanvasToolKeys { static const LogicalKeyboardKey lane = LogicalKeyboardKey.keyF; static const LogicalKeyboardKey link = LogicalKeyboardKey.keyL; + /// 新建…(新建菜单)。空白处右键菜单「新建…」行尾的键位提示也取它。 + static const LogicalKeyboardKey createMenu = LogicalKeyboardKey.tab; + /// 按住临时平移。不进 [kCanvasShortcuts](要按下与松开两个事件,见 /// _CanvasShortcutsState._onKeyEvent);设置页清单单独收它 /// (features/settings/util/shortcut_catalog.dart)。 @@ -158,6 +173,27 @@ class _EditingAwareAction extends Action { } } +/// 只在画布快捷键层**自己**持主焦点时才生效的 Action(Tab = 新建…)。 +/// +/// Tab 是焦点遍历键(WidgetsApp 默认表:Tab → NextFocusIntent)。本层的 Shortcuts 比那张表离焦点更近, +/// 焦点落在本层子树里任何一个控件上按 Tab,都会先查到这里——光对文本编辑让位不够,工具条键、检查器按钮、 +/// 缩放条格子上的 Tab 也得放行。所以门槛是「主焦点就是画布本身」:不是的时候 isEnabled = false, +/// ShortcutManager 把按键原样放行(KeyEventResult.ignored),冒泡到 WidgetsApp 那层照常遍历焦点。 +/// 文本编辑让位(父类)照旧叠在上面:主焦点在文本框里时画布也不可能持主焦点,两道门是同一个结论。 +class _CanvasFocusedAction extends _EditingAwareAction { + _CanvasFocusedAction(this._canvasFocus, super.onInvoke); + + final FocusNode _canvasFocus; + + @override + bool isEnabled(T intent) => + _canvasFocus.hasPrimaryFocus && super.isEnabled(intent); + + @override + bool consumesKey(T intent) => + _canvasFocus.hasPrimaryFocus && super.consumesKey(intent); +} + // 键位映射:⌘ 与 Ctrl 变体同时注册,跨平台一致(桌面 macOS/Windows)。 // // 公开(P7):设置「快捷键」页的只读清单从这张表反推出来,不另抄一份——手抄的 @@ -225,6 +261,9 @@ kCanvasShortcuts = { // 左栏开合(PR-4)。 CanvasToolKeys.toggleLeftRailMeta: const CanvasToggleLeftRailIntent(), CanvasToolKeys.toggleLeftRailControl: const CanvasToggleLeftRailIntent(), + // 新建…(PR-8a)。只在画布本身持焦时接管,见 _CanvasFocusedAction;按住不放不连开。 + const SingleActivator(CanvasToolKeys.createMenu, includeRepeats: false): + const CanvasOpenCreateMenuIntent(), }; /// 画布快捷键层:包裹画布主体(含 Inspector)。 @@ -250,6 +289,15 @@ class CanvasShortcuts extends ConsumerStatefulWidget { static void reclaimFocus(BuildContext context) => context.findAncestorStateOfType<_CanvasShortcutsState>()?._reclaimFocus(); + /// 画布上的浮层(新建菜单)收掉之后调用:焦点「落空」了才要回本层——主焦点是空的,或者落回了本层 + /// 所在的作用域本身(没有任何具体控件持焦)。被收掉的菜单里的搜索框失焦就落在那儿;不要回来的话 + /// Delete / Esc / 工具单键全都收不到。别的控件已经拿到焦点(点到了别的文本框、开了对话框)就不抢。 + /// 帧后再判:桌面上点到文本框外面,文本框自己的失焦与收菜单是同一次按下里的两件事,谁先谁后不定, + /// 失焦又在微任务里才落地——收菜单的这一刻还看不出焦点最后落在哪。 + static void reclaimFocusIfOrphaned(BuildContext context) => context + .findAncestorStateOfType<_CanvasShortcutsState>() + ?._reclaimFocusIfOrphaned(); + @override ConsumerState createState() => _CanvasShortcutsState(); } @@ -290,6 +338,16 @@ class _CanvasShortcutsState extends ConsumerState { if (widget.isActive && !_focusNode.hasPrimaryFocus) _focusNode.requestFocus(); } + void _reclaimFocusIfOrphaned() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !widget.isActive) return; + final FocusNode? focused = primaryFocus; + if (focused == null || identical(focused, _focusNode.enclosingScope)) { + _focusNode.requestFocus(); + } + }); + } + @override void dispose() { _focusNode.dispose(); @@ -357,6 +415,12 @@ class _CanvasShortcutsState extends ConsumerState { _EditingAwareAction( (_) => _toggleLeftRail(), ), + // Tab:只在本层自己持主焦点时开新建菜单,别处的 Tab 一律放行给焦点遍历。 + CanvasOpenCreateMenuIntent: + _CanvasFocusedAction( + _focusNode, + (_) => _openCreateMenu(), + ), }, child: Focus( focusNode: _focusNode, @@ -423,17 +487,24 @@ class _CanvasShortcutsState extends ConsumerState { void _selectAll() { final canvasId = ref.read(currentCanvasIdProvider); if (canvasId == null) return; - final nodes = ref.read(canvasNodesControllerProvider(canvasId)).valueOrNull; - if (nodes == null) return; - ref - .read(canvasSelectionControllerProvider(canvasId).notifier) - .selectAll(nodes.map((n) => n.id)); + // 与空白处右键菜单「全选」同一个动作(canvas_tool_actions.dart)。 + selectAllNodes(ref, canvasId: canvasId); } - /// Esc 一次剥一层:平移工具 → 回选择;连线模式 → 退出连线;否则清空节点 + 边选择。 + /// Esc 一次剥一层:拖线中 → 取消拖线(松手时不连线、不开菜单);菜单开着 → 关菜单; + /// 平移工具 → 回选择;连线模式 → 退出连线;否则清空节点 + 边选择。 void _escape() { final canvasId = ref.read(currentCanvasIdProvider); if (canvasId == null) return; + if (ref.read(canvasWireDragProvider(canvasId)) != null) { + ref.read(canvasWireDragProvider(canvasId).notifier).cancel(); + return; + } + // 菜单拿着焦点时 Esc 由菜单自己收;这一层兜的是菜单开着、焦点却回到了画布的情形。 + if (ref.read(canvasMenuRequestProvider(canvasId)) != null) { + ref.read(canvasMenuRequestProvider(canvasId).notifier).close(); + return; + } if (ref.read(canvasToolModeProvider(canvasId)) == CanvasToolMode.pan) { ref.read(canvasToolModeProvider(canvasId).notifier).select(); return; @@ -503,4 +574,15 @@ class _CanvasShortcutsState extends ConsumerState { if (ref.read(currentCanvasIdProvider) == null) return; ref.read(leftRailProvider.notifier).toggle(); } + + /// 新建菜单开在视口中心(不带连线上下文)。视口还没上报尺寸(首帧前)时不知道中心在哪,不开。 + void _openCreateMenu() { + final String? canvasId = ref.read(currentCanvasIdProvider); + if (canvasId == null) return; + final Size viewport = ref.read(canvasViewportSizeProvider(canvasId)); + if (viewport.isEmpty) return; + ref + .read(canvasMenuRequestProvider(canvasId).notifier) + .openCreate(viewport.center(Offset.zero)); + } } diff --git a/lib/features/canvas/widgets/canvas_view.dart b/lib/features/canvas/widgets/canvas_view.dart index 079303c7..e779c037 100644 --- a/lib/features/canvas/widgets/canvas_view.dart +++ b/lib/features/canvas/widgets/canvas_view.dart @@ -6,8 +6,15 @@ // - 平移工具(H / 按住空格,canvas_tool_mode.dart):_PanGate 让舞台内容与泳道标题栏层 // 不收指针、拖哪儿都是拖画布;_StagePanCursor 换手形光标 // - 可播放性 IO 判定在 playableVideoPathProvider;连线编排在 LinkActionController +// - 新建菜单(create_node_menu.dart):从卡片出端口拖线(_NodeCardSlot 报拖线、_PendingWireLayer 画 +// 实时虚线、舞台层判落点——落在卡上 = linkTo,落在空白 = 带上下文开菜单)、空白处双击 / 右键 +// (_BlankPointerDetector)都开它;菜单宿主 CanvasMenuHost 包在 _CanvasBody 最外层 +import 'dart:async'; + +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -35,7 +42,9 @@ import '../providers/selected_edge_controller.dart'; import '../util/canvas_extent.dart'; import '../util/canvas_node_delete.dart'; import '../util/canvas_snackbars.dart'; +import '../util/canvas_tool_actions.dart'; import '../util/canvas_zoom.dart'; +import '../util/drop_placement.dart'; import '../util/edge_hit_test.dart'; import '../util/edge_style.dart'; import '../util/lineage_edges.dart'; @@ -47,6 +56,7 @@ import '../util/lane_pin_geometry.dart'; import '../../shell/widgets/shell_empty_state.dart'; import 'canvas_empty_state.dart'; import 'canvas_grid_painter.dart'; +import 'create_node_menu.dart'; import 'edge_painter.dart'; import 'left_rail/left_rail_actions.dart'; import 'lane_background.dart'; @@ -138,7 +148,11 @@ class _PendingFocusApplierState extends ConsumerState<_PendingFocusApplier> { ref.read(canvasFocusRequestProvider.notifier).consume(request); for (final CanvasNode node in widget.nodes) { if (node.id == request.nodeId) { - focusNodeOnCanvas(ref, canvasId: widget.canvasId, node: node); + if (request.center) { + focusNodeOnCanvas(ref, canvasId: widget.canvasId, node: node); + } else { + ref.read(canvasSelectionControllerProvider(widget.canvasId).notifier).select(node.id); + } return; } } @@ -389,7 +403,9 @@ class _CanvasBody extends ConsumerWidget { ); // 节点 Inspector 已迁到右侧面板(canvas_inspector_panel.dart),本层只剩画布。 - return leftArea; + // 新建菜单 / 空白处右键菜单的宿主包在画布区最外层:菜单浮在 Overlay 上,锚点坐标系就是这一块 + // (= 舞台视口;空态画布同样包着,Tab 在空画布上也开得出来)。 + return CanvasMenuHost(canvasId: canvasId, child: leftArea); } } @@ -490,19 +506,21 @@ class _CanvasStage extends ConsumerWidget { canvasTransformControllerProvider(canvasId), ); - void onEdgeLayerTap(TapDownDetails d) { - final hitId = hitTestEdge( - point: d.localPosition, - edges: edges, - // 命中用渲染位置(含分道位移),与 EdgePainter 同源。 - nodes: displacedNodes( - nodes: visibleNodes, - lanes: laneSlices, + // 舞台坐标这一点压着哪条连线。命中用渲染位置(含分道位移),与 EdgePainter 同源。 + String? edgeAt(Offset stagePoint) => hitTestEdge( + point: stagePoint, + edges: edges, + nodes: displacedNodes( + nodes: visibleNodes, + lanes: laneSlices, + direction: direction, + transform: transformController.value, + ), direction: direction, - transform: transformController.value, - ), - direction: direction, - ); + ); + + void onEdgeLayerTap(TapDownDetails d) { + final hitId = edgeAt(d.localPosition); if (hitId != null) { ref.read(selectedEdgeControllerProvider(canvasId).notifier).select(hitId); return; @@ -510,6 +528,22 @@ class _CanvasStage extends ConsumerWidget { onEmptyTap(); } + // 空白处双击 → 在双击点开新建菜单;空白处右键 → 在那一点开空白处右键菜单(create_node_menu.dart)。 + // 压着连线不算空白:连线自己的右键菜单不归这一版。菜单锚点是视口坐标(舞台坐标过一遍相机)。 + void onBlankDoubleDown(Offset stagePoint) { + if (edgeAt(stagePoint) != null) return; + ref.read(canvasMenuRequestProvider(canvasId).notifier).openCreate( + MatrixUtils.transformPoint(transformController.value, stagePoint), + ); + } + + void onBlankSecondaryDown(Offset stagePoint) { + if (edgeAt(stagePoint) != null) return; + ref.read(canvasMenuRequestProvider(canvasId).notifier).openBlank( + MatrixUtils.transformPoint(transformController.value, stagePoint), + ); + } + final selectedGeometry = _selectedEdgeGeometry( selectedEdgeId: selectedEdgeId, edges: edges, @@ -535,6 +569,46 @@ class _CanvasStage extends ConsumerWidget { if (n.laneId == null || !laneStarts.containsKey(n.laneId)) n, ]; + // 卡片的绘制顺序:无道的先画,再逐条道画道里的(折叠的道不画)。拖线落点找「最上面那张卡」 + // 按它倒着找,与肉眼看到的叠放一致。 + final List paintOrder = [ + ...freeNodes, + for (final StyleLane lane in lanes) + if (!collapsedIds.contains(lane.id)) + for (final CanvasNode n in nodes) + if (n.laneId == lane.id) n, + ]; + + // 从出端口拖线后松手:落在另一张卡上 = 连线(走连线模式那一条 linkTo:智能默认角色、23505 去重、 + // 结果提示都是它的);落在空白处 = 在松手点开新建菜单,带「从哪张卡拖出来」的上下文; + // 落回源卡片自己 = 什么都不做。 + void onWireDrop(WireDrag drag) { + final Matrix4 m = transformController.value; + final String? target = topmostNodeAt( + point: drag.pointer, + drawn: displacedNodes( + nodes: paintOrder, + lanes: laneSlices, + direction: direction, + transform: m, + ), + ); + if (target == drag.sourceNodeId) return; + if (target != null) { + linkByPortDrop( + ref, + canvasId: canvasId, + sourceNodeId: drag.sourceNodeId, + targetNodeId: target, + ); + return; + } + ref.read(canvasMenuRequestProvider(canvasId).notifier).openCreate( + MatrixUtils.transformPoint(m, drag.pointer), + sourceNodeId: drag.sourceNodeId, + ); + } + return Container( color: colors.surface1, // 视口尺寸上报给缩放层(围绕视口中心缩放需要)。 @@ -613,6 +687,8 @@ class _CanvasStage extends ConsumerWidget { // InteractiveViewer 自己的 opaque 手势层上——拖到卡片上也是拖画布。 child: _PanGate( canvasId: canvasId, + // 舞台坐标系的锚:拖线时卡片插槽往上找它,把指针的全局坐标换成舞台坐标。 + child: _StageSpace( child: SizedBox( width: stage.width, height: stage.height, @@ -620,9 +696,13 @@ class _CanvasStage extends ConsumerWidget { // 分道位移组会把泳道内容平移出舞台矩形,不裁。 clipBehavior: Clip.none, children: [ - // 连线层(含点击命中)。translucent 让空白处也冒泡。 + // 连线层(含点击命中)。translucent 让空白处也冒泡。卡片盖着的地方点不到这一层, + // 所以这里收到的双击 / 右键就是「空白处」的(_BlankPointerDetector)。 Positioned.fill( - child: GestureDetector( + child: _BlankPointerDetector( + onDoubleDown: onBlankDoubleDown, + onSecondaryDown: onBlankSecondaryDown, + child: GestureDetector( behavior: HitTestBehavior.translucent, onTapDown: onEdgeLayerTap, // HI-15:连线层独立 layer,节点局部动画不连带边层重绘。 @@ -681,6 +761,7 @@ class _CanvasStage extends ConsumerWidget { ), ), ), + ), ), // 无道节点:全局变换直出。 // HI-13/HI-15:拖拽位移在 NodeCard 内部局部累积,落点一次性 @@ -700,6 +781,7 @@ class _CanvasStage extends ConsumerWidget { hasInEdge: ports.inbound.contains(node.id), hasOutEdge: ports.outbound.contains(node.id), onTap: () => onNodeTap(node), + onWireDrop: onWireDrop, onDelete: () => deleteNodeWithUndo( context, ref, @@ -753,6 +835,7 @@ class _CanvasStage extends ConsumerWidget { hasOutEdge: ports.outbound.contains(node.id), onTap: () => onNodeTap(node), + onWireDrop: onWireDrop, onDelete: () => deleteNodeWithUndo( context, ref, @@ -805,9 +888,22 @@ class _CanvasStage extends ConsumerWidget { }, ), ), + // 拖线中的实时虚线:画在卡片之上(拖过别的卡片时线不被压住),不收指针。 + Positioned.fill( + child: IgnorePointer( + child: _PendingWireLayer( + canvasId: canvasId, + nodes: visibleNodes, + laneSlices: laneSlices, + direction: direction, + transform: transformController, + ), + ), + ), ], ), ), + ), ), ), ), @@ -1114,6 +1210,9 @@ _selectedEdgeGeometry({ /// 单个节点卡片插槽:各自 watch 自己的选中/链接态——改选中只重建涉及的卡片, /// 不连带整个舞台层(丝滑核心)。端口占用([hasInEdge] / [hasOutEdge])由舞台层 /// 按存量边 + 产出线整层算一次传进来。 +/// +/// 出端口拖线也从这里报:指针的全局坐标换成舞台坐标写进 canvasWireDragProvider,松手时把这条拖线 +/// 交给舞台层([onWireDrop])判落点。 class _NodeCardSlot extends ConsumerWidget { const _NodeCardSlot({ required this.node, @@ -1126,6 +1225,7 @@ class _NodeCardSlot extends ConsumerWidget { required this.hasOutEdge, required this.onTap, required this.onDelete, + required this.onWireDrop, }); final CanvasNode node; @@ -1144,6 +1244,41 @@ class _NodeCardSlot extends ConsumerWidget { final VoidCallback onTap; final VoidCallback onDelete; + /// 从本卡出端口拖线后松手:舞台层判落点(连线 / 开新建菜单 / 什么都不做)。 + final void Function(WireDrag drag) onWireDrop; + + /// 指针全局坐标 → 舞台坐标:沿渲染树往上找舞台坐标系的锚([_StageSpace])。卡片可能在分道位移组 + /// 里,位移组在锚之下,所以换出来的是舞台坐标(与 displacedNodes 同一个坐标系),不是道内坐标。 + static Offset? _toStage(BuildContext context, Offset global) => context + .findAncestorRenderObjectOfType<_RenderStageSpace>() + ?.globalToLocal(global); + + PortDragCallbacks _portDrag(BuildContext context, WidgetRef ref) { + WireDragController wire() => + ref.read(canvasWireDragProvider(canvasId).notifier); + return PortDragCallbacks( + onStart: (Offset global) { + // 平移生效时舞台内容整层不收指针(_PanGate),端口本来就按不到;这里再拦一道, + // 「平移时端口不可拖」不只靠那一层闸。 + if (ref.read(canvasPanActiveProvider(canvasId))) return; + final Offset? stage = _toStage(context, global); + if (stage != null) wire().start(node.id, stage); + }, + onUpdate: (Offset global) { + final Offset? stage = _toStage(context, global); + if (stage != null) wire().moveTo(node.id, stage); + }, + onEnd: () { + // 已被 Esc 取消(或根本没开始)的拖线,finish 交回 null:松手什么都不做。 + final WireDrag? drag = wire().finish(node.id); + if (drag != null) onWireDrop(drag); + }, + onCancel: () { + wire().finish(node.id); + }, + ); + } + @override Widget build(BuildContext context, WidgetRef ref) { // .select:仅当本节点的"是否被选中"翻转时才重建本卡片。 @@ -1151,6 +1286,12 @@ class _NodeCardSlot extends ConsumerWidget { canvasSelectionControllerProvider(canvasId).select((s) => s.contains(node.id)), ); final linkSourceId = ref.watch(linkModeControllerProvider(canvasId)); + // 拖线的源:.select 掉指针位置——只在拖线开始 / 结束那一下翻转,指针每动一下不重建卡片。 + final String? wireSourceId = ref.watch( + canvasWireDragProvider(canvasId).select((WireDrag? d) => d?.sourceNodeId), + ); + // 拖线期间的高亮与连线模式同一套样式:源卡片 = 起点,其余卡片 = 可连的目标。 + final String? activeSourceId = linkSourceId ?? wireSourceId; return RepaintBoundary( child: NodeCard( node: node, @@ -1160,33 +1301,22 @@ class _NodeCardSlot extends ConsumerWidget { onDragEnd: (totalDelta) { // 泳道钉死模型:落点中心先经"原道锚定"映射到屏幕坐标判归属, // 再按"新道锚定"逆映射回世界坐标——保证换道落点视觉位置不变。 - final m = transform.value; - final s = scaleOf(m); + // 判定本体在 util/drop_placement.dart(resolveLaneDrop),新建菜单的落点调的是同一个函数。 final horizontal = direction == LaneDirection.horizontal; - // lane-stack 空间(泳道栈随平移整体走)内锚定/落道均无平移项。 - const tCross = 0.0; final center = node.position + totalDelta + Offset(kNodeCardSize.width / 2, kNodeCardSize.height / 2); final crossWorld = horizontal ? center.dy : center.dx; - final crossScreen = crossToScreen( - laneStart: laneStartOf(node.laneId, laneSlices), - world: crossWorld, - scale: s, - crossTranslation: tCross, - ); - final laneId = laneIdAtPoint( - point: horizontal ? Offset(0, crossScreen) : Offset(crossScreen, 0), - lanes: laneSlices, + final drop = resolveLaneDrop( + centerCrossWorld: crossWorld, + fromLaneId: node.laneId, + laneSlices: laneSlices, direction: direction, + scale: scaleOf(transform.value), ); - final newCrossWorld = crossToWorld( - laneStart: laneStartOf(laneId, laneSlices), - screen: crossScreen, - scale: s, - crossTranslation: tCross, - ); + final laneId = drop.laneId; + final newCrossWorld = drop.crossWorld; final adjustedDelta = horizontal ? Offset(totalDelta.dx, totalDelta.dy + newCrossWorld - crossWorld) : Offset( @@ -1209,10 +1339,11 @@ class _NodeCardSlot extends ConsumerWidget { onStartLink: () => ref.read(linkModeControllerProvider(canvasId).notifier).start(node.id), onDelete: onDelete, - isLinkSource: linkSourceId == node.id, - isLinkCandidate: linkSourceId != null && linkSourceId != node.id, + isLinkSource: activeSourceId == node.id, + isLinkCandidate: activeSourceId != null && activeSourceId != node.id, hasInEdge: hasInEdge, hasOutEdge: hasOutEdge, + portDrag: _portDrag(context, ref), ), ); } @@ -1367,3 +1498,124 @@ class _StagePanCursorState extends ConsumerState<_StagePanCursor> { ); } } + +/// 舞台坐标系的锚:拖线时卡片插槽沿渲染树往上找它,把指针的全局坐标换成舞台坐标 +/// (globalToLocal 把 InteractiveViewer 的相机一并逆掉)。本身只透传:不画、不改布局、不改命中。 +class _StageSpace extends SingleChildRenderObjectWidget { + const _StageSpace({required super.child}); + + @override + RenderObject createRenderObject(BuildContext context) => _RenderStageSpace(); +} + +class _RenderStageSpace extends RenderProxyBox {} + +/// 空白处的双击与右键:包在连线层上——卡片盖着的地方到不了这一层,收到的就是空白处的指针。 +/// +/// 不用 GestureDetector 的 onDoubleTap:双击识别器一进手势竞技场,连线层原来的单击(选连线 / 点空白 +/// 清选中)就得等它放弃,每一次单击都慢 300ms。这里只听按下事件、自己数两下:第二下在 +/// [kDoubleTapTimeout] 之内、[kDoubleTapSlop] 之内落下就算双击;不进竞技场,单击照旧即时生效。 +/// 右键(次键按下)直接报。回调给的是本层的局部坐标 = 舞台坐标。 +class _BlankPointerDetector extends StatefulWidget { + const _BlankPointerDetector({ + required this.onDoubleDown, + required this.onSecondaryDown, + required this.child, + }); + + final ValueChanged onDoubleDown; + final ValueChanged onSecondaryDown; + final Widget child; + + @override + State<_BlankPointerDetector> createState() => _BlankPointerDetectorState(); +} + +class _BlankPointerDetectorState extends State<_BlankPointerDetector> { + /// 上一下主键按下的全局位置;双击窗口一过就清掉。 + Offset? _firstDown; + Timer? _window; + + @override + void dispose() { + _window?.cancel(); + super.dispose(); + } + + void _reset() { + _window?.cancel(); + _window = null; + _firstDown = null; + } + + void _onPointerDown(PointerDownEvent event) { + if (event.buttons == kSecondaryButton) { + _reset(); + widget.onSecondaryDown(event.localPosition); + return; + } + if (event.buttons != kPrimaryButton) return; + final Offset? first = _firstDown; + if (first != null && (event.position - first).distance <= kDoubleTapSlop) { + _reset(); + widget.onDoubleDown(event.localPosition); + return; + } + _window?.cancel(); + _firstDown = event.position; + _window = Timer(kDoubleTapTimeout, _reset); + } + + @override + Widget build(BuildContext context) => + Listener(onPointerDown: _onPointerDown, child: widget.child); +} + +/// 拖线中的实时虚线(从源卡片的出点到指针):只随拖线状态与相机重建,不连带舞台;不在拖线时什么都不画。 +class _PendingWireLayer extends ConsumerWidget { + const _PendingWireLayer({ + required this.canvasId, + required this.nodes, + required this.laneSlices, + required this.direction, + required this.transform, + }); + + final String canvasId; + + /// 可见节点(折叠道里的不在内):源卡片画在哪,线就从哪出。 + final List nodes; + final List<({String id, double size})> laneSlices; + final LaneDirection direction; + final TransformationController transform; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final WireDrag? drag = ref.watch(canvasWireDragProvider(canvasId)); + if (drag == null) return const SizedBox.shrink(); + CanvasNode? source; + for (final CanvasNode n in nodes) { + if (n.id == drag.sourceNodeId) source = n; + } + final CanvasNode? from = source; + if (from == null) return const SizedBox.shrink(); + final Color color = context.inkColors.accent; + // 出点吃分道位移(与卡片渲染位置同源),缩放时跟着走。 + return ValueListenableBuilder( + valueListenable: transform, + builder: (BuildContext context, Matrix4 m, Widget? _) => CustomPaint( + painter: PendingWirePainter( + source: displacedNodes( + nodes: [from], + lanes: laneSlices, + direction: direction, + transform: m, + ).single, + to: drag.pointer, + color: color, + direction: direction, + ), + ), + ); + } +} diff --git a/lib/features/canvas/widgets/create_node_menu.dart b/lib/features/canvas/widgets/create_node_menu.dart new file mode 100644 index 00000000..35bcc2fb --- /dev/null +++ b/lib/features/canvas/widgets/create_node_menu.dart @@ -0,0 +1,925 @@ +// 新建菜单(画布重设计稿 › 画布交互「拖线到空白处 → 建节点」「右键菜单」)。 +// +// 一个菜单、四个入口:从卡片出端口拖到空白处松手(带「从哪张卡拖出来」的连线上下文)、双击舞台空白、 +// 画布持焦时按 Tab、空白处右键菜单里的「新建…」。菜单项判据在 util/create_menu_model.dart,建节点 / +// 连线 / 回滚在 util/canvas_tool_actions.dart(addNodeFromMenu),本文件只管状态、浮层与键盘: +// +// - canvasMenuRequestProvider:开着哪个菜单、锚在视口哪一点、带不带源节点。入口都只写它 +// (CanvasShortcuts 的 Tab、canvas_view 的双击 / 右键 / 拖线松手);宿主 CanvasMenuHost 看着它开关。 +// - canvasWireDragProvider:从出端口拖线的实时状态(源节点 + 指针的舞台坐标)。卡片插槽写, +// 实时虚线层与卡片高亮读,Esc 取消。 +// - CanvasMenuHost:包在画布区外层(canvas_view 的 _CanvasBody),菜单经 OverlayPortal 浮到 Overlay 上, +// 锚点跟着画布区走(CompositedTransformFollower,与铃铛下拉同一做法)。点外面关(TapRegion); +// 焦点整个离开菜单也关(开对话框 / 切标签 / 命令面板盖上来);关菜单时焦点还给画布,单键快捷键接着能用。 +// - CanvasMenuPanel:菜单本体。新建菜单顶上是搜索框(一打开就持焦,直接打字过滤);空白处右键菜单没有。 +// ↑ / ↓ 移动高亮(鼠标悬停也移),↵ 执行,Esc 关。 +// +// 空白处右键菜单这一版只放「新建…」「导入脚本…」、分隔线、「全选」「适应视图」:「粘贴」「导入媒体」没有 +// 后端(画布没有剪贴板,也没有媒体导入的写入路径),按「无后端不画」不放(BOARD 有记)。 +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../../l10n/l10n_x.dart'; +import '../../../theme/app_theme.dart'; +import '../../../theme/components/ink_icon.dart'; +import '../../../theme/icons/ink_glyph.dart'; +import '../../../theme/primitives/ink_activatable.dart'; +import '../../../theme/tokens.dart'; +import '../../../theme/typography.dart'; +import '../../settings/util/shortcut_catalog.dart'; +import '../../storyboard/widgets/script_import_dialog.dart'; +import '../models/canvas_node.dart'; +import '../providers/canvas_nodes_controller.dart'; +import '../providers/canvas_transform_controller.dart'; +import '../util/canvas_tool_actions.dart'; +import '../util/create_menu_model.dart'; +import '../util/drop_placement.dart'; +import 'canvas_shortcuts.dart'; + +// ===== 拖线状态 ===== + +/// 一次从出端口的拖线:源节点 + 指针此刻的舞台坐标。 +@immutable +class WireDrag { + const WireDrag({required this.sourceNodeId, required this.pointer}); + + final String sourceNodeId; + + /// 指针在舞台坐标系里的位置(世界坐标 + kStageOrigin,与 displacedNodes 同一个坐标系)。 + final Offset pointer; +} + +/// 从出端口拖线的实时状态,按画布分族。纯 UI 态,不落库。 +final canvasWireDragProvider = + AutoDisposeNotifierProviderFamily( + WireDragController.new, + name: 'canvasWireDragProvider', +); + +class WireDragController extends AutoDisposeFamilyNotifier { + @override + WireDrag? build(String canvasId) => null; + + /// 从 [sourceNodeId] 的出端口开始拖线。 + void start(String sourceNodeId, Offset pointer) => + state = WireDrag(sourceNodeId: sourceNodeId, pointer: pointer); + + /// 指针移动。不是这条拖线(已被 Esc 取消)就不动。 + void moveTo(String sourceNodeId, Offset pointer) { + if (state?.sourceNodeId != sourceNodeId) return; + state = WireDrag(sourceNodeId: sourceNodeId, pointer: pointer); + } + + /// 松手:收掉拖线并把它交回,由舞台判落点。已被 Esc 取消(或不是这条)⇒ null。 + WireDrag? finish(String sourceNodeId) { + final WireDrag? drag = state; + if (drag == null || drag.sourceNodeId != sourceNodeId) return null; + state = null; + return drag; + } + + /// Esc:取消拖线——松手时不连线、不开菜单,不落任何写入。 + void cancel() { + if (state != null) state = null; + } +} + +// ===== 菜单请求 ===== + +/// 菜单种类。 +enum CanvasMenuKind { + /// 新建菜单(带搜索框)。 + create, + + /// 空白处右键菜单。 + blank, +} + +/// 一次开菜单的请求。每次开都是一个新实例,关菜单按实例比(identical):旧菜单迟到的「点外面」/ +/// 「失焦」关不掉刚开的新菜单(右键换个地方再右键、右键菜单里点「新建…」都会碰上)。 +@immutable +class CanvasMenuRequest { + const CanvasMenuRequest({ + required this.kind, + required this.anchor, + this.sourceNodeId, + }); + + final CanvasMenuKind kind; + + /// 菜单左上角在画布视口里的位置(视口局部坐标)——新节点的左上角也落在这一点。 + final Offset anchor; + + /// 从哪张卡的出端口拖出来的(带连线上下文);null = 不带上下文。 + final String? sourceNodeId; +} + +/// 画布上此刻开着的菜单,按画布分族。纯 UI 态,不落库。 +final canvasMenuRequestProvider = AutoDisposeNotifierProviderFamily< + CanvasMenuController, CanvasMenuRequest?, String>( + CanvasMenuController.new, + name: 'canvasMenuRequestProvider', +); + +class CanvasMenuController + extends AutoDisposeFamilyNotifier { + @override + CanvasMenuRequest? build(String canvasId) => null; + + /// 开新建菜单;[sourceNodeId] 非空 = 带连线上下文(从那张卡的出端口拖出来的)。 + void openCreate(Offset anchor, {String? sourceNodeId}) => + state = CanvasMenuRequest( + kind: CanvasMenuKind.create, + anchor: anchor, + sourceNodeId: sourceNodeId, + ); + + /// 开空白处右键菜单。 + void openBlank(Offset anchor) => + state = CanvasMenuRequest(kind: CanvasMenuKind.blank, anchor: anchor); + + /// 关菜单。给了 [only] 就只关它那一个——已经换成别的菜单了就不动。 + void close([CanvasMenuRequest? only]) { + final CanvasMenuRequest? current = state; + if (current == null) return; + if (only != null && !identical(current, only)) return; + state = null; + } +} + +/// 空白处右键菜单的项。 +enum BlankMenuAction { + /// 新建…(Tab):原地换成新建菜单。 + create, + + /// 导入脚本…:开脚本导入框,第一镜落在右键那一点。 + importScript, + + /// 全选(与 Ctrl+A / ⌘A 同一个动作)。 + selectAll, + + /// 适应视图(与缩放条「适应」同一个动作)。 + fitView, +} + +/// 测试锚点。 +class CanvasMenuKeys { + CanvasMenuKeys._(); + + /// 新建菜单的面板。 + static const Key createMenu = ValueKey('canvasMenu.create'); + + /// 空白处右键菜单的面板。 + static const Key blankMenu = ValueKey('canvasMenu.blank'); + + /// 新建菜单的搜索框。 + static const Key search = ValueKey('canvasMenu.search'); +} + +// ===== 宿主 ===== + +/// 新建菜单 / 空白处右键菜单的宿主:包在画布区外层,按 [canvasMenuRequestProvider] 开关浮层、执行选中的项。 +class CanvasMenuHost extends ConsumerStatefulWidget { + const CanvasMenuHost({super.key, required this.canvasId, required this.child}); + + final String canvasId; + + /// 画布区(舞台 + 舞台上的浮层)。菜单的锚点坐标系就是它。 + final Widget child; + + @override + ConsumerState createState() => _CanvasMenuHostState(); +} + +class _CanvasMenuHostState extends ConsumerState { + final OverlayPortalController _portal = + OverlayPortalController(debugLabel: 'canvasMenu'); + final LayerLink _link = LayerLink(); + ProviderSubscription? _sub; + + @override + void initState() { + super.initState(); + _listen(); + } + + @override + void didUpdateWidget(CanvasMenuHost oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.canvasId == widget.canvasId) return; + // 换画布:请求按画布分族,改听新画布的(它从「没开菜单」开始),旧画布的菜单随之收掉。 + _sub?.close(); + _listen(); + } + + void _listen() { + _sub = ref.listenManual( + canvasMenuRequestProvider(widget.canvasId), + (CanvasMenuRequest? previous, CanvasMenuRequest? next) => _sync(next), + fireImmediately: true, + ); + } + + /// 请求 → 浮层。还没挂上时 show() 只是记一笔,OverlayPortal 挂上那一刻生效(与铃铛下拉同)。 + /// 每开一次都 show 一次:后开的菜单压在其它浮层(任务弹层 / 铃铛下拉)之上。 + void _sync(CanvasMenuRequest? request) { + if (request == null) { + if (_portal.isShowing) _portal.hide(); + } else { + _portal.show(); + } + } + + /// 关 [request] 那一个菜单(已经换成别的菜单就不动)。[restoreFocus]:菜单拿着焦点(搜索框 / 菜单本身), + /// 它一拆焦点就落空、画布快捷键全收不到——帧后焦点要是还落空就还给画布(CanvasShortcuts 那边判)。 + /// 焦点是自己走掉的(开了对话框 / 切了标签)就传 false,不抢。 + void _dismiss(CanvasMenuRequest request, {required bool restoreFocus}) { + if (!identical(ref.read(canvasMenuRequestProvider(widget.canvasId)), request)) { + return; + } + ref.read(canvasMenuRequestProvider(widget.canvasId).notifier).close(request); + if (restoreFocus) CanvasShortcuts.reclaimFocusIfOrphaned(context); + } + + CanvasNode? _nodeById(String? id) { + if (id == null) return null; + final List nodes = + ref.read(canvasNodesControllerProvider(widget.canvasId)).valueOrNull ?? + const []; + for (final CanvasNode n in nodes) { + if (n.id == id) return n; + } + return null; + } + + void _pickCreate(CanvasMenuRequest request, CreateMenuAction action) { + _dismiss(request, restoreFocus: true); + final String canvasId = widget.canvasId; + if (action == CreateMenuAction.lane) { + addLaneViaDialog(context, ref, canvasId: canvasId); + return; + } + final CanvasNode? source = _nodeById(request.sourceNodeId); + final List nodes = + ref.read(canvasNodesControllerProvider(canvasId)).valueOrNull ?? const []; + final CanvasNodeType? type = createdNodeType(action, source: source, nodes: nodes); + if (type == null) return; + addNodeFromMenu( + context, + ref, + canvasId: canvasId, + type: type, + placement: menuNodePlacement( + ref, + canvasId: canvasId, + viewportPoint: request.anchor, + ), + // 「下一镜」的叙事边从叙事锚点连出(产物卡 ⇒ 它的来源配置节点),其余从拖出来的那张卡。 + sourceNodeId: createdEdgeSourceId(action, source: source, nodes: nodes), + edge: createdEdge(action), + ); + } + + void _pickBlank(CanvasMenuRequest request, BlankMenuAction action) { + final String canvasId = widget.canvasId; + switch (action) { + case BlankMenuAction.create: + // 原地换成新建菜单:同一个锚点,不带上下文。 + ref.read(canvasMenuRequestProvider(canvasId).notifier).openCreate(request.anchor); + case BlankMenuAction.importScript: + _dismiss(request, restoreFocus: true); + // 整条链从右键那一点起铺。导入的镜头不进泳道,按不在道里的卡算世界坐标。 + showScriptImportDialog( + context, + canvasId: canvasId, + origin: viewportToWorld( + request.anchor, + ref.read(canvasTransformControllerProvider(canvasId)).value, + ), + ); + case BlankMenuAction.selectAll: + _dismiss(request, restoreFocus: true); + selectAllNodes(ref, canvasId: canvasId); + case BlankMenuAction.fitView: + _dismiss(request, restoreFocus: true); + fitCanvasView(ref, canvasId: canvasId); + } + } + + @override + Widget build(BuildContext context) { + final CanvasMenuRequest? request = + ref.watch(canvasMenuRequestProvider(widget.canvasId)); + return CompositedTransformTarget( + link: _link, + child: OverlayPortal( + controller: _portal, + overlayChildBuilder: (BuildContext context) { + final CanvasMenuRequest? r = request; + if (r == null) return const SizedBox.shrink(); + return _CanvasMenuLayer( + // 每个请求一份菜单状态:换了菜单(右键菜单 →「新建…」)搜索词与高亮从头来。 + key: ObjectKey(r), + canvasId: widget.canvasId, + link: _link, + request: r, + onDismiss: () => _dismiss(r, restoreFocus: true), + onFocusLost: () => _dismiss(r, restoreFocus: false), + onPickCreate: (CreateMenuAction a) => _pickCreate(r, a), + onPickBlank: (BlankMenuAction a) => _pickBlank(r, a), + ); + }, + child: widget.child, + ), + ); + } +} + +/// 浮层根:铺满 Overlay 的 Align(本身不吃点击)+ 跟着画布区走的一块(与画布区同大),菜单在这一块里 +/// 按锚点摆放。 +class _CanvasMenuLayer extends ConsumerWidget { + const _CanvasMenuLayer({ + super.key, + required this.canvasId, + required this.link, + required this.request, + required this.onDismiss, + required this.onFocusLost, + required this.onPickCreate, + required this.onPickBlank, + }); + + final String canvasId; + final LayerLink link; + final CanvasMenuRequest request; + final VoidCallback onDismiss; + final VoidCallback onFocusLost; + final ValueChanged onPickCreate; + final ValueChanged onPickBlank; + + /// 焦点整个离开菜单(开了对话框 / 切了标签 / 命令面板盖上来):菜单跟着收,不留在别的界面上空。 + void _onFocusChange(bool hasFocus) { + if (!hasFocus) onFocusLost(); + } + + List>> _createGroups( + WidgetRef ref, + AppLocalizations l, + ) { + final List nodes = + ref.watch(canvasNodesControllerProvider(canvasId)).valueOrNull ?? + const []; + final String? sourceId = request.sourceNodeId; + CanvasNode? source; + if (sourceId != null) { + for (final CanvasNode n in nodes) { + if (n.id == sourceId) source = n; + } + } + // 拖出来的那张卡已经不在了(菜单开着时被删):一项都不出,也不退回不带上下文的那一组。 + final List> groups = sourceId != null && source == null + ? const >[] + : createMenuGroups(source: source, nodes: nodes); + return >>[ + for (final List g in groups) + >[ + for (final CreateMenuAction a in g) _createEntry(l, a), + ], + ]; + } + + List>> _blankGroups(AppLocalizations l) { + final bool isMac = defaultTargetPlatform == TargetPlatform.macOS; + return >>[ + >[ + CanvasMenuEntry( + value: BlankMenuAction.create, + label: l.canvasBlankMenuNew, + glyph: InkGlyph.nodeLibrary, + keyHint: CanvasToolKeys.createMenu.keyLabel, + ), + // 与工具条「更多 → 导入脚本…」同一句:开的是同一个导入框。 + CanvasMenuEntry( + value: BlankMenuAction.importScript, + label: l.canvasAddImportScript, + glyph: InkGlyph.script, + ), + ], + >[ + // 键位从快捷键表里取(与设置「快捷键」页同源),按平台出 ⌘A / Ctrl+A。 + CanvasMenuEntry( + value: BlankMenuAction.selectAll, + label: l.canvasBlankMenuSelectAll, + keyHint: canvasShortcutLabel( + (Intent intent) => intent is SelectAllNodesIntent, + isMac: isMac, + ), + ), + // 稿上的「Shift 1」这一版没有绑定,不画假键位。 + CanvasMenuEntry( + value: BlankMenuAction.fitView, + label: l.canvasBlankMenuFitView, + glyph: InkGlyph.fit, + ), + ], + ]; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AppLocalizations l = context.l10n; + final Size viewport = ref.watch(canvasViewportSizeProvider(canvasId)); + final Widget panel = switch (request.kind) { + CanvasMenuKind.create => CanvasMenuPanel( + key: CanvasMenuKeys.createMenu, + width: CanvasMenuPanel.createWidth, + groups: _createGroups(ref, l), + onSelected: onPickCreate, + onDismiss: onDismiss, + emptyText: l.canvasCreateMenuNoMatch, + searchHint: l.canvasCreateMenuSearchHint, + onFocusChange: _onFocusChange, + ), + CanvasMenuKind.blank => CanvasMenuPanel( + key: CanvasMenuKeys.blankMenu, + width: CanvasMenuPanel.blankWidth, + groups: _blankGroups(l), + onSelected: onPickBlank, + onDismiss: onDismiss, + emptyText: l.canvasCreateMenuNoMatch, + onFocusChange: _onFocusChange, + ), + }; + return Align( + alignment: Alignment.topLeft, + child: CompositedTransformFollower( + link: link, + showWhenUnlinked: false, + child: SizedBox.fromSize( + size: viewport, + child: CustomSingleChildLayout( + delegate: _MenuAnchorLayout(request.anchor), + // 点外面关(与铃铛下拉同一做法)。里面再包一层 TextFieldTapRegion:桌面上点到文本框以外, + // 文本框会自己失焦——点菜单行时搜索框一失焦,菜单就被当成「焦点离开」收掉,行的点击落空。 + // 整块菜单算进文本框的点击区,点行时搜索框不失焦。 + child: TapRegion( + onTapOutside: (_) => onDismiss(), + child: TextFieldTapRegion(child: panel), + ), + ), + ), + ), + ); + } +} + +/// 菜单的左上角对准锚点;右边 / 下边放不下就往回收,不出画布区(视口还没量出来时不收)。 +class _MenuAnchorLayout extends SingleChildLayoutDelegate { + _MenuAnchorLayout(this.anchor); + + final Offset anchor; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) => + const BoxConstraints(); + + @override + Offset getPositionForChild(Size size, Size childSize) { + if (size.isEmpty) return anchor; + final double maxX = math.max(0.0, size.width - childSize.width); + final double maxY = math.max(0.0, size.height - childSize.height); + return Offset(anchor.dx.clamp(0.0, maxX), anchor.dy.clamp(0.0, maxY)); + } + + @override + bool shouldRelayout(_MenuAnchorLayout oldDelegate) => oldDelegate.anchor != anchor; +} + +/// 新建菜单一项的呈现:文案 + 图标 + 键位提示。键位取工具条用的同一组常量(CanvasToolKeys), +/// 与工具条 tooltip、快捷键表同源。 +CanvasMenuEntry _createEntry( + AppLocalizations l, + CreateMenuAction action, +) => + switch (action) { + CreateMenuAction.asFirstFrame => CanvasMenuEntry( + value: action, + label: l.canvasCreateMenuAsFirstFrame, + glyph: InkGlyph.videoConfig, + ), + CreateMenuAction.asLastFrame => CanvasMenuEntry( + value: action, + label: l.canvasCreateMenuAsLastFrame, + glyph: InkGlyph.videoConfig, + ), + CreateMenuAction.asReference => CanvasMenuEntry( + value: action, + label: l.canvasCreateMenuAsReference, + glyph: InkGlyph.imageConfig, + ), + CreateMenuAction.nextShot => CanvasMenuEntry( + value: action, + label: l.canvasCreateMenuNextShot, + glyph: InkGlyph.link, + ), + CreateMenuAction.imageConfig => CanvasMenuEntry( + value: action, + label: l.canvasCreateMenuImageConfig, + glyph: InkGlyph.imageConfig, + keyHint: CanvasToolKeys.imageNode.keyLabel, + ), + CreateMenuAction.videoConfig => CanvasMenuEntry( + value: action, + label: l.canvasCreateMenuVideoConfig, + glyph: InkGlyph.videoConfig, + keyHint: CanvasToolKeys.videoNode.keyLabel, + ), + CreateMenuAction.text => CanvasMenuEntry( + value: action, + label: l.canvasCreateMenuText, + glyph: InkGlyph.text, + keyHint: CanvasToolKeys.textNode.keyLabel, + ), + CreateMenuAction.shot => CanvasMenuEntry( + value: action, + label: l.canvasCreateMenuShot, + glyph: InkGlyph.shot, + ), + CreateMenuAction.lane => CanvasMenuEntry( + value: action, + label: l.canvasCreateMenuLane, + glyph: InkGlyph.lane, + keyHint: CanvasToolKeys.lane.keyLabel, + ), + }; + +// ===== 菜单本体 ===== + +/// 菜单的一行(呈现参数)。 +@immutable +class CanvasMenuEntry { + const CanvasMenuEntry({ + required this.value, + required this.label, + this.glyph, + this.keyHint, + }); + + /// 选中这一行时交回给调用方的值。 + final T value; + + /// 文案;搜索按它过滤。 + final String label; + + /// 左侧 16 号图标(fg4);null = 留出同宽的空位,文字照样对齐。 + final InkGlyph? glyph; + + /// 右侧键位提示(等宽 10 号 fg5);null = 不画。 + final String? keyHint; +} + +/// 菜单本体(稿:宽 236 / 196、内边距 4、surface4 底 + overlayBorder 边、圆角 6、浮层阴影; +/// 行 28 高、圆角 3、左右 10、图标与文字间距 8;分组之间 1px borderStrong 分隔线、上下各 4)。 +/// +/// 新建菜单([searchHint] 非空)顶上是搜索框,一打开就持焦,直接打字就按文字过滤(不区分大小写的包含 +/// 匹配),一项都没匹配上时出一行 [emptyText]。空白处右键菜单没有搜索框,焦点落在菜单本身。两种都是 +/// ↑ / ↓ 移动高亮(鼠标悬停也移,高亮行 surface5 底)、↵ 执行高亮那一行、Esc 关;点某一行 = 执行它。 +class CanvasMenuPanel extends StatefulWidget { + const CanvasMenuPanel({ + super.key, + required this.width, + required this.groups, + required this.onSelected, + required this.onDismiss, + required this.emptyText, + this.searchHint, + this.onFocusChange, + }); + + /// 新建菜单宽(稿 box-sizing: border-box,含边与内边距)。 + static const double createWidth = 236; + + /// 空白处右键菜单宽。 + static const double blankWidth = 196; + + /// 行高。 + static const double rowHeight = 28; + + /// 搜索框高:稿 content-box,28 + 上下 1px 边(与左栏筛选框同一个算法)。 + static const double searchHeight = 30; + + final double width; + + /// 分组;组与组之间画分隔线。 + final List>> groups; + final ValueChanged onSelected; + final VoidCallback onDismiss; + + /// 一项都没匹配上时那一行的文案。 + final String emptyText; + + /// 搜索框占位文案;null = 没有搜索框。 + final String? searchHint; + + /// 焦点进 / 出整个菜单(搜索框、菜单本身、Tab 进去的某一行都算在里面)。 + final ValueChanged? onFocusChange; + + @override + State> createState() => _CanvasMenuPanelState(); +} + +class _CanvasMenuPanelState extends State> { + final FocusNode _panelFocus = + FocusNode(debugLabel: 'canvasMenu', skipTraversal: true); + final FocusNode _searchFocus = FocusNode(debugLabel: 'canvasMenu.search'); + String _query = ''; + int _highlight = 0; + + bool get _searchable => widget.searchHint != null; + + @override + void initState() { + super.initState(); + // 挂上之后再要焦点(与铃铛下拉同一做法):新建菜单直接能打字,右键菜单的 ↑ / ↓ 直接能用。 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + (_searchable ? _searchFocus : _panelFocus).requestFocus(); + }); + } + + @override + void dispose() { + _searchFocus.dispose(); + _panelFocus.dispose(); + super.dispose(); + } + + List>> get _visibleGroups => + filterMenuGroups>( + widget.groups, + _query, + (CanvasMenuEntry entry) => entry.label, + ); + + List> get _visibleEntries => >[ + for (final List> group in _visibleGroups) ...group, + ]; + + void _onQueryChanged(String query) => setState(() { + _query = query; + _highlight = 0; + }); + + void _setHighlight(int index) { + if (index != _highlight) setState(() => _highlight = index); + } + + /// ↑ / ↓:首尾相接。 + void _moveHighlight(int delta) { + final int count = _visibleEntries.length; + if (count == 0) return; + setState(() => _highlight = (_highlight.clamp(0, count - 1) + delta) % count); + } + + void _activateHighlighted() { + final List> entries = _visibleEntries; + if (entries.isEmpty) return; + widget.onSelected(entries[_highlight.clamp(0, entries.length - 1)].value); + } + + /// 菜单的键盘:挂在包着整块菜单的 Focus 上——搜索框持焦时按键先过它自己、再冒泡到这里, + /// 比 app 层的文本编辑快捷键(↑ / ↓ 挪光标)离焦点更近,所以 ↑ / ↓ / ↵ / Esc 在这里先截住。 + KeyEventResult _onKey(FocusNode node, KeyEvent event) { + final LogicalKeyboardKey key = event.logicalKey; + final bool pressed = event is KeyDownEvent || event is KeyRepeatEvent; + if (key == LogicalKeyboardKey.arrowDown || key == LogicalKeyboardKey.arrowUp) { + if (pressed) _moveHighlight(key == LogicalKeyboardKey.arrowDown ? 1 : -1); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) { + if (event is KeyDownEvent) _activateHighlighted(); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.escape) { + if (event is KeyDownEvent) widget.onDismiss(); + return KeyEventResult.handled; + } + // 没有搜索框(空白处右键菜单)时,菜单开着别的键不往画布漏——不然按一下 I 会在菜单底下建个节点。 + // 有搜索框时放行:字母要进搜索框,画布的单键快捷键在打字时本来就让位。松开事件一律放行 + // (按住空格临时平移之类要靠松开收尾)。 + if (_searchable || event is KeyUpEvent) return KeyEventResult.ignored; + return KeyEventResult.skipRemainingHandlers; + } + + @override + Widget build(BuildContext context) { + final InkColors c = context.inkColors; + final List>> groups = _visibleGroups; + final int count = groups.fold( + 0, + (int n, List> group) => n + group.length, + ); + final int highlight = count == 0 ? -1 : _highlight.clamp(0, count - 1); + final List rows = []; + int index = 0; + for (int g = 0; g < groups.length; g++) { + if (g > 0) rows.add(const _MenuSeparator()); + for (final CanvasMenuEntry entry in groups[g]) { + final int i = index++; + rows.add( + _MenuRow( + key: ValueKey(entry.value), + entry: entry, + highlighted: i == highlight, + onPointerOver: () => _setHighlight(i), + onTap: () => widget.onSelected(entry.value), + ), + ); + } + } + if (count == 0) rows.add(_MenuEmptyRow(text: widget.emptyText)); + final String? hint = widget.searchHint; + return Focus( + focusNode: _panelFocus, + onKeyEvent: _onKey, + onFocusChange: widget.onFocusChange, + child: Material( + type: MaterialType.transparency, + child: Container( + width: widget.width, + padding: const EdgeInsets.all(InkSpacing.xs), + decoration: BoxDecoration( + color: c.surface4, + border: Border.all(color: c.overlayBorder), + borderRadius: BorderRadius.circular(InkRadius.bentoBtn), + boxShadow: InkShadow.overlay, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (hint != null) + _MenuSearchField( + focusNode: _searchFocus, + hint: hint, + onChanged: _onQueryChanged, + ), + ...rows, + ], + ), + ), + ), + ); + } +} + +/// 搜索框(稿:28 高 content-box、surface2 底 + outline 边、圆角 3;左侧 13 号 search 图标, +/// 占位文字 11 号 fg6)。 +class _MenuSearchField extends StatelessWidget { + const _MenuSearchField({ + required this.focusNode, + required this.hint, + required this.onChanged, + }); + + final FocusNode focusNode; + final String hint; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final InkColors c = context.inkColors; + final InkTypography t = context.inkTypography; + return Container( + height: CanvasMenuPanel.searchHeight, + margin: const EdgeInsets.only(bottom: InkSpacing.xs), + padding: const EdgeInsets.symmetric(horizontal: InkSpacing.sm), + decoration: BoxDecoration( + color: c.surface2, + border: Border.all(color: c.outline), + borderRadius: BorderRadius.circular(InkRadius.s3), + ), + child: Row( + children: [ + InkIcon(InkGlyph.search, size: InkIconSize.xs, color: c.fg6), + const SizedBox(width: InkSpacing.s6), + Expanded( + child: TextField( + key: CanvasMenuKeys.search, + focusNode: focusNode, + onChanged: onChanged, + style: t.meta.copyWith(color: c.fg2), + cursorColor: c.accent, + decoration: InputDecoration.collapsed( + hintText: hint, + hintStyle: t.meta.copyWith(color: c.fg6), + ), + ), + ), + ], + ), + ); + } +} + +/// 一行:左侧 16 号图标(fg4)| 8 | 文字(fg2)| 键位提示(等宽 10 号 fg5)。点击壳走 InkActivatable。 +class _MenuRow extends StatelessWidget { + const _MenuRow({ + super.key, + required this.entry, + required this.highlighted, + required this.onPointerOver, + required this.onTap, + }); + + final CanvasMenuEntry entry; + final bool highlighted; + + /// 鼠标进入 / 在行内移动:把高亮挪到这一行(稿:悬停也移动高亮)。 + final VoidCallback onPointerOver; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final InkColors c = context.inkColors; + final InkTypography t = context.inkTypography; + final InkGlyph? glyph = entry.glyph; + final String? keyHint = entry.keyHint; + return MouseRegion( + onEnter: (_) => onPointerOver(), + onHover: (_) => onPointerOver(), + child: InkActivatable( + onTap: onTap, + semanticLabel: entry.label, + selected: highlighted, + focusRingRadius: BorderRadius.circular(InkRadius.s3), + builder: (BuildContext context, bool hovered, bool focused) => Container( + height: CanvasMenuPanel.rowHeight, + padding: const EdgeInsets.symmetric(horizontal: InkSpacing.s10), + decoration: BoxDecoration( + color: highlighted ? c.surface5 : null, + borderRadius: BorderRadius.circular(InkRadius.s3), + ), + child: Row( + children: [ + if (glyph != null) + InkIcon(glyph, size: InkIconSize.sm, color: c.fg4) + else + const SizedBox(width: InkIconSize.sm), + const SizedBox(width: InkSpacing.sm), + Expanded( + child: Text( + entry.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: t.body.copyWith(color: c.fg2), + ), + ), + if (keyHint != null) ...[ + const SizedBox(width: InkSpacing.sm), + Text(keyHint, style: t.monoSmall.copyWith(color: c.fg5)), + ], + ], + ), + ), + ), + ); + } +} + +/// 分组之间的分隔线:1px borderStrong,上下各 4。 +class _MenuSeparator extends StatelessWidget { + const _MenuSeparator(); + + @override + Widget build(BuildContext context) => Container( + height: 1, + margin: const EdgeInsets.symmetric(vertical: InkSpacing.xs), + color: context.inkColors.borderStrong, + ); +} + +/// 一项都没匹配上:一行 fg5 说明。 +class _MenuEmptyRow extends StatelessWidget { + const _MenuEmptyRow({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) => Container( + height: CanvasMenuPanel.rowHeight, + padding: const EdgeInsets.symmetric(horizontal: InkSpacing.s10), + alignment: Alignment.centerLeft, + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.inkTypography.body.copyWith(color: context.inkColors.fg5), + ), + ); +} diff --git a/lib/features/canvas/widgets/edge_painter.dart b/lib/features/canvas/widgets/edge_painter.dart index e80b0913..f0e0a588 100644 --- a/lib/features/canvas/widgets/edge_painter.dart +++ b/lib/features/canvas/widgets/edge_painter.dart @@ -9,6 +9,10 @@ // 胶囊下那段线),1px 描边与 10px 文字都用连线色。标签等所有线画完再统一画, // 不会被后画的线压住。 // 端口圆点由 NodeCard 自己画,本层不画。 +// +// 从出端口拖线时的实时虚线([PendingWirePainter],画布重设计稿 › 拖线到空白处 → 建节点)也在本文件: +// 同一条 edgePath 贝塞尔、同一个虚线切法,只是它画在卡片之上(canvas_view 舞台的最上层), +// 拖过别的卡片时线不会被压住。 import 'dart:math' as math; import 'dart:ui' show PathMetric, Tangent; @@ -31,6 +35,15 @@ const EdgeInsets _kLabelPadding = EdgeInsets.symmetric( /// 一个待画的标签:画完全部连线后统一画在最上面。 typedef _PendingLabel = ({EdgeLabelKind kind, Color color, Offset at}); +/// 拖线中的实时虚线线宽(稿:1.75,与首帧 / 尾帧数据线同宽)。 +const double kPendingWireWidth = 1.75; + +/// 实时虚线的线型:画 4、空 3。 +const EdgeDash kPendingWireDash = (on: 4.0, off: 3.0); + +/// 实时虚线在指针那头的实心点半径。 +const double kPendingWireDotRadius = 3.5; + class EdgePainter extends CustomPainter { EdgePainter({ required this.edges, @@ -279,3 +292,57 @@ class EdgePainter extends CustomPainter { old.dragDelta != dragDelta; } } + +/// 从出端口拖线时的实时虚线:源节点出点 → 指针。 +/// +/// 曲线与存量连线是同一条贝塞尔(edgePath,出点同 edgeSourceAnchor),所以松手连上之后那条线的起头 +/// 和拖的时候一模一样;颜色由调用方给(accent),线宽 / 虚线 / 末端实心点见上面三个常量。 +class PendingWirePainter extends CustomPainter { + PendingWirePainter({ + required this.source, + required this.to, + required this.color, + this.direction = LaneDirection.horizontal, + }); + + /// 拖线的源节点,坐标已是渲染位置(舞台坐标 + 分道位移,见 displacedNodes)。 + final CanvasNode source; + + /// 指针(舞台坐标)。 + final Offset to; + + final Color color; + + /// 泳道主轴——决定出点方位与曲线走向,与 [EdgePainter.direction] 同源。 + final LaneDirection direction; + + @override + void paint(Canvas canvas, Size size) { + final Path path = edgePath( + edgeSourceAnchor(source, direction: direction), + to, + direction: direction, + ); + canvas.drawPath( + EdgePainter._dashed(path, kPendingWireDash), + Paint() + ..color = color + ..strokeWidth = kPendingWireWidth + ..style = PaintingStyle.stroke, + ); + canvas.drawCircle( + to, + kPendingWireDotRadius, + Paint() + ..color = color + ..style = PaintingStyle.fill, + ); + } + + @override + bool shouldRepaint(covariant PendingWirePainter oldDelegate) => + oldDelegate.source != source || + oldDelegate.to != to || + oldDelegate.color != color || + oldDelegate.direction != direction; +} diff --git a/lib/features/canvas/widgets/node_card.dart b/lib/features/canvas/widgets/node_card.dart index 40954a85..8ccf24e9 100644 --- a/lib/features/canvas/widgets/node_card.dart +++ b/lib/features/canvas/widgets/node_card.dart @@ -23,6 +23,12 @@ // // 拖拽(HI-13):位移累积在本卡片局部状态(Transform.translate),每帧只重建 // 自身;onPanEnd 把累计位移一次性回调 onDragEnd,由上层提交 controller。 +// +// 出端口可拖(画布重设计稿 › 拖线到空白处 → 建节点):按住出端口拖出去是拖线、不是拖卡片——端口上 +// 的拖拽手势比卡片的更深,先过触摸阈值、先赢手势竞技场。卡片只管把指针的全局坐标经 [PortDragCallbacks] +// 交出去,落在哪、连不连线、开不开新建菜单都归画布(canvas_view 的卡片插槽)。外观一点没动:圆点还是 +// 10px,只是外面多了一圈透明的命中区。拖线期间端口一直亮着——它一收起来,上面的拖拽手势就跟着拆掉, +// 松手再也回调不到(拖出卡片时悬停态先掉)。 import 'dart:async'; import 'dart:io'; @@ -51,6 +57,29 @@ import '../util/camera_labels.dart'; import '../util/lane_tint.dart'; import 'video_node_body.dart'; +/// 从出端口拖线的四个回调。坐标一律是指针的**全局**坐标——卡片不知道舞台的变换,换算归画布。 +@immutable +class PortDragCallbacks { + const PortDragCallbacks({ + required this.onStart, + required this.onUpdate, + required this.onEnd, + required this.onCancel, + }); + + /// 拖动被认出来(过了触摸阈值)的那一刻。 + final ValueChanged onStart; + + /// 每次移动。 + final ValueChanged onUpdate; + + /// 松手。 + final VoidCallback onEnd; + + /// 手势被系统取消(或卡片在拖线途中被拆掉)。 + final VoidCallback onCancel; +} + class NodeCard extends ConsumerStatefulWidget { const NodeCard({ super.key, @@ -65,6 +94,7 @@ class NodeCard extends ConsumerStatefulWidget { this.isLinkCandidate = false, this.hasInEdge = false, this.hasOutEdge = false, + this.portDrag, }); final CanvasNode node; @@ -96,8 +126,15 @@ class NodeCard extends ConsumerStatefulWidget { /// 出口这一侧真挂着一条画出来的线 → 出端口常驻。 final bool hasOutEdge; + /// 从出端口拖线(见 [PortDragCallbacks])。null = 端口只是个圆点、按住拖动拖的是卡片。 + final PortDragCallbacks? portDrag; + static const double thumbHeight = 126; // 224 × 9 / 16 + /// 出端口的拖线命中区边长:以圆点为中心的方块(圆点本身 10px,按不准)。卡片盒外的那一半点不到—— + /// 命中先按卡片盒界筛——所以真正按得住的是卡片右缘内侧那一条。 + static const double portHitExtent = 20; + /// 入 / 出端口圆点的测试锚点(每张卡片内唯一)。 static const Key inPortKey = ValueKey('node-card-in-port'); static const Key outPortKey = ValueKey('node-card-out-port'); @@ -127,8 +164,23 @@ class _NodeCardState extends ConsumerState { /// 拖拽期捕获的广播口——dispose 兜底清残留用(dispose 后 ref 不可用)。 StateController? _dragBroadcast; + /// 正从出端口往外拖线:拖线期间端口一直亮着(见头注)。 + bool _portDragging = false; + @override void dispose() { + // 拖线途中卡片被拆掉(节点被删 / 换画布):手势跟着拆、松手不会再回调,拖线状态得替它收掉。 + // 同样延到微任务里做,画布已经整个拆了就静默放弃。 + final PortDragCallbacks? portDrag = widget.portDrag; + if (_portDragging && portDrag != null) { + scheduleMicrotask(() { + try { + portDrag.onCancel(); + } on StateError { + // 画布(连同它的 ProviderContainer)已经拆了——拖线状态随之回收,无需再收。 + } + }); + } // 拖拽中被删(如 Delete 快捷键)不会走 onPanEnd,广播会残留脏值; // 微任务延后清理避开 widget 树锁,容器已销毁时静默放弃。 final broadcast = _dragBroadcast; @@ -169,6 +221,45 @@ class _NodeCardState extends ConsumerState { setState(() => _hovered = value); } + void _setPortDragging(bool value) { + if (_portDragging == value || !mounted) return; + setState(() => _portDragging = value); + } + + /// 出端口:10px 圆点居中放在 [NodeCard.portHitExtent] 见方的透明命中区里;可拖时命中区收拖拽手势。 + Widget _outPort(Color color) { + final Widget dot = Center( + child: Container( + key: NodeCard.outPortKey, + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + ); + final PortDragCallbacks? drag = widget.portDrag; + if (drag == null) return dot; + return MouseRegion( + cursor: SystemMouseCursors.precise, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (DragStartDetails d) { + _setPortDragging(true); + drag.onStart(d.globalPosition); + }, + onPanUpdate: (DragUpdateDetails d) => drag.onUpdate(d.globalPosition), + onPanEnd: (DragEndDetails _) { + _setPortDragging(false); + drag.onEnd(); + }, + onPanCancel: () { + _setPortDragging(false); + drag.onCancel(); + }, + child: dot, + ), + ); + } + @override Widget build(BuildContext context) { final node = widget.node; @@ -181,8 +272,8 @@ class _NodeCardState extends ConsumerState { // 稿:shot 节点只有出端口。 final bool hasIn = !(node.type == CanvasNodeType.shot && node.role == NodeRole.config); - // 端口按需显示:悬停 / 选中 / 连线模式两侧都亮;平时只留挂着线的那一侧。 - final bool revealPorts = _hovered || highlighted; + // 端口按需显示:悬停 / 选中 / 连线模式两侧都亮;平时只留挂着线的那一侧。拖线途中也亮着(见头注)。 + final bool revealPorts = _hovered || highlighted || _portDragging; final bool showInPort = hasIn && (revealPorts || widget.hasInEdge); final bool showOutPort = revealPorts || widget.hasOutEdge; @@ -285,14 +376,11 @@ class _NodeCardState extends ConsumerState { ), if (showOutPort) Positioned( - right: -5, - top: NodeCard.thumbHeight / 2 - 5, - child: Container( - key: NodeCard.outPortKey, - width: 10, - height: 10, - decoration: BoxDecoration(color: outPort, shape: BoxShape.circle), - ), + right: -NodeCard.portHitExtent / 2, + top: NodeCard.thumbHeight / 2 - NodeCard.portHitExtent / 2, + width: NodeCard.portHitExtent, + height: NodeCard.portHitExtent, + child: _outPort(outPort), ), ], ), diff --git a/lib/features/settings/util/shortcut_catalog.dart b/lib/features/settings/util/shortcut_catalog.dart index 99213c2b..884189ba 100644 --- a/lib/features/settings/util/shortcut_catalog.dart +++ b/lib/features/settings/util/shortcut_catalog.dart @@ -42,6 +42,7 @@ enum ShortcutAction { canvasAddVideoNode, canvasAddTextNode, canvasAddLane, + canvasOpenCreateMenu, canvasToggleLink, canvasToggleLeftRail, } @@ -63,6 +64,7 @@ ShortcutAction? shortcutActionOf(Intent intent) => switch (intent) { CanvasAddNodeIntent(type: CanvasNodeType.text) => ShortcutAction.canvasAddTextNode, CanvasAddLaneIntent() => ShortcutAction.canvasAddLane, + CanvasOpenCreateMenuIntent() => ShortcutAction.canvasOpenCreateMenu, CanvasToggleLinkIntent() => ShortcutAction.canvasToggleLink, CanvasToggleLeftRailIntent() => ShortcutAction.canvasToggleLeftRail, _ => null, @@ -107,6 +109,22 @@ List buildShortcutRows({required bool isMac}) { ]; } +/// [kCanvasShortcuts] 里第一条满足 [matches] 的绑定在本平台的展示串;一条都出不了字 ⇒ null。 +/// +/// 菜单行尾的键位提示用它(画布空白处右键菜单的「全选」):与本清单同源,改了绑定提示跟着变, +/// 不会出现菜单上写着一个早就不存在的键。 +String? canvasShortcutLabel( + bool Function(Intent intent) matches, { + required bool isMac, +}) { + for (final MapEntry e in kCanvasShortcuts.entries) { + if (!matches(e.value)) continue; + final String? label = formatShortcutActivator(e.key, isMac: isMac); + if (label != null) return label; + } + return null; +} + /// 激活器 → 本平台展示串;null = 本平台不展示这一条。 /// /// 不展示的三种:另一平台的修饰键变体(Windows 不显示 ⌘)、小键盘 / 同义重复键 @@ -163,4 +181,6 @@ final Map _keyLabels = { LogicalKeyboardKey.keyL.keyId: 'L', // 左栏开合(CanvasToolKeys.toggleLeftRail*):⌘\ / Ctrl+\。 LogicalKeyboardKey.backslash.keyId: r'\', + // 新建…(CanvasToolKeys.createMenu)。 + LogicalKeyboardKey.tab.keyId: 'Tab', }; diff --git a/lib/features/settings/widgets/shortcuts_section.dart b/lib/features/settings/widgets/shortcuts_section.dart index 337b3adf..8909208e 100644 --- a/lib/features/settings/widgets/shortcuts_section.dart +++ b/lib/features/settings/widgets/shortcuts_section.dart @@ -41,6 +41,8 @@ class ShortcutsSection extends StatelessWidget { l.settingsShortcutCanvasAddVideoNode, ShortcutAction.canvasAddTextNode => l.settingsShortcutCanvasAddTextNode, ShortcutAction.canvasAddLane => l.settingsShortcutCanvasAddLane, + ShortcutAction.canvasOpenCreateMenu => + l.settingsShortcutCanvasOpenCreateMenu, ShortcutAction.canvasToggleLink => l.settingsShortcutCanvasToggleLink, ShortcutAction.canvasToggleLeftRail => l.settingsShortcutCanvasToggleLeftRail, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0c91b035..2d74a1dd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -742,6 +742,47 @@ "@canvasToolPanHoldSpace": { "description": "Second shortcut hint in the Pan tool tooltip, after the H key: holding the Space bar pans temporarily" }, + "canvasCreateMenuSearchHint": "Type to search…", + "@canvasCreateMenuSearchHint": { + "description": "Canvas New menu (opened by dragging a wire from a node's output port to empty space, double-clicking empty canvas, Tab, or right-click > New…): placeholder of the search box at the top. Typing filters the menu items." + }, + "canvasCreateMenuNoMatch": "No matching actions", + "@canvasCreateMenuNoMatch": { + "description": "Canvas New menu: the only row shown when the search text matches no item" + }, + "canvasCreateMenuAsFirstFrame": "First frame · New video", + "@canvasCreateMenuAsFirstFrame": { + "description": "Canvas New menu, opened by dragging a wire out of an image node: create a video config node at the drop point and link the image to it as its first frame" + }, + "canvasCreateMenuAsLastFrame": "Last frame · New video", + "@canvasCreateMenuAsLastFrame": { + "description": "Canvas New menu, opened by dragging a wire out of an image node: create a video config node and link the image to it as its last frame" + }, + "canvasCreateMenuAsReference": "Reference · New image", + "@canvasCreateMenuAsReference": { + "description": "Canvas New menu, opened by dragging a wire out of an image node: create an image config node and link the image to it as a reference image" + }, + "canvasCreateMenuNextShot": "Next shot (narrative)", + "@canvasCreateMenuNextShot": { + "description": "Canvas New menu, opened by dragging a wire out of a config node: create a config node of the same type and link it as the next shot in the story order" + }, + "canvasCreateMenuImageConfig": "Image config", + "canvasCreateMenuVideoConfig": "Video config", + "canvasCreateMenuText": "Text", + "canvasCreateMenuShot": "Shot", + "canvasCreateMenuLane": "Lane", + "@canvasCreateMenuLane": { + "description": "Canvas New menu without a wire: opens the New lane dialog (the four rows above it create nodes of that type)" + }, + "canvasBlankMenuNew": "New…", + "@canvasBlankMenuNew": { + "description": "Canvas right-click menu on empty space: opens the New menu (search and create a node) at the click point" + }, + "canvasBlankMenuSelectAll": "Select all", + "canvasBlankMenuFitView": "Fit view", + "@canvasBlankMenuFitView": { + "description": "Canvas right-click menu on empty space: same as the Fit cell of the zoom bar (back to the default camera)" + }, "leftRailOutline": "Outline", "@leftRailOutline": { "description": "Canvas left sidebar: the Outline page — rail button tooltip and the panel title" @@ -1282,6 +1323,10 @@ "@settingsShortcutCanvasToggleLeftRail": { "description": "Settings > Shortcuts row: toggles the canvas left sidebar panel (same action as the rail collapse button)" }, + "settingsShortcutCanvasOpenCreateMenu": "New…", + "@settingsShortcutCanvasOpenCreateMenu": { + "description": "Settings > Shortcuts row for Tab: opens the canvas New menu (search and create a node) in the middle of the canvas" + }, "settingsPerformanceConcurrencyTitle": "Concurrency and quota", "settingsPerformanceGlobalLimit": "Global concurrent jobs", "settingsPerformanceImageCache": "Image cache limit", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 876f3a40..8c09f8e2 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -663,6 +663,20 @@ "canvasToolShotNode": "分镜节点", "canvasToolMore": "更多", "canvasToolPanHoldSpace": "按住空格", + "canvasCreateMenuSearchHint": "直接打字搜索…", + "canvasCreateMenuNoMatch": "没有匹配的操作", + "canvasCreateMenuAsFirstFrame": "当首帧 · 新建视频配置", + "canvasCreateMenuAsLastFrame": "当尾帧 · 新建视频配置", + "canvasCreateMenuAsReference": "当参考 · 新建图像配置", + "canvasCreateMenuNextShot": "下一镜(叙事)", + "canvasCreateMenuImageConfig": "图像配置", + "canvasCreateMenuVideoConfig": "视频配置", + "canvasCreateMenuText": "文本", + "canvasCreateMenuShot": "镜头", + "canvasCreateMenuLane": "泳道", + "canvasBlankMenuNew": "新建…", + "canvasBlankMenuSelectAll": "全选", + "canvasBlankMenuFitView": "适应视图", "leftRailOutline": "大纲", "leftRailAssets": "素材", "leftRailCharacters": "角色", @@ -1055,6 +1069,7 @@ "settingsShortcutCanvasAddLane": "新建泳道", "settingsShortcutCanvasToggleLink": "开始 / 退出连线", "settingsShortcutCanvasToggleLeftRail": "展开 / 收起画布左栏", + "settingsShortcutCanvasOpenCreateMenu": "新建…", "settingsPerformanceConcurrencyTitle": "并发与配额", "settingsPerformanceGlobalLimit": "全局并发上限", "settingsPerformanceImageCache": "图像缓存上限", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 3c08e197..640d78d7 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -3068,6 +3068,90 @@ abstract class AppLocalizations { /// **'hold Space'** String get canvasToolPanHoldSpace; + /// Canvas New menu (opened by dragging a wire from a node's output port to empty space, double-clicking empty canvas, Tab, or right-click > New…): placeholder of the search box at the top. Typing filters the menu items. + /// + /// In en, this message translates to: + /// **'Type to search…'** + String get canvasCreateMenuSearchHint; + + /// Canvas New menu: the only row shown when the search text matches no item + /// + /// In en, this message translates to: + /// **'No matching actions'** + String get canvasCreateMenuNoMatch; + + /// Canvas New menu, opened by dragging a wire out of an image node: create a video config node at the drop point and link the image to it as its first frame + /// + /// In en, this message translates to: + /// **'First frame · New video'** + String get canvasCreateMenuAsFirstFrame; + + /// Canvas New menu, opened by dragging a wire out of an image node: create a video config node and link the image to it as its last frame + /// + /// In en, this message translates to: + /// **'Last frame · New video'** + String get canvasCreateMenuAsLastFrame; + + /// Canvas New menu, opened by dragging a wire out of an image node: create an image config node and link the image to it as a reference image + /// + /// In en, this message translates to: + /// **'Reference · New image'** + String get canvasCreateMenuAsReference; + + /// Canvas New menu, opened by dragging a wire out of a config node: create a config node of the same type and link it as the next shot in the story order + /// + /// In en, this message translates to: + /// **'Next shot (narrative)'** + String get canvasCreateMenuNextShot; + + /// No description provided for @canvasCreateMenuImageConfig. + /// + /// In en, this message translates to: + /// **'Image config'** + String get canvasCreateMenuImageConfig; + + /// No description provided for @canvasCreateMenuVideoConfig. + /// + /// In en, this message translates to: + /// **'Video config'** + String get canvasCreateMenuVideoConfig; + + /// No description provided for @canvasCreateMenuText. + /// + /// In en, this message translates to: + /// **'Text'** + String get canvasCreateMenuText; + + /// No description provided for @canvasCreateMenuShot. + /// + /// In en, this message translates to: + /// **'Shot'** + String get canvasCreateMenuShot; + + /// Canvas New menu without a wire: opens the New lane dialog (the four rows above it create nodes of that type) + /// + /// In en, this message translates to: + /// **'Lane'** + String get canvasCreateMenuLane; + + /// Canvas right-click menu on empty space: opens the New menu (search and create a node) at the click point + /// + /// In en, this message translates to: + /// **'New…'** + String get canvasBlankMenuNew; + + /// No description provided for @canvasBlankMenuSelectAll. + /// + /// In en, this message translates to: + /// **'Select all'** + String get canvasBlankMenuSelectAll; + + /// Canvas right-click menu on empty space: same as the Fit cell of the zoom bar (back to the default camera) + /// + /// In en, this message translates to: + /// **'Fit view'** + String get canvasBlankMenuFitView; + /// Canvas left sidebar: the Outline page — rail button tooltip and the panel title /// /// In en, this message translates to: @@ -5006,6 +5090,12 @@ abstract class AppLocalizations { /// **'Show / hide the canvas left sidebar'** String get settingsShortcutCanvasToggleLeftRail; + /// Settings > Shortcuts row for Tab: opens the canvas New menu (search and create a node) in the middle of the canvas + /// + /// In en, this message translates to: + /// **'New…'** + String get settingsShortcutCanvasOpenCreateMenu; + /// No description provided for @settingsPerformanceConcurrencyTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index e95edc8b..25d4afe5 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1694,6 +1694,48 @@ class AppLocalizationsEn extends AppLocalizations { @override String get canvasToolPanHoldSpace => 'hold Space'; + @override + String get canvasCreateMenuSearchHint => 'Type to search…'; + + @override + String get canvasCreateMenuNoMatch => 'No matching actions'; + + @override + String get canvasCreateMenuAsFirstFrame => 'First frame · New video'; + + @override + String get canvasCreateMenuAsLastFrame => 'Last frame · New video'; + + @override + String get canvasCreateMenuAsReference => 'Reference · New image'; + + @override + String get canvasCreateMenuNextShot => 'Next shot (narrative)'; + + @override + String get canvasCreateMenuImageConfig => 'Image config'; + + @override + String get canvasCreateMenuVideoConfig => 'Video config'; + + @override + String get canvasCreateMenuText => 'Text'; + + @override + String get canvasCreateMenuShot => 'Shot'; + + @override + String get canvasCreateMenuLane => 'Lane'; + + @override + String get canvasBlankMenuNew => 'New…'; + + @override + String get canvasBlankMenuSelectAll => 'Select all'; + + @override + String get canvasBlankMenuFitView => 'Fit view'; + @override String get leftRailOutline => 'Outline'; @@ -2849,6 +2891,9 @@ class AppLocalizationsEn extends AppLocalizations { String get settingsShortcutCanvasToggleLeftRail => 'Show / hide the canvas left sidebar'; + @override + String get settingsShortcutCanvasOpenCreateMenu => 'New…'; + @override String get settingsPerformanceConcurrencyTitle => 'Concurrency and quota'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index da15584d..b651fd07 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1621,6 +1621,48 @@ class AppLocalizationsZh extends AppLocalizations { @override String get canvasToolPanHoldSpace => '按住空格'; + @override + String get canvasCreateMenuSearchHint => '直接打字搜索…'; + + @override + String get canvasCreateMenuNoMatch => '没有匹配的操作'; + + @override + String get canvasCreateMenuAsFirstFrame => '当首帧 · 新建视频配置'; + + @override + String get canvasCreateMenuAsLastFrame => '当尾帧 · 新建视频配置'; + + @override + String get canvasCreateMenuAsReference => '当参考 · 新建图像配置'; + + @override + String get canvasCreateMenuNextShot => '下一镜(叙事)'; + + @override + String get canvasCreateMenuImageConfig => '图像配置'; + + @override + String get canvasCreateMenuVideoConfig => '视频配置'; + + @override + String get canvasCreateMenuText => '文本'; + + @override + String get canvasCreateMenuShot => '镜头'; + + @override + String get canvasCreateMenuLane => '泳道'; + + @override + String get canvasBlankMenuNew => '新建…'; + + @override + String get canvasBlankMenuSelectAll => '全选'; + + @override + String get canvasBlankMenuFitView => '适应视图'; + @override String get leftRailOutline => '大纲'; @@ -2722,6 +2764,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsShortcutCanvasToggleLeftRail => '展开 / 收起画布左栏'; + @override + String get settingsShortcutCanvasOpenCreateMenu => '新建…'; + @override String get settingsPerformanceConcurrencyTitle => '并发与配额'; diff --git a/test/_harness/fake_canvas.dart b/test/_harness/fake_canvas.dart index 59af4875..59aa5483 100644 --- a/test/_harness/fake_canvas.dart +++ b/test/_harness/fake_canvas.dart @@ -63,6 +63,7 @@ class RecordingNodesController extends FakeNodesController { required CanvasNodeType type, NodeRole role = NodeRole.config, String? sourceNodeId, + String? laneId, Offset position = Offset.zero, Size? size, Map typeConfig = const {}, diff --git a/test/features/canvas/util/create_menu_model_test.dart b/test/features/canvas/util/create_menu_model_test.dart new file mode 100644 index 00000000..9e9a8b8d --- /dev/null +++ b/test/features/canvas/util/create_menu_model_test.dart @@ -0,0 +1,267 @@ +// 新建菜单项判据(util/create_menu_model.dart):带不带连线上下文、源是什么,菜单就出哪些项; +// 每一项建什么节点、连什么线;搜索过滤。 +// +// 钉的几条:图像源出首帧 / 尾帧 / 参考(拿掉 sourceCarriesImage 的判断,视频产物那条红); +// 「下一镜」的叙事边只连配置节点:配置卡以自己为锚,产物卡以它的来源配置节点为锚(见 create_menu_model +// 头注);空组不出、不留孤立分隔线。 +import 'package:flutter_test/flutter_test.dart'; +import 'package:inkframe/features/canvas/models/canvas_edge.dart'; +import 'package:inkframe/features/canvas/models/canvas_node.dart'; +import 'package:inkframe/features/canvas/util/create_menu_model.dart'; + +CanvasNode _node( + String id, + CanvasNodeType type, { + NodeRole role = NodeRole.config, + String? sourceNodeId, + Map typeConfig = const {}, + DateTime? createdAt, +}) => + CanvasNode( + id: id, + label: id, + type: type, + role: role, + sourceNodeId: sourceNodeId, + typeConfig: typeConfig, + createdAt: createdAt, + ); + +const List _imageThree = [ + CreateMenuAction.asFirstFrame, + CreateMenuAction.asLastFrame, + CreateMenuAction.asReference, +]; + +void main() { + group('createMenuGroups', () { + test('不带上下文:图像配置 · 视频配置 · 文本 · 镜头 · 泳道,一组', () { + expect(createMenuGroups(), >[ + [ + CreateMenuAction.imageConfig, + CreateMenuAction.videoConfig, + CreateMenuAction.text, + CreateMenuAction.shot, + CreateMenuAction.lane, + ], + ]); + }); + + test('图像配置源:首帧 / 尾帧 / 参考一组,「下一镜」另一组', () { + final CanvasNode src = _node('img', CanvasNodeType.image); + expect( + createMenuGroups(source: src, nodes: [src]), + >[ + _imageThree, + [CreateMenuAction.nextShot], + ], + ); + }); + + test('图像产物源:首帧 / 尾帧 / 参考 +「下一镜」(锚点 = 它的来源配置节点)', () { + final CanvasNode cfg = _node('cfg', CanvasNodeType.image); + final CanvasNode res = _node( + 'res', + CanvasNodeType.image, + role: NodeRole.result, + sourceNodeId: 'cfg', + typeConfig: const {'image_url': 'a.png'}, + ); + expect( + createMenuGroups(source: res, nodes: [cfg, res]), + >[ + _imageThree, + [CreateMenuAction.nextShot], + ], + ); + }); + + test('视频产物源:不出首帧 / 尾帧 / 参考(那是图像语义),只有「下一镜」', () { + final CanvasNode cfg = _node('cfg', CanvasNodeType.video); + final CanvasNode res = _node( + 'res', + CanvasNodeType.video, + role: NodeRole.result, + sourceNodeId: 'cfg', + typeConfig: const {'video_url': 'a.mp4'}, + ); + final List> groups = + createMenuGroups(source: res, nodes: [cfg, res]); + for (final CreateMenuAction a in _imageThree) { + expect(groups.expand((List g) => g), isNot(contains(a))); + } + expect(groups, >[ + [CreateMenuAction.nextShot], + ]); + }); + + test('视频配置 / 镜头源:只有「下一镜」', () { + for (final CanvasNodeType type in [ + CanvasNodeType.video, + CanvasNodeType.shot, + ]) { + final CanvasNode src = _node('s', type); + expect( + createMenuGroups(source: src, nodes: [src]), + >[ + [CreateMenuAction.nextShot], + ], + reason: type.name, + ); + } + }); + + test('文本源:一项都没有(文本进了叙事链也成不了一镜,又不是图)', () { + final CanvasNode src = _node('t', CanvasNodeType.text); + expect(createMenuGroups(source: src, nodes: [src]), isEmpty); + }); + + test('名下最新的产物是一张图 ⇒ 带图像产物;最新的是视频 ⇒ 不算', () { + final CanvasNode cfg = _node('cfg', CanvasNodeType.shot); + CanvasNode result(String id, {required bool image, required int day}) => _node( + id, + image ? CanvasNodeType.image : CanvasNodeType.video, + role: NodeRole.result, + sourceNodeId: 'cfg', + typeConfig: image + ? const {'image_url': 'a.png'} + : const {'video_url': 'a.mp4'}, + createdAt: DateTime.utc(2026, 1, day), + ); + + final List imageLatest = [ + cfg, + result('old', image: false, day: 1), + result('new', image: true, day: 2), + ]; + expect(sourceCarriesImage(cfg, imageLatest), isTrue); + + final List videoLatest = [ + cfg, + result('old', image: true, day: 1), + result('new', image: false, day: 2), + ]; + expect(sourceCarriesImage(cfg, videoLatest), isFalse); + }); + }); + + group('narrativeAnchorOf', () { + test('shot / image / video 的配置节点以自己为锚;文本配置没有锚点', () { + for (final CanvasNodeType type in [ + CanvasNodeType.shot, + CanvasNodeType.image, + CanvasNodeType.video, + ]) { + final CanvasNode n = _node('a', type); + expect(narrativeAnchorOf(n, [n]), same(n), reason: type.name); + } + final CanvasNode text = _node('t', CanvasNodeType.text); + expect(narrativeAnchorOf(text, [text]), isNull); + }); + + test('产物以它的来源配置节点为锚;来源已删 / 来源是文本 ⇒ 没有锚点', () { + final CanvasNode cfg = _node('cfg', CanvasNodeType.image); + final CanvasNode res = + _node('res', CanvasNodeType.image, role: NodeRole.result, sourceNodeId: 'cfg'); + expect(narrativeAnchorOf(res, [cfg, res]), same(cfg)); + expect(narrativeAnchorOf(res, [res]), isNull, reason: '来源配置节点已删'); + + final CanvasNode textCfg = _node('cfg', CanvasNodeType.text); + expect(narrativeAnchorOf(res, [textCfg, res]), isNull, reason: '来源是文本'); + }); + }); + + group('createdNodeType / createdEdge', () { + test('首帧 / 尾帧 → 视频配置 + 对应角色的 data 边;参考 → 图像配置 + data 参考边', () { + expect(createdNodeType(CreateMenuAction.asFirstFrame), CanvasNodeType.video); + expect(createdNodeType(CreateMenuAction.asLastFrame), CanvasNodeType.video); + expect(createdNodeType(CreateMenuAction.asReference), CanvasNodeType.image); + expect( + createdEdge(CreateMenuAction.asFirstFrame), + (type: EdgeType.data, role: EdgeRole.firstFrame), + ); + expect( + createdEdge(CreateMenuAction.asLastFrame), + (type: EdgeType.data, role: EdgeRole.lastFrame), + ); + expect( + createdEdge(CreateMenuAction.asReference), + (type: EdgeType.data, role: EdgeRole.reference), + ); + }); + + test('下一镜 → 与源同类型的配置节点 + narrative 边;没有源就不建', () { + expect( + createdNodeType( + CreateMenuAction.nextShot, + source: _node('s', CanvasNodeType.shot), + ), + CanvasNodeType.shot, + ); + expect( + createdNodeType( + CreateMenuAction.nextShot, + source: _node('v', CanvasNodeType.video), + ), + CanvasNodeType.video, + ); + expect(createdNodeType(CreateMenuAction.nextShot), isNull); + expect( + createdEdge(CreateMenuAction.nextShot), + (type: EdgeType.narrative, role: EdgeRole.reference), + ); + }); + + test('从产物卡续「下一镜」:建来源配置节点的类型,叙事边从来源配置节点连出', () { + final CanvasNode cfg = _node('cfg', CanvasNodeType.video); + final CanvasNode res = + _node('res', CanvasNodeType.video, role: NodeRole.result, sourceNodeId: 'cfg'); + final List nodes = [cfg, res]; + expect( + createdNodeType(CreateMenuAction.nextShot, source: res, nodes: nodes), + CanvasNodeType.video, + ); + expect(createdEdgeSourceId(CreateMenuAction.nextShot, source: res, nodes: nodes), 'cfg'); + // 首帧 / 尾帧 / 参考的 data 边从拖出来的那张卡本身连出。 + expect(createdEdgeSourceId(CreateMenuAction.asFirstFrame, source: res, nodes: nodes), 'res'); + expect(createdEdgeSourceId(CreateMenuAction.imageConfig), isNull); + }); + + test('不带上下文的项:建对应类型、不连线;泳道不建节点', () { + expect(createdNodeType(CreateMenuAction.imageConfig), CanvasNodeType.image); + expect(createdNodeType(CreateMenuAction.videoConfig), CanvasNodeType.video); + expect(createdNodeType(CreateMenuAction.text), CanvasNodeType.text); + expect(createdNodeType(CreateMenuAction.shot), CanvasNodeType.shot); + expect(createdNodeType(CreateMenuAction.lane), isNull); + for (final CreateMenuAction a in kPlainCreateActions) { + expect(createdEdge(a), isNull, reason: a.name); + } + }); + }); + + group('filterMenuGroups', () { + final List> groups = >[ + ['Alpha', 'Beta'], + ['Gamma'], + ]; + String label(String s) => s; + + test('不区分大小写的包含匹配', () { + expect(filterMenuGroups(groups, 'LPH', label), >[ + ['Alpha'], + ]); + expect(filterMenuGroups(groups, 'a', label), groups); + }); + + test('整组都没匹配上的组不出(不留孤立的分隔线)', () { + expect(filterMenuGroups(groups, 'gam', label), >[ + ['Gamma'], + ]); + }); + + test('一项都没匹配上 ⇒ 空;空白查询原样返回', () { + expect(filterMenuGroups(groups, 'zzz', label), isEmpty); + expect(filterMenuGroups(groups, ' ', label), groups); + }); + }); +} diff --git a/test/features/canvas/util/drop_placement_test.dart b/test/features/canvas/util/drop_placement_test.dart new file mode 100644 index 00000000..31b52f23 --- /dev/null +++ b/test/features/canvas/util/drop_placement_test.dart @@ -0,0 +1,231 @@ +// 落点换算(util/drop_placement.dart)。 +// +// 新建菜单的落点 = 新卡片的左上角画在菜单锚点上:落进泳道时按拖卡片落点同一套判定(resolveLaneDrop) +// 换到那条道的锚定——不管相机怎么缩放平移、落没落进道,卡片画出来的左上角都在锚点上。「画出来」与 +// canvas_view 同源:displacedNodes 给舞台坐标(世界 + 舞台原点 + 分道位移),再过相机。 +// 另钉「舞台上一点落在哪张卡上」按绘制顺序取最上面那张(拖线落点判「连线还是开菜单」靠它)。 +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:inkframe/features/canvas/models/canvas_node.dart'; +import 'package:inkframe/features/canvas/util/canvas_extent.dart'; +import 'package:inkframe/features/canvas/util/canvas_zoom.dart'; +import 'package:inkframe/features/canvas/util/drop_placement.dart'; +import 'package:inkframe/features/canvas/util/lane_geometry.dart'; +import 'package:inkframe/features/canvas/util/lane_pin_geometry.dart'; +import 'package:inkframe/theme/tokens.dart'; + +typedef _Slices = List<({String id, double size})>; + +const _Slices _noLanes = <({String id, double size})>[]; + +/// 相机:缩放 [s],世界原点画在视口的 [origin] 上(screen = s · stage + t)。 +Matrix4 _camera(double s, Offset origin) => Matrix4.identity() + ..translateByDouble( + origin.dx - s * kStageHalf, + origin.dy - s * kStageHalf, + 0.0, + 1.0, + ) + ..scaleByDouble(s, s, 1.0, 1.0); + +/// 卡片左上角画在视口里的哪一点(与 canvas_view 的渲染同源)。 +Offset _drawnTopLeft( + CanvasNode node, + Matrix4 m, + _Slices lanes, + LaneDirection direction, +) { + final CanvasNode drawn = displacedNodes( + nodes: [node], + lanes: lanes, + direction: direction, + transform: m, + ).single; + return MatrixUtils.transformPoint(m, drawn.position); +} + +CanvasNode _placed(NodePlacement p) => CanvasNode( + id: 'n', + label: 'n', + type: CanvasNodeType.image, + position: p.world, + laneId: p.laneId, + ); + +void _expectOffset(Offset actual, Offset expected) { + expect(actual.dx, closeTo(expected.dx, 1e-4)); + expect(actual.dy, closeTo(expected.dy, 1e-4)); +} + +void main() { + group('createNodePlacement', () { + test('初始相机、没有泳道:世界坐标 = 视口坐标 − 让出的工具条那一条', () { + final NodePlacement p = createNodePlacement( + viewportPoint: const Offset(300, 200), + transform: initialCanvasTransform(), + laneSlices: _noLanes, + direction: LaneDirection.horizontal, + ); + expect(p.laneId, isNull); + _expectOffset(p.world, const Offset(300, 200 - InkToolbarSize.stageTopInset)); + }); + + test('缩放 + 平移过的相机:不在道里的卡片画出来左上角就在锚点上', () { + final Matrix4 m = _camera(0.5, const Offset(137, 91)); + const Offset at = Offset(420, 310); + final NodePlacement p = createNodePlacement( + viewportPoint: at, + transform: m, + laneSlices: _noLanes, + direction: LaneDirection.horizontal, + ); + expect(p.laneId, isNull); + _expectOffset( + _drawnTopLeft(_placed(p), m, _noLanes, LaneDirection.horizontal), + at, + ); + }); + + test('横向泳道:卡片中心落在哪条道就归哪条道,画出来左上角仍在锚点上', () { + final Matrix4 m = _camera(0.5, const Offset(0, 64)); + const _Slices lanes = <({String id, double size})>[ + (id: 'a', size: 300.0), + (id: 'b', size: 400.0), + ]; + // 泳道栈锚在世界原点:a 在视口 y [64, 364),b 在 [364, 764)。卡片半高 × 0.5 ≈ 48, + // 锚点 y = 400 ⇒ 卡片中心在 y ≈ 448,落在 b。 + const Offset at = Offset(250, 400); + final NodePlacement p = createNodePlacement( + viewportPoint: at, + transform: m, + laneSlices: lanes, + direction: LaneDirection.horizontal, + ); + expect(p.laneId, 'b'); + _expectOffset( + _drawnTopLeft(_placed(p), m, lanes, LaneDirection.horizontal), + at, + ); + }); + + test('竖向泳道:同一套判定换到横轴上', () { + final Matrix4 m = _camera(1.5, const Offset(40, 0)); + const _Slices lanes = <({String id, double size})>[ + (id: 'a', size: 250.0), + (id: 'b', size: 250.0), + ]; + // 泳道栈锚在世界原点:a 在视口 x [40, 290),b 在 [290, 540)。卡片半宽 × 1.5 = 168, + // 锚点 x = 200 ⇒ 卡片中心在 x = 368,落在 b。 + const Offset at = Offset(200, 120); + final NodePlacement p = createNodePlacement( + viewportPoint: at, + transform: m, + laneSlices: lanes, + direction: LaneDirection.vertical, + ); + expect(p.laneId, 'b'); + _expectOffset( + _drawnTopLeft(_placed(p), m, lanes, LaneDirection.vertical), + at, + ); + }); + + test('卡片中心落在所有道之外:不归道,按全局变换落', () { + final Matrix4 m = _camera(1, const Offset(0, 64)); + const _Slices lanes = <({String id, double size})>[(id: 'a', size: 200.0)]; + // a 在视口 y [64, 264);锚点 y = 600 远在下面。 + const Offset at = Offset(100, 600); + final NodePlacement p = createNodePlacement( + viewportPoint: at, + transform: m, + laneSlices: lanes, + direction: LaneDirection.horizontal, + ); + expect(p.laneId, isNull); + _expectOffset(p.world, const Offset(100, 600 - 64)); + }); + }); + + group('resolveLaneDrop(拖卡片落点与新建菜单落点共用)', () { + const _Slices lanes = <({String id, double size})>[ + (id: 'a', size: 300.0), + (id: 'b', size: 200.0), + ]; + + test('道里落回同一条道:横截坐标原样', () { + // 屏幕 = 300 + 0.5 × (400 − 300) = 350 ⇒ b;逆回去还是 400。 + final ({String? laneId, double crossWorld}) drop = resolveLaneDrop( + centerCrossWorld: 400, + fromLaneId: 'b', + laneSlices: lanes, + direction: LaneDirection.horizontal, + scale: 0.5, + ); + expect(drop.laneId, 'b'); + expect(drop.crossWorld, closeTo(400, 1e-9)); + }); + + test('从道里拖出所有道:不归道,换成全局变换下的世界坐标(屏幕位置不变)', () { + // 屏幕 = 300 + 0.5 × (1000 − 300) = 650 ⇒ 不在任何道(栈厚 500)⇒ 全局:650 / 0.5 = 1300。 + final ({String? laneId, double crossWorld}) drop = resolveLaneDrop( + centerCrossWorld: 1000, + fromLaneId: 'b', + laneSlices: lanes, + direction: LaneDirection.horizontal, + scale: 0.5, + ); + expect(drop.laneId, isNull); + expect(drop.crossWorld, closeTo(1300, 1e-9)); + }); + + test('从不在道里拖进一条道:按那条道的锚定换算', () { + // 屏幕 = 0.5 × 700 = 350 ⇒ b;b 的锚定:300 + (350 − 300) / 0.5 = 400。 + final ({String? laneId, double crossWorld}) drop = resolveLaneDrop( + centerCrossWorld: 700, + fromLaneId: null, + laneSlices: lanes, + direction: LaneDirection.horizontal, + scale: 0.5, + ); + expect(drop.laneId, 'b'); + expect(drop.crossWorld, closeTo(400, 1e-9)); + }); + }); + + test('viewportToWorld:初始相机下 = 视口坐标 − 让出的工具条那一条', () { + _expectOffset( + viewportToWorld(const Offset(10, 100), initialCanvasTransform()), + const Offset(10, 100 - InkToolbarSize.stageTopInset), + ); + }); + + group('topmostNodeAt', () { + CanvasNode card(String id, Offset at) => + CanvasNode(id: id, label: id, type: CanvasNodeType.text, position: at); + final List drawn = [ + card('under', Offset.zero), + card('over', const Offset(100, 50)), + ]; + + test('两张重叠:取后画的(压在上面的)那张', () { + expect(topmostNodeAt(point: const Offset(150, 100), drawn: drawn), 'over'); + }); + + test('只压着一张就是它;哪张都不压着 ⇒ null', () { + expect(topmostNodeAt(point: const Offset(20, 20), drawn: drawn), 'under'); + expect(topmostNodeAt(point: const Offset(900, 900), drawn: drawn), isNull); + }); + + test('命中区就是 kNodeCardSize:右缘本身不算在内(与卡片盒的命中同一个口径)', () { + final List one = [card('a', Offset.zero)]; + expect( + topmostNodeAt(point: Offset(kNodeCardSize.width - 0.5, 10), drawn: one), + 'a', + ); + expect( + topmostNodeAt(point: Offset(kNodeCardSize.width, 10), drawn: one), + isNull, + ); + }); + }); +} diff --git a/test/features/canvas/widgets/canvas_create_menu_test.dart b/test/features/canvas/widgets/canvas_create_menu_test.dart new file mode 100644 index 00000000..4f8c1dfd --- /dev/null +++ b/test/features/canvas/widgets/canvas_create_menu_test.dart @@ -0,0 +1,704 @@ +// 新建菜单接到画布上(canvas_view / canvas_shortcuts / create_node_menu / canvas_tool_actions)。 +// +// - 从出端口拖线:落在另一张卡上 = 连线(走连线模式那一条 linkTo,image → video 默认首帧);落在空白处 = +// 在松手点开带上下文的新建菜单,选「当首帧」⇒ 节点落在松手点 + 一条首帧 data 边 + 新节点被单选。 +// - 边写失败:刚建的节点被删掉(不留半截)+ 错误卡。 +// - 拖线途中 Esc:取消拖线,松手什么都不写。 +// - 平移工具开着:端口不可拖(拖的是画布)。 +// - 双击空白 / Tab / 空白处右键「新建…」:开的是同一个新建菜单(不带上下文)。 +// - 打字时 Tab 不开菜单;焦点在画布里的按钮上,Tab 照常走焦点遍历。 +// - 视频产物源:菜单不出首帧 / 尾帧 / 参考。 +// +// 变异对照:拿掉 addNodeFromMenu 的回滚 ⇒「边写失败」红;拿掉 Esc 里取消拖线那一层 ⇒「拖线途中 Esc」红; +// 把 Tab 的 _CanvasFocusedAction 换成不设门的 Action ⇒「打字时 Tab」「按钮上 Tab」两条红。 +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:inkframe/core/di/file_resolver.dart'; +import 'package:inkframe/core/errors/ink_error.dart'; +import 'package:inkframe/core/interfaces/toast_service.dart'; +import 'package:inkframe/features/canvas/models/canvas_edge.dart'; +import 'package:inkframe/features/canvas/models/canvas_node.dart'; +import 'package:inkframe/features/canvas/models/style_lane.dart'; +import 'package:inkframe/features/canvas/providers/canvas_edges_controller.dart'; +import 'package:inkframe/features/canvas/providers/canvas_lanes_controller.dart'; +import 'package:inkframe/features/canvas/providers/canvas_nodes_controller.dart'; +import 'package:inkframe/features/canvas/providers/canvas_selection_controller.dart'; +import 'package:inkframe/features/canvas/providers/canvas_tool_mode.dart'; +import 'package:inkframe/features/canvas/providers/link_mode_controller.dart'; +import 'package:inkframe/features/canvas/util/lane_geometry.dart'; +import 'package:inkframe/features/canvas/widgets/canvas_shortcuts.dart'; +import 'package:inkframe/features/canvas/widgets/canvas_view.dart'; +import 'package:inkframe/features/canvas/widgets/create_node_menu.dart'; +import 'package:inkframe/features/canvas/widgets/node_card.dart'; +import 'package:inkframe/features/notifications/providers/toast_service_provider.dart'; +import 'package:inkframe/features/shell/models/shell_state.dart'; +import 'package:inkframe/features/shell/providers/shell_controller.dart'; +import 'package:inkframe/theme/tokens.dart'; + +import '../../../_harness/fake_canvas.dart'; +import '../../../_harness/fake_toast_service.dart'; +import '../../../_harness/test_app.dart'; + +/// 节点:增删都落在内存态里(新节点要进画布,定位请求才落得了地),并记下建了什么、删了什么。 +class _Nodes extends CanvasNodesController { + _Nodes(this._seed); + final List _seed; + + /// addNode 收到的类型、落点与泳道归属,按调用顺序。新节点 id 依次是 new-1、new-2…… + final List<({CanvasNodeType type, Offset position, String? laneId})> added = + <({CanvasNodeType type, Offset position, String? laneId})>[]; + + /// removeNode 收到的 id,按调用顺序。 + final List removed = []; + + @override + Future> build(String canvasId) async => _seed; + + @override + Future addNode({ + required String label, + required CanvasNodeType type, + NodeRole role = NodeRole.config, + String? sourceNodeId, + String? laneId, + Offset position = Offset.zero, + Size? size, + Map typeConfig = const {}, + }) async { + added.add((type: type, position: position, laneId: laneId)); + final CanvasNode node = CanvasNode( + id: 'new-${added.length}', + label: label, + type: type, + role: role, + canvasId: arg, + position: position, + ); + state = AsyncData>([ + ...state.valueOrNull ?? const [], + node, + ]); + return node; + } + + @override + Future removeNode(String id) async { + removed.add(id); + final List previous = state.valueOrNull ?? const []; + CanvasNode? gone; + for (final CanvasNode n in previous) { + if (n.id == id) gone = n; + } + state = AsyncData>([ + for (final CanvasNode n in previous) + if (n.id != id) n, + ]); + final CanvasNode? node = gone; + return node == null ? null : (node: node, edgeIds: const []); + } +} + +/// 连线:记下建出来的边;[fail] = 写库必失败(真控制器失败时回滚内存再上抛 InkError)。 +class _Edges extends CanvasEdgesController { + _Edges({this.fail = false}); + final bool fail; + final List added = []; + + @override + Future> build(String canvasId) async => const []; + + @override + Future addEdge({ + required String sourceNodeId, + required String targetNodeId, + EdgeType edgeType = EdgeType.data, + EdgeRole role = EdgeRole.reference, + EdgeRole Function(List current)? roleResolver, + int sortOrder = 0, + }) async { + if (fail) { + throw const LocalIOError(extra: {'op': 'createEdge'}); + } + final List current = state.valueOrNull ?? const []; + final CanvasEdge edge = CanvasEdge( + id: 'e${added.length + 1}', + canvasId: arg, + sourceNodeId: sourceNodeId, + targetNodeId: targetNodeId, + edgeType: edgeType, + // 与真控制器同口径:解析器在「此刻的边集合」上求值。 + role: roleResolver?.call(current) ?? role, + sortOrder: sortOrder, + ); + added.add(edge); + state = AsyncData>([...current, edge]); + return edge; + } +} + +/// 拖线的源(图像配置)与连线的目标(视频配置),并排在舞台左上。 +const CanvasNode _img = CanvasNode( + id: 'img', + label: 'Shot 01', + type: CanvasNodeType.image, + canvasId: 'c1', + position: Offset(40, 40), +); +const CanvasNode _vid = CanvasNode( + id: 'vid', + label: 'Shot 02', + type: CanvasNodeType.video, + canvasId: 'c1', + position: Offset(400, 40), +); + +/// 一条从世界原点起、厚 2000 的横向泳道:舞台左上一大片都在道里。 +class _OneLane extends CanvasLanesController { + @override + Future> build(String canvasId) async => + const [StyleLane(id: 'lane-1', canvasId: 'c1', label: 'Ink', size: 2000)]; +} + +Future _pump( + WidgetTester tester, { + List nodes = const [_img, _vid], + bool withLane = false, + bool failEdges = false, + RecordingToastService? toast, + Widget Function(Widget canvas)? around, +}) async { + const Widget canvas = CanvasView(); + await pumpInkApp( + tester, + Scaffold( + body: CanvasShortcuts( + isActive: true, + child: around == null ? canvas : around(canvas), + ), + ), + overrides: [ + shellControllerProvider.overrideWith( + () => ShellNavigator(initial: const ShellState(canvasId: 'c1')), + ), + canvasNodesControllerProvider.overrideWith(() => _Nodes(nodes)), + canvasEdgesControllerProvider.overrideWith(() => _Edges(fail: failEdges)), + canvasLanesControllerProvider + .overrideWith(() => withLane ? _OneLane() : EmptyLanesController()), + canvasLaneDirectionProvider('c1') + .overrideWith((_) async => LaneDirection.horizontal), + fileResolverServiceProvider.overrideWithValue(StubFileResolver()), + toastServiceProvider.overrideWithValue(toast ?? RecordingToastService()), + ], + surfaceSize: const Size(1200, 800), + ); + await tester.pumpAndSettle(); + return ProviderScope.containerOf(tester.element(find.byType(CanvasView))); +} + +_Nodes _nodesOf(ProviderContainer c) => + c.read(canvasNodesControllerProvider('c1').notifier) as _Nodes; + +_Edges _edgesOf(ProviderContainer c) => + c.read(canvasEdgesControllerProvider('c1').notifier) as _Edges; + +Finder _card(String id) => find.byKey(ValueKey('node-card-$id')); + +/// 选中源卡片(端口按需显示:选中时两侧端口都亮出来),返回它出端口上的按点。 +/// +/// 圆点的中心正落在卡片右缘上——那一半在卡片盒外、点不到(命中先按卡片盒筛),所以往里收 3px 按。 +Future _revealPort( + WidgetTester tester, + ProviderContainer c, + String id, +) async { + c.read(canvasSelectionControllerProvider('c1').notifier).select(id); + await tester.pump(); + return tester.getCenter( + find.descendant(of: _card(id), matching: find.byKey(NodeCard.outPortKey)), + ) - + const Offset(3, 0); +} + +/// 从 [from] 按下、过触摸阈值、移到 [to];[beforeRelease] 在松手前跑(按 Esc 之类),然后松手。 +Future _dragWire( + WidgetTester tester, + Offset from, + Offset to, { + Future Function()? beforeRelease, +}) async { + final TestGesture gesture = await tester.startGesture(from); + await gesture.moveBy(const Offset(30, 0)); + await tester.pump(); + await gesture.moveTo(to); + await tester.pump(); + if (beforeRelease != null) await beforeRelease(); + await gesture.up(); + await tester.pumpAndSettle(); +} + +Matrix4 _ivTransform(WidgetTester tester) => tester + .widget(find.byType(InteractiveViewer)) + .transformationController! + .value; + +const String _firstFrame = 'First frame · New video'; +const String _lastFrame = 'Last frame · New video'; +const String _reference = 'Reference · New image'; +const String _nextShot = 'Next shot (narrative)'; +const List _plainLabels = [ + 'Image config', + 'Video config', + 'Text', + 'Shot', + 'Lane', +]; + +void _expectMenuAt(WidgetTester tester, Key key, Offset at) { + final Finder menu = find.byKey(key); + expect(menu, findsOneWidget); + final Offset topLeft = tester.getTopLeft(menu); + expect(topLeft.dx, closeTo(at.dx, 0.5)); + expect(topLeft.dy, closeTo(at.dy, 0.5)); +} + +/// 不带上下文的新建菜单开在 [at]:五项都在,带上下文的项一项没有。 +void _expectPlainCreateMenuAt(WidgetTester tester, Offset at) { + _expectMenuAt(tester, CanvasMenuKeys.createMenu, at); + final Finder menu = find.byKey(CanvasMenuKeys.createMenu); + for (final String label in _plainLabels) { + expect( + find.descendant(of: menu, matching: find.text(label)), + findsOneWidget, + reason: label, + ); + } + expect(find.text(_firstFrame), findsNothing); +} + +void main() { + group('从出端口拖线', () { + testWidgets('拖到另一张卡上:走 linkTo 建出一条边,image → video 默认首帧,出连线成功提示', + (tester) async { + final RecordingToastService toast = RecordingToastService(); + final ProviderContainer c = await _pump(tester, toast: toast); + final Offset grab = await _revealPort(tester, c, 'img'); + + await _dragWire(tester, grab, tester.getCenter(_card('vid'))); + + final List edges = _edgesOf(c).added; + expect(edges, hasLength(1)); + expect(edges.single.sourceNodeId, 'img'); + expect(edges.single.targetNodeId, 'vid'); + expect(edges.single.edgeType, EdgeType.data); + expect( + edges.single.role, + EdgeRole.firstFrame, + reason: '与连线模式同一条 linkTo:image → video 默认首帧', + ); + expect(toast.titles, ['Link created']); + expect(toast.kinds, [ToastKind.success]); + expect(c.read(linkModeControllerProvider('c1')), isNull, + reason: 'linkTo 收尾时退出连线模式'); + expect(find.byKey(CanvasMenuKeys.createMenu), findsNothing, + reason: '落在卡上不开菜单'); + expect(_nodesOf(c).added, isEmpty); + }); + + testWidgets('拖到空白处:新建菜单开在松手点(带上下文);选「当首帧」⇒ 节点落在松手点 + 首帧 data 边 + 新节点被单选', + (tester) async { + final ProviderContainer c = await _pump(tester); + final Offset grab = await _revealPort(tester, c, 'img'); + const Offset drop = Offset(700, 500); + final Matrix4 cameraBefore = _ivTransform(tester).clone(); + + await _dragWire(tester, grab, drop); + + _expectMenuAt(tester, CanvasMenuKeys.createMenu, drop); + for (final String label in [_firstFrame, _lastFrame, _reference, _nextShot]) { + expect(find.text(label), findsOneWidget, reason: label); + } + expect(find.text('Image config'), findsNothing, + reason: '带上下文时不出不带上下文的那五项'); + + await tester.tap(find.text(_firstFrame)); + await tester.pumpAndSettle(); + + final _Nodes nodes = _nodesOf(c); + expect(nodes.added, hasLength(1)); + expect(nodes.added.single.type, CanvasNodeType.video); + // 初始相机下视口 = 世界 + (0, stageTopInset):节点左上角落在松手点。 + expect(nodes.added.single.position.dx, closeTo(drop.dx, 0.5)); + expect( + nodes.added.single.position.dy, + closeTo(drop.dy - InkToolbarSize.stageTopInset, 0.5), + ); + final CanvasEdge edge = _edgesOf(c).added.single; + expect(edge.sourceNodeId, 'img'); + expect(edge.targetNodeId, 'new-1'); + expect(edge.edgeType, EdgeType.data); + expect(edge.role, EdgeRole.firstFrame); + expect(c.read(canvasSelectionControllerProvider('c1')), {'new-1'}, + reason: '新节点建好后被单选'); + expect(_ivTransform(tester), cameraBefore, + reason: '新节点就落在松手点:只单选、不挪相机'); + expect(find.byKey(CanvasMenuKeys.createMenu), findsNothing); + }); + + testWidgets('从产物卡拖到空白选「下一镜」:建来源配置的类型,叙事边从来源配置节点连出', (tester) async { + const CanvasNode take = CanvasNode( + id: 'take', + label: 'Shot 01 take', + type: CanvasNodeType.image, + role: NodeRole.result, + sourceNodeId: 'img', + canvasId: 'c1', + position: Offset(40, 320), + typeConfig: {'image_url': 'images/take.png'}, + ); + final ProviderContainer c = await _pump(tester, nodes: const [_img, _vid, take]); + final Offset grab = await _revealPort(tester, c, 'take'); + + await _dragWire(tester, grab, const Offset(700, 560)); + expect(find.text(_firstFrame), findsOneWidget, reason: '图像产物照样出首帧 / 尾帧 / 参考'); + await tester.tap(find.text(_nextShot)); + await tester.pumpAndSettle(); + + expect(_nodesOf(c).added.single.type, CanvasNodeType.image, reason: '与来源配置同类型'); + final CanvasEdge edge = _edgesOf(c).added.single; + expect(edge.edgeType, EdgeType.narrative); + expect(edge.sourceNodeId, 'img', reason: '叙事边只连配置节点:从产物的来源配置节点连出'); + expect(edge.targetNodeId, 'new-1'); + }); + + testWidgets('落点在泳道里:泳道归属随建节点一次写进去', (tester) async { + final ProviderContainer c = await _pump(tester, withLane: true); + final Offset grab = await _revealPort(tester, c, 'img'); + + await _dragWire(tester, grab, const Offset(700, 500)); + await tester.tap(find.text(_firstFrame)); + await tester.pumpAndSettle(); + + expect(_nodesOf(c).added.single.laneId, 'lane-1'); + }); + + testWidgets('拖线途中按 Esc:取消拖线,松手不开菜单、不连线、不建节点', (tester) async { + final ProviderContainer c = await _pump(tester); + final Offset grab = await _revealPort(tester, c, 'img'); + + await _dragWire( + tester, + grab, + const Offset(700, 500), + beforeRelease: () async { + expect(c.read(canvasWireDragProvider('c1')), isNotNull, + reason: '前置:拖线已经开始'); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(c.read(canvasWireDragProvider('c1')), isNull, + reason: 'Esc 先取消拖线'); + }, + ); + + expect(find.byKey(CanvasMenuKeys.createMenu), findsNothing); + expect(_nodesOf(c).added, isEmpty); + expect(_edgesOf(c).added, isEmpty); + expect(c.read(canvasSelectionControllerProvider('c1')), {'img'}, + reason: 'Esc 这一下只剥拖线这一层,不清选中'); + }); + + testWidgets('拖线途中按 Esc 之后落在另一张卡上:也不连线', (tester) async { + final ProviderContainer c = await _pump(tester); + final Offset grab = await _revealPort(tester, c, 'img'); + + await _dragWire( + tester, + grab, + tester.getCenter(_card('vid')), + beforeRelease: () async { + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + }, + ); + + expect(_edgesOf(c).added, isEmpty); + }); + + testWidgets('平移工具开着:端口不可拖——从端口起拖拖的是画布,不起拖线、不开菜单', (tester) async { + final ProviderContainer c = await _pump(tester); + final Offset grab = await _revealPort(tester, c, 'img'); + c.read(canvasToolModeProvider('c1').notifier).pan(); + await tester.pump(); + final Matrix4 before = _ivTransform(tester).clone(); + + final TestGesture gesture = await tester.startGesture(grab); + await gesture.moveBy(const Offset(30, 0)); + await tester.pump(); + await gesture.moveTo(const Offset(700, 500)); + await tester.pump(); + expect(c.read(canvasWireDragProvider('c1')), isNull, + reason: '平移时端口按不到,起不了拖线'); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byKey(CanvasMenuKeys.createMenu), findsNothing); + expect(_edgesOf(c).added, isEmpty); + expect(_ivTransform(tester), isNot(before), reason: '拖的是画布'); + }); + }); + + group('新建菜单:建节点与连线', () { + testWidgets('边写失败:刚建的节点被删掉(不留半截),并弹错误卡', (tester) async { + final RecordingToastService toast = RecordingToastService(); + final ProviderContainer c = + await _pump(tester, failEdges: true, toast: toast); + c + .read(canvasMenuRequestProvider('c1').notifier) + .openCreate(const Offset(700, 500), sourceNodeId: 'img'); + await tester.pumpAndSettle(); + + await tester.tap(find.text(_firstFrame)); + await tester.pumpAndSettle(); + + final _Nodes nodes = _nodesOf(c); + expect(nodes.added, hasLength(1), reason: '先建节点'); + expect(nodes.removed, ['new-1'], reason: '边写失败 ⇒ 回滚删掉刚建的节点'); + expect( + c.read(canvasNodesControllerProvider('c1')).valueOrNull!.map((CanvasNode n) => n.id), + isNot(contains('new-1')), + ); + expect(toast.titles, ['Failed to add node']); + expect(toast.kinds, [ToastKind.error]); + expect(c.read(canvasSelectionControllerProvider('c1')), isNot(contains('new-1'))); + }); + + testWidgets('「下一镜(叙事)」:建与源同类型的配置节点 + 一条 narrative 边(源 → 新)', (tester) async { + final ProviderContainer c = await _pump(tester); + c + .read(canvasMenuRequestProvider('c1').notifier) + .openCreate(const Offset(700, 500), sourceNodeId: 'img'); + await tester.pumpAndSettle(); + + await tester.tap(find.text(_nextShot)); + await tester.pumpAndSettle(); + + expect(_nodesOf(c).added.single.type, CanvasNodeType.image); + final CanvasEdge edge = _edgesOf(c).added.single; + expect(edge.sourceNodeId, 'img'); + expect(edge.targetNodeId, 'new-1'); + expect(edge.edgeType, EdgeType.narrative); + }); + + testWidgets('视频产物源:菜单不出首帧 / 尾帧 / 参考三项', (tester) async { + const CanvasNode take = CanvasNode( + id: 'take', + label: 'Take', + type: CanvasNodeType.video, + role: NodeRole.result, + canvasId: 'c1', + sourceNodeId: 'vid', + position: Offset(700, 40), + typeConfig: {'video_url': 'take.mp4'}, + ); + final ProviderContainer c = + await _pump(tester, nodes: const [_img, _vid, take]); + c + .read(canvasMenuRequestProvider('c1').notifier) + .openCreate(const Offset(700, 500), sourceNodeId: 'take'); + await tester.pumpAndSettle(); + + expect(find.byKey(CanvasMenuKeys.createMenu), findsOneWidget); + expect(find.text(_firstFrame), findsNothing); + expect(find.text(_lastFrame), findsNothing); + expect(find.text(_reference), findsNothing); + }); + + testWidgets('菜单键盘:↓ ↵ 执行第二项(视频配置),节点落在视口中心、不连线', (tester) async { + final ProviderContainer c = await _pump(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + final _Nodes nodes = _nodesOf(c); + expect(nodes.added.single.type, CanvasNodeType.video); + expect(nodes.added.single.position.dx, closeTo(600, 0.5)); + expect( + nodes.added.single.position.dy, + closeTo(400 - InkToolbarSize.stageTopInset, 0.5), + ); + expect(_edgesOf(c).added, isEmpty, reason: '不带上下文:只建节点、不连线'); + }); + + testWidgets('菜单里 Esc:关菜单、什么都不建;焦点回到画布,再按 Tab 又能打开', (tester) async { + final ProviderContainer c = await _pump(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(find.byKey(CanvasMenuKeys.createMenu), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.byKey(CanvasMenuKeys.createMenu), findsNothing); + expect(_nodesOf(c).added, isEmpty); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(find.byKey(CanvasMenuKeys.createMenu), findsOneWidget, + reason: '焦点回到了画布本身,Tab 又归它'); + }); + + testWidgets('点菜单外面:菜单关掉,什么都不建', (tester) async { + final ProviderContainer c = await _pump(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(find.byKey(CanvasMenuKeys.createMenu), findsOneWidget); + + await tester.tapAt(const Offset(1000, 700)); + await tester.pumpAndSettle(); + + expect(find.byKey(CanvasMenuKeys.createMenu), findsNothing); + expect(_nodesOf(c).added, isEmpty); + }); + }); + + group('三个不带上下文的入口开的是同一个菜单', () { + testWidgets('双击舞台空白 → 在双击点开新建菜单', (tester) async { + await _pump(tester); + const Offset p = Offset(700, 500); + await tester.tapAt(p); + await tester.tapAt(p); + await tester.pumpAndSettle(); + + _expectPlainCreateMenuAt(tester, p); + }); + + testWidgets('单击空白不开菜单:两下隔得比双击窗口久,也不算双击', (tester) async { + await _pump(tester); + await tester.tapAt(const Offset(700, 500)); + await tester.pump(const Duration(milliseconds: 400)); + await tester.tapAt(const Offset(700, 500)); + await tester.pumpAndSettle(); + + expect(find.byKey(CanvasMenuKeys.createMenu), findsNothing); + }); + + testWidgets('画布持焦按 Tab → 在视口中心开新建菜单', (tester) async { + await _pump(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // 视口 1200 × 800,中心 (600, 400)。 + _expectPlainCreateMenuAt(tester, const Offset(600, 400)); + }); + + testWidgets('空白处右键 → 空白处右键菜单(只有四项);点「新建…」→ 原地换成新建菜单', (tester) async { + await _pump(tester); + const Offset p = Offset(700, 500); + await tester.tapAt( + p, + buttons: kSecondaryMouseButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pumpAndSettle(); + + _expectMenuAt(tester, CanvasMenuKeys.blankMenu, p); + final Finder blank = find.byKey(CanvasMenuKeys.blankMenu); + for (final String label in ['New…', 'Import script…', 'Select all', 'Fit view']) { + expect( + find.descendant(of: blank, matching: find.text(label)), + findsOneWidget, + reason: label, + ); + } + expect(find.text('Tab'), findsOneWidget, reason: '「新建…」行尾的键位提示'); + + await tester.tap(find.text('New…')); + await tester.pumpAndSettle(); + + expect(find.byKey(CanvasMenuKeys.blankMenu), findsNothing); + _expectPlainCreateMenuAt(tester, p); + }); + + testWidgets('空白处右键菜单「全选」:选中画布上全部节点', (tester) async { + final ProviderContainer c = await _pump(tester); + await tester.tapAt( + const Offset(700, 500), + buttons: kSecondaryMouseButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Select all')); + await tester.pumpAndSettle(); + + expect(c.read(canvasSelectionControllerProvider('c1')), {'img', 'vid'}); + expect(find.byKey(CanvasMenuKeys.blankMenu), findsNothing); + }); + }); + + group('Tab 只在画布本身持焦时接管', () { + testWidgets('在文本框里打字时按 Tab:不开新建菜单(文本编辑让位)', (tester) async { + final TextEditingController field = TextEditingController(); + addTearDown(field.dispose); + final ProviderContainer c = await _pump( + tester, + around: (Widget canvas) => Column( + children: [ + Expanded(child: canvas), + // 提示词条 / 检查器文本框替身:同处于 CanvasShortcuts 子树内的 EditableText。 + TextField(controller: field), + ], + ), + ); + + await tester.tap(find.byType(TextField)); + await tester.enterText(find.byType(TextField), 'hello'); + await tester.pumpAndSettle(); + expect(isEditingText(), isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + expect(c.read(canvasMenuRequestProvider('c1')), isNull); + expect(find.byKey(CanvasMenuKeys.createMenu), findsNothing); + }); + + testWidgets('焦点在画布里的按钮上按 Tab:照常走焦点遍历,不开新建菜单', (tester) async { + final FocusNode first = FocusNode(debugLabel: 'first'); + final FocusNode second = FocusNode(debugLabel: 'second'); + addTearDown(first.dispose); + addTearDown(second.dispose); + final ProviderContainer c = await _pump( + tester, + around: (Widget canvas) => Column( + children: [ + Expanded(child: canvas), + // 检查器按钮 / 工具条键替身:同处于 CanvasShortcuts 子树内、但不是文本框的可聚焦控件。 + Row( + children: [ + TextButton(focusNode: first, onPressed: () {}, child: const Text('one')), + TextButton(focusNode: second, onPressed: () {}, child: const Text('two')), + ], + ), + ], + ), + ); + + first.requestFocus(); + await tester.pump(); + expect(first.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + expect(c.read(canvasMenuRequestProvider('c1')), isNull); + expect(find.byKey(CanvasMenuKeys.createMenu), findsNothing); + expect(second.hasPrimaryFocus, isTrue, reason: 'Tab 照常把焦点移到下一个控件'); + }); + }); +} diff --git a/test/features/canvas/widgets/canvas_empty_state_test.dart b/test/features/canvas/widgets/canvas_empty_state_test.dart index c03ffb4c..57a00d22 100644 --- a/test/features/canvas/widgets/canvas_empty_state_test.dart +++ b/test/features/canvas/widgets/canvas_empty_state_test.dart @@ -35,6 +35,7 @@ class _FailingNodesController extends CanvasNodesController { required CanvasNodeType type, NodeRole role = NodeRole.config, String? sourceNodeId, + String? laneId, Offset position = Offset.zero, Size? size, Map typeConfig = const {}, diff --git a/test/features/canvas/widgets/canvas_focus_request_test.dart b/test/features/canvas/widgets/canvas_focus_request_test.dart index 346603f7..ccaae214 100644 --- a/test/features/canvas/widgets/canvas_focus_request_test.dart +++ b/test/features/canvas/widgets/canvas_focus_request_test.dart @@ -88,6 +88,20 @@ void main() { expect(center.dy, closeTo(stage.dy, 1)); }); + testWidgets('center: false:只单选,不挪相机(新建菜单建出来的节点就在用户刚点的地方)', (tester) async { + final ProviderContainer c = await _pump(tester); + final InteractiveViewer viewer = tester.widget(find.byType(InteractiveViewer)); + final Matrix4 before = viewer.transformationController!.value.clone(); + + c.read(canvasFocusRequestProvider.notifier).request(canvasId: 'c1', nodeId: 'far', center: false); + await tester.pump(); + await tester.pump(); + + expect(c.read(canvasSelectionControllerProvider('c1')), {'far'}); + expect(c.read(canvasFocusRequestProvider), isNull); + expect(viewer.transformationController!.value, before, reason: '相机原地不动'); + }); + testWidgets('别的画布的请求:本画布不动它,留给那张画布挂上来后落地', (tester) async { final ProviderContainer c = await _pump(tester); c.read(canvasFocusRequestProvider.notifier).request(canvasId: 'c2', nodeId: 'x'); diff --git a/test/features/canvas/widgets/create_node_menu_test.dart b/test/features/canvas/widgets/create_node_menu_test.dart new file mode 100644 index 00000000..bb46a383 --- /dev/null +++ b/test/features/canvas/widgets/create_node_menu_test.dart @@ -0,0 +1,162 @@ +// 新建菜单本体(create_node_menu.dart 的 CanvasMenuPanel):搜索框一打开就持焦、打字按文字过滤 +// (不区分大小写的包含匹配)、一项都没匹配上出一行说明;↑ / ↓ 移动高亮(首尾相接、跨分组)、↵ 执行高亮项、 +// Esc 关;鼠标悬停也移动高亮;点一行 = 执行它。没有搜索框的空白处右键菜单焦点落在菜单本身,键盘一样能用, +// 菜单开着时别的键不往外漏。 +// +// 拿掉 ↑ / ↓ / ↵ / Esc 任何一个分支,对应用例当场红;拿掉过滤,「打字过滤」与「空匹配」红。 +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:inkframe/features/canvas/widgets/canvas_shortcuts.dart'; +import 'package:inkframe/features/canvas/widgets/create_node_menu.dart'; + +import '../../../_harness/test_app.dart'; + +const List>> _groups = >>[ + >[ + CanvasMenuEntry(value: 'a', label: 'Alpha'), + CanvasMenuEntry(value: 'b', label: 'Beta', keyHint: 'B'), + ], + >[ + CanvasMenuEntry(value: 'c', label: 'Gamma'), + ], +]; + +/// 记下菜单交回来的东西:选中了哪些项、关了几次、有几个键漏到了菜单外面。 +class _Probe { + final List picked = []; + int dismissed = 0; + int leaked = 0; +} + +Future<_Probe> _pump(WidgetTester tester, {bool searchable = true}) async { + final _Probe probe = _Probe(); + await pumpInkApp( + tester, + Scaffold( + // 菜单外面一层快捷键:右键菜单开着时「I」不该漏到这里(画布的单键快捷键就在菜单外面)。 + body: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.keyI): () => probe.leaked++, + }, + child: Align( + alignment: Alignment.topLeft, + child: CanvasMenuPanel( + width: CanvasMenuPanel.createWidth, + groups: _groups, + onSelected: probe.picked.add, + onDismiss: () => probe.dismissed++, + emptyText: 'Nothing here', + searchHint: searchable ? 'Search' : null, + ), + ), + ), + ), + ); + // 菜单挂上之后帧后才要焦点。 + await tester.pumpAndSettle(); + return probe; +} + +Future _key(WidgetTester tester, LogicalKeyboardKey key) async { + await tester.sendKeyEvent(key); + await tester.pump(); +} + +void main() { + testWidgets('搜索框一打开就持焦:直接打字就是在搜', (tester) async { + await _pump(tester); + expect(find.byType(TextField), findsOneWidget); + expect(isEditingText(), isTrue, reason: '主焦点应在搜索框里'); + expect(find.text('Search'), findsOneWidget, reason: '占位文字'); + }); + + testWidgets('↵ 执行高亮项:一打开高亮在第一项', (tester) async { + final _Probe probe = await _pump(tester); + await _key(tester, LogicalKeyboardKey.enter); + expect(probe.picked, ['a']); + }); + + testWidgets('↓ 往下移(跨分组),↵ 执行移到的那一项', (tester) async { + final _Probe probe = await _pump(tester); + await _key(tester, LogicalKeyboardKey.arrowDown); + await _key(tester, LogicalKeyboardKey.arrowDown); + await _key(tester, LogicalKeyboardKey.enter); + expect(probe.picked, ['c']); + }); + + testWidgets('↑ 从第一项往上首尾相接到最后一项', (tester) async { + final _Probe probe = await _pump(tester); + await _key(tester, LogicalKeyboardKey.arrowUp); + await _key(tester, LogicalKeyboardKey.enter); + expect(probe.picked, ['c']); + }); + + testWidgets('Esc 关菜单,不执行任何项', (tester) async { + final _Probe probe = await _pump(tester); + await _key(tester, LogicalKeyboardKey.escape); + expect(probe.dismissed, 1); + expect(probe.picked, isEmpty); + }); + + testWidgets('打字过滤:不区分大小写的包含匹配,整组没匹配上的连分隔线一起不出', (tester) async { + final _Probe probe = await _pump(tester); + await tester.enterText(find.byKey(CanvasMenuKeys.search), 'GAM'); + await tester.pump(); + expect(find.text('Gamma'), findsOneWidget); + expect(find.text('Alpha'), findsNothing); + expect(find.text('Beta'), findsNothing); + // 过滤之后高亮回到第一个看得见的项。 + await _key(tester, LogicalKeyboardKey.enter); + expect(probe.picked, ['c']); + }); + + testWidgets('一项都没匹配上:出一行说明,↵ 什么都不做', (tester) async { + final _Probe probe = await _pump(tester); + await tester.enterText(find.byKey(CanvasMenuKeys.search), 'zzz'); + await tester.pump(); + expect(find.text('Nothing here'), findsOneWidget); + expect(find.text('Alpha'), findsNothing); + await _key(tester, LogicalKeyboardKey.enter); + expect(probe.picked, isEmpty); + }); + + testWidgets('鼠标悬停把高亮挪到那一行,↵ 执行它', (tester) async { + final _Probe probe = await _pump(tester); + final TestGesture mouse = + await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: const Offset(700, 550)); + addTearDown(mouse.removePointer); + await tester.pump(); + + await mouse.moveTo(tester.getCenter(find.text('Beta'))); + await tester.pump(); + await _key(tester, LogicalKeyboardKey.enter); + expect(probe.picked, ['b']); + }); + + testWidgets('点一行 = 执行那一行', (tester) async { + final _Probe probe = await _pump(tester); + await tester.tap(find.text('Gamma')); + await tester.pump(); + expect(probe.picked, ['c']); + }); + + testWidgets('键位提示画在行尾', (tester) async { + await _pump(tester); + expect(find.text('B'), findsOneWidget); + }); + + testWidgets('没有搜索框(空白处右键菜单):焦点落在菜单本身,↓ ↵ 照样能用;别的键不往外漏', (tester) async { + final _Probe probe = await _pump(tester, searchable: false); + expect(find.byType(TextField), findsNothing); + + await _key(tester, LogicalKeyboardKey.keyI); + expect(probe.leaked, 0, reason: '菜单开着时按 I 不该落到菜单外面的快捷键上'); + + await _key(tester, LogicalKeyboardKey.arrowDown); + await _key(tester, LogicalKeyboardKey.enter); + expect(probe.picked, ['b']); + }); +} diff --git a/test/features/command_palette/command_palette_test.dart b/test/features/command_palette/command_palette_test.dart index f9b38e67..b897e3e0 100644 --- a/test/features/command_palette/command_palette_test.dart +++ b/test/features/command_palette/command_palette_test.dart @@ -160,6 +160,7 @@ class _RecordingNodesController extends CanvasNodesController { required CanvasNodeType type, NodeRole role = NodeRole.config, String? sourceNodeId, + String? laneId, Offset position = Offset.zero, Size? size, Map typeConfig = const {}, @@ -181,6 +182,7 @@ class _FailingNodesController extends CanvasNodesController { required CanvasNodeType type, NodeRole role = NodeRole.config, String? sourceNodeId, + String? laneId, Offset position = Offset.zero, Size? size, Map typeConfig = const {}, diff --git a/test/features/settings/shortcut_catalog_test.dart b/test/features/settings/shortcut_catalog_test.dart index 8ecee0a6..7e04bf95 100644 --- a/test/features/settings/shortcut_catalog_test.dart +++ b/test/features/settings/shortcut_catalog_test.dart @@ -88,6 +88,32 @@ void main() { ); }); + // PR-8a:Tab = 新建…进清单。键位与空白处右键菜单「新建…」行尾的提示同源(CanvasToolKeys.createMenu)。 + test('新建…:两个平台都列「Tab」,与 CanvasToolKeys.createMenu 是同一个键', () { + expect(CanvasToolKeys.createMenu, LogicalKeyboardKey.tab); + expect( + kCanvasShortcuts[const SingleActivator(CanvasToolKeys.createMenu, includeRepeats: false)], + isA(), + ); + for (final bool isMac in [true, false]) { + expect( + _byAction(isMac: isMac)[ShortcutAction.canvasOpenCreateMenu], + ['Tab'], + ); + } + }); + + // 菜单行尾的键位提示从快捷键表里取(空白处右键菜单的「全选」),按平台出字。 + test('canvasShortcutLabel:按平台取表里第一条能出字的绑定;没有匹配 ⇒ null', () { + bool selectAll(Intent intent) => intent is SelectAllNodesIntent; + expect(canvasShortcutLabel(selectAll, isMac: true), '⌘A'); + expect(canvasShortcutLabel(selectAll, isMac: false), 'Ctrl+A'); + expect( + canvasShortcutLabel((Intent intent) => false, isMac: true), + isNull, + ); + }); + test('Windows:Ctrl 变体上屏,⌘ 变体不上屏', () { final Map> rows = _byAction(isMac: false); expect(rows[ShortcutAction.commandPalette], ['Ctrl+K']);