Files
Genarrative/src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts
T
suzmii 1e17d2c852
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m44s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m57s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m44s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m53s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m21s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m27s
Project CI / Frontend tests (pull_request) Successful in 7m4s
Project CI / AI game creator shell web tests (pull_request) Successful in 6m22s
Project CI / Native shell tests (pull_request) Successful in 10m13s
补齐画布验收第一批:润色回包提示、未提交草稿恢复与派生名预检
AI 润色回包与原文相同时给出明确提示,不再落下点了等于没点的「恢复原文」假入口
润色「与原文相同」的判据改为规范化之后要写回宿主的文本,回包被长度上限截回原文同样算没变化
共享聊天输入区显示润色提示,并让提示随用户手改提示词失效
快速编辑未提交草稿改按资源路径归属,收起面板或被旁路重投影后都不再静默丢掉
恢复入口按路径与本会话草稿对号,并区分「本会话未提交」与原生账本条目
账本读取失败时入口仍报出本会话草稿条数,「继续编辑」重开面板回填提示词与 @ 引用
派生资源名预检接入快速编辑与角色动画两条提交路径,早于正规化写盘
名称预检自身抛错时回写面板失败态,不再留下「点了没反应」的提交按钮
浮层关闭判定改在捕获阶段冻结,避免浮层内按钮卸载自己时整块面板被自己人收掉
恢复入口动作按钮收进统一容器,补移动端纵向排布与禁用态样式
空草稿表改工厂函数,跨语言对表用例按模块路径解析 Rust 源码
补回归用例:旁路重投影后的草稿、原样回包提示、提交前名称拦截、草稿表读写与跨卡隔离
同步 decision-log 与画布验收待办的进展说明
2026-09-20 17:36:02 +08:00

