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 与画布验收待办的进展说明
186 lines
7.4 KiB
TypeScript
186 lines
7.4 KiB
TypeScript
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]);
|
||
}
|