github-issue-tower-defence-management 1.159.0 → 1.161.0

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 (30) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/bin/adapter/entry-points/console/ui-dist/assets/{index-B78L-jft.js → index-CbEryCNT.js} +30 -27
  4. package/bin/adapter/entry-points/console/ui-dist/assets/{index-BbUBeonj.css → index-U8I8ESZj.css} +1 -1
  5. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  6. package/bin/domain/entities/WorkflowStatus.js +5 -1
  7. package/bin/domain/entities/WorkflowStatus.js.map +1 -1
  8. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +3 -2
  9. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagram.test.tsx +37 -1
  12. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagram.tsx +20 -1
  13. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagramModalScreen.stories.tsx +27 -0
  14. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagramModalScreen.test.tsx +85 -0
  15. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagramModalScreen.tsx +97 -0
  16. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.test.tsx +225 -59
  17. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.tsx +46 -1
  18. package/src/adapter/entry-points/console/ui/src/features/console/lib/mermaidZoom.test.ts +88 -0
  19. package/src/adapter/entry-points/console/ui/src/features/console/lib/mermaidZoom.ts +34 -0
  20. package/src/adapter/entry-points/console/ui/src/index.css +65 -0
  21. package/src/adapter/entry-points/console/ui-dist/assets/{index-B78L-jft.js → index-CbEryCNT.js} +30 -27
  22. package/src/adapter/entry-points/console/ui-dist/assets/{index-BbUBeonj.css → index-U8I8ESZj.css} +1 -1
  23. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  24. package/src/domain/entities/WorkflowStatus.ts +5 -0
  25. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +31 -0
  26. package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +8 -2
  27. package/types/domain/entities/WorkflowStatus.d.ts +1 -0
  28. package/types/domain/entities/WorkflowStatus.d.ts.map +1 -1
  29. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts +1 -0
  30. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
@@ -3,37 +3,32 @@ import type { ConsoleComment } from '../../logic/types';
3
3
  import {
4
4
  appendAttachmentMarkdown,
5
5
  ConsoleCommentComposer,
6
+ insertUploadPlaceholder,
7
+ removePlaceholder,
8
+ replacePlaceholderWithMarkdown,
6
9
  } from './ConsoleCommentComposer';
7
10
 
8
11
  jest.mock('../../lib/mermaidLoader', () => ({
9
12
  renderMermaidToSvg: jest.fn(async () => '<svg></svg>'),
10
13
  }));
11
14
 
