@rohal12/spindle 0.16.1 → 0.18.1

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.
Files changed (60) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/package.json +2 -2
  3. package/src/action-registry.ts +2 -1
  4. package/src/components/Passage.tsx +69 -4
  5. package/src/components/PassageDialog.tsx +69 -0
  6. package/src/components/StoryInterface.tsx +2 -2
  7. package/src/components/macros/Button.tsx +33 -46
  8. package/src/components/macros/Checkbox.tsx +32 -35
  9. package/src/components/macros/Computed.tsx +49 -54
  10. package/src/components/macros/Cycle.tsx +43 -53
  11. package/src/components/macros/Dialog.tsx +40 -0
  12. package/src/components/macros/Do.tsx +15 -21
  13. package/src/components/macros/For.tsx +57 -75
  14. package/src/components/macros/Goto.tsx +18 -22
  15. package/src/components/macros/If.tsx +39 -53
  16. package/src/components/macros/Include.tsx +30 -46
  17. package/src/components/macros/Listbox.tsx +38 -48
  18. package/src/components/macros/MacroError.tsx +18 -0
  19. package/src/components/macros/MacroLink.tsx +38 -53
  20. package/src/components/macros/MenubarButtons.tsx +158 -0
  21. package/src/components/macros/Meter.tsx +35 -52
  22. package/src/components/macros/Numberbox.tsx +30 -47
  23. package/src/components/macros/PassageDisplay.tsx +37 -30
  24. package/src/components/macros/Print.tsx +19 -36
  25. package/src/components/macros/Radiobutton.tsx +33 -38
  26. package/src/components/macros/Repeat.tsx +49 -54
  27. package/src/components/macros/SaveManager.tsx +400 -0
  28. package/src/components/macros/Set.tsx +19 -24
  29. package/src/components/{SettingsDialog.tsx → macros/SettingsControls.tsx} +18 -36
  30. package/src/components/macros/Span.tsx +8 -0
  31. package/src/components/macros/Stop.tsx +11 -12
  32. package/src/components/macros/StoryTitle.tsx +16 -16
  33. package/src/components/macros/Switch.tsx +41 -59
  34. package/src/components/macros/Textarea.tsx +26 -45
  35. package/src/components/macros/Textbox.tsx +27 -44
  36. package/src/components/macros/Timed.tsx +54 -55
  37. package/src/components/macros/Type.tsx +61 -62
  38. package/src/components/macros/Unset.tsx +21 -22
  39. package/src/components/macros/Widget.tsx +11 -15
  40. package/src/components/macros/option-utils.ts +13 -0
  41. package/src/define-macro.ts +154 -0
  42. package/src/expression.ts +10 -8
  43. package/src/index.tsx +7 -0
  44. package/src/macros/register-builtins.ts +5 -7
  45. package/src/markup/ast.ts +11 -16
  46. package/src/markup/render.tsx +0 -13
  47. package/src/saves/save-manager.ts +43 -0
  48. package/src/saves/types.ts +9 -2
  49. package/src/store.ts +283 -52
  50. package/src/story-api.ts +33 -12
  51. package/src/styles.css +37 -68
  52. package/src/components/PassageLink.tsx +0 -49
  53. package/src/components/SaveLoadDialog.tsx +0 -408
  54. package/src/components/macros/Back.tsx +0 -32
  55. package/src/components/macros/Forward.tsx +0 -34
  56. package/src/components/macros/QuickLoad.tsx +0 -42
  57. package/src/components/macros/QuickSave.tsx +0 -30
  58. package/src/components/macros/Restart.tsx +0 -35
  59. package/src/components/macros/Saves.tsx +0 -24
  60. package/src/components/macros/SettingsButton.tsx +0 -28
package/src/styles.css CHANGED
@@ -44,25 +44,6 @@ tw-storydata {
44
44
  }
45
45
  }
46
46
 