186 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef } from 'react';
type FloatingOptionBoundaryRef = {
readonly current: HTMLElement | null;
};
/** 浮层边界登记:谁算「浮层内部」,关闭判定与滚轮归属共用同一份。 */
type FloatingOverlayBoundaryOptions = {
boundaryRefs?: Array<FloatingOptionBoundaryRef | null | undefined>;
isInsideExtraOverlay?: (target: EventTarget | null) => boolean;
};
type UseImageCanvasFloatingOptionDismissOptions = {
isOpen: boolean;
boundaryRefs: Array<FloatingOptionBoundaryRef | null | undefined>;
onDismiss: () => void;
/** Escape 关闭后把焦点还给的触发按钮;不传则只关闭不移焦。 */
restoreFocusRef?: FloatingOptionBoundaryRef | null;
/**
* 宿主自己的「点这里不算外部」补充判据。
*
* 共享 hook 只认识 `image-canvas-editor__portal-menu` 这一类共享弹出层;宿主自己
* portal 到 body 的浮层(例如 `@` 资源选择器与它的候选菜单)用它在同一处登记,
* 否则点浮层里的候选项会被判成点外部、把宿主面板一起收掉。
* 不传时(网页端画布)判定与既有行为逐字一致。
*/
isInsideExtraOverlay?: (target: EventTarget | null) => boolean;
};
function isEventInsideBoundary(
target: EventTarget | null,
boundaryRefs: Array<FloatingOptionBoundaryRef | null | undefined>,
) {
if (!(target instanceof Node)) {
return false;
}
return boundaryRefs.some((boundaryRef) => {
const element = boundaryRef?.current;
return element ? element.contains(target) : false;
});
}
function isEventInsideFloatingMenu(target: EventTarget | null) {
return (
target instanceof Element &&
target.closest('.image-canvas-editor__portal-menu') !== null
);
}
/**
* 事件是否落在「已登记为浮层」的那几块 DOM 里。
*
* 这些浮层 portal 到 `document.body`(共享弹出层)或由宿主额外登记(`isInsideExtraOverlay`),
* 关闭判定与滚轮归属共用这一份口径,避免两处各写一套白名单。
*/
function isEventInsideRegisteredOverlay(
target: EventTarget | null,
isInsideExtraOverlay?: (target: EventTarget | null) => boolean,
) {
return (
isEventInsideFloatingMenu(target) || isInsideExtraOverlay?.(target) === true
);
}
/**
* 「点外部关闭」的边界判据:DOM 在边界里(含 portal 出去的浮层登记)就算内部。
*/
export function isEventInsideFloatingOverlay(
target: EventTarget | null,
{ boundaryRefs = [], isInsideExtraOverlay }: FloatingOverlayBoundaryOptions,
) {
return (
isEventInsideBoundary(target, boundaryRefs) ||
isEventInsideRegisteredOverlay(target, isInsideExtraOverlay)
);
}
/**
* 「这一下滚轮归浮层还是归画布」的判据,与上面的关闭判定同源。
*
* React 的 portal 事件沿 **React 树** 冒泡(React 把委托监听挂在 portal 容器 `document.body`
* 上),所以 portal 出去的浮层里的滚轮照样会走到宿主的 `onWheel`。DOM 上根本不在宿主里的
* 那一下,只可能是浮层派出来的:必须原样放过,否则用户在 `@` 选择器列表上滚轮时,列表
* 在滚、背后的画布也一起平移 / 缩放。
*
* 两条判据:
* 1. DOM 不在边界里 —— portal 出去的浮层(`@` 选择器、候选菜单、共享弹出层……),一律归浮层;
* 2. DOM 仍在边界里、但已登记为浮层内部的(`isInsideExtraOverlay` 与共享弹出层),同样归浮层。
*
* 返回 `true` 表示这次滚轮归浮层,宿主不得消费(不 preventDefault、不动视口)。
*/
export function isFloatingOverlayWheelEvent(
target: EventTarget | null,
{ boundaryRefs = [], isInsideExtraOverlay }: FloatingOverlayBoundaryOptions,
) {
if (!isEventInsideBoundary(target, boundaryRefs)) {
return true;
}
return isEventInsideRegisteredOverlay(target, isInsideExtraOverlay);
}
export function useImageCanvasFloatingOptionDismiss({
isOpen,
boundaryRefs,
onDismiss,
restoreFocusRef,
isInsideExtraOverlay,
}: UseImageCanvasFloatingOptionDismissOptions) {
/**
* 本次点击「算不算点在浮层里」的冻结判定。
*
* 判定必须在事件派发的最早期做,不能在冒泡到 `document` 时才拿 `event.target` 现算:
* 浮层里的按钮可能在**这一次点击**里把自己卸载掉(AGC 画布验收 AGC-005:快速编辑的
* 「恢复原文」被点后原文快照清空、按钮不再渲染,验收视频里表现为整个编辑面板连同选中
* 一起被收掉)。目标节点一旦脱离 DOM,`element.contains(target)` 一律为 false,
* 「点浮层内部」就被误判成「点外部」,面板被自己的按钮关掉。
*
* 捕获阶段挂在 `document` 上是最早的一站(React 的委托监听在 root 容器上,位置更靠内),
* 此刻 DOM 还是用户看到的那一份;判定口径本身不变,仍是
* {@link isEventInsideFloatingOverlay}(边界 DOM + portal 到 body 的浮层登记)。
* 真正触发关闭仍然留在冒泡阶段,宿主其它处理器看到的事件顺序与既有行为逐字一致。
*/
const clickVerdictRef = useRef<{
event: MouseEvent;
inside: boolean;
} | null>(null);
useEffect(() => {
if (!isOpen || typeof document === 'undefined') {
return undefined;
}
const resolveInside = (event: MouseEvent) =>
isEventInsideFloatingOverlay(event.target, {
boundaryRefs,
isInsideExtraOverlay,
});
const handleClickCapture = (event: MouseEvent) => {
clickVerdictRef.current = { event, inside: resolveInside(event) };
};
const handleClick = (event: MouseEvent) => {
// 中文注释:选项项点击后要保留浮层;父级面板其它区域点击才收起。
const frozen = clickVerdictRef.current;
clickVerdictRef.current = null;
// 捕获阶段没跑到(例如事件不是这条派发路径派出来的)时退回现算,行为与改动前一致。
const isInside =
frozen && frozen.event === event ? frozen.inside : resolveInside(event);
if (isInside) {
return;
}
onDismiss();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') {
return;
}
// 画布全局快捷键在 window 上监听同一个 Escape,且会把整个生成 dialog 关掉。
// 浮层打开时 Escape 归浮层所有:document 在冒泡路径上早于 window,这里截断,
// 否则用户只想收起参数浮层却会连整个面板一起丢失。
event.stopPropagation();
// 浮层 portal 到 body,关闭时焦点会掉到 body;只有焦点确实在浮层里才把它还给
// 触发按钮,避免抢走用户正在编辑的输入框。
const shouldRestoreFocus = isEventInsideFloatingMenu(
document.activeElement,
);
onDismiss();
if (shouldRestoreFocus) {
restoreFocusRef?.current?.focus();
}
};
document.addEventListener('click', handleClickCapture, true);
document.addEventListener('click', handleClick);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('click', handleClickCapture, true);
document.removeEventListener('click', handleClick);
document.removeEventListener('keydown', handleKeyDown);
};
}, [boundaryRefs, isInsideExtraOverlay, isOpen, onDismiss, restoreFocusRef]);
}