@gadmin2n/schematics 0.0.134 → 0.0.136

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.
@@ -32,7 +32,8 @@
32
32
  "postgadminGenerate": "node scripts/notify-dev-servers.js",
33
33
  "push:db": "dotenv -e .env.local -e .env -- prisma db push --skip-generate",
34
34
  "seed": "dotenv -e .env.local -e .env -- ts-node seed/index.ts",
35
- "prisma:studio": "dotenv -e .env.local -e .env -- prisma studio"
35
+ "prisma:studio": "dotenv -e .env.local -e .env -- prisma studio",
36
+ "gen:jwt": "dotenv -e .env.local -e .env -- ts-node scripts/gen-jwt.ts"
36
37
  },
37
38
  "dependencies": {
38
39
  "@agendajs/postgres-backend": "^3.0.5",
@@ -89,7 +90,7 @@
89
90
  },
90
91
  "devDependencies": {
91
92
  "@faker-js/faker": "^10.4.0",
92
- "@gadmin2n/prisma-nest-generator": "^0.0.48",
93
+ "@gadmin2n/prisma-nest-generator": "^0.0.49",
93
94
  "@gadmin2n/prisma-react-generator": "^0.0.66",
94
95
  "@nestjs/cli": "^11.0.21",
95
96
  "@nestjs/testing": "^10.4.15",
@@ -0,0 +1,85 @@
1
+ /**
2
+ * gen-jwt.ts — 使用 TaiHuId.sign() 为 seed 用户生成 JWT token
3
+ *
4
+ * Usage:
5
+ * yarn gen:jwt --userid kavenma
6
+ * yarn gen:jwt --userid kavenma --expiresIn 3600
7
+ *
8
+ * userid 必须在 seed/users.ts 中存在。
9
+ */
10
+
11
+ import seedUsers from '../seed/users';
12
+ import TaiHuId from '../src/lib/taihu';
13
+
14
+ function parseArgs(): Record<string, string> {
15
+ const argv = process.argv.slice(2);
16
+ const flags: Record<string, string> = {};
17
+ for (let i = 0; i < argv.length; i++) {
18
+ if (argv[i].startsWith('--')) {
19
+ const key = argv[i].slice(2);
20
+ const next = argv[i + 1];
21
+ flags[key] = next && !next.startsWith('--') ? argv[++i] : 'true';
22
+ }
23
+ }
24
+ return flags;
25
+ }
26
+
27
+ async function main() {
28
+ const flags = parseArgs();
29
+ const { userid, expiresIn } = flags;
30
+
31
+ if (!userid) {
32
+ console.error('[gen-jwt] --userid is required.');
33
+ console.error(
34
+ `[gen-jwt] Available userids: ${seedUsers
35
+ .map((u) => u.userid)
36
+ .join(', ')}`,
37
+ );
38
+ process.exit(1);
39
+ }
40
+
41
+ const user = seedUsers.find((u) => u.userid === userid);
42
+ if (!user) {
43
+ console.error(`[gen-jwt] userid "${userid}" not found in seed/users.ts.`);
44
+ console.error(
45
+ `[gen-jwt] Available userids: ${seedUsers
46
+ .map((u) => u.userid)
47
+ .join(', ')}`,
48
+ );
49
+ process.exit(1);
50
+ }
51
+
52
+ const ttl = expiresIn ? parseInt(expiresIn, 10) : 7 * 24 * 60 * 60; // 默认 7 天
53
+
54
+ const token = await TaiHuId.sign(
55
+ {
56
+ user_id: user.userid,
57
+ sub: user.userid,
58
+ name: user.username,
59
+ email: `${user.userid}@tencent.com`,
60
+ },
61
+ ttl,
62
+ );
63
+
64
+ console.log(
65
+ `[gen-jwt] ✅ Generated JWT for userid=${user.userid} (expiresIn=${ttl}s):\n`,
66
+ );
67
+ console.log(token);
68
+
69
+ console.log('\n[gen-jwt] 🍪 如何在浏览器中种入 cookie:');
70
+ console.log(' 1) 打开目标站点(例如 http://localhost:3000),进入 DevTools');
71
+ console.log(' 2) Application → Cookies → 选择当前域名');
72
+ console.log(
73
+ ' 新增一条 cookie:Name=gadmin-token Value=<上面的 token> Path=/',
74
+ );
75
+ console.log(' 3) 刷新页面即可以该 userid 身份登录\n');
76
+ console.log(' 或在 DevTools Console 中执行(同域下即时生效):');
77
+ console.log(
78
+ ` document.cookie = "gadmin-token=${token}; path=/; max-age=${ttl}"`,
79
+ );
80
+ }
81
+
82
+ main().catch((e) => {
83
+ console.error('[gen-jwt] Fatal error:', e);
84
+ process.exit(1);
85
+ });
@@ -32,8 +32,31 @@ export const AllowUnauthorizedRequest = () =>
32
32
 
33
33
  export const AllowApiKeyRequest = () => SetMetadata('allowApiKeyRequest', true);
34
34
 
35
- async function getIdentity(headers, cookies): Promise<UserType> {
36
- // 先验证woa登录态 https://iwiki.woa.com/p/4007728669#nodejs-%E7%A4%BA%E4%BE%8B
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 { userid: staffname, username: staffname };
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
- // 再验证taihu登录态 https://iwiki.woa.com/p/4008986665
53
- const token = headers[GAdminTokenName] || cookies[GAdminTokenName];
54
- const userInfo = await TaiHuId.verify(token);
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
- return { userid: userInfo.sub, username: userInfo.sub };
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((userInfo) => {
138
- request.user = userInfo;
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: ${userInfo.userid}, 用户名: ${userInfo.username}, 端点: ${request.method} ${request.url}`,
190
+ `auth_success - 认证成功 - 用户ID: ${user.userid}, 用户名: ${user.username}, 端点: ${request.method} ${request.url}, 来源: ${source}`,
142
191
  );
143
192
 
144
193
  return true;
@@ -146,6 +146,7 @@
146
146
  "canvas": {
147
147
  "componentLib": "Components",
148
148
  "backToList": "Back to list",
149
+ "undo": "Undo",
149
150
  "save": "Save",
150
151
  "saveDirty": "Save •",
151
152
  "preview": "Preview",
@@ -148,6 +148,7 @@
148
148
  "canvas": {
149
149
  "componentLib": "组件库",
150
150
  "backToList": "返回列表",
151
+ "undo": "撤销",
151
152
  "save": "保存",
152
153
  "saveDirty": "保存 •",
153
154
  "preview": "预览",
@@ -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((items: CanvasItem[]) => {
65
- setCanvasItems(items);
66
- setIsDirty(true);
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((layout: LayoutItem[]) => {
70
- setCanvasLayout(layout);
71
- setIsDirty(true);
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
- (items: CanvasItem[], layout: LayoutItem[]) => {
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}
@@ -115,9 +115,18 @@ interface CanvasPageProps {
115
115
  isDirty: boolean;
116
116
  canvasName: string;
117
117
  isPublished: boolean;
118
- onItemsChange: (items: CanvasItemType[]) => void;
119
- onLayoutChange: (layout: LayoutItem[]) => void;
120
- onBothChange: (items: CanvasItemType[], layout: LayoutItem[]) => void;
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((id: string, code: string) => {
364
- const { items, onItemsChange } = liveRef.current;
365
- onItemsChange(
366
- items.map((item) => (item.id === id ? { ...item, code } : item)),
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) => updateCode(editingItem.id, code)}
914
+ onChange={(code) =>
915
+ updateCode(editingItem.id, code, { coalesce: true })
916
+ }
872
917
  onClose={() => setEditingId(null)}
873
918
  />
874
919
  )}
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gadmin2n/schematics",
3
- "version": "0.0.134",
3
+ "version": "0.0.136",
4
4
  "description": "Gadmin - modern, fast, powerful node.js web framework (@schematics)",
5
5
  "main": "dist/index.js",
6
6
  "files": [