@gadmin2n/schematics 0.0.133 → 0.0.135
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/lib/auth.guard.ts +66 -17
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/common.json +1 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/common.json +1 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasEditPage.tsx +58 -10
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasPage.tsx +56 -11
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasToolbar.tsx +21 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/hooks/useCanvasHistory.ts +65 -0
- package/package.json +1 -1
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
},
|
|
90
90
|
"devDependencies": {
|
|
91
91
|
"@faker-js/faker": "^10.4.0",
|
|
92
|
-
"@gadmin2n/prisma-nest-generator": "^0.0.
|
|
92
|
+
"@gadmin2n/prisma-nest-generator": "^0.0.49",
|
|
93
93
|
"@gadmin2n/prisma-react-generator": "^0.0.65",
|
|
94
94
|
"@nestjs/cli": "^11.0.21",
|
|
95
95
|
"@nestjs/testing": "^10.4.15",
|
|
@@ -32,8 +32,31 @@ export const AllowUnauthorizedRequest = () =>
|
|
|
32
32
|
|
|
33
33
|
export const AllowApiKeyRequest = () => SetMetadata('allowApiKeyRequest', true);
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
type AuthSource = 'gadmin-token' | 'woa' | 'apikey';
|
|
36
|
+
|
|
37
|
+
async function getIdentity(
|
|
38
|
+
headers,
|
|
39
|
+
cookies,
|
|
40
|
+
): Promise<{ user: UserType; source: AuthSource }> {
|
|
41
|
+
// 优先验证 gadmin-token(JWT)
|
|
42
|
+
const token = headers[GAdminTokenName] || cookies[GAdminTokenName];
|
|
43
|
+
if (token) {
|
|
44
|
+
try {
|
|
45
|
+
const userInfo = await TaiHuId.verify(token);
|
|
46
|
+
logger.info(
|
|
47
|
+
`gadmin_token_auth_success - Token认证成功 - 用户ID: ${userInfo.sub}`,
|
|
48
|
+
);
|
|
49
|
+
return {
|
|
50
|
+
user: { userid: userInfo.sub, username: userInfo.sub },
|
|
51
|
+
source: 'gadmin-token',
|
|
52
|
+
};
|
|
53
|
+
} catch (e) {
|
|
54
|
+
logger.warn(`gadmin_token_auth_failed - Token验证失败: ${e.message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// gadmin-token 不存在或失效,回退到 WOA 认证
|
|
59
|
+
// https://iwiki.woa.com/p/4007728669#nodejs-%E7%A4%BA%E4%BE%8B
|
|
37
60
|
try {
|
|
38
61
|
const { staffid, staffname } = await getWoaIdentity(
|
|
39
62
|
headers,
|
|
@@ -43,23 +66,33 @@ async function getIdentity(headers, cookies): Promise<UserType> {
|
|
|
43
66
|
logger.info(
|
|
44
67
|
`woa_auth_success - 用户ID: ${staffid}, 用户名: ${staffname}`,
|
|
45
68
|
);
|
|
46
|
-
return {
|
|
69
|
+
return {
|
|
70
|
+
user: { userid: staffname, username: staffname },
|
|
71
|
+
source: 'woa',
|
|
72
|
+
};
|
|
47
73
|
}
|
|
48
74
|
} catch (e) {
|
|
49
75
|
logger.warn(`woa_auth_failed - WOA认证失败: ${e.message}`);
|
|
50
76
|
}
|
|
51
77
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
logger.info(
|
|
57
|
-
`taihu_auth_success - Taihu认证成功 - 用户ID: ${userInfo.sub}, Token长度: ${
|
|
58
|
-
token ? token.length : 0
|
|
59
|
-
}`,
|
|
60
|
-
);
|
|
78
|
+
// 全部失败,抛出异常触发太湖登录流程
|
|
79
|
+
// https://iwiki.woa.com/p/4008986665
|
|
80
|
+
throw new UnauthorizedException('no valid auth credential');
|
|
81
|
+
}
|
|
61
82
|
|
|
62
|
-
|
|
83
|
+
async function issueGAdminTokenCookie(res, user: UserType) {
|
|
84
|
+
const expiresIn = 7 * 24 * 3600;
|
|
85
|
+
const payload = {
|
|
86
|
+
user_id: user.userid,
|
|
87
|
+
sub: user.userid,
|
|
88
|
+
name: user.username,
|
|
89
|
+
email: `${user.userid}@tencent.com`,
|
|
90
|
+
};
|
|
91
|
+
const token = await TaiHuId.sign(payload, expiresIn);
|
|
92
|
+
res.cookie(GAdminTokenName, token, {
|
|
93
|
+
maxAge: expiresIn * 1000,
|
|
94
|
+
httpOnly: false,
|
|
95
|
+
});
|
|
63
96
|
}
|
|
64
97
|
|
|
65
98
|
@Injectable()
|
|
@@ -95,6 +128,7 @@ export default class AuthGuard implements CanActivate {
|
|
|
95
128
|
|
|
96
129
|
canActivate(context: ExecutionContext): boolean | Promise<boolean> {
|
|
97
130
|
const request = context.switchToHttp().getRequest();
|
|
131
|
+
const response = context.switchToHttp().getResponse();
|
|
98
132
|
const handler = context.getHandler();
|
|
99
133
|
const controller = context.getClass();
|
|
100
134
|
|
|
@@ -124,7 +158,10 @@ export default class AuthGuard implements CanActivate {
|
|
|
124
158
|
|
|
125
159
|
return (
|
|
126
160
|
allowApiKeyRequest
|
|
127
|
-
? this.getApikeyIdentity(request)
|
|
161
|
+
? this.getApikeyIdentity(request).then((user) => ({
|
|
162
|
+
user,
|
|
163
|
+
source: 'apikey' as AuthSource,
|
|
164
|
+
}))
|
|
128
165
|
: Promise.reject("don't allow api key request.")
|
|
129
166
|
)
|
|
130
167
|
.catch((error) => {
|
|
@@ -134,11 +171,23 @@ export default class AuthGuard implements CanActivate {
|
|
|
134
171
|
|
|
135
172
|
return getIdentity(request.headers, request.cookies);
|
|
136
173
|
})
|
|
137
|
-
.then((
|
|
138
|
-
request.user =
|
|
174
|
+
.then(async ({ user, source }) => {
|
|
175
|
+
request.user = user;
|
|
176
|
+
|
|
177
|
+
// WOA 认证通过时补签 gadmin-token cookie,后续请求走本地 JWT 校验,
|
|
178
|
+
// 避免每次都调用 WOA 认证接口。
|
|
179
|
+
if (source === 'woa') {
|
|
180
|
+
try {
|
|
181
|
+
await issueGAdminTokenCookie(response, user);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
logger.warn(
|
|
184
|
+
`gadmin_token_sign_failed - 签发gadmin-token失败: ${e.message}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
139
188
|
|
|
140
189
|
logger.info(
|
|
141
|
-
`auth_success - 认证成功 - 用户ID: ${
|
|
190
|
+
`auth_success - 认证成功 - 用户ID: ${user.userid}, 用户名: ${user.username}, 端点: ${request.method} ${request.url}, 来源: ${source}`,
|
|
142
191
|
);
|
|
143
192
|
|
|
144
193
|
return true;
|
package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasEditPage.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import type { SavedCanvas, CanvasItem } from './types';
|
|
|
7
7
|
import type { LayoutItem } from 'react-grid-layout';
|
|
8
8
|
import { fetchCanvas, updateCanvas, unpublishCanvas } from './canvasApi';
|
|
9
9
|
import { useTranslation } from 'react-i18next';
|
|
10
|
+
import { useCanvasHistory } from './hooks/useCanvasHistory';
|
|
10
11
|
|
|
11
12
|
const CanvasEditPage: React.FC = () => {
|
|
12
13
|
const { id } = useParams<{ id: string }>();
|
|
@@ -21,6 +22,7 @@ const CanvasEditPage: React.FC = () => {
|
|
|
21
22
|
const [publishModalOpen, setPublishModalOpen] = useState(false);
|
|
22
23
|
const [unpublishModalOpen, setUnpublishModalOpen] = useState(false);
|
|
23
24
|
const [unpublishing, setUnpublishing] = useState(false);
|
|
25
|
+
const history = useCanvasHistory();
|
|
24
26
|
|
|
25
27
|
const liveRef = useRef({
|
|
26
28
|
canvas,
|
|
@@ -51,6 +53,7 @@ const CanvasEditPage: React.FC = () => {
|
|
|
51
53
|
setCanvas(data);
|
|
52
54
|
setCanvasItems(data.items ?? []);
|
|
53
55
|
setCanvasLayout(data.layout ?? []);
|
|
56
|
+
history.clear();
|
|
54
57
|
})
|
|
55
58
|
.catch((err) => {
|
|
56
59
|
console.error('[CanvasEditPage] load failed:', err);
|
|
@@ -61,25 +64,68 @@ const CanvasEditPage: React.FC = () => {
|
|
|
61
64
|
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
62
65
|
|
|
63
66
|
// ── Change handlers ──
|
|
64
|
-
const handleItemsChange = useCallback(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
const handleItemsChange = useCallback(
|
|
68
|
+
(items: CanvasItem[], opts?: { coalesce?: boolean }) => {
|
|
69
|
+
history.push(
|
|
70
|
+
{
|
|
71
|
+
items: liveRef.current.canvasItems,
|
|
72
|
+
layout: liveRef.current.canvasLayout,
|
|
73
|
+
},
|
|
74
|
+
opts?.coalesce ?? false,
|
|
75
|
+
);
|
|
76
|
+
setCanvasItems(items);
|
|
77
|
+
setIsDirty(true);
|
|
78
|
+
},
|
|
79
|
+
[history],
|
|
80
|
+
);
|
|
68
81
|
|
|
69
|
-
const handleLayoutChange = useCallback(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
82
|
+
const handleLayoutChange = useCallback(
|
|
83
|
+
(layout: LayoutItem[], opts?: { coalesce?: boolean }) => {
|
|
84
|
+
history.push(
|
|
85
|
+
{
|
|
86
|
+
items: liveRef.current.canvasItems,
|
|
87
|
+
layout: liveRef.current.canvasLayout,
|
|
88
|
+
},
|
|
89
|
+
opts?.coalesce ?? false,
|
|
90
|
+
);
|
|
91
|
+
setCanvasLayout(layout);
|
|
92
|
+
setIsDirty(true);
|
|
93
|
+
},
|
|
94
|
+
[history],
|
|
95
|
+
);
|
|
73
96
|
|
|
74
97
|
const handleBothChange = useCallback(
|
|
75
|
-
(
|
|
98
|
+
(
|
|
99
|
+
items: CanvasItem[],
|
|
100
|
+
layout: LayoutItem[],
|
|
101
|
+
opts?: { coalesce?: boolean },
|
|
102
|
+
) => {
|
|
103
|
+
history.push(
|
|
104
|
+
{
|
|
105
|
+
items: liveRef.current.canvasItems,
|
|
106
|
+
layout: liveRef.current.canvasLayout,
|
|
107
|
+
},
|
|
108
|
+
opts?.coalesce ?? false,
|
|
109
|
+
);
|
|
76
110
|
setCanvasItems(items);
|
|
77
111
|
setCanvasLayout(layout);
|
|
78
112
|
setIsDirty(true);
|
|
79
113
|
},
|
|
80
|
-
[],
|
|
114
|
+
[history],
|
|
81
115
|
);
|
|
82
116
|
|
|
117
|
+
// ── Undo: pop a snapshot and write it back directly, WITHOUT going
|
|
118
|
+
// through handleItemsChange/handleLayoutChange/handleBothChange —
|
|
119
|
+
// those push to history, which would re-add the state we're leaving
|
|
120
|
+
// and make consecutive undos toggle instead of walking further back. ──
|
|
121
|
+
const handleUndo = useCallback(() => {
|
|
122
|
+
const snapshot = history.pop();
|
|
123
|
+
if (!snapshot) return;
|
|
124
|
+
setCanvasItems(snapshot.items);
|
|
125
|
+
setCanvasLayout(snapshot.layout);
|
|
126
|
+
setIsDirty(true);
|
|
127
|
+
}, [history]);
|
|
128
|
+
|
|
83
129
|
// ── Save ──
|
|
84
130
|
const handleSave = useCallback(async () => {
|
|
85
131
|
const { canvas, canvasItems, canvasLayout } = liveRef.current;
|
|
@@ -160,6 +206,8 @@ const CanvasEditPage: React.FC = () => {
|
|
|
160
206
|
isDirty={isDirty}
|
|
161
207
|
canvasName={canvas.name}
|
|
162
208
|
isPublished={!!canvas.publishedPageCode}
|
|
209
|
+
canUndo={history.canUndo}
|
|
210
|
+
onUndo={handleUndo}
|
|
163
211
|
onItemsChange={handleItemsChange}
|
|
164
212
|
onLayoutChange={handleLayoutChange}
|
|
165
213
|
onBothChange={handleBothChange}
|
package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasPage.tsx
CHANGED
|
@@ -115,9 +115,18 @@ interface CanvasPageProps {
|
|
|
115
115
|
isDirty: boolean;
|
|
116
116
|
canvasName: string;
|
|
117
117
|
isPublished: boolean;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
canUndo: boolean;
|
|
119
|
+
onUndo: () => void;
|
|
120
|
+
onItemsChange: (
|
|
121
|
+
items: CanvasItemType[],
|
|
122
|
+
opts?: { coalesce?: boolean },
|
|
123
|
+
) => void;
|
|
124
|
+
onLayoutChange: (layout: LayoutItem[], opts?: { coalesce?: boolean }) => void;
|
|
125
|
+
onBothChange: (
|
|
126
|
+
items: CanvasItemType[],
|
|
127
|
+
layout: LayoutItem[],
|
|
128
|
+
opts?: { coalesce?: boolean },
|
|
129
|
+
) => void;
|
|
121
130
|
onSave: () => void;
|
|
122
131
|
onBack: () => void;
|
|
123
132
|
onPublish: () => void;
|
|
@@ -132,6 +141,8 @@ const CanvasPage: React.FC<CanvasPageProps> = ({
|
|
|
132
141
|
isDirty,
|
|
133
142
|
canvasName,
|
|
134
143
|
isPublished,
|
|
144
|
+
canUndo,
|
|
145
|
+
onUndo,
|
|
135
146
|
onItemsChange,
|
|
136
147
|
onLayoutChange,
|
|
137
148
|
onBothChange,
|
|
@@ -360,18 +371,22 @@ const CanvasPage: React.FC<CanvasPageProps> = ({
|
|
|
360
371
|
}, []);
|
|
361
372
|
|
|
362
373
|
// 更新组件代码
|
|
363
|
-
const updateCode = useCallback(
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
374
|
+
const updateCode = useCallback(
|
|
375
|
+
(id: string, code: string, opts?: { coalesce?: boolean }) => {
|
|
376
|
+
const { items, onItemsChange } = liveRef.current;
|
|
377
|
+
onItemsChange(
|
|
378
|
+
items.map((item) => (item.id === id ? { ...item, code } : item)),
|
|
379
|
+
opts,
|
|
380
|
+
);
|
|
381
|
+
},
|
|
382
|
+
[],
|
|
383
|
+
);
|
|
369
384
|
|
|
370
385
|
// ── canvas:patch HMR 监听 — agent 通过 POST /canvas-patch 触发画布实时更新 ──
|
|
371
386
|
useEffect(() => {
|
|
372
387
|
if (!import.meta.hot) return;
|
|
373
388
|
const handler = ({ itemId, code }: { itemId: string; code: string }) => {
|
|
374
|
-
updateCode(itemId, code);
|
|
389
|
+
updateCode(itemId, code, { coalesce: true });
|
|
375
390
|
};
|
|
376
391
|
import.meta.hot.on('canvas:patch', handler);
|
|
377
392
|
return () => {
|
|
@@ -404,6 +419,7 @@ const CanvasPage: React.FC<CanvasPageProps> = ({
|
|
|
404
419
|
minH: 2,
|
|
405
420
|
},
|
|
406
421
|
],
|
|
422
|
+
{ coalesce: true },
|
|
407
423
|
);
|
|
408
424
|
};
|
|
409
425
|
import.meta.hot.on('canvas:add-item', handler);
|
|
@@ -412,6 +428,31 @@ const CanvasPage: React.FC<CanvasPageProps> = ({
|
|
|
412
428
|
};
|
|
413
429
|
}, []);
|
|
414
430
|
|
|
431
|
+
// ── 全局 Ctrl/Cmd+Z 撤销快捷键 — 焦点在代码编辑器或普通输入框内时,
|
|
432
|
+
// 交给它们自己处理本地撤销,不拦截 ──
|
|
433
|
+
useEffect(() => {
|
|
434
|
+
const handler = (e: KeyboardEvent) => {
|
|
435
|
+
const isUndoCombo =
|
|
436
|
+
(e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === 'z';
|
|
437
|
+
if (!isUndoCombo) return;
|
|
438
|
+
const active = document.activeElement as HTMLElement | null;
|
|
439
|
+
if (active) {
|
|
440
|
+
if (active.closest('.monaco-editor')) return;
|
|
441
|
+
if (
|
|
442
|
+
active.tagName === 'INPUT' ||
|
|
443
|
+
active.tagName === 'TEXTAREA' ||
|
|
444
|
+
active.isContentEditable
|
|
445
|
+
) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
e.preventDefault();
|
|
450
|
+
onUndo();
|
|
451
|
+
};
|
|
452
|
+
window.addEventListener('keydown', handler);
|
|
453
|
+
return () => window.removeEventListener('keydown', handler);
|
|
454
|
+
}, [onUndo]);
|
|
455
|
+
|
|
415
456
|
// ── MultiChart 工具栏切换图表类型时,同步更新代码中的 defaultVariant ──
|
|
416
457
|
useEffect(() => {
|
|
417
458
|
const handler = (e: Event) => {
|
|
@@ -615,6 +656,8 @@ const CanvasPage: React.FC<CanvasPageProps> = ({
|
|
|
615
656
|
isDirty={isDirty}
|
|
616
657
|
canvasName={canvasName}
|
|
617
658
|
isPublished={isPublished}
|
|
659
|
+
canUndo={canUndo}
|
|
660
|
+
onUndo={onUndo}
|
|
618
661
|
onSave={onSave}
|
|
619
662
|
onBack={onBack}
|
|
620
663
|
onPublish={onPublish}
|
|
@@ -868,7 +911,9 @@ const CanvasPage: React.FC<CanvasPageProps> = ({
|
|
|
868
911
|
code={editingItem.code}
|
|
869
912
|
props={editingDef.props}
|
|
870
913
|
variants={editingDef.variants}
|
|
871
|
-
onChange={(code) =>
|
|
914
|
+
onChange={(code) =>
|
|
915
|
+
updateCode(editingItem.id, code, { coalesce: true })
|
|
916
|
+
}
|
|
872
917
|
onClose={() => setEditingId(null)}
|
|
873
918
|
/>
|
|
874
919
|
)}
|
package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasToolbar.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
RocketOutlined,
|
|
8
8
|
EyeOutlined,
|
|
9
9
|
RobotOutlined,
|
|
10
|
+
UndoOutlined,
|
|
10
11
|
} from '@ant-design/icons';
|
|
11
12
|
import { useTranslation } from 'react-i18next';
|
|
12
13
|
|
|
@@ -14,6 +15,8 @@ interface CanvasToolbarProps {
|
|
|
14
15
|
isDirty: boolean;
|
|
15
16
|
canvasName: string;
|
|
16
17
|
isPublished: boolean;
|
|
18
|
+
canUndo: boolean;
|
|
19
|
+
onUndo: () => void;
|
|
17
20
|
onSave: () => void;
|
|
18
21
|
onBack: () => void;
|
|
19
22
|
onPublish: () => void;
|
|
@@ -26,6 +29,8 @@ const CanvasToolbar: React.FC<CanvasToolbarProps> = ({
|
|
|
26
29
|
isDirty,
|
|
27
30
|
canvasName,
|
|
28
31
|
isPublished,
|
|
32
|
+
canUndo,
|
|
33
|
+
onUndo,
|
|
29
34
|
onSave,
|
|
30
35
|
onBack,
|
|
31
36
|
onPublish,
|
|
@@ -96,6 +101,22 @@ const CanvasToolbar: React.FC<CanvasToolbarProps> = ({
|
|
|
96
101
|
gap: 10,
|
|
97
102
|
}}
|
|
98
103
|
>
|
|
104
|
+
<Tooltip title={t('canvas.undo')}>
|
|
105
|
+
<Button
|
|
106
|
+
size="small"
|
|
107
|
+
icon={<UndoOutlined />}
|
|
108
|
+
onClick={onUndo}
|
|
109
|
+
disabled={!canUndo}
|
|
110
|
+
style={{
|
|
111
|
+
borderRadius: 8,
|
|
112
|
+
border: '1px solid #e0e0e0',
|
|
113
|
+
background: 'transparent',
|
|
114
|
+
color: canUndo ? '#555' : '#ccc',
|
|
115
|
+
fontWeight: 500,
|
|
116
|
+
transition: 'all 200ms ease',
|
|
117
|
+
}}
|
|
118
|
+
/>
|
|
119
|
+
</Tooltip>
|
|
99
120
|
{isAgentAllowed && (
|
|
100
121
|
<Button
|
|
101
122
|
size="small"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import type { LayoutItem } from 'react-grid-layout';
|
|
3
|
+
import type { CanvasItem } from '../types';
|
|
4
|
+
|
|
5
|
+
export interface CanvasSnapshot {
|
|
6
|
+
items: CanvasItem[];
|
|
7
|
+
layout: LayoutItem[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Max number of undo steps retained before the oldest is dropped. */
|
|
11
|
+
const MAX_HISTORY_SIZE = 50;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Calls made with coalesce=true (rapid Monaco keystrokes, bursts of AI
|
|
15
|
+
* canvas:patch/canvas:add-item HMR events) merge into the same history
|
|
16
|
+
* entry as long as consecutive calls land within this window. A gap
|
|
17
|
+
* larger than this — or any coalesce=false call — starts a fresh entry.
|
|
18
|
+
*/
|
|
19
|
+
const COALESCE_WINDOW_MS = 5000;
|
|
20
|
+
|
|
21
|
+
export function useCanvasHistory() {
|
|
22
|
+
const stackRef = useRef<CanvasSnapshot[]>([]);
|
|
23
|
+
// Timestamp of the last coalesce=true push that extended the current
|
|
24
|
+
// batch; null when the last push was discrete (coalesce=false) or no
|
|
25
|
+
// batch is in progress.
|
|
26
|
+
const lastCoalescedAtRef = useRef<number | null>(null);
|
|
27
|
+
const [canUndo, setCanUndo] = useState(false);
|
|
28
|
+
|
|
29
|
+
const push = useCallback((snapshot: CanvasSnapshot, coalesce: boolean) => {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const continuingBatch =
|
|
32
|
+
coalesce &&
|
|
33
|
+
lastCoalescedAtRef.current !== null &&
|
|
34
|
+
now - lastCoalescedAtRef.current < COALESCE_WINDOW_MS;
|
|
35
|
+
|
|
36
|
+
if (continuingBatch) {
|
|
37
|
+
// Still the same drag/typing/AI-turn batch — extend the window,
|
|
38
|
+
// don't push another entry.
|
|
39
|
+
lastCoalescedAtRef.current = now;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
stackRef.current.push(snapshot);
|
|
44
|
+
if (stackRef.current.length > MAX_HISTORY_SIZE) {
|
|
45
|
+
stackRef.current.shift();
|
|
46
|
+
}
|
|
47
|
+
lastCoalescedAtRef.current = coalesce ? now : null;
|
|
48
|
+
setCanUndo(true);
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const pop = useCallback((): CanvasSnapshot | undefined => {
|
|
52
|
+
const snapshot = stackRef.current.pop();
|
|
53
|
+
lastCoalescedAtRef.current = null;
|
|
54
|
+
setCanUndo(stackRef.current.length > 0);
|
|
55
|
+
return snapshot;
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
const clear = useCallback(() => {
|
|
59
|
+
stackRef.current = [];
|
|
60
|
+
lastCoalescedAtRef.current = null;
|
|
61
|
+
setCanUndo(false);
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
return { push, pop, clear, canUndo };
|
|
65
|
+
}
|