47
- .passage-link {
48
- color: #64b5f6;
49
- text-decoration: none;
50
- border-bottom: 1px solid rgba(100, 181, 246, 0.3);
51
- transition:
52
- color 0.2s,
53
- border-color 0.2s;
54
- cursor: pointer;
55
- }
56
-
57
- .passage-link:hover {
58
- color: #90caf9;
59
- border-bottom-color: #90caf9;
60
- }
61
-
62
- .passage-link:active {
63
- color: #42a5f5;
64
- }
65
-
66
47
  .macro-button {
67
48
  background: rgba(100, 181, 246, 0.15);
68
49
  color: #64b5f6;
@@ -154,9 +135,9 @@ tw-storydata {
154
135
  background: transparent;
155
136
  }
156
137
 
157
- /* Settings dialog */
138
+ /* Generic dialog */
158
139
 
159
- .settings-overlay {
140
+ .dialog-overlay {
160
141
  position: fixed;
161
142
  inset: 0;
162
143
  z-index: 200;
@@ -166,28 +147,33 @@ tw-storydata {
166
147
  background: rgba(0, 0, 0, 0.6);
167
148
  }
168
149
 
169
- .settings-panel {
150
+ .dialog-panel {
170
151
  background: #16213e;
171
152
  border: 1px solid rgba(255, 255, 255, 0.1);
172
153
  border-radius: 8px;
173
154
  width: 90%;
174
- max-width: 400px;
155
+ max-width: 500px;
175
156
  max-height: 80vh;
176
- overflow-y: auto;
157
+ display: flex;
158
+ flex-direction: column;
177
159
  color: #e0e0e0;
178
160
  font-family: system-ui, sans-serif;
161
+ position: relative;
179
162
  }
180
163
 
181
- .settings-header {
182
- display: flex;
183
- align-items: center;
184
- justify-content: space-between;
185
- padding: 1em 1.25em;
186
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
187
- font-weight: 600;
164
+ .dialog-saves {
165
+ max-width: 550px;
188
166
  }
189
167
 
190
- .settings-close {
168
+ .dialog-settings {
169
+ max-width: 400px;
170
+ }
171
+
172
+ .dialog-close {
173
+ position: absolute;
174
+ top: 0.75em;
175
+ right: 0.75em;
176
+ z-index: 1;
191
177
  background: transparent;
192
178
  border: none;
193
179
  color: #9e9e9e;
@@ -197,11 +183,19 @@ tw-storydata {
197
183
  line-height: 1;
198
184
  }
199
185
 
200
- .settings-close:hover {
186
+ .dialog-close:hover {
201
187
  color: #e0e0e0;
202
188
  }
203
189
 
204
- .settings-body {
190
+ .dialog-body {
191
+ overflow-y: auto;
192
+ flex: 1;
193
+ min-height: 0;
194
+ }
195
+
196
+ /* Settings controls */
197
+
198
+ .settings-controls {
205
199
  padding: 1em 1.25em;
206
200
  display: flex;
207
201
  flex-direction: column;
@@ -235,29 +229,13 @@ tw-storydata {
235
229
  height: 1.1em;
236
230
  }
237
231
 
238
- /* Saves dialog */
239
-
240
- .saves-overlay {
241
- position: fixed;
242
- inset: 0;
243
- z-index: 200;
244
- display: flex;
245
- align-items: center;
246
- justify-content: center;
247
- background: rgba(0, 0, 0, 0.6);
248
- }
232
+ /* Save manager */
249
233
 
250
- .saves-panel {
251
- background: #16213e;
252
- border: 1px solid rgba(255, 255, 255, 0.1);
253
- border-radius: 8px;
254
- width: 90%;
255
- max-width: 550px;
256
- max-height: 80vh;
234
+ .save-manager {
257
235
  display: flex;
258
236
  flex-direction: column;
259
- color: #e0e0e0;
260
- font-family: system-ui, sans-serif;
237
+ flex: 1;
238
+ min-height: 0;
261
239
  }
262
240
 
263
241
  .saves-header {
@@ -265,6 +243,7 @@ tw-storydata {
265
243
  align-items: center;
266
244
  justify-content: space-between;
267
245
  padding: 1em 1.25em;
246
+ padding-right: 3em;
268
247
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
269
248
  font-weight: 600;
270
249
  flex-shrink: 0;
@@ -307,20 +286,6 @@ tw-storydata {
307
286
  color: #e0e0e0;
308
287
  }
309
288
 
310
- .saves-close {
311
- background: transparent;
312
- border: none;
313
- color: #9e9e9e;
314
- font-size: 1.1em;
315
- cursor: pointer;
316
- padding: 0.25em;
317
- line-height: 1;
318
- }
319
-
320
- .saves-close:hover {
321
- color: #e0e0e0;
322
- }
323
-
324
289
  .saves-body {
325
290
  padding: 0.75em 1.25em;
326
291
  overflow-y: auto;
@@ -560,6 +525,10 @@ tw-storydata {
560
525
  border-bottom-color: #90caf9;
561
526
  }
562
527
 
528
+ .macro-link:active {
529
+ color: #42a5f5;
530
+ }
531
+
563
532
  /* Macro: form inputs */
564
533
 
565
534
  .macro-textbox,
@@ -1,49 +0,0 @@
1
- import { useStoryStore } from '../store';
2
- import { useAction } from '../hooks/use-action';
3
- import { useInterpolate } from '../hooks/use-interpolate';
4
-
5
- interface PassageLinkProps {
6
- target: string;
7
- className?: string;
8
- id?: string;
9
- children: preact.ComponentChildren;
10
- }
11
-
12
- export function PassageLink({
13
- target,
14
- className,
15
- id,
16
- children,
17
- }: PassageLinkProps) {
18
- const navigate = useStoryStore((s) => s.navigate);
19
- const resolve = useInterpolate();
20
- className = resolve(className);
21
- id = resolve(id);
22
-
23
- const handleClick = (e: MouseEvent) => {
24
- e.preventDefault();
25
- navigate(target);
26
- };
27
-
28
- useAction({
29
- type: 'link',
30
- key: target,
31
- authorId: id,
32
- label: typeof children === 'string' ? children : target,
33
- target,
34
- perform: () => navigate(target),
35
- });
36
-
37
- const cls = className ? `passage-link ${className}` : 'passage-link';
38
-
39
- return (
40
- <a
41
- href="#"
42
- id={id}
43
- class={cls}
44
- onClick={handleClick}
45
- >
46
- {children}
47
- </a>
48
- );
49
- }
@@ -1,408 +0,0 @@
1
- import { useState, useEffect, useCallback, useRef } from 'preact/hooks';
2
- import { useStoryStore } from '../store';
3
- import { isSaveExport, type SaveRecord } from '../saves/types';
4
- import {
5
- getSavesGrouped,
6
- createSave,
7
- overwriteSave,
8
- deleteSaveById,
9
- renameSave,
10
- exportSave,
11
- importSave,
12
- type PlaythroughGroup,
13
- } from '../saves/save-manager';
14
-
15
- interface SaveLoadDialogProps {
16
- onClose: () => void;
17
- }
18
-
19
- function relativeTime(iso: string): string {
20
- const diff = Date.now() - new Date(iso).getTime();
21
- const secs = Math.floor(diff / 1000);
22
- if (secs < 60) return 'just now';
23
- const mins = Math.floor(secs / 60);
24
- if (mins < 60) return `${mins}m ago`;
25
- const hours = Math.floor(mins / 60);
26
- if (hours < 24) return `${hours}h ago`;
27
- const days = Math.floor(hours / 24);
28
- if (days < 30) return `${days}d ago`;
29
- return new Date(iso).toLocaleDateString();
30
- }
31
-
32
- function formatDate(iso: string): string {
33
- return new Date(iso).toLocaleDateString(undefined, {
34
- month: 'short',
35
- day: 'numeric',
36
- year: 'numeric',
37
- });
38
- }
39
-
40
- export function SaveLoadDialog({ onClose }: SaveLoadDialogProps) {
41
- const [mode, setMode] = useState<'save' | 'load'>('load');
42
- const [groups, setGroups] = useState<PlaythroughGroup[]>([]);
43
- const [loading, setLoading] = useState(true);
44
- const [status, setStatus] = useState<{
45
- text: string;
46
- type: 'success' | 'error';
47
- } | null>(null);
48
- const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
49
- const [renamingId, setRenamingId] = useState<string | null>(null);
50
- const [renameValue, setRenameValue] = useState('');
51
- const renameInputRef = useRef<HTMLInputElement>(null);
52
- const fileInputRef = useRef<HTMLInputElement>(null);
53
-
54
- const storyData = useStoryStore((s) => s.storyData);
55
- const playthroughId = useStoryStore((s) => s.playthroughId);
56
- const getSavePayload = useStoryStore((s) => s.getSavePayload);
57
- const loadFromPayload = useStoryStore((s) => s.loadFromPayload);
58
- const ifid = storyData?.ifid ?? '';
59
-
60
- const refresh = useCallback(async () => {
61
- if (!ifid) return;
62
- const data = await getSavesGrouped(ifid);
63
- setGroups(data);
64
- setLoading(false);
65
- }, [ifid]);
66
-
67
- useEffect(() => {
68
- refresh();
69
- }, [refresh]);
70
-
71
- useEffect(() => {
72
- if (renamingId && renameInputRef.current) {
73
- renameInputRef.current.focus();
74
- renameInputRef.current.select();
75
- }
76
- }, [renamingId]);
77
-
78
- const statusTimer = useRef<number>();
79
- const showStatus = (text: string, type: 'success' | 'error' = 'success') => {
80
- clearTimeout(statusTimer.current);
81
- setStatus({ text, type });
82
- statusTimer.current = window.setTimeout(() => setStatus(null), 3000);
83
- };
84
-
85
- const toggleCollapse = (id: string) => {
86
- setCollapsed((prev) => {
87
- const next = new Set(prev);
88
- if (next.has(id)) next.delete(id);
89
- else next.add(id);
90
- return next;
91
- });
92
- };
93
-
94
- const handleNewSave = async () => {
95
- if (!ifid || !playthroughId) return;
96
- try {
97
- const payload = getSavePayload();
98
- await createSave(ifid, playthroughId, payload);
99
- showStatus('Save created');
100
- await refresh();
101
- } catch {
102
- showStatus('Failed to create save', 'error');
103
- }
104
- };
105
-
106
- const handleOverwrite = async (saveId: string) => {
107
- try {
108
- const payload = getSavePayload();
109
- await overwriteSave(saveId, payload);
110
- showStatus('Save overwritten');
111
- await refresh();
112
- } catch {
113
- showStatus('Failed to overwrite save', 'error');
114
- }
115
- };
116
-
117
- const handleLoad = async (save: SaveRecord) => {
118
- try {
119
- loadFromPayload(save.payload);
120
- showStatus('Game loaded');
121
- setTimeout(onClose, 500);
122
- } catch {
123
- showStatus('Failed to load save', 'error');
124
- }
125
- };
126
-
127
- const handleDelete = async (saveId: string) => {
128
- if (!confirm('Delete this save?')) return;
129
- try {
130
- await deleteSaveById(saveId);
131
- showStatus('Save deleted');
132
- await refresh();
133
- } catch {
134
- showStatus('Failed to delete save', 'error');
135
- }
136
- };
137
-
138
- const handleRenameStart = (save: SaveRecord) => {
139
- setRenamingId(save.meta.id);
140
- setRenameValue(save.meta.title);
141
- };
142
-
143
- const handleRenameConfirm = async () => {
144
- if (!renamingId || !renameValue.trim()) return;
145
- try {
146
- await renameSave(renamingId, renameValue.trim());
147
- setRenamingId(null);
148
- showStatus('Save renamed');
149
- await refresh();
150
- } catch {
151
- showStatus('Failed to rename', 'error');
152
- }
153
- };
154
-
155
- const handleRenameKeyDown = (e: KeyboardEvent) => {
156
- if (e.key === 'Enter') handleRenameConfirm();
157
- else if (e.key === 'Escape') setRenamingId(null);
158
- };
159
-
160
- const handleExport = async (saveId: string) => {
161
- try {
162
- const data = await exportSave(saveId);
163
- if (!data) return;
164
- const blob = new Blob([JSON.stringify(data, null, 2)], {
165
- type: 'application/json',
166
- });
167
- const url = URL.createObjectURL(blob);
168
- const a = document.createElement('a');
169
- a.href = url;
170
- a.download = `save-${data.save.meta.title.replace(/[^a-z0-9]/gi, '_')}.json`;
171
- a.click();
172
- URL.revokeObjectURL(url);
173
- showStatus('Save exported');
174
- } catch {
175
- showStatus('Failed to export save', 'error');
176
- }
177
- };
178
-
179
- const handleImport = () => {
180
- fileInputRef.current?.click();
181
- };
182
-
183
- const handleFileSelected = async (e: Event) => {
184
- const input = e.target as HTMLInputElement;
185
- const file = input.files?.[0];
186
- if (!file) return;
187
- input.value = '';
188
-
189
- try {
190
- const text = await file.text();
191
- const data = JSON.parse(text);
192
- if (!isSaveExport(data)) {
193
- throw new Error('Invalid save file format');
194
- }
195
- await importSave(data, ifid);
196
- showStatus('Save imported');
197
- await refresh();
198
- } catch (err) {
199
- showStatus(
200
- err instanceof Error ? err.message : 'Failed to import save',
201
- 'error',
202
- );
203
- }
204
- };
205
-
206
- const handleBackdrop = (e: MouseEvent) => {
207
- if ((e.target as HTMLElement).classList.contains('saves-overlay')) {
208
- onClose();
209
- }
210
- };
211
-
212
- const totalSaves = groups.reduce((n, g) => n + g.saves.length, 0);
213
-
214
- return (
215
- <div
216
- class="saves-overlay"
217
- onClick={handleBackdrop}
218
- >
219
- <div class="saves-panel">
220
- <div class="saves-header">
221
- <div class="saves-header-left">
222
- <div class="saves-mode-toggle">
223
- <button
224
- class={mode === 'save' ? 'active' : ''}
225
- onClick={() => setMode('save')}
226
- >
227
- Save
228
- </button>
229
- <button
230
- class={mode === 'load' ? 'active' : ''}
231
- onClick={() => setMode('load')}
232
- >
233
- Load
234
- </button>
235
- </div>
236
- </div>
237
- <button
238
- class="saves-close"
239
- onClick={onClose}
240
- >
241
- ✕
242
- </button>
243
- </div>
244
-
245
- <div class="saves-toolbar">
246
- <button
247
- class="saves-toolbar-button"
248
- onClick={handleImport}
249
- >
250
- Import
251
- </button>
252
- <input
253
- ref={fileInputRef}
254
- type="file"
255
- accept=".json"
256
- style="display:none"
257
- onChange={handleFileSelected}
258
- />
259
- </div>
260
-
261
- <div class="saves-body">
262
- {loading ? (
263
- <div class="saves-empty">Loading...</div>
264
- ) : totalSaves === 0 && mode === 'load' ? (
265
- <div class="saves-empty">No saves yet</div>
266
- ) : (
267
- groups.map((group) => {
268
- const isCollapsed = collapsed.has(group.playthrough.id);
269
- const isCurrentPt = group.playthrough.id === playthroughId;
270
-
271
- // Save mode: only show current playthrough
272
- // Load mode: hide empty non-current playthroughs (irrecoverable)
273
- if (mode === 'save' && !isCurrentPt) return null;
274
- if (mode === 'load' && group.saves.length === 0 && !isCurrentPt)
275
- return null;
276
-
277
- return (
278
- <div
279
- class="playthrough-group"
280
- key={group.playthrough.id}
281
- >
282
- <div
283
- class="playthrough-header"
284
- onClick={() => toggleCollapse(group.playthrough.id)}
285
- >
286
- <span
287
- class={`playthrough-chevron ${isCollapsed ? '' : 'open'}`}
288
- >
289
- ▶
290
- </span>
291
- <span class="playthrough-label">
292
- {group.playthrough.label}
293
- {isCurrentPt ? ' (current)' : ''}
294
- </span>
295
- <span class="playthrough-date">
296
- {formatDate(group.playthrough.createdAt)}
297
- </span>
298
- </div>
299
-
300
- {!isCollapsed && (
301
- <div class="playthrough-saves">
302
- {group.saves.map((save) => (
303
- <div
304
- class="save-slot"
305
- key={save.meta.id}
306
- >
307
- <div class="save-slot-info">
308
- {renamingId === save.meta.id ? (
309
- <input
310
- ref={renameInputRef}
311
- class="save-rename-input"
312
- value={renameValue}
313
- onInput={(e) =>
314
- setRenameValue(
315
- (e.target as HTMLInputElement).value,
316
- )
317
- }
318
- onKeyDown={handleRenameKeyDown}
319
- onBlur={handleRenameConfirm}
320
- />
321
- ) : (
322
- <div class="save-slot-title">
323
- {save.meta.title}
324
- </div>
325
- )}
326
- <div class="save-slot-meta">
327
- <span>{save.meta.passage}</span>
328
- <span>{relativeTime(save.meta.updatedAt)}</span>
329
- </div>
330
- </div>
331
- <div class="save-slot-actions">
332
- {mode === 'save' ? (
333
- <button
334
- class="save-slot-action primary"
335
- onClick={() => handleOverwrite(save.meta.id)}
336
- >
337
- Save Here
338
- </button>
339
- ) : (
340
- <button
341
- class="save-slot-action primary"
342
- onClick={() => handleLoad(save)}
343
- >
344
- Load
345
- </button>
346
- )}
347
- <button
348
- class="save-slot-action"
349
- onClick={() => handleRenameStart(save)}
350
- >
351
- Rename
352
- </button>
353
- <button
354
- class="save-slot-action"
355
- onClick={() => handleExport(save.meta.id)}
356
- >
357
- Export
358
- </button>
359
- <button
360
- class="save-slot-action danger"
361
- onClick={() => handleDelete(save.meta.id)}
362
- >
363
- Delete
364
- </button>
365
- </div>
366
- </div>
367
- ))}
368
-
369
- {mode === 'save' && isCurrentPt && (
370
- <button
371
- class="save-slot-new"
372
- onClick={handleNewSave}
373
- >
374
- + New Save
375
- </button>
376
- )}
377
- </div>
378
- )}
379
- </div>
380
- );
381
- })
382
- )}
383
-
384
- {mode === 'save' &&
385
- !loading &&
386
- !groups.some((g) => g.playthrough.id === playthroughId) && (
387
- <div class="playthrough-group">
388
- <div class="playthrough-saves">
389
- <button
390
- class="save-slot-new"
391
- onClick={handleNewSave}
392
- >
393
- + New Save
394
- </button>
395
- </div>
396
- </div>
397
- )}
398
- </div>
399
-
400
- {status && (
401
- <div class={`saves-status ${status.type === 'error' ? 'error' : ''}`}>
402
- {status.text}
403
- </div>
404
- )}
405
- </div>
406
- </div>
407
- );
408
- }
@@ -1,32 +0,0 @@
1
- import { useStoryStore } from '../../store';
2
- import { useAction } from '../../hooks/use-action';
3
- import { registerMacro } from '../../registry';
4
- import type { MacroProps } from '../../registry';
5
-
6
- export function Back({ className, id }: MacroProps) {
7
- const goBack = useStoryStore((s) => s.goBack);
8
- const canGoBack = useStoryStore((s) => s.historyIndex > 0);
9
- const cls = className ? `menubar-button ${className}` : 'menubar-button';
10
-
11
- useAction({
12
- type: 'back',
13
- key: 'back',
14
- authorId: id,
15
- label: 'Back',
16
- disabled: !canGoBack,
17
- perform: () => goBack(),
18
- });
19
-
20
- return (
21
- <button
22
- id={id}
23
- class={cls}
24
- onClick={goBack}
25
- disabled={!canGoBack}
26
- >
27
- ← Back
28
- </button>
29
- );
30
- }
31
-
32
- registerMacro('back', Back);
@@ -1,34 +0,0 @@
1
- import { useStoryStore } from '../../store';
2
- import { useAction } from '../../hooks/use-action';
3
- import { registerMacro } from '../../registry';
4
- import type { MacroProps } from '../../registry';
5
-
6
- export function Forward({ className, id }: MacroProps) {
7
- const goForward = useStoryStore((s) => s.goForward);
8
- const canGoForward = useStoryStore(
9
- (s) => s.historyIndex < s.history.length - 1,
10
- );
11
- const cls = className ? `menubar-button ${className}` : 'menubar-button';
12
-
13
- useAction({
14
- type: 'forward',
15
- key: 'forward',
16
- authorId: id,
17
- label: 'Forward',
18
- disabled: !canGoForward,
19
- perform: () => goForward(),
20
- });
21
-
22
- return (
23
- <button
24
- id={id}
25
- class={cls}
26
- onClick={goForward}
27
- disabled={!canGoForward}
28
- >
29
- Forward →
30
- </button>
31
- );
32
- }
33
-
34
- registerMacro('forward', Forward);