| @@ -601,6 +601,103 @@ HmiEditorResult HmiEditorService::updateControl( | |||
| return {true, HmiEditorError::None, {}, control.id}; | |||
| } | |||
| HmiEditorResult HmiEditorService::pasteControls( | |||
| const std::string &page_id, | |||
| const std::vector<HmiControl> &controls, | |||
| int offset_x, | |||
| int offset_y) | |||
| { | |||
| const HmiPage *existing_page = findPage(page_id); | |||
| if (existing_page == nullptr) | |||
| { | |||
| return failure(HmiEditorError::PageNotFound, "未找到 HMI 页面"); | |||
| } | |||
| if (controls.empty()) | |||
| { | |||
| return failure(HmiEditorError::InvalidOperation, "请先复制 HMI 控件"); | |||
| } | |||
| if (controls.size() > ProjectLimits::kMaximumHmiControlsPerPage | |||
| || existing_page->controls.size() + controls.size() | |||
| > ProjectLimits::kMaximumHmiControlsPerPage) | |||
| { | |||
| return failure(HmiEditorError::InvalidControl, "单个 HMI 页面最多包含 512 个控件"); | |||
| } | |||
| // 让整组控件尽量保留相对位置,靠近页面边界时自动向页面内收拢 | |||
| int minimum_x = controls.front().bounds.x; | |||
| int minimum_y = controls.front().bounds.y; | |||
| int maximum_x = controls.front().bounds.x + controls.front().bounds.width; | |||
| int maximum_y = controls.front().bounds.y + controls.front().bounds.height; | |||
| for (const HmiControl &control : controls) | |||
| { | |||
| minimum_x = std::min(minimum_x, control.bounds.x); | |||
| minimum_y = std::min(minimum_y, control.bounds.y); | |||
| maximum_x = std::max( | |||
| maximum_x, control.bounds.x + control.bounds.width); | |||
| maximum_y = std::max( | |||
| maximum_y, control.bounds.y + control.bounds.height); | |||
| } | |||
| if (maximum_x - minimum_x > existing_page->width | |||
| || maximum_y - minimum_y > existing_page->height) | |||
| { | |||
| return failure(HmiEditorError::InvalidControl, "复制的控件尺寸超出目标 HMI 页面"); | |||
| } | |||
| int final_offset_x = offset_x; | |||
| int final_offset_y = offset_y; | |||
| if (minimum_x + final_offset_x < 0) | |||
| { | |||
| final_offset_x = -minimum_x; | |||
| } | |||
| if (minimum_y + final_offset_y < 0) | |||
| { | |||
| final_offset_y = -minimum_y; | |||
| } | |||
| if (maximum_x + final_offset_x > existing_page->width) | |||
| { | |||
| final_offset_x = existing_page->width - maximum_x; | |||
| } | |||
| if (maximum_y + final_offset_y > existing_page->height) | |||
| { | |||
| final_offset_y = existing_page->height - maximum_y; | |||
| } | |||
| const HistoryState before = captureState(); | |||
| const bool modified_before = project_service_.isModified(); | |||
| Project &project = project_service_.editProject(); | |||
| auto target_page = std::find_if( | |||
| project.hmiPages.begin(), project.hmiPages.end(), | |||
| [&page_id](const HmiPage &candidate) { return candidate.id == page_id; }); | |||
| std::vector<std::string> new_ids; | |||
| new_ids.reserve(controls.size()); | |||
| for (const HmiControl &source : controls) | |||
| { | |||
| const HmiControlDescriptor *descriptor = findHmiControlDescriptor(source.type); | |||
| if (descriptor == nullptr) | |||
| { | |||
| project.hmiPages = before.pages; | |||
| project.initialHmiPageId = before.initial_page_id; | |||
| project_service_.restoreModifiedState(modified_before); | |||
| return failure(HmiEditorError::InvalidControl, "不支持的 HMI 控件类型"); | |||
| } | |||
| HmiControl candidate = source; | |||
| candidate.id = makeUniqueId(*target_page, descriptor->idPrefix); | |||
| candidate.bounds.x += final_offset_x; | |||
| candidate.bounds.y += final_offset_y; | |||
| std::string error; | |||
| if (!validateEditableControl(*target_page, candidate, &error)) | |||
| { | |||
| project.hmiPages = before.pages; | |||
| project.initialHmiPageId = before.initial_page_id; | |||
| project_service_.restoreModifiedState(modified_before); | |||
| return failure(HmiEditorError::InvalidControl, error); | |||
| } | |||
| new_ids.push_back(candidate.id); | |||
| target_page->controls.push_back(std::move(candidate)); | |||
| } | |||
| recordHistory(before); | |||
| return {true, HmiEditorError::None, {}, new_ids.front()}; | |||
| } | |||
| bool HmiEditorService::canUndo() const | |||
| { | |||
| return history_.canUndo(); | |||
| @@ -186,6 +186,20 @@ public: | |||
| const std::string &control_id, | |||
| const HmiControl &control); | |||
| /** | |||
| * @brief 批量粘贴 HMI 控件 | |||
| * @param page_id 目标页面唯一标识 | |||
| * @param controls 待复制的控件配置,不使用其中的 ID | |||
| * @param offset_x 控件整体横向偏移 | |||
| * @param offset_y 控件整体纵向偏移 | |||
| * @return 成功时返回第一个新控件 ID,失败时整批回滚 | |||
| */ | |||
| HmiEditorResult pasteControls( | |||
| const std::string &page_id, | |||
| const std::vector<HmiControl> &controls, | |||
| int offset_x = 20, | |||
| int offset_y = 20); | |||
| /** @brief 判断是否存在可撤销的 HMI 编辑操作 */ | |||
| bool canUndo() const; | |||
| /** @brief 判断是否存在可重做的 HMI 编辑操作 */ | |||
| @@ -6,6 +6,7 @@ | |||
| #include <algorithm> | |||
| #include <cctype> | |||
| #include <cstddef> | |||
| #include <functional> | |||
| #include <iterator> | |||
| #include <unordered_set> | |||
| #include <type_traits> | |||
| @@ -144,6 +145,40 @@ bool isSubset(const NodeIdSet &subset, const NodeIdSet &values) | |||
| [&values](const std::string &value) { return values.count(value) != 0U; }); | |||
| } | |||
| bool hasContiguousDirectNodeRange( | |||
| const ConditionExpression &expression, | |||
| const NodeIdSet &selected_ids) | |||
| { | |||
| if (expression.kind == ConditionExpressionKind::Series) | |||
| { | |||
| std::vector<std::size_t> matching_indices; | |||
| for (std::size_t index = 0; index < expression.children.size(); ++index) | |||
| { | |||
| const ConditionExpression &child = expression.children[index]; | |||
| if (child.kind == ConditionExpressionKind::Node | |||
| && selected_ids.count(child.node->id) != 0U) | |||
| { | |||
| matching_indices.push_back(index); | |||
| } | |||
| } | |||
| if (matching_indices.size() == selected_ids.size() | |||
| && !matching_indices.empty() | |||
| && matching_indices.back() - matching_indices.front() + 1U | |||
| == matching_indices.size()) | |||
| { | |||
| return true; | |||
| } | |||
| } | |||
| for (const ConditionExpression &child : expression.children) | |||
| { | |||
| if (hasContiguousDirectNodeRange(child, selected_ids)) | |||
| { | |||
| return true; | |||
| } | |||
| } | |||
| return false; | |||
| } | |||
| bool sameValues(const NodeIdSet &left, const NodeIdSet &right) | |||
| { | |||
| return left.size() == right.size() && isSubset(left, right); | |||
| @@ -580,6 +615,10 @@ LogicEditorService::HistoryState LogicEditorService::captureState() const | |||
| void LogicEditorService::recordHistory(HistoryState before) | |||
| { | |||
| if (suppress_history_) | |||
| { | |||
| return; | |||
| } | |||
| const HistoryState after = captureState(); | |||
| history_.record(std::move(before), after, &LogicEditorService::statesEqual); | |||
| } | |||
| @@ -2178,6 +2217,7 @@ LogicEditorResult LogicEditorService::updateNodeConfig( | |||
| { | |||
| return {true, LogicEditorError::None, {}, node_id}; | |||
| } | |||
| HistoryState before = captureState(); | |||
| const bool modified_before = project_service_.isModified(); | |||
| Project &project = project_service_.editProject(); | |||
| @@ -2210,6 +2250,289 @@ LogicEditorResult LogicEditorService::updateNodeConfig( | |||
| return failure(LogicEditorError::NodeNotFound, "未找到逻辑节点"); | |||
| } | |||
| LogicEditorResult LogicEditorService::pasteConditionNodes( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<LogicNode> &nodes, | |||
| const LogicConditionPasteTarget &target) | |||
| { | |||
| const ControlLogic *existing_logic = findLogic(logic_id); | |||
| if (existing_logic == nullptr) | |||
| { | |||
| return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); | |||
| } | |||
| if (nodes.empty()) | |||
| { | |||
| return failure(LogicEditorError::InvalidOperation, "请先复制梯形图条件"); | |||
| } | |||
| if (nodes.size() > ProjectLimits::kMaximumExpressionNodesPerRung) | |||
| { | |||
| return failure(LogicEditorError::InvalidOperation, "复制的梯形图条件数量超出网络限制"); | |||
| } | |||
| for (const LogicNode &source : nodes) | |||
| { | |||
| if (!source.isCondition() || !source.validate()) | |||
| { | |||
| return failure(LogicEditorError::InvalidNode, "只能粘贴有效的梯形图条件指令"); | |||
| } | |||
| } | |||
| const LadderRung *existing_rung = rung_id.empty() | |||
| ? nullptr : findRung(logic_id, rung_id); | |||
| if (!rung_id.empty() && existing_rung == nullptr) | |||
| { | |||
| return failure(LogicEditorError::RungNotFound, "未找到梯形图网络"); | |||
| } | |||
| if (rung_id.empty() | |||
| && existing_logic->rungs.size() >= ProjectLimits::kMaximumRungsPerLogic) | |||
| { | |||
| return failure(LogicEditorError::InvalidOperation, "单组控制逻辑最多包含 1024 个网络"); | |||
| } | |||
| const HistoryState before = captureState(); | |||
| const bool modified_before = project_service_.isModified(); | |||
| suppress_history_ = true; | |||
| std::string target_rung_id = rung_id; | |||
| std::vector<std::string> new_ids; | |||
| new_ids.reserve(nodes.size()); | |||
| LogicEditorResult current; | |||
| for (std::size_t index = 0; index < nodes.size(); ++index) | |||
| { | |||
| const LogicNode &source = nodes[index]; | |||
| if (index > 0U) | |||
| { | |||
| current = insertConditionAfter( | |||
| logic_id, target_rung_id, new_ids.back(), source.config); | |||
| } | |||
| else | |||
| { | |||
| switch (target.kind) | |||
| { | |||
| case LogicConditionPasteTargetKind::EmptyColumn: | |||
| current = insertConditionAtColumn( | |||
| logic_id, rung_id, target.column, source.config); | |||
| break; | |||
| case LogicConditionPasteTargetKind::BranchEmptyColumn: | |||
| current = insertConditionInBranchAtColumn( | |||
| logic_id, rung_id, target.expressionId, target.column, source.config); | |||
| break; | |||
| case LogicConditionPasteTargetKind::AfterNode: | |||
| current = insertConditionAfter( | |||
| logic_id, rung_id, target.expressionId, source.config); | |||
| break; | |||
| case LogicConditionPasteTargetKind::ReplaceWire: | |||
| current = replaceWireWithCondition( | |||
| logic_id, rung_id, target.expressionId, source.config); | |||
| break; | |||
| case LogicConditionPasteTargetKind::ReplaceWireColumn: | |||
| current = replaceWireColumnWithCondition( | |||
| logic_id, rung_id, target.expressionId, target.column, source.config); | |||
| break; | |||
| case LogicConditionPasteTargetKind::Append: | |||
| default: | |||
| current = appendCondition(logic_id, rung_id, source.config); | |||
| break; | |||
| } | |||
| } | |||
| if (!current.succeeded) | |||
| { | |||
| suppress_history_ = false; | |||
| rollbackEdit(before, modified_before); | |||
| return current; | |||
| } | |||
| if (target_rung_id.empty()) | |||
| { | |||
| target_rung_id = rungIdForNode(logic_id, current.id); | |||
| } | |||
| Project &editable_project = project_service_.editProject(); | |||
| LadderRung *editable_rung = findEditableRung( | |||
| editable_project, logic_id, target_rung_id); | |||
| LogicNode *editable_node = editable_rung != nullptr | |||
| && editable_rung->condition.has_value() | |||
| ? findConditionNode(*editable_rung->condition, current.id) : nullptr; | |||
| if (editable_node == nullptr) | |||
| { | |||
| suppress_history_ = false; | |||
| rollbackEdit(before, modified_before); | |||
| return failure(LogicEditorError::NodeNotFound, "粘贴后的梯形图节点无法定位"); | |||
| } | |||
| editable_node->configured = source.configured; | |||
| new_ids.push_back(current.id); | |||
| } | |||
| suppress_history_ = false; | |||
| recordHistory(before); | |||
| return {true, LogicEditorError::None, {}, new_ids.front()}; | |||
| } | |||
| bool LogicEditorService::areConditionNodesContiguous( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<std::string> &node_ids) const | |||
| { | |||
| const LadderRung *rung = findRung(logic_id, rung_id); | |||
| if (rung == nullptr || !rung->condition.has_value() || node_ids.empty()) | |||
| { | |||
| return false; | |||
| } | |||
| NodeIdSet selected_ids(node_ids.cbegin(), node_ids.cend()); | |||
| if (selected_ids.size() != node_ids.size()) | |||
| { | |||
| return false; | |||
| } | |||
| for (const std::string &node_id : node_ids) | |||
| { | |||
| if (findConditionNode(*rung->condition, node_id) == nullptr) | |||
| { | |||
| return false; | |||
| } | |||
| } | |||
| if (node_ids.size() == 1U) | |||
| { | |||
| return true; | |||
| } | |||
| return hasContiguousDirectNodeRange(*rung->condition, selected_ids); | |||
| } | |||
| LogicEditorResult LogicEditorService::pasteRung( | |||
| const std::string &logic_id, | |||
| const LadderRung &source) | |||
| { | |||
| const ControlLogic *existing_logic = findLogic(logic_id); | |||
| if (existing_logic == nullptr) | |||
| { | |||
| return failure(LogicEditorError::LogicNotFound, "未找到控制逻辑"); | |||
| } | |||
| std::string source_error; | |||
| if (!source.validate(&source_error)) | |||
| { | |||
| return failure(LogicEditorError::InvalidOperation, source_error); | |||
| } | |||
| if (existing_logic->rungs.size() >= ProjectLimits::kMaximumRungsPerLogic) | |||
| { | |||
| return failure(LogicEditorError::InvalidOperation, "单组控制逻辑最多包含 1024 个网络"); | |||
| } | |||
| const HistoryState before = captureState(); | |||
| const bool modified_before = project_service_.isModified(); | |||
| Project &project = project_service_.editProject(); | |||
| auto editable_logic = std::find_if( | |||
| project.controlLogics.begin(), project.controlLogics.end(), | |||
| [&logic_id](const ControlLogic &candidate) { return candidate.id == logic_id; }); | |||
| NodeIdSet reserved_node_ids; | |||
| NodeIdSet reserved_expression_ids; | |||
| NodeIdSet reserved_wire_ids; | |||
| auto unique_node_id = [&editable_logic, &reserved_node_ids]( | |||
| const std::string &prefix) | |||
| { | |||
| for (std::size_t index = 1;; ++index) | |||
| { | |||
| const std::string candidate = prefix + '-' + std::to_string(index); | |||
| if (reserved_node_ids.count(candidate) == 0U | |||
| && std::none_of( | |||
| editable_logic->rungs.cbegin(), editable_logic->rungs.cend(), | |||
| [&candidate](const LadderRung &rung) | |||
| { | |||
| return (rung.output.has_value() && rung.output->id == candidate) | |||
| || (rung.condition.has_value() | |||
| && findConditionNode(*rung.condition, candidate) != nullptr); | |||
| })) | |||
| { | |||
| reserved_node_ids.insert(candidate); | |||
| return candidate; | |||
| } | |||
| } | |||
| }; | |||
| auto unique_wire_id = [&editable_logic, &reserved_wire_ids]() | |||
| { | |||
| for (std::size_t index = 1;; ++index) | |||
| { | |||
| const std::string candidate = "wire-" + std::to_string(index); | |||
| if (reserved_wire_ids.count(candidate) == 0U | |||
| && std::none_of( | |||
| editable_logic->rungs.cbegin(), editable_logic->rungs.cend(), | |||
| [&candidate](const LadderRung &rung) | |||
| { | |||
| return rung.condition.has_value() | |||
| && findConditionExpression(*rung.condition, candidate) != nullptr; | |||
| })) | |||
| { | |||
| reserved_wire_ids.insert(candidate); | |||
| return candidate; | |||
| } | |||
| } | |||
| }; | |||
| auto unique_expression_id = [&editable_logic, &reserved_expression_ids]() | |||
| { | |||
| for (std::size_t index = 1;; ++index) | |||
| { | |||
| const std::string candidate = "expression-" + std::to_string(index); | |||
| if (reserved_expression_ids.count(candidate) == 0U | |||
| && std::none_of( | |||
| editable_logic->rungs.cbegin(), editable_logic->rungs.cend(), | |||
| [&candidate](const LadderRung &rung) | |||
| { | |||
| return rung.condition.has_value() | |||
| && findConditionExpression(*rung.condition, candidate) != nullptr; | |||
| })) | |||
| { | |||
| reserved_expression_ids.insert(candidate); | |||
| return candidate; | |||
| } | |||
| } | |||
| }; | |||
| auto clone_node = [&unique_node_id](const LogicNode &source_node) | |||
| { | |||
| return LogicNode{ | |||
| unique_node_id(nodePrefix(source_node.config)), | |||
| source_node.config, | |||
| source_node.configured}; | |||
| }; | |||
| std::function<ConditionExpression(const ConditionExpression &)> clone_expression; | |||
| clone_expression = [&unique_wire_id, &unique_expression_id, &clone_node, &clone_expression]( | |||
| const ConditionExpression &source_expression) | |||
| { | |||
| ConditionExpression clone; | |||
| clone.kind = source_expression.kind; | |||
| if (source_expression.kind == ConditionExpressionKind::Node) | |||
| { | |||
| clone.node = clone_node(*source_expression.node); | |||
| clone.id = clone.node->id; | |||
| } | |||
| else if (source_expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| clone.id = unique_wire_id(); | |||
| clone.wire = source_expression.wire; | |||
| } | |||
| else | |||
| { | |||
| clone.id = unique_expression_id(); | |||
| for (const ConditionExpression &child : source_expression.children) | |||
| { | |||
| clone.children.push_back(clone_expression(child)); | |||
| } | |||
| } | |||
| return clone; | |||
| }; | |||
| LadderRung pasted = source; | |||
| pasted.id = makeUniqueRungId(*editable_logic); | |||
| if (source.condition.has_value()) | |||
| { | |||
| pasted.condition = clone_expression(*source.condition); | |||
| } | |||
| if (source.output.has_value()) | |||
| { | |||
| pasted.output = clone_node(*source.output); | |||
| } | |||
| std::string validation_error; | |||
| if (!pasted.validate(&validation_error)) | |||
| { | |||
| rollbackEdit(before, modified_before); | |||
| return failure(LogicEditorError::InvalidOperation, validation_error); | |||
| } | |||
| editable_logic->rungs.push_back(std::move(pasted)); | |||
| recordHistory(before); | |||
| return {true, LogicEditorError::None, {}, editable_logic->rungs.back().id}; | |||
| } | |||
| LogicEditorResult LogicEditorService::removeNode( | |||
| const std::string &logic_id, const std::string &node_id) | |||
| { | |||
| @@ -33,6 +33,23 @@ struct LogicEditorResult | |||
| std::string id; // 成功时返回新建或更新对象的稳定 ID | |||
| }; | |||
| enum class LogicConditionPasteTargetKind | |||
| { | |||
| Append, | |||
| EmptyColumn, | |||
| BranchEmptyColumn, | |||
| AfterNode, | |||
| ReplaceWire, | |||
| ReplaceWireColumn | |||
| }; | |||
| struct LogicConditionPasteTarget | |||
| { | |||
| LogicConditionPasteTargetKind kind = LogicConditionPasteTargetKind::Append; | |||
| std::string expressionId; | |||
| int column = 0; | |||
| }; | |||
| // 负责把 UI 编辑命令转换为结构化表达式树操作,并维护撤销/重做 | |||
| class LogicEditorService | |||
| { | |||
| @@ -191,6 +208,32 @@ public: | |||
| const std::string &logic_id, | |||
| const std::string &node_id, | |||
| const LogicNodeConfig &config); | |||
| /** | |||
| * @brief 批量粘贴条件节点到目标网络末尾 | |||
| * @param logic_id 目标控制逻辑 | |||
| * @param rung_id 目标网络;为空时自动创建网络 | |||
| * @param nodes 待复制节点,只读取配置和 configured 状态 | |||
| * @return 成功时返回第一个新节点 ID,失败时整批回滚 | |||
| */ | |||
| LogicEditorResult pasteConditionNodes( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<LogicNode> &nodes, | |||
| const LogicConditionPasteTarget &target = {}); | |||
| /** 判断所选条件是否位于同一串联层级并且视觉连续 */ | |||
| bool areConditionNodesContiguous( | |||
| const std::string &logic_id, | |||
| const std::string &rung_id, | |||
| const std::vector<std::string> &node_ids) const; | |||
| /** | |||
| * @brief 将整条网络复制到目标控制逻辑末尾 | |||
| * @param logic_id 目标控制逻辑 | |||
| * @param source 要复制的网络 | |||
| * @return 成功时返回新网络 ID,所有节点和表达式都会获得新 ID | |||
| */ | |||
| LogicEditorResult pasteRung( | |||
| const std::string &logic_id, | |||
| const LadderRung &source); | |||
| /** @brief 删除指定节点并归一化受影响的表达式树 */ | |||
| LogicEditorResult removeNode( | |||
| const std::string &logic_id, const std::string &node_id); | |||
| @@ -273,4 +316,5 @@ private: | |||
| ProjectService &project_service_; // 不拥有的工程服务依赖 | |||
| EditorHistory<HistoryState> history_; // 当前编辑会话的撤销/重做历史 | |||
| bool suppress_history_ = false; // 复合粘贴期间由外层统一记录一次历史 | |||
| }; | |||
| @@ -1713,6 +1713,78 @@ LogicEditorResult LogicEditorWidget::setOutput( | |||
| return result; | |||
| } | |||
| LogicEditorResult LogicEditorWidget::pasteConditionNodes( | |||
| const std::vector<LogicNode> &nodes) | |||
| { | |||
| const std::vector<std::pair<std::string, int>> selected_empty_slots = | |||
| selectedEmptySlots(); | |||
| const std::vector<std::pair<std::string, int>> selected_wire_cells = | |||
| selectedWireCells(); | |||
| const std::vector<std::string> selected_expressions = selectedExpressionIds(); | |||
| const std::vector<std::string> selected_wires = selectedWireIds(); | |||
| const std::vector<std::string> selected_ids = selectedNodeIds(); | |||
| LogicEditorResult result; | |||
| LogicConditionPasteTarget target; | |||
| if (selected_empty_slots.size() > 1U || selected_wire_cells.size() > 1U | |||
| || (!selected_empty_slots.empty() | |||
| && (!selected_wire_cells.empty() | |||
| || !selected_expressions.empty() || !selected_ids.empty())) | |||
| || (!selected_wire_cells.empty() | |||
| && (!selected_ids.empty() || selected_expressions.size() > 1U)) | |||
| || selected_expressions.size() > 1U || selected_wires.size() > 1U | |||
| || selected_ids.size() > 1U) | |||
| { | |||
| result = {false, LogicEditorError::InvalidOperation, | |||
| "粘贴梯形图条件时只能选择一个目标位置", {}}; | |||
| } | |||
| else | |||
| { | |||
| if (selected_empty_slots.size() == 1U) | |||
| { | |||
| const auto &slot = selected_empty_slots.front(); | |||
| target.kind = slot.first.empty() | |||
| ? LogicConditionPasteTargetKind::EmptyColumn | |||
| : LogicConditionPasteTargetKind::BranchEmptyColumn; | |||
| target.expressionId = slot.first; | |||
| target.column = slot.second; | |||
| } | |||
| else if (selected_wire_cells.size() == 1U) | |||
| { | |||
| target.kind = LogicConditionPasteTargetKind::ReplaceWireColumn; | |||
| target.expressionId = selected_wire_cells.front().first; | |||
| target.column = selected_wire_cells.front().second; | |||
| } | |||
| else if (selected_wires.size() == 1U) | |||
| { | |||
| target.kind = LogicConditionPasteTargetKind::ReplaceWire; | |||
| target.expressionId = selected_wires.front(); | |||
| } | |||
| else if (selected_ids.size() == 1U) | |||
| { | |||
| const LogicNode *selected_node = editor_service_.findNode( | |||
| logic_id_, selected_ids.front()); | |||
| if (selected_node != nullptr && selected_node->isCondition()) | |||
| { | |||
| target.kind = LogicConditionPasteTargetKind::AfterNode; | |||
| target.expressionId = selected_ids.front(); | |||
| } | |||
| } | |||
| result = editor_service_.pasteConditionNodes( | |||
| logic_id_, currentRungId(), nodes, target); | |||
| } | |||
| if (result.succeeded) | |||
| { | |||
| reloadLogic(); | |||
| selectNode(result.id); | |||
| emit graphChanged(); | |||
| } | |||
| else | |||
| { | |||
| reportFailure(result); | |||
| } | |||
| return result; | |||
| } | |||
| LogicEditorResult LogicEditorWidget::deleteSelected() | |||
| { | |||
| const std::vector<std::string> expression_ids = selectedExpressionIds(); | |||
| @@ -78,6 +78,8 @@ public: | |||
| /** 设置当前网络的输出线圈 */ | |||
| LogicEditorResult setOutput( | |||
| const LogicNodeConfig &config, bool configured = false); | |||
| /** 按当前画布选择位置粘贴一组连续条件 */ | |||
| LogicEditorResult pasteConditionNodes(const std::vector<LogicNode> &nodes); | |||
| /** 删除当前选中的节点、线段或网络 */ | |||
| LogicEditorResult deleteSelected(); | |||
| @@ -47,6 +47,8 @@ | |||
| #include <QToolButton> | |||
| #include <QTextEdit> | |||
| #include <algorithm> | |||
| namespace { | |||
| bool isTextEditingObject(QObject *object) | |||
| @@ -70,6 +72,8 @@ bool isEditorShortcut(const QKeyEvent &event) | |||
| { | |||
| return event.matches(QKeySequence::Undo) | |||
| || event.matches(QKeySequence::Redo) | |||
| || event.matches(QKeySequence::Copy) | |||
| || event.matches(QKeySequence::Paste) | |||
| || (event.modifiers() == Qt::NoModifier | |||
| && (event.key() == Qt::Key_Delete || event.key() == Qt::Key_Escape)); | |||
| } | |||
| @@ -366,6 +370,10 @@ void MainWindow::configureActions() | |||
| this, &MainWindow::undoActiveEditor); | |||
| connect(ui_->redoAction, &QAction::triggered, | |||
| this, &MainWindow::redoActiveEditor); | |||
| connect(ui_->copyAction, &QAction::triggered, | |||
| this, &MainWindow::copyActiveSelection); | |||
| connect(ui_->pasteAction, &QAction::triggered, | |||
| this, &MainWindow::pasteActiveSelection); | |||
| connect(ui_->deleteSelectionAction, &QAction::triggered, | |||
| this, &MainWindow::deleteActiveSelection); | |||
| connect(ui_->clearSelectionAction, &QAction::triggered, | |||
| @@ -930,6 +938,160 @@ void MainWindow::redoActiveEditor() | |||
| updateEditActions(); | |||
| } | |||
| void MainWindow::copyActiveSelection() | |||
| { | |||
| if (!runtime_mode_service_.policy().allowsProjectEditing | |||
| || isTextEditingObject(qApp->focusWidget())) | |||
| { | |||
| return; | |||
| } | |||
| if (ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab) | |||
| { | |||
| const std::vector<std::string> ids = hmi_editor_widget_->selectedControlIds(); | |||
| std::vector<HmiControl> controls; | |||
| controls.reserve(ids.size()); | |||
| for (const std::string &id : ids) | |||
| { | |||
| const HmiControl *control = hmi_editor_service_.findControl( | |||
| current_hmi_page_id_, id); | |||
| if (control != nullptr) | |||
| { | |||
| controls.push_back(*control); | |||
| } | |||
| } | |||
| if (controls.empty()) | |||
| { | |||
| return; | |||
| } | |||
| editor_clipboard_ = HmiClipboardData{std::move(controls), 0}; | |||
| statusBar()->showMessage(tr("已复制 %1 个 HMI 控件").arg(ids.size()), 3000); | |||
| } | |||
| else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) | |||
| { | |||
| const std::vector<std::string> ids = logic_editor_widget_->selectedNodeIds(); | |||
| if (!ids.empty()) | |||
| { | |||
| const std::string rung_id = logic_editor_widget_->selectedRungId(); | |||
| if (rung_id.empty()) | |||
| { | |||
| statusBar()->showMessage(tr("只能复制同一网络中的梯形图指令"), 3000); | |||
| return; | |||
| } | |||
| std::vector<LogicNode> nodes; | |||
| nodes.reserve(ids.size()); | |||
| bool all_conditions = true; | |||
| for (const std::string &id : ids) | |||
| { | |||
| const LogicNode *node = logic_editor_service_.findNode( | |||
| current_logic_id_, id); | |||
| if (node == nullptr) | |||
| { | |||
| return; | |||
| } | |||
| all_conditions = all_conditions && node->isCondition(); | |||
| nodes.push_back(*node); | |||
| } | |||
| if (all_conditions | |||
| && logic_editor_service_.areConditionNodesContiguous( | |||
| current_logic_id_, rung_id, ids)) | |||
| { | |||
| editor_clipboard_ = LogicNodesClipboardData{std::move(nodes)}; | |||
| statusBar()->showMessage(tr("已复制 %1 个梯形图条件").arg(ids.size()), 3000); | |||
| } | |||
| else if (ids.size() == 1U && nodes.front().isOutput()) | |||
| { | |||
| editor_clipboard_ = LogicOutputClipboardData{nodes.front()}; | |||
| statusBar()->showMessage(tr("已复制梯形图输出指令"), 3000); | |||
| } | |||
| else | |||
| { | |||
| statusBar()->showMessage( | |||
| tr("只能复制同一串联层级中连续的条件,或单个输出指令"), 3000); | |||
| } | |||
| updateEditActions(); | |||
| return; | |||
| } | |||
| const std::string rung_id = logic_editor_widget_->selectedRungId(); | |||
| const LadderRung *rung = logic_editor_service_.findRung( | |||
| current_logic_id_, rung_id); | |||
| if (rung != nullptr) | |||
| { | |||
| editor_clipboard_ = LogicRungClipboardData{*rung}; | |||
| statusBar()->showMessage(tr("已复制整条梯形图网络"), 3000); | |||
| } | |||
| } | |||
| updateEditActions(); | |||
| } | |||
| void MainWindow::pasteActiveSelection() | |||
| { | |||
| if (!runtime_mode_service_.policy().allowsProjectEditing | |||
| || isTextEditingObject(qApp->focusWidget())) | |||
| { | |||
| return; | |||
| } | |||
| if (ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab) | |||
| { | |||
| auto *data = std::get_if<HmiClipboardData>(&editor_clipboard_); | |||
| if (data == nullptr) | |||
| { | |||
| return; | |||
| } | |||
| const int offset = 20 * std::min(data->pasteCount + 1, 10); | |||
| const HmiEditorResult result = hmi_editor_service_.pasteControls( | |||
| current_hmi_page_id_, data->controls, offset, offset); | |||
| if (!result.succeeded) | |||
| { | |||
| showProjectResult(tr("粘贴 HMI 控件"), fromUtf8(result.message), false); | |||
| return; | |||
| } | |||
| ++data->pasteCount; | |||
| refreshProjectUi(); | |||
| hmi_editor_widget_->reloadPage(); | |||
| hmi_editor_widget_->selectControl(result.id); | |||
| showControlProperties(result.id); | |||
| statusBar()->showMessage(tr("已粘贴 HMI 控件"), 3000); | |||
| } | |||
| else if (ui_->editorTabWidget->currentWidget() == ui_->logicEditorTab) | |||
| { | |||
| LogicEditorResult result; | |||
| if (const auto *data = std::get_if<LogicNodesClipboardData>(&editor_clipboard_)) | |||
| { | |||
| result = logic_editor_widget_->pasteConditionNodes(data->nodes); | |||
| if (result.succeeded) | |||
| { | |||
| selected_logic_node_id_ = result.id; | |||
| showLogicNodeProperties(result.id); | |||
| } | |||
| } | |||
| else if (const auto *data = std::get_if<LogicOutputClipboardData>(&editor_clipboard_)) | |||
| { | |||
| result = logic_editor_widget_->setOutput( | |||
| data->output.config, data->output.configured); | |||
| } | |||
| else if (const auto *data = std::get_if<LogicRungClipboardData>(&editor_clipboard_)) | |||
| { | |||
| result = logic_editor_service_.pasteRung(current_logic_id_, data->rung); | |||
| if (result.succeeded) | |||
| { | |||
| logic_editor_widget_->reloadLogic(); | |||
| } | |||
| } | |||
| if (!result.succeeded) | |||
| { | |||
| if (!result.message.empty()) | |||
| { | |||
| showProjectResult(tr("粘贴梯形图"), fromUtf8(result.message), false); | |||
| } | |||
| return; | |||
| } | |||
| refreshProjectUi(); | |||
| statusBar()->showMessage(tr("已粘贴梯形图对象"), 3000); | |||
| } | |||
| updateEditActions(); | |||
| } | |||
| void MainWindow::deleteActiveSelection() | |||
| { | |||
| if (ui_->editorTabWidget->currentWidget() == ui_->hmiEditorTab) | |||
| @@ -970,6 +1132,13 @@ void MainWindow::updateEditActions() | |||
| ui_->redoAction->setEnabled( | |||
| editable && ((hmi_active && hmi_editor_service_.canRedo()) | |||
| || (logic_active && logic_editor_service_.canRedo()))); | |||
| ui_->copyAction->setEnabled(editable && (hmi_active || logic_active)); | |||
| const bool hmi_clipboard = std::holds_alternative<HmiClipboardData>(editor_clipboard_); | |||
| const bool logic_clipboard = std::holds_alternative<LogicNodesClipboardData>(editor_clipboard_) | |||
| || std::holds_alternative<LogicOutputClipboardData>(editor_clipboard_) | |||
| || std::holds_alternative<LogicRungClipboardData>(editor_clipboard_); | |||
| ui_->pasteAction->setEnabled(editable && ((hmi_active && hmi_clipboard) | |||
| || (logic_active && logic_clipboard))); | |||
| ui_->deleteSelectionAction->setEnabled(editable && (hmi_active || logic_active)); | |||
| ui_->clearSelectionAction->setEnabled(editable && (hmi_active || logic_active)); | |||
| ui_->insertHorizontalWireAction->setEnabled(editable && logic_active); | |||
| @@ -1318,6 +1487,7 @@ void MainWindow::createNewProject() | |||
| clearEditorHistories(); | |||
| selected_control_id_.clear(); | |||
| selected_logic_node_id_.clear(); | |||
| editor_clipboard_ = std::monostate{}; | |||
| current_hmi_page_id_ = project_service_.project().initialHmiPageId; | |||
| current_logic_id_ = logic_editor_service_.firstLogicId(); | |||
| refreshProjectUi(); | |||
| @@ -1384,6 +1554,7 @@ void MainWindow::loadProject() | |||
| clearEditorHistories(); | |||
| selected_control_id_.clear(); | |||
| selected_logic_node_id_.clear(); | |||
| editor_clipboard_ = std::monostate{}; | |||
| current_hmi_page_id_ = project_service_.project().initialHmiPageId; | |||
| current_logic_id_ = logic_editor_service_.firstLogicId(); | |||
| refreshProjectUi(); | |||
| @@ -16,6 +16,8 @@ | |||
| #include <QMainWindow> | |||
| #include <memory> | |||
| #include <variant> | |||
| #include <vector> | |||
| QT_BEGIN_NAMESPACE | |||
| class QAction; | |||
| @@ -133,6 +135,10 @@ private: | |||
| void undoActiveEditor(); | |||
| /** 重做当前处于焦点的编辑器操作 */ | |||
| void redoActiveEditor(); | |||
| /** 复制当前编辑画布中的选中对象 */ | |||
| void copyActiveSelection(); | |||
| /** 粘贴剪贴板中的对象到当前编辑画布 */ | |||
| void pasteActiveSelection(); | |||
| /** 删除当前处于焦点的编辑器选中项 */ | |||
| void deleteActiveSelection(); | |||
| /** 清除当前处于焦点的编辑器选择 */ | |||
| @@ -281,4 +287,29 @@ private: | |||
| std::string current_logic_id_; | |||
| /** 是否已经安排了待处理的 PLC 状态刷新 */ | |||
| bool plc_status_update_pending_ = false; | |||
| struct HmiClipboardData | |||
| { | |||
| std::vector<HmiControl> controls; | |||
| int pasteCount = 0; | |||
| }; | |||
| struct LogicNodesClipboardData | |||
| { | |||
| std::vector<LogicNode> nodes; | |||
| }; | |||
| struct LogicOutputClipboardData | |||
| { | |||
| LogicNode output; | |||
| }; | |||
| struct LogicRungClipboardData | |||
| { | |||
| LadderRung rung; | |||
| }; | |||
| using EditorClipboard = std::variant< | |||
| std::monostate, | |||
| HmiClipboardData, | |||
| LogicNodesClipboardData, | |||
| LogicOutputClipboardData, | |||
| LogicRungClipboardData>; | |||
| EditorClipboard editor_clipboard_; | |||
| }; | |||
| @@ -237,6 +237,8 @@ | |||
| </property> | |||
| <addaction name="undoAction"/> | |||
| <addaction name="redoAction"/> | |||
| <addaction name="copyAction"/> | |||
| <addaction name="pasteAction"/> | |||
| <addaction name="separator"/> | |||
| <addaction name="deleteSelectionAction"/> | |||
| <addaction name="deleteHorizontalWireAction"/> | |||
| @@ -1026,6 +1028,16 @@ | |||
| <property name="toolTip"><string>重做当前编辑器最近撤销的操作</string></property> | |||
| <property name="shortcut"><string>Ctrl+Y</string></property> | |||
| </action> | |||
| <action name="copyAction"> | |||
| <property name="text"><string>复制</string></property> | |||
| <property name="toolTip"><string>复制当前编辑画布中选中的对象</string></property> | |||
| <property name="shortcut"><string>Ctrl+C</string></property> | |||
| </action> | |||
| <action name="pasteAction"> | |||
| <property name="text"><string>粘贴</string></property> | |||
| <property name="toolTip"><string>粘贴已复制的编辑对象</string></property> | |||
| <property name="shortcut"><string>Ctrl+V</string></property> | |||
| </action> | |||
| <action name="deleteSelectionAction"> | |||
| <property name="text"><string>删除所选</string></property> | |||
| <property name="toolTip"><string>删除当前画布中选中的对象</string></property> | |||
| @@ -222,6 +222,46 @@ void testHistoryAndAtomicBatchDelete() | |||
| "HMI history must retain exactly the configured 100 most recent steps"); | |||
| } | |||
| void testBatchPasteControls() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| HmiEditorService service(project_service); | |||
| const std::string page_id = service.ensureDefaultPage().id; | |||
| const HmiEditorResult first = service.addControl(page_id, HmiControlType::Label); | |||
| const HmiEditorResult second = service.addControl(page_id, HmiControlType::Label); | |||
| require(first.succeeded && second.succeeded, | |||
| "controls for paste testing must be created"); | |||
| HmiControl first_copy = *service.findControl(page_id, first.id); | |||
| HmiControl second_copy = *service.findControl(page_id, second.id); | |||
| first_copy.bounds = {100, 80, 80, 32}; | |||
| first_copy.text = "复制标签"; | |||
| first_copy.properties[HmiAppearanceProperty::kTextColor] = "#E53935"; | |||
| second_copy.bounds = {220, 80, 80, 32}; | |||
| require(service.updateControl(page_id, first.id, first_copy).succeeded | |||
| && service.updateControl(page_id, second.id, second_copy).succeeded, | |||
| "source controls must be movable before paste"); | |||
| service.clearHistory(); | |||
| const HmiEditorResult pasted = service.pasteControls( | |||
| page_id, {first_copy, second_copy}); | |||
| require(pasted.succeeded | |||
| && service.findPage(page_id)->controls.size() == 4U | |||
| && pasted.id != first.id && pasted.id != second.id, | |||
| "batch paste must create controls with fresh ids"); | |||
| const HmiControl *pasted_control = service.findControl(page_id, pasted.id); | |||
| require(pasted_control != nullptr | |||
| && pasted_control->bounds.x == first_copy.bounds.x + 20 | |||
| && pasted_control->bounds.y == first_copy.bounds.y + 20 | |||
| && pasted_control->text == first_copy.text | |||
| && pasted_control->properties == first_copy.properties, | |||
| "pasted controls must preserve content and appearance with an offset"); | |||
| require(service.undo().succeeded | |||
| && service.findPage(page_id)->controls.size() == 2U, | |||
| "batch control paste must be one undoable operation"); | |||
| } | |||
| void testAppearanceEditing() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -376,6 +416,7 @@ int main() | |||
| // 编辑和运行场景分别验证服务层两条独立职责 | |||
| testControlEditing(); | |||
| testHistoryAndAtomicBatchDelete(); | |||
| testBatchPasteControls(); | |||
| testAppearanceEditing(); | |||
| testRuntimeUsesRegisterRepository(); | |||
| testPageLifecycleAndNavigation(); | |||
| @@ -63,6 +63,24 @@ int wireColumns(const ConditionExpression &expression) | |||
| return columns; | |||
| } | |||
| int conditionNodes(const ConditionExpression &expression) | |||
| { | |||
| if (expression.kind == ConditionExpressionKind::Node) | |||
| { | |||
| return 1; | |||
| } | |||
| if (expression.kind == ConditionExpressionKind::Wire) | |||
| { | |||
| return 0; | |||
| } | |||
| int count = 0; | |||
| for (const ConditionExpression &child : expression.children) | |||
| { | |||
| count += conditionNodes(child); | |||
| } | |||
| return count; | |||
| } | |||
| void testEmptyLogicCreatesNetworksOnFirstEdit() | |||
| { | |||
| TestProjectStorage storage; | |||
| @@ -997,6 +1015,101 @@ void testHistoryAndAtomicBatchDelete() | |||
| (void)second; | |||
| } | |||
| void testBatchPasteNodesAndRung() | |||
| { | |||
| TestProjectStorage storage; | |||
| ProjectService project_service(storage); | |||
| LogicEditorService service(project_service); | |||
| const std::string logic_id = service.ensureDefaultLogic().id; | |||
| const std::string rung_id = makeEmptyRung(service, logic_id); | |||
| const LogicEditorResult first = service.appendCondition( | |||
| logic_id, rung_id, contact(10)); | |||
| const LogicEditorResult second = service.appendCondition( | |||
| logic_id, rung_id, contact(11)); | |||
| require(first.succeeded && second.succeeded, | |||
| "nodes for paste testing must be created"); | |||
| const LogicNode first_copy = *service.findNode(logic_id, first.id); | |||
| const LogicNode second_copy = *service.findNode(logic_id, second.id); | |||
| service.clearHistory(); | |||
| const LogicEditorResult pasted = service.pasteConditionNodes( | |||
| logic_id, rung_id, {first_copy, second_copy}); | |||
| require(pasted.succeeded | |||
| && service.findNode(logic_id, pasted.id) != nullptr | |||
| && service.findRung(logic_id, rung_id)->condition.has_value() | |||
| && conditionNodes(*service.findRung(logic_id, rung_id)->condition) == 4U, | |||
| "batch condition paste must append fresh nodes"); | |||
| require(pasted.id != first.id && pasted.id != second.id | |||
| && service.undo().succeeded | |||
| && conditionNodes(*service.findRung(logic_id, rung_id)->condition) == 2U, | |||
| "batch condition paste must use fresh ids and one undo step"); | |||
| require(service.areConditionNodesContiguous( | |||
| logic_id, rung_id, {first.id, second.id}), | |||
| "adjacent nodes in one series must be copyable as a range"); | |||
| const std::string wire_rung_id = makeEmptyRung(service, logic_id); | |||
| const LogicEditorResult wire = service.appendWire(logic_id, wire_rung_id, 3); | |||
| service.clearHistory(); | |||
| LogicConditionPasteTarget wire_target; | |||
| wire_target.kind = LogicConditionPasteTargetKind::ReplaceWireColumn; | |||
| wire_target.expressionId = wire.id; | |||
| wire_target.column = 1; | |||
| const LogicEditorResult pasted_on_wire = service.pasteConditionNodes( | |||
| logic_id, wire_rung_id, {first_copy, second_copy}, wire_target); | |||
| const LadderRung *wire_rung = service.findRung(logic_id, wire_rung_id); | |||
| require(pasted_on_wire.succeeded && wire_rung != nullptr | |||
| && wire_rung->condition.has_value() | |||
| && conditionNodes(*wire_rung->condition) == 2 | |||
| && conditionColumns(*wire_rung->condition) == 3, | |||
| "condition paste must replace the selected wire cell and consume following wire cells"); | |||
| require(service.undo().succeeded | |||
| && wireColumns(*service.findRung(logic_id, wire_rung_id)->condition) == 3, | |||
| "wire-targeted paste must be undone as one edit"); | |||
| const std::string full_rung_id = makeEmptyRung(service, logic_id); | |||
| for (int address = 0; address < 9; ++address) | |||
| { | |||
| require(service.appendCondition(logic_id, full_rung_id, contact(address)).succeeded, | |||
| "nine conditions must fit before an atomic paste failure test"); | |||
| } | |||
| service.clearHistory(); | |||
| require(!service.pasteConditionNodes( | |||
| logic_id, full_rung_id, {first_copy, second_copy}).succeeded | |||
| && conditionNodes(*service.findRung(logic_id, full_rung_id)->condition) == 9 | |||
| && !service.canUndo(), | |||
| "a multi-node paste that exceeds ten columns must roll back completely"); | |||
| const LogicEditorResult third = service.appendCondition( | |||
| logic_id, rung_id, contact(12)); | |||
| require(third.succeeded | |||
| && !service.areConditionNodesContiguous( | |||
| logic_id, rung_id, {first.id, third.id}), | |||
| "non-adjacent nodes must not be copied as one condition range"); | |||
| const LogicEditorResult output = service.setOutput( | |||
| logic_id, | |||
| rung_id, | |||
| CoilNodeConfig{RegisterAddress{RegisterArea::M, 20}, CoilMode::Set}, | |||
| true); | |||
| require(output.succeeded, "the source rung must accept an output before copying"); | |||
| const LadderRung source = *service.findRung(logic_id, rung_id); | |||
| service.clearHistory(); | |||
| const std::size_t rung_count_before_paste = service.findLogic(logic_id)->rungs.size(); | |||
| const LogicEditorResult pasted_rung = service.pasteRung(logic_id, source); | |||
| require(pasted_rung.succeeded | |||
| && service.findLogic(logic_id)->rungs.size() | |||
| == rung_count_before_paste + 1U | |||
| && pasted_rung.id != source.id, | |||
| "whole rung paste must append a new network"); | |||
| const LadderRung *copy = service.findRung(logic_id, pasted_rung.id); | |||
| require(copy != nullptr && copy->condition.has_value() | |||
| && copy->condition->id != source.condition->id | |||
| && copy->output.has_value() && source.output.has_value() | |||
| && copy->output->id != source.output->id | |||
| && std::get<CoilNodeConfig>(copy->output->config).address.index() == 20 | |||
| && std::get<CoilNodeConfig>(copy->output->config).mode == CoilMode::Set, | |||
| "whole rung paste must regenerate ids and preserve output configuration"); | |||
| } | |||
| } // namespace | |||
| int main() | |||
| @@ -1020,6 +1133,7 @@ int main() | |||
| testLogicLifecycleAndOrdering(); | |||
| testEdgeNodesAndRungComments(); | |||
| testHistoryAndAtomicBatchDelete(); | |||
| testBatchPasteNodesAndRung(); | |||
| } | |||
| catch (const std::exception &error) | |||
| { | |||