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.
- package/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/{index-B78L-jft.js → index-CbEryCNT.js} +30 -27
- package/bin/adapter/entry-points/console/ui-dist/assets/{index-BbUBeonj.css → index-U8I8ESZj.css} +1 -1
- package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
- package/bin/domain/entities/WorkflowStatus.js +5 -1
- package/bin/domain/entities/WorkflowStatus.js.map +1 -1
- package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +3 -2
- package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagram.test.tsx +37 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagram.tsx +20 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagramModalScreen.stories.tsx +27 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagramModalScreen.test.tsx +85 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagramModalScreen.tsx +97 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.test.tsx +225 -59
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.tsx +46 -1
- package/src/adapter/entry-points/console/ui/src/features/console/lib/mermaidZoom.test.ts +88 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/mermaidZoom.ts +34 -0
- package/src/adapter/entry-points/console/ui/src/index.css +65 -0
- package/src/adapter/entry-points/console/ui-dist/assets/{index-B78L-jft.js → index-CbEryCNT.js} +30 -27
- package/src/adapter/entry-points/console/ui-dist/assets/{index-BbUBeonj.css → index-U8I8ESZj.css} +1 -1
- package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
- package/src/domain/entities/WorkflowStatus.ts +5 -0
- package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +31 -0
- package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +8 -2
- package/types/domain/entities/WorkflowStatus.d.ts +1 -0
- package/types/domain/entities/WorkflowStatus.d.ts.map +1 -1
- package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts +1 -0
- 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('
|
|
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
|
-
|
|
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={
|
|
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\n',
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
resolveUpload('');
|
|
123
|
+
await waitFor(() => {
|
|
124
|
+
expect(textarea.value).toBe(
|
|
125
|
+
'See the screen:\n\n\n\n\n',
|
|
139
126
|
);
|
|
140
127
|
});
|
|
141
128
|
expect(onUploadFile).toHaveBeenCalledWith(file);
|
|
142
129
|
});
|
|
143
130
|
|
|
144
|
-
it('
|
|
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('');
|
|
196
|
+
await waitFor(() => {
|
|
197
|
+
expect(textarea.value).toBe(
|
|
198
|
+
'The fix works:\n\n\n\n\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 () => '',
|
|
147
206
|
);
|
|
148
207
|
const { getByPlaceholderText } = render(
|
|
149
208
|
<ConsoleCommentComposer
|
|
150
209
|
initiallyOpen
|
|
151
|
-
onSubmit={
|
|
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
|
-
'\n',
|
|
221
|
+
'\n\n\n\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 () => '',
|
|
175
230
|
);
|
|
176
231
|
const { getByPlaceholderText } = render(
|
|
177
232
|
<ConsoleCommentComposer
|
|
178
233
|
initiallyOpen
|
|
179
|
-
onSubmit={
|
|
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
|
-
'\n',
|
|
245
|
+
'\n\n\n\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={
|
|
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
|
+
'',
|
|
337
|
+
),
|
|
338
|
+
).toBe(
|
|
339
|
+
'See the screen:\n\n\n\n\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', ''),
|
|
348
|
+
).toBe('text\n\n\n\n\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', ''),
|
|
354
|
+
).toBe('text\n\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('', '')).toBe('\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) =>
|
|
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;
|