15
+ const stubSubmit = async (body: string): Promise<ConsoleComment> => ({
16
+ author: 'HiromiShikata',
17
+ body,
18
+ createdAt: '2026-06-19T11:58:00.000Z',
19
+ });
20
+
12
21
  describe('ConsoleCommentComposer', () => {
13
22
  it('shows the form when it starts open', () => {
14
23
  const { getByPlaceholderText } = render(
15
- <ConsoleCommentComposer
16
- initiallyOpen
17
- onSubmit={async (body) => ({
18
- author: 'HiromiShikata',
19
- body,
20
- createdAt: '2026-06-19T11:58:00.000Z',
21
- })}
22
- />,
24
+ <ConsoleCommentComposer initiallyOpen onSubmit={stubSubmit} />,
23
25
  );
24
26
  expect(getByPlaceholderText('Leave a comment…')).toBeInTheDocument();
25
27
  });
26
28
 
27
29
  it('offers the opening control and hides the form when it starts closed', () => {
28
30
  const { queryByPlaceholderText, getByText } = render(
29
- <ConsoleCommentComposer
30
- initiallyOpen={false}
31
- onSubmit={async (body) => ({
32
- author: 'HiromiShikata',
33
- body,
34
- createdAt: '2026-06-19T11:58:00.000Z',
35
- })}
36
- />,
31
+ <ConsoleCommentComposer initiallyOpen={false} onSubmit={stubSubmit} />,
37
32
  );
38
33
  expect(queryByPlaceholderText('Leave a comment…')).toBeNull();
39
34
  expect(getByText('💬 Add a comment')).toBeInTheDocument();
@@ -41,14 +36,7 @@ describe('ConsoleCommentComposer', () => {
41
36
 
42
37
  it('opens the form on the control and closes it again so the body regains the height', () => {
43
38
  const { getByText, getByPlaceholderText, queryByPlaceholderText } = render(
44
- <ConsoleCommentComposer
45
- initiallyOpen={false}
46
- onSubmit={async (body) => ({
47
- author: 'HiromiShikata',
48
- body,
49
- createdAt: '2026-06-19T11:58:00.000Z',
50
- })}
51
- />,
39
+ <ConsoleCommentComposer initiallyOpen={false} onSubmit={stubSubmit} />,
52
40
  );
53
41
  fireEvent.click(getByText('💬 Add a comment'));
54
42
  expect(getByPlaceholderText('Leave a comment…')).toBeInTheDocument();
@@ -98,30 +86,23 @@ describe('ConsoleCommentComposer', () => {
98
86
 
99
87
  it('does not render the attach control when no upload handler is given', () => {
100
88
  const { queryByText } = render(
101
- <ConsoleCommentComposer
102
- initiallyOpen
103
- onSubmit={async (body) => ({
104
- author: 'HiromiShikata',
105
- body,
106
- createdAt: '2026-06-19T11:58:00.000Z',
107
- })}
108
- />,
89
+ <ConsoleCommentComposer initiallyOpen onSubmit={stubSubmit} />,
109
90
  );
110
91
  expect(queryByText('📎 Attach files')).toBeNull();
111
92
  });
112
93
 
113
- it('uploads a selected file and inserts the returned markdown into the draft', async () => {
94
+ it('inserts a placeholder when a file upload starts and replaces it on success', async () => {
95
+ let resolveUpload!: (markdown: string) => void;
114
96
  const onUploadFile = jest.fn(
115
- async () => '![shot](https://github.com/user-attachments/assets/abc)',
97
+ () =>
98
+ new Promise<string>((resolve) => {
99
+ resolveUpload = resolve;
100
+ }),
116
101
  );
117
102
  const { getByPlaceholderText, getByLabelText } = render(
118
103
  <ConsoleCommentComposer
119
104
  initiallyOpen
120
- onSubmit={async (body) => ({
121
- author: 'HiromiShikata',
122
- body,
123
- createdAt: '2026-06-19T11:58:00.000Z',
124
- })}
105
+ onSubmit={stubSubmit}
125
106
  onUploadFile={onUploadFile}
126
107
  />,
127
108
  );
@@ -135,24 +116,98 @@ describe('ConsoleCommentComposer', () => {
135
116
  });
136
117
  await waitFor(() => {
137
118
  expect(textarea.value).toBe(
138
- 'See the screen:\n![shot](https://github.com/user-attachments/assets/abc)\n',
119
+ 'See the screen:\n\n\n\n![uploading shot.png]()\n',
120
+ );
121
+ });
122
+ resolveUpload('![shot](https://github.com/user-attachments/assets/abc)');
123
+ await waitFor(() => {
124
+ expect(textarea.value).toBe(
125
+ 'See the screen:\n\n\n\n![shot](https://github.com/user-attachments/assets/abc)\n',
139
126
  );
140
127
  });
141
128
  expect(onUploadFile).toHaveBeenCalledWith(file);
142
129
  });
143
130
 
144
- it('uploads a pasted file', async () => {
131
+ it('removes the placeholder and the three empty lines when an upload fails', async () => {
132
+ let rejectUpload!: (error: Error) => void;
133
+ const onUploadFile = jest.fn(
134
+ () =>
135
+ new Promise<string>((_, reject) => {
136
+ rejectUpload = reject;
137
+ }),
138
+ );
139
+ const { getByPlaceholderText, getByLabelText, findByRole } = render(
140
+ <ConsoleCommentComposer
141
+ initiallyOpen
142
+ onSubmit={stubSubmit}
143
+ onUploadFile={onUploadFile}
144
+ />,
145
+ );
146
+ const textarea = getByPlaceholderText(
147
+ 'Leave a comment…',
148
+ ) as HTMLTextAreaElement;
149
+ fireEvent.change(textarea, { target: { value: 'See the screen:' } });
150
+ const file = new File(['binary'], 'shot.png', { type: 'image/png' });
151
+ fireEvent.change(getByLabelText('Attach files'), {
152
+ target: { files: [file] },
153
+ });
154
+ await waitFor(() => {
155
+ expect(textarea.value).toContain('![uploading shot.png]()');
156
+ });
157
+ rejectUpload(new Error('No GitHub web session is available'));
158
+ await waitFor(() => {
159
+ expect(textarea.value).toBe('See the screen:\n');
160
+ });
161
+ const alert = await findByRole('alert');
162
+ expect(alert.textContent).toContain('No GitHub web session is available');
163
+ });
164
+
165
+ it('preserves text typed in the draft while an upload is in flight', async () => {
166
+ let resolveUpload!: (markdown: string) => void;
167
+ const onUploadFile = jest.fn(
168
+ () =>
169
+ new Promise<string>((resolve) => {
170
+ resolveUpload = resolve;
171
+ }),
172
+ );
173
+ const { getByPlaceholderText, getByLabelText } = render(
174
+ <ConsoleCommentComposer
175
+ initiallyOpen
176
+ onSubmit={stubSubmit}
177
+ onUploadFile={onUploadFile}
178
+ />,
179
+ );
180
+ const textarea = getByPlaceholderText(
181
+ 'Leave a comment…',
182
+ ) as HTMLTextAreaElement;
183
+ const file = new File(['binary'], 'shot.png', { type: 'image/png' });
184
+ fireEvent.change(getByLabelText('Attach files'), {
185
+ target: { files: [file] },
186
+ });
187
+ await waitFor(() => {
188
+ expect(textarea.value).toContain('![uploading shot.png]()');
189
+ });
190
+ fireEvent.change(textarea, {
191
+ target: {
192
+ value: 'The fix works:\n\n\n\n![uploading shot.png]()\n',
193
+ },
194
+ });
195
+ resolveUpload('![shot](https://github.com/user-attachments/assets/abc)');
196
+ await waitFor(() => {
197
+ expect(textarea.value).toBe(
198
+ 'The fix works:\n\n\n\n![shot](https://github.com/user-attachments/assets/abc)\n',
199
+ );
200
+ });
201
+ });
202
+
203
+ it('uploads a pasted file and reserves position with a placeholder', async () => {
145
204
  const onUploadFile = jest.fn(
146
205
  async () => '![pasted](https://github.com/user-attachments/assets/def)',
147
206
  );
148
207
  const { getByPlaceholderText } = render(
149
208
  <ConsoleCommentComposer
150
209
  initiallyOpen
151
- onSubmit={async (body) => ({
152
- author: 'HiromiShikata',
153
- body,
154
- createdAt: '2026-06-19T11:58:00.000Z',
155
- })}
210
+ onSubmit={stubSubmit}
156
211
  onUploadFile={onUploadFile}
157
212
  />,
158
213
  );
@@ -163,24 +218,20 @@ describe('ConsoleCommentComposer', () => {
163
218
  fireEvent.paste(textarea, { clipboardData: { files: [file] } });
164
219
  await waitFor(() => {
165
220
  expect(textarea.value).toBe(
166
- '![pasted](https://github.com/user-attachments/assets/def)\n',
221
+ '\n\n\n![pasted](https://github.com/user-attachments/assets/def)\n',
167
222
  );
168
223
  });
169
224
  expect(onUploadFile).toHaveBeenCalledWith(file);
170
225
  });
171
226
 
172
- it('uploads a dropped file', async () => {
227
+ it('uploads a dropped file and reserves position with a placeholder', async () => {
173
228
  const onUploadFile = jest.fn(
174
229
  async () => '![dropped](https://github.com/user-attachments/assets/ghi)',
175
230
  );
176
231
  const { getByPlaceholderText } = render(
177
232
  <ConsoleCommentComposer
178
233
  initiallyOpen
179
- onSubmit={async (body) => ({
180
- author: 'HiromiShikata',
181
- body,
182
- createdAt: '2026-06-19T11:58:00.000Z',
183
- })}
234
+ onSubmit={stubSubmit}
184
235
  onUploadFile={onUploadFile}
185
236
  />,
186
237
  );
@@ -191,21 +242,51 @@ describe('ConsoleCommentComposer', () => {
191
242
  fireEvent.drop(textarea, { dataTransfer: { files: [file] } });
192
243
  await waitFor(() => {
193
244
  expect(textarea.value).toBe(
194
- '![dropped](https://github.com/user-attachments/assets/ghi)\n',
245
+ '\n\n\n![dropped](https://github.com/user-attachments/assets/ghi)\n',
195
246
  );
196
247
  });
197
248
  expect(onUploadFile).toHaveBeenCalledWith(file);
198
249
  });
199
250
 
251
+ it('removes the placeholder when text was typed into a reserved empty line and the upload fails', async () => {
252
+ let rejectUpload!: (error: Error) => void;
253
+ const onUploadFile = jest.fn(
254
+ () =>
255
+ new Promise<string>((_, reject) => {
256
+ rejectUpload = reject;
257
+ }),
258
+ );
259
+ const { getByPlaceholderText, getByLabelText } = render(
260
+ <ConsoleCommentComposer
261
+ initiallyOpen
262
+ onSubmit={stubSubmit}
263
+ onUploadFile={onUploadFile}
264
+ />,
265
+ );
266
+ const textarea = getByPlaceholderText(
267
+ 'Leave a comment…',
268
+ ) as HTMLTextAreaElement;
269
+ const file = new File(['binary'], 'shot.png', { type: 'image/png' });
270
+ fireEvent.change(getByLabelText('Attach files'), {
271
+ target: { files: [file] },
272
+ });
273
+ await waitFor(() => {
274
+ expect(textarea.value).toContain('![uploading shot.png]()');
275
+ });
276
+ fireEvent.change(textarea, {
277
+ target: { value: '\n見てください\n\n![uploading shot.png]()\n' },
278
+ });
279
+ rejectUpload(new Error('network error'));
280
+ await waitFor(() => {
281
+ expect(textarea.value).not.toContain('![uploading shot.png]()');
282
+ });
283
+ });
284
+
200
285
  it('shows the upload failure reason when the upload rejects', async () => {
201
286
  const { getByLabelText, findByRole } = render(
202
287
  <ConsoleCommentComposer
203
288
  initiallyOpen
204
- onSubmit={async (body) => ({
205
- author: 'HiromiShikata',
206
- body,
207
- createdAt: '2026-06-19T11:58:00.000Z',
208
- })}
289
+ onSubmit={stubSubmit}
209
290
  onUploadFile={async () => {
210
291
  throw new Error('No GitHub web session is available');
211
292
  }}
@@ -219,6 +300,91 @@ describe('ConsoleCommentComposer', () => {
219
300
  });
220
301
  });
221
302
 
303
+ describe('insertUploadPlaceholder', () => {
304
+ it('writes the placeholder with three empty lines when the draft is empty', () => {
305
+ expect(insertUploadPlaceholder('', 'shot.png')).toBe(
306
+ '\n\n\n![uploading shot.png]()\n',
307
+ );
308
+ });
309
+
310
+ it('normalizes the draft to end with one newline then adds three empty lines before the placeholder', () => {
311
+ expect(insertUploadPlaceholder('See the screen:', 'shot.png')).toBe(
312
+ 'See the screen:\n\n\n\n![uploading shot.png]()\n',
313
+ );
314
+ });
315
+
316
+ it('does not double the trailing newline when the draft already ends with one', () => {
317
+ expect(insertUploadPlaceholder('See the screen:\n', 'shot.png')).toBe(
318
+ 'See the screen:\n\n\n\n![uploading shot.png]()\n',
319
+ );
320
+ });
321
+
322
+ it('strips multiple trailing newlines before normalizing', () => {
323
+ expect(insertUploadPlaceholder('See the screen:\n\n', 'shot.png')).toBe(
324
+ 'See the screen:\n\n\n\n![uploading shot.png]()\n',
325
+ );
326
+ });
327
+ });
328
+
329
+ describe('replacePlaceholderWithMarkdown', () => {
330
+ it('replaces the placeholder line with the returned markdown', () => {
331
+ const draft = 'See the screen:\n\n\n\n![uploading shot.png]()\n';
332
+ expect(
333
+ replacePlaceholderWithMarkdown(
334
+ draft,
335
+ 'shot.png',
336
+ '![shot](https://github.com/user-attachments/assets/abc)',
337
+ ),
338
+ ).toBe(
339
+ 'See the screen:\n\n\n\n![shot](https://github.com/user-attachments/assets/abc)\n',
340
+ );
341
+ });
342
+
343
+ it('replaces only the first occurrence when two uploads share the same file name', () => {
344
+ const draft =
345
+ 'text\n\n\n\n![uploading file.png]()\n\n\n\n![uploading file.png]()\n';
346
+ expect(
347
+ replacePlaceholderWithMarkdown(draft, 'file.png', '![img](url)'),
348
+ ).toBe('text\n\n\n\n![img](url)\n\n\n\n![uploading file.png]()\n');
349
+ });
350
+
351
+ it('falls back to appending when the placeholder is absent', () => {
352
+ expect(
353
+ replacePlaceholderWithMarkdown('text\n', 'shot.png', '![shot](url)'),
354
+ ).toBe('text\n![shot](url)\n');
355
+ });
356
+ });
357
+
358
+ describe('removePlaceholder', () => {
359
+ it('removes the placeholder and the three empty lines added with it', () => {
360
+ const draft = 'See the screen:\n\n\n\n![uploading shot.png]()\n';
361
+ expect(removePlaceholder(draft, 'shot.png')).toBe('See the screen:\n');
362
+ });
363
+
364
+ it('leaves the draft empty when the placeholder was inserted into an empty draft', () => {
365
+ const draft = '\n\n\n![uploading shot.png]()\n';
366
+ expect(removePlaceholder(draft, 'shot.png')).toBe('');
367
+ });
368
+
369
+ it('returns the draft unchanged when the placeholder is absent', () => {
370
+ expect(removePlaceholder('text\n', 'shot.png')).toBe('text\n');
371
+ });
372
+
373
+ it('removes the placeholder when text was typed into the second reserved empty line', () => {
374
+ const draft = '\n見てください\n\n![uploading shot.png]()\n';
375
+ expect(removePlaceholder(draft, 'shot.png')).not.toContain(
376
+ '![uploading shot.png]()',
377
+ );
378
+ });
379
+
380
+ it('removes the placeholder when text was typed into the third reserved empty line', () => {
381
+ const draft = '\n\n見てください\n![uploading shot.png]()\n';
382
+ expect(removePlaceholder(draft, 'shot.png')).not.toContain(
383
+ '![uploading shot.png]()',
384
+ );
385
+ });
386
+ });
387
+
222
388
  describe('appendAttachmentMarkdown', () => {
223
389
  it('returns the markdown alone when the draft is empty', () => {
224
390
  expect(appendAttachmentMarkdown('', '![a](b)')).toBe('![a](b)\n');
@@ -30,6 +30,47 @@ export const appendAttachmentMarkdown = (
30
30
  return `${draft}\n${markdown}\n`;
31
31
  };
32
32
 
33
+ export const insertUploadPlaceholder = (
34
+ draft: string,
35
+ fileName: string,
36
+ ): string => {
37
+ const placeholder = `![uploading ${fileName}]()`;
38
+ const prefix = draft.length === 0 ? '' : draft.replace(/\n*$/, '\n');
39
+ return `${prefix}\n\n\n${placeholder}\n`;
40
+ };
41
+
42
+ export const replacePlaceholderWithMarkdown = (
43
+ draft: string,
44
+ fileName: string,
45
+ markdown: string,
46
+ ): string => {
47
+ const placeholder = `![uploading ${fileName}]()`;
48
+ const index = draft.indexOf(placeholder);
49
+ if (index === -1) {
50
+ return appendAttachmentMarkdown(draft, markdown);
51
+ }
52
+ return (
53
+ draft.slice(0, index) + markdown + draft.slice(index + placeholder.length)
54
+ );
55
+ };
56
+
57
+ export const removePlaceholder = (draft: string, fileName: string): string => {
58
+ const placeholder = `![uploading ${fileName}]()`;
59
+ const index = draft.indexOf(placeholder);
60
+ if (index === -1) {
61
+ return draft;
62
+ }
63
+ let start = index;
64
+ let newlinesRemoved = 0;
65
+ while (newlinesRemoved < 3 && start > 0 && draft[start - 1] === '\n') {
66
+ start--;
67
+ newlinesRemoved++;
68
+ }
69
+ const trailingNewline = draft[index + placeholder.length] === '\n' ? 1 : 0;
70
+ const end = index + placeholder.length + trailingNewline;
71
+ return draft.slice(0, start) + draft.slice(end);
72
+ };
73
+
33
74
  export const ConsoleCommentComposer = ({
34
75
  initiallyOpen,
35
76
  onSubmit,
@@ -66,12 +107,16 @@ export const ConsoleCommentComposer = ({
66
107
  return;
67
108
  }
68
109
  for (const file of files) {
110
+ setDraft((previous) => insertUploadPlaceholder(previous, file.name));
69
111
  setUploadStatus({ kind: 'uploading', fileName: file.name });
70
112
  try {
71
113
  const markdown = await onUploadFile(file);
72
- setDraft((previous) => appendAttachmentMarkdown(previous, markdown));
114
+ setDraft((previous) =>
115
+ replacePlaceholderWithMarkdown(previous, file.name, markdown),
116
+ );
73
117
  setUploadStatus({ kind: 'idle' });
74
118
  } catch (error) {
119
+ setDraft((previous) => removePlaceholder(previous, file.name));
75
120
  setUploadStatus({
76
121
  kind: 'error',
77
122
  message:
@@ -0,0 +1,88 @@
1
+ import {
2
+ clampMermaidZoomScale,
3
+ formatMermaidZoomScale,
4
+ isMermaidZoomScaleAtMaximum,
5
+ isMermaidZoomScaleAtMinimum,
6
+ MERMAID_ZOOM_MAX_SCALE,
7
+ MERMAID_ZOOM_MIN_SCALE,
8
+ MERMAID_ZOOM_SCALE_STEP,
9
+ mermaidZoomCanvasWidth,
10
+ mermaidZoomScaleIn,
11
+ mermaidZoomScaleOut,
12
+ } from './mermaidZoom';
13
+
14
+ describe('clampMermaidZoomScale', () => {
15
+ it('keeps a scale inside the range untouched', () => {
16
+ expect(clampMermaidZoomScale(2.5)).toBe(2.5);
17
+ });
18
+
19
+ it('raises a scale below the minimum to the minimum', () => {
20
+ expect(clampMermaidZoomScale(0.2)).toBe(MERMAID_ZOOM_MIN_SCALE);
21
+ });
22
+
23
+ it('lowers a scale above the maximum to the maximum', () => {
24
+ expect(clampMermaidZoomScale(12)).toBe(MERMAID_ZOOM_MAX_SCALE);
25
+ });
26
+
27
+ it('falls back to the minimum for a value that is not a number', () => {
28
+ expect(clampMermaidZoomScale(Number.NaN)).toBe(MERMAID_ZOOM_MIN_SCALE);
29
+ });
30
+ });
31
+
32
+ describe('mermaidZoomScaleIn', () => {
33
+ it('adds one step', () => {
34
+ expect(mermaidZoomScaleIn(MERMAID_ZOOM_MIN_SCALE)).toBe(
35
+ MERMAID_ZOOM_MIN_SCALE + MERMAID_ZOOM_SCALE_STEP,
36
+ );
37
+ });
38
+
39
+ it('stops at the maximum', () => {
40
+ expect(mermaidZoomScaleIn(MERMAID_ZOOM_MAX_SCALE)).toBe(
41
+ MERMAID_ZOOM_MAX_SCALE,
42
+ );
43
+ });
44
+ });
45
+
46
+ describe('mermaidZoomScaleOut', () => {
47
+ it('subtracts one step', () => {
48
+ expect(mermaidZoomScaleOut(3)).toBe(3 - MERMAID_ZOOM_SCALE_STEP);
49
+ });
50
+
51
+ it('stops at the minimum', () => {
52
+ expect(mermaidZoomScaleOut(MERMAID_ZOOM_MIN_SCALE)).toBe(
53
+ MERMAID_ZOOM_MIN_SCALE,
54
+ );
55
+ });
56
+ });
57
+
58
+ describe('zoom boundary reporting', () => {
59
+ it('reports the minimum boundary', () => {
60
+ expect(isMermaidZoomScaleAtMinimum(MERMAID_ZOOM_MIN_SCALE)).toBe(true);
61
+ expect(isMermaidZoomScaleAtMinimum(MERMAID_ZOOM_MIN_SCALE + 0.5)).toBe(
62
+ false,
63
+ );
64
+ });
65
+
66
+ it('reports the maximum boundary', () => {
67
+ expect(isMermaidZoomScaleAtMaximum(MERMAID_ZOOM_MAX_SCALE)).toBe(true);
68
+ expect(isMermaidZoomScaleAtMaximum(MERMAID_ZOOM_MAX_SCALE - 0.5)).toBe(
69
+ false,
70
+ );
71
+ });
72
+ });
73
+
74
+ describe('presentation helpers', () => {
75
+ it('formats the scale as a percentage', () => {
76
+ expect(formatMermaidZoomScale(1)).toBe('100%');
77
+ expect(formatMermaidZoomScale(2.5)).toBe('250%');
78
+ });
79
+
80
+ it('derives the canvas width from the scale', () => {
81
+ expect(mermaidZoomCanvasWidth(1)).toBe('100%');
82
+ expect(mermaidZoomCanvasWidth(3)).toBe('300%');
83
+ });
84
+
85
+ it('derives the canvas width from a clamped scale', () => {
86
+ expect(mermaidZoomCanvasWidth(99)).toBe(`${MERMAID_ZOOM_MAX_SCALE * 100}%`);
87
+ });
88
+ });
@@ -0,0 +1,34 @@
1
+ export const MERMAID_ZOOM_MIN_SCALE = 1;
2
+ export const MERMAID_ZOOM_MAX_SCALE = 6;
3
+ export const MERMAID_ZOOM_SCALE_STEP = 0.5;
4
+
5
+ export const clampMermaidZoomScale = (scale: number): number => {
6
+ if (Number.isNaN(scale)) {
7
+ return MERMAID_ZOOM_MIN_SCALE;
8
+ }
9
+ if (scale < MERMAID_ZOOM_MIN_SCALE) {
10
+ return MERMAID_ZOOM_MIN_SCALE;
11
+ }
12
+ if (scale > MERMAID_ZOOM_MAX_SCALE) {
13
+ return MERMAID_ZOOM_MAX_SCALE;
14
+ }
15
+ return scale;
16
+ };
17
+
18
+ export const mermaidZoomScaleIn = (scale: number): number =>
19
+ clampMermaidZoomScale(clampMermaidZoomScale(scale) + MERMAID_ZOOM_SCALE_STEP);
20
+
21
+ export const mermaidZoomScaleOut = (scale: number): number =>
22
+ clampMermaidZoomScale(clampMermaidZoomScale(scale) - MERMAID_ZOOM_SCALE_STEP);
23
+
24
+ export const isMermaidZoomScaleAtMinimum = (scale: number): boolean =>
25
+ clampMermaidZoomScale(scale) <= MERMAID_ZOOM_MIN_SCALE;
26
+
27
+ export const isMermaidZoomScaleAtMaximum = (scale: number): boolean =>
28
+ clampMermaidZoomScale(scale) >= MERMAID_ZOOM_MAX_SCALE;
29
+
30
+ export const formatMermaidZoomScale = (scale: number): string =>
31
+ `${Math.round(clampMermaidZoomScale(scale) * 100)}%`;
32
+
33
+ export const mermaidZoomCanvasWidth = (scale: number): string =>
34
+ `${clampMermaidZoomScale(scale) * 100}%`;
@@ -610,6 +610,71 @@ body {
610
610
  font-size: 0.8125rem;
611
611
  }
612
612
 
613
+ .console-mermaid-enlarge {
614
+ display: block;
615
+ width: 100%;
616
+ margin-top: 6px;
617
+ padding: 10px 12px;
618
+ background: #21262d;
619
+ color: #e6edf3;
620
+ border: 1px solid #30363d;
621
+ border-radius: 6px;
622
+ font-size: 0.8125rem;
623
+ }
624
+
625
+ .console-mermaid-modal {
626
+ position: fixed;
627
+ inset: 0;
628
+ z-index: 1000;
629
+ display: flex;
630
+ flex-direction: column;
631
+ background: #0d1117;
632
+ }
633
+
634
+ .console-mermaid-modal-bar {
635
+ display: flex;
636
+ align-items: center;
637
+ gap: 8px;
638
+ padding: 8px 12px;
639
+ border-bottom: 1px solid #30363d;
640
+ }
641
+
642
+ .console-mermaid-modal-button,
643
+ .console-mermaid-modal-close {
644
+ min-width: 44px;
645
+ min-height: 44px;
646
+ background: #21262d;
647
+ color: #e6edf3;
648
+ border: 1px solid #30363d;
649
+ border-radius: 6px;
650
+ font-size: 1.125rem;
651
+ }
652
+
653
+ .console-mermaid-modal-button:disabled {
654
+ opacity: 0.4;
655
+ }
656
+
657
+ .console-mermaid-modal-close {
658
+ margin-left: auto;
659
+ }
660
+
661
+ .console-mermaid-modal-scale {
662
+ min-width: 4.5rem;
663
+ text-align: center;
664
+ color: #8b949e;
665
+ font-size: 0.875rem;
666
+ }
667
+
668
+ .console-mermaid-modal-viewport {
669
+ flex: 1;
670
+ overflow: auto;
671
+ -webkit-overflow-scrolling: touch;
672
+ }
673
+
674
+ .console-mermaid-modal-canvas {
675
+ min-width: 100%;
676
+ }
677
+
613
678
  .console-comment {
614
679
  padding: 12px 16px;
615
680
  margin-bottom: 10px;