@skillcdn/console 0.1.19 → 0.1.20
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/README.md +1 -1
- package/dist/cli/help.js +2 -2
- package/dist/cli/help.js.map +1 -1
- package/dist/components/board.d.ts +18 -8
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +180 -27
- package/dist/components/board.js.map +1 -1
- package/dist/components/comments.d.ts +0 -2
- package/dist/components/comments.d.ts.map +1 -1
- package/dist/components/comments.js +4 -39
- package/dist/components/comments.js.map +1 -1
- package/dist/components/documents.d.ts +2 -0
- package/dist/components/documents.d.ts.map +1 -1
- package/dist/components/documents.js +51 -5
- package/dist/components/documents.js.map +1 -1
- package/dist/components/files.d.ts +11 -0
- package/dist/components/files.d.ts.map +1 -0
- package/dist/components/files.js +44 -0
- package/dist/components/files.js.map +1 -0
- package/dist/components/runs.d.ts.map +1 -1
- package/dist/components/runs.js +34 -7
- package/dist/components/runs.js.map +1 -1
- package/dist/console.css +96 -74
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +3 -3
- package/dist/console.js.map +1 -1
- package/dist/docs-page.d.ts.map +1 -1
- package/dist/docs-page.js +14 -1
- package/dist/docs-page.js.map +1 -1
- package/dist/i18n/en.d.ts +6 -3
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +6 -3
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +6 -3
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/docs/specs/cli.md +2 -2
- package/docs/specs/rest.md +2 -2
- package/package.json +1 -1
- package/skills/working-the-board/SKILL.md +3 -3
- package/src/cli/help.ts +2 -2
- package/src/components/board.tsx +300 -73
- package/src/components/comments.tsx +10 -39
- package/src/components/documents.tsx +87 -4
- package/src/components/files.ts +49 -0
- package/src/components/runs.tsx +73 -15
- package/src/console.tsx +12 -6
- package/src/docs-page.tsx +18 -1
- package/src/i18n/en.ts +7 -3
- package/src/i18n/ko.ts +7 -3
- package/src/index.ts +1 -1
- package/src/styles/console.css +96 -74
package/src/components/board.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
2
3
|
type ReactNode,
|
|
3
4
|
type PointerEvent as ReactPointerEvent,
|
|
4
5
|
useCallback,
|
|
@@ -6,6 +7,7 @@ import {
|
|
|
6
7
|
useRef,
|
|
7
8
|
useState,
|
|
8
9
|
} from "react";
|
|
10
|
+
import { createPortal } from "react-dom";
|
|
9
11
|
import {
|
|
10
12
|
type RestCategory,
|
|
11
13
|
type RestGoal,
|
|
@@ -16,6 +18,8 @@ import {
|
|
|
16
18
|
} from "../api.js";
|
|
17
19
|
import { useWords } from "../i18n/index.js";
|
|
18
20
|
import type { BoardFilter } from "../router.js";
|
|
21
|
+
import { OPEN_TASK_STATES } from "../vocabulary.js";
|
|
22
|
+
import { isPlainClick } from "./shell.js";
|
|
19
23
|
import {
|
|
20
24
|
Avatar,
|
|
21
25
|
Badge,
|
|
@@ -31,27 +35,40 @@ import {
|
|
|
31
35
|
// A card says what a person needs at a glance and nothing more: the number and the title, the
|
|
32
36
|
// category, what is due, who is on it, and what blocks it. Whether an agent is at it is not
|
|
33
37
|
// said, since the board cannot know (ADR-0020); the task's page shows its runs. A card is moved
|
|
34
|
-
// to another column by
|
|
35
|
-
//
|
|
36
|
-
//
|
|
38
|
+
// to another column by hand: pressed anywhere but on its title and its menu, then dragged, with
|
|
39
|
+
// a mouse at once and under a finger once it has rested a moment, so that a finger that moves
|
|
40
|
+
// at once scrolls as it always did. The card's menu holds the rest: the way to the task's form,
|
|
41
|
+
// and the way to put the task away once it is done or dropped (ADR-0029). The keyboard changes a
|
|
42
|
+
// state on the task's page. The board takes its data as props and nothing from the network;
|
|
43
|
+
// what it is told on a click goes back up.
|
|
37
44
|
|
|
38
45
|
export interface BoardProps {
|
|
39
46
|
readonly tasks: readonly RestTask[];
|
|
40
47
|
/** The link to a task's own page, as the composition routes it. */
|
|
41
48
|
readonly taskHref: (task: RestTask) => string;
|
|
42
49
|
readonly onOpen: (task: RestTask) => void;
|
|
43
|
-
/** Called when a person moves a task to another column. Left out, the
|
|
50
|
+
/** Called when a person moves a task to another column. Left out, the cards stay where they are. */
|
|
44
51
|
readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
|
|
45
52
|
/** The day to count due days from; the browser's today when left out. */
|
|
46
53
|
readonly today?: string | undefined;
|
|
47
54
|
/** What a column shows when it has nothing; the way to write the first task, for example. */
|
|
48
55
|
readonly empty?: ReactNode;
|
|
49
|
-
/** Where
|
|
50
|
-
readonly
|
|
56
|
+
/** Where a task is edited, as the composition routes it; given, each card's menu leads there. */
|
|
57
|
+
readonly editHref?: ((task: RestTask) => string) | undefined;
|
|
58
|
+
/** Called when the menu's edit is chosen, to show the form without leaving the page; left out, the link is followed. */
|
|
59
|
+
readonly onEdit?: ((task: RestTask) => void) | undefined;
|
|
60
|
+
/** Called when a task done or dropped is put away from its card's menu (ADR-0029). Left out, the menu offers nothing of it. */
|
|
61
|
+
readonly onArchive?: ((task: RestTask) => void) | undefined;
|
|
51
62
|
}
|
|
52
63
|
|
|
53
|
-
/** How far a
|
|
64
|
+
/** How far a mouse travels on a card before the press is a drag and not a click. */
|
|
54
65
|
const DRAG_SLACK = 4;
|
|
66
|
+
/**
|
|
67
|
+
* How long a finger rests on a card before the card comes loose, and how far it may wander
|
|
68
|
+
* meanwhile; a finger that moves sooner is scrolling the page, not moving the card.
|
|
69
|
+
*/
|
|
70
|
+
const HOLD_MS = 300;
|
|
71
|
+
const HOLD_SLACK = 8;
|
|
55
72
|
/** How near the pointer comes to the board's edge before the board scrolls to show more columns. */
|
|
56
73
|
const EDGE = 48;
|
|
57
74
|
const EDGE_STEP = 12;
|
|
@@ -67,47 +84,54 @@ interface Drag {
|
|
|
67
84
|
readonly from: { readonly x: number; readonly y: number };
|
|
68
85
|
readonly x: number;
|
|
69
86
|
readonly y: number;
|
|
70
|
-
/** Whether the
|
|
87
|
+
/** Whether the card has come loose: the mouse has travelled, or the finger has rested. */
|
|
71
88
|
readonly moving: boolean;
|
|
89
|
+
/** Whether the press is a finger's, which must rest before the card comes loose. */
|
|
90
|
+
readonly hold: boolean;
|
|
72
91
|
/** The column under the pointer, or none. */
|
|
73
92
|
readonly over: TaskState | undefined;
|
|
74
93
|
}
|
|
75
94
|
|
|
76
|
-
export
|
|
95
|
+
export interface TaskCardProps {
|
|
77
96
|
readonly task: RestTask;
|
|
78
97
|
readonly href: string;
|
|
79
98
|
readonly onOpen: (task: RestTask) => void;
|
|
80
|
-
readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
|
|
81
99
|
readonly today?: string | undefined;
|
|
82
|
-
/** Called when the card
|
|
83
|
-
readonly
|
|
100
|
+
/** Called when the card is pressed anywhere but on its controls, to drag it to another column; left out, the card stays where it is. */
|
|
101
|
+
readonly onPress?: ((task: RestTask, event: ReactPointerEvent<HTMLElement>) => void) | undefined;
|
|
84
102
|
/** Whether the card is on its way to another column. */
|
|
85
103
|
readonly dragging?: boolean | undefined;
|
|
86
|
-
|
|
104
|
+
/** Where the task is edited; given, the card's menu leads there. */
|
|
105
|
+
readonly editHref?: string | undefined;
|
|
106
|
+
/** Called when the menu's edit is chosen, to show the form without leaving the page; left out, the link is followed. */
|
|
107
|
+
readonly onEdit?: ((task: RestTask) => void) | undefined;
|
|
108
|
+
/** Called when the task is put away from the card's menu; offered once the task is done or dropped (ADR-0029). */
|
|
109
|
+
readonly onArchive?: ((task: RestTask) => void) | undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function TaskCard(props: TaskCardProps) {
|
|
87
113
|
const words = useWords();
|
|
88
|
-
const { task } = props;
|
|
114
|
+
const { task, onPress } = props;
|
|
115
|
+
const closed = !OPEN_TASK_STATES.includes(task.state);
|
|
116
|
+
const archive = closed ? props.onArchive : undefined;
|
|
89
117
|
return (
|
|
90
118
|
<article
|
|
91
|
-
className={cx(
|
|
119
|
+
className={cx(
|
|
120
|
+
"sc-card",
|
|
121
|
+
onPress !== undefined && "sc-card-movable",
|
|
122
|
+
props.dragging === true && "sc-card-dragging",
|
|
123
|
+
)}
|
|
92
124
|
aria-label={`#${task.number} ${task.title}`}
|
|
125
|
+
onPointerDown={onPress === undefined ? undefined : (event) => onPress(task, event)}
|
|
93
126
|
>
|
|
94
|
-
{props.
|
|
95
|
-
<
|
|
96
|
-
type="button"
|
|
97
|
-
className="sc-card-grip"
|
|
98
|
-
title={words.board.grip}
|
|
99
|
-
aria-hidden="true"
|
|
100
|
-
tabIndex={-1}
|
|
101
|
-
onPointerDown={(event) => props.onGrip?.(task, event)}
|
|
102
|
-
>
|
|
103
|
-
⋮⋮
|
|
104
|
-
</button>
|
|
127
|
+
{(props.editHref !== undefined || archive !== undefined) && (
|
|
128
|
+
<CardMenu task={task} editHref={props.editHref} onEdit={props.onEdit} onArchive={archive} />
|
|
105
129
|
)}
|
|
106
130
|
<a
|
|
107
131
|
className="sc-card-title"
|
|
108
132
|
href={props.href}
|
|
109
133
|
onClick={(event) => {
|
|
110
|
-
if (event
|
|
134
|
+
if (isPlainClick(event)) {
|
|
111
135
|
event.preventDefault();
|
|
112
136
|
props.onOpen(task);
|
|
113
137
|
}
|
|
@@ -129,25 +153,179 @@ export function TaskCard(props: {
|
|
|
129
153
|
<span className="sc-card-spacer" />
|
|
130
154
|
{task.assignee !== null && <Avatar person={task.assignee} size="sm" />}
|
|
131
155
|
</div>
|
|
132
|
-
{props.onMove !== undefined && (
|
|
133
|
-
<label className="sc-card-move">
|
|
134
|
-
<span className="sc-visually-hidden">{words.board.moveTo}</span>
|
|
135
|
-
<select
|
|
136
|
-
value={task.state}
|
|
137
|
-
onChange={(event) => props.onMove?.(task, event.target.value as TaskState)}
|
|
138
|
-
>
|
|
139
|
-
{TASK_STATES.map((state) => (
|
|
140
|
-
<option key={state} value={state}>
|
|
141
|
-
{words.vocabulary.state[state]}
|
|
142
|
-
</option>
|
|
143
|
-
))}
|
|
144
|
-
</select>
|
|
145
|
-
</label>
|
|
146
|
-
)}
|
|
147
156
|
</article>
|
|
148
157
|
);
|
|
149
158
|
}
|
|
150
159
|
|
|
160
|
+
/** Where the menu stands: from the top and the right edge of the window, over everything. */
|
|
161
|
+
interface MenuPlace {
|
|
162
|
+
readonly top: number;
|
|
163
|
+
readonly right: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const MENU_GAP = 4;
|
|
167
|
+
const MENU_MARGIN = 8;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The card's menu: three dots in its corner, which open the ways a card has beyond being moved,
|
|
171
|
+
* each given by the composition. The menu is rendered over the document, so that no column
|
|
172
|
+
* clips it; a press elsewhere, the escape key, a scroll or a change of the window closes it.
|
|
173
|
+
*/
|
|
174
|
+
function CardMenu(props: {
|
|
175
|
+
readonly task: RestTask;
|
|
176
|
+
readonly editHref: string | undefined;
|
|
177
|
+
readonly onEdit: ((task: RestTask) => void) | undefined;
|
|
178
|
+
readonly onArchive: ((task: RestTask) => void) | undefined;
|
|
179
|
+
}) {
|
|
180
|
+
const words = useWords();
|
|
181
|
+
const [place, setPlace] = useState<MenuPlace | null>(null);
|
|
182
|
+
const button = useRef<HTMLButtonElement>(null);
|
|
183
|
+
const panel = useRef<HTMLDivElement>(null);
|
|
184
|
+
const open = place !== null;
|
|
185
|
+
const close = useCallback(() => setPlace(null), []);
|
|
186
|
+
const toggle = () => {
|
|
187
|
+
const rect = button.current?.getBoundingClientRect();
|
|
188
|
+
if (open || rect === undefined) {
|
|
189
|
+
close();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
setPlace({
|
|
193
|
+
top: rect.bottom + MENU_GAP,
|
|
194
|
+
right: Math.max(MENU_MARGIN, window.innerWidth - rect.right),
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (!open) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// Opened near the bottom of the window, the menu stands above its button instead.
|
|
202
|
+
const shown = panel.current?.getBoundingClientRect();
|
|
203
|
+
const anchor = button.current?.getBoundingClientRect();
|
|
204
|
+
if (
|
|
205
|
+
shown !== undefined &&
|
|
206
|
+
anchor !== undefined &&
|
|
207
|
+
shown.bottom > window.innerHeight - MENU_MARGIN
|
|
208
|
+
) {
|
|
209
|
+
setPlace((current) =>
|
|
210
|
+
current === null
|
|
211
|
+
? current
|
|
212
|
+
: { ...current, top: Math.max(MENU_MARGIN, anchor.top - MENU_GAP - shown.height) },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
panel.current?.querySelector<HTMLElement>("[role='menuitem']")?.focus({ preventScroll: true });
|
|
216
|
+
const away = (event: PointerEvent) => {
|
|
217
|
+
const target = event.target;
|
|
218
|
+
if (
|
|
219
|
+
target instanceof Node &&
|
|
220
|
+
(panel.current?.contains(target) === true || button.current?.contains(target) === true)
|
|
221
|
+
) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
close();
|
|
225
|
+
};
|
|
226
|
+
const key = (event: KeyboardEvent) => {
|
|
227
|
+
if (event.key === "Escape") {
|
|
228
|
+
close();
|
|
229
|
+
button.current?.focus();
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
document.addEventListener("pointerdown", away);
|
|
233
|
+
document.addEventListener("keydown", key);
|
|
234
|
+
window.addEventListener("scroll", close, true);
|
|
235
|
+
window.addEventListener("resize", close);
|
|
236
|
+
return () => {
|
|
237
|
+
document.removeEventListener("pointerdown", away);
|
|
238
|
+
document.removeEventListener("keydown", key);
|
|
239
|
+
window.removeEventListener("scroll", close, true);
|
|
240
|
+
window.removeEventListener("resize", close);
|
|
241
|
+
};
|
|
242
|
+
}, [open, close]);
|
|
243
|
+
// The arrow keys walk the items, around the ends.
|
|
244
|
+
const walk = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
245
|
+
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
const items = [...(panel.current?.querySelectorAll<HTMLElement>("[role='menuitem']") ?? [])];
|
|
250
|
+
if (items.length === 0) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const active = document.activeElement;
|
|
254
|
+
const at = active instanceof HTMLElement ? items.indexOf(active) : -1;
|
|
255
|
+
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
256
|
+
items[(at + step + items.length) % items.length]?.focus();
|
|
257
|
+
};
|
|
258
|
+
const { task } = props;
|
|
259
|
+
return (
|
|
260
|
+
<>
|
|
261
|
+
<button
|
|
262
|
+
type="button"
|
|
263
|
+
ref={button}
|
|
264
|
+
className="sc-card-menu-button"
|
|
265
|
+
aria-label={words.board.menu}
|
|
266
|
+
aria-haspopup="menu"
|
|
267
|
+
aria-expanded={open}
|
|
268
|
+
onClick={toggle}
|
|
269
|
+
>
|
|
270
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
271
|
+
<circle cx="8" cy="3" r="1.5" fill="currentColor" />
|
|
272
|
+
<circle cx="8" cy="8" r="1.5" fill="currentColor" />
|
|
273
|
+
<circle cx="8" cy="13" r="1.5" fill="currentColor" />
|
|
274
|
+
</svg>
|
|
275
|
+
</button>
|
|
276
|
+
{place !== null &&
|
|
277
|
+
typeof document !== "undefined" &&
|
|
278
|
+
createPortal(
|
|
279
|
+
<div
|
|
280
|
+
ref={panel}
|
|
281
|
+
className="sc-card-menu"
|
|
282
|
+
role="menu"
|
|
283
|
+
aria-label={`#${task.number} ${task.title}`}
|
|
284
|
+
style={{ top: place.top, right: place.right }}
|
|
285
|
+
onKeyDown={walk}
|
|
286
|
+
onBlur={(event) => {
|
|
287
|
+
const next = event.relatedTarget;
|
|
288
|
+
if (!(next instanceof Node && panel.current?.contains(next) === true)) {
|
|
289
|
+
close();
|
|
290
|
+
}
|
|
291
|
+
}}
|
|
292
|
+
>
|
|
293
|
+
{props.editHref !== undefined && (
|
|
294
|
+
<a
|
|
295
|
+
role="menuitem"
|
|
296
|
+
className="sc-menu-link"
|
|
297
|
+
href={props.editHref}
|
|
298
|
+
onClick={(event) => {
|
|
299
|
+
close();
|
|
300
|
+
if (props.onEdit !== undefined && isPlainClick(event)) {
|
|
301
|
+
event.preventDefault();
|
|
302
|
+
props.onEdit(task);
|
|
303
|
+
}
|
|
304
|
+
}}
|
|
305
|
+
>
|
|
306
|
+
{words.common.edit}
|
|
307
|
+
</a>
|
|
308
|
+
)}
|
|
309
|
+
{props.onArchive !== undefined && (
|
|
310
|
+
<button
|
|
311
|
+
type="button"
|
|
312
|
+
role="menuitem"
|
|
313
|
+
className="sc-menu-link"
|
|
314
|
+
onClick={() => {
|
|
315
|
+
close();
|
|
316
|
+
props.onArchive?.(task);
|
|
317
|
+
}}
|
|
318
|
+
>
|
|
319
|
+
{words.task.archive}
|
|
320
|
+
</button>
|
|
321
|
+
)}
|
|
322
|
+
</div>,
|
|
323
|
+
document.body,
|
|
324
|
+
)}
|
|
325
|
+
</>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
151
329
|
/** The order a column reads in: what is due soonest first, what has no day after, the newest first among those. */
|
|
152
330
|
export function byDue(a: RestTask, b: RestTask): number {
|
|
153
331
|
if (a.dueOn !== b.dueOn) {
|
|
@@ -170,43 +348,62 @@ export function Board(props: BoardProps) {
|
|
|
170
348
|
// What the pointer's release reads, so that the move is made outside a state update.
|
|
171
349
|
const current = useRef<Drag | null>(null);
|
|
172
350
|
current.current = drag;
|
|
351
|
+
// Whether the held card has come loose, kept outside the state so that the touch that follows
|
|
352
|
+
// the hold at once is answered before the state has rendered.
|
|
353
|
+
const loose = useRef(false);
|
|
173
354
|
|
|
174
|
-
const
|
|
175
|
-
|
|
355
|
+
const press = useCallback((task: RestTask, event: ReactPointerEvent<HTMLElement>) => {
|
|
356
|
+
// The card's own controls, its title and its menu, are pressed for themselves.
|
|
357
|
+
if (
|
|
358
|
+
event.button !== 0 ||
|
|
359
|
+
(event.target instanceof Element && event.target.closest("a, button") !== null)
|
|
360
|
+
) {
|
|
176
361
|
return;
|
|
177
362
|
}
|
|
178
|
-
|
|
179
|
-
|
|
363
|
+
const hold = event.pointerType !== "mouse";
|
|
364
|
+
if (!hold) {
|
|
365
|
+
// The press is the drag's beginning and nothing else: no text is selected on the way.
|
|
366
|
+
event.preventDefault();
|
|
367
|
+
}
|
|
180
368
|
const from = { x: event.clientX, y: event.clientY };
|
|
181
|
-
setDrag({ task, from, x: from.x, y: from.y, moving: false, over: undefined });
|
|
369
|
+
setDrag({ task, from, x: from.x, y: from.y, moving: false, hold, over: undefined });
|
|
182
370
|
}, []);
|
|
183
371
|
|
|
184
372
|
// While a card is held, the pointer is followed over the whole document: the column under it
|
|
185
373
|
// is marked, the board scrolls when the pointer nears its edge, and the release moves the task.
|
|
374
|
+
// A finger's hold is timed here, and once the card has come loose the page no longer scrolls
|
|
375
|
+
// under the finger, nor asks for the menu a long press would otherwise bring up.
|
|
186
376
|
const held = drag !== null;
|
|
187
377
|
useEffect(() => {
|
|
188
378
|
if (!held) {
|
|
189
379
|
return;
|
|
190
380
|
}
|
|
381
|
+
const columnUnder = (x: number, y: number) =>
|
|
382
|
+
columnStateOf(
|
|
383
|
+
document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-column]")?.dataset.column,
|
|
384
|
+
);
|
|
191
385
|
const follow = (event: PointerEvent) => {
|
|
192
386
|
const { clientX: x, clientY: y } = event;
|
|
193
|
-
const under = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-column]");
|
|
194
387
|
setDrag((before) => {
|
|
195
388
|
if (before === null) {
|
|
196
389
|
return before;
|
|
197
390
|
}
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
391
|
+
const travelled = Math.hypot(x - before.from.x, y - before.from.y);
|
|
392
|
+
if (!before.moving) {
|
|
393
|
+
// A finger that wanders before it has rested is scrolling, and lets the card go; a
|
|
394
|
+
// mouse that has barely moved is still only pressing.
|
|
395
|
+
if (before.hold) {
|
|
396
|
+
return travelled > HOLD_SLACK ? null : { ...before, x, y };
|
|
397
|
+
}
|
|
398
|
+
if (travelled <= DRAG_SLACK) {
|
|
399
|
+
return { ...before, x, y };
|
|
400
|
+
}
|
|
401
|
+
loose.current = true;
|
|
402
|
+
}
|
|
403
|
+
return { ...before, x, y, moving: true, over: columnUnder(x, y) };
|
|
207
404
|
});
|
|
208
405
|
const element = board.current;
|
|
209
|
-
if (element !== null) {
|
|
406
|
+
if (element !== null && loose.current) {
|
|
210
407
|
const rect = element.getBoundingClientRect();
|
|
211
408
|
if (x < rect.left + EDGE) {
|
|
212
409
|
element.scrollLeft -= EDGE_STEP;
|
|
@@ -215,27 +412,62 @@ export function Board(props: BoardProps) {
|
|
|
215
412
|
}
|
|
216
413
|
}
|
|
217
414
|
};
|
|
218
|
-
const
|
|
415
|
+
const loosen = () => {
|
|
416
|
+
setDrag((before) => {
|
|
417
|
+
if (before === null || before.moving) {
|
|
418
|
+
return before;
|
|
419
|
+
}
|
|
420
|
+
loose.current = true;
|
|
421
|
+
return { ...before, moving: true, over: columnUnder(before.x, before.y) };
|
|
422
|
+
});
|
|
423
|
+
};
|
|
424
|
+
const release = (event: PointerEvent) => {
|
|
219
425
|
const last = current.current;
|
|
220
|
-
if (last?.moving === true &&
|
|
221
|
-
|
|
426
|
+
if (last?.moving === true && event.type === "pointerup") {
|
|
427
|
+
const over = columnUnder(event.clientX, event.clientY);
|
|
428
|
+
if (over !== undefined && over !== last.task.state) {
|
|
429
|
+
onMove?.(last.task, over);
|
|
430
|
+
}
|
|
222
431
|
}
|
|
432
|
+
loose.current = false;
|
|
223
433
|
setDrag(null);
|
|
224
434
|
};
|
|
435
|
+
const block = (event: TouchEvent) => {
|
|
436
|
+
if (loose.current) {
|
|
437
|
+
event.preventDefault();
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
const quiet = (event: Event) => event.preventDefault();
|
|
441
|
+
const timer = current.current?.hold === true ? window.setTimeout(loosen, HOLD_MS) : undefined;
|
|
225
442
|
window.addEventListener("pointermove", follow);
|
|
226
443
|
window.addEventListener("pointerup", release);
|
|
227
444
|
window.addEventListener("pointercancel", release);
|
|
445
|
+
document.addEventListener("touchmove", block, { passive: false });
|
|
446
|
+
document.addEventListener("contextmenu", quiet);
|
|
228
447
|
return () => {
|
|
448
|
+
if (timer !== undefined) {
|
|
449
|
+
window.clearTimeout(timer);
|
|
450
|
+
}
|
|
229
451
|
window.removeEventListener("pointermove", follow);
|
|
230
452
|
window.removeEventListener("pointerup", release);
|
|
231
453
|
window.removeEventListener("pointercancel", release);
|
|
454
|
+
document.removeEventListener("touchmove", block);
|
|
455
|
+
document.removeEventListener("contextmenu", quiet);
|
|
456
|
+
loose.current = false;
|
|
232
457
|
};
|
|
233
458
|
}, [held, onMove]);
|
|
234
459
|
|
|
235
|
-
/** The drag under way, once the
|
|
460
|
+
/** The drag under way, once the card has come loose; nothing while a card is merely pressed. */
|
|
236
461
|
const dragging = drag?.moving === true ? drag : null;
|
|
237
462
|
return (
|
|
238
|
-
<div
|
|
463
|
+
<div
|
|
464
|
+
ref={board}
|
|
465
|
+
className={cx(
|
|
466
|
+
"sc-board",
|
|
467
|
+
onMove !== undefined && "sc-board-movable",
|
|
468
|
+
dragging !== null && "sc-board-dragging",
|
|
469
|
+
)}
|
|
470
|
+
>
|
|
239
471
|
{TASK_STATES.map((state) => {
|
|
240
472
|
const tasks = props.tasks.filter((task) => task.state === state).sort(byDue);
|
|
241
473
|
return (
|
|
@@ -251,14 +483,7 @@ export function Board(props: BoardProps) {
|
|
|
251
483
|
>
|
|
252
484
|
<header className="sc-column-header">
|
|
253
485
|
<h2 className="sc-column-title">{words.vocabulary.state[state]}</h2>
|
|
254
|
-
<span className="sc-column-
|
|
255
|
-
{props.archiveHref !== undefined && (state === "done" || state === "dropped") && (
|
|
256
|
-
<a className="sc-column-archive" href={props.archiveHref}>
|
|
257
|
-
{words.board.archive}
|
|
258
|
-
</a>
|
|
259
|
-
)}
|
|
260
|
-
<span className="sc-column-count">{tasks.length}</span>
|
|
261
|
-
</span>
|
|
486
|
+
<span className="sc-column-count">{tasks.length}</span>
|
|
262
487
|
</header>
|
|
263
488
|
<div className="sc-column-body">
|
|
264
489
|
{tasks.map((task) => (
|
|
@@ -267,10 +492,12 @@ export function Board(props: BoardProps) {
|
|
|
267
492
|
task={task}
|
|
268
493
|
href={props.taskHref(task)}
|
|
269
494
|
onOpen={props.onOpen}
|
|
270
|
-
onMove={onMove}
|
|
271
495
|
today={props.today}
|
|
272
|
-
|
|
496
|
+
onPress={onMove === undefined ? undefined : press}
|
|
273
497
|
dragging={dragging?.task.id === task.id}
|
|
498
|
+
editHref={props.editHref?.(task)}
|
|
499
|
+
onEdit={props.onEdit}
|
|
500
|
+
onArchive={props.onArchive}
|
|
274
501
|
/>
|
|
275
502
|
))}
|
|
276
503
|
{tasks.length === 0 && state === "idea" && props.empty}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "../api.js";
|
|
12
12
|
import { useWords } from "../i18n/index.js";
|
|
13
13
|
import { DecisionCard, type DecisionCardProps } from "./decision-list.js";
|
|
14
|
+
import { isPicture, namedFile, placed, placing, unusedName } from "./files.js";
|
|
14
15
|
import { Markdown } from "./markdown.js";
|
|
15
16
|
import { Lightbox, PictureThumb, usePicture } from "./pictures.js";
|
|
16
17
|
import { RunCard } from "./runs.js";
|
|
@@ -21,24 +22,6 @@ import { Button, formatBytes, PersonChip, personName, Time } from "./ui.js";
|
|
|
21
22
|
// it, in the order they came. A picture is a thumbnail that opens large over the page. Takes
|
|
22
23
|
// its data as props and nothing from the network.
|
|
23
24
|
|
|
24
|
-
/** The kinds of picture the pages show in place: what a browser renders and cannot run. */
|
|
25
|
-
const PICTURE_TYPES: ReadonlySet<string> = new Set([
|
|
26
|
-
"image/png",
|
|
27
|
-
"image/jpeg",
|
|
28
|
-
"image/gif",
|
|
29
|
-
"image/webp",
|
|
30
|
-
]);
|
|
31
|
-
|
|
32
|
-
/** Whether a file is a picture the pages show in place, by the media type the console kept. */
|
|
33
|
-
export function isPicture(contentType: string): boolean {
|
|
34
|
-
return PICTURE_TYPES.has(contentType);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Whether the text places a file by its name, as `` or ``. */
|
|
38
|
-
function placed(body: string, name: string): boolean {
|
|
39
|
-
return body.includes(`](${name})`) || body.includes(`](./${name})`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
25
|
export interface CommentCardProps {
|
|
43
26
|
readonly comment: RestComment;
|
|
44
27
|
/** Where a file put in the comment is read: the project's address for it. Left out, a file is named and not linked. */
|
|
@@ -55,7 +38,7 @@ export function CommentCard(props: CommentCardProps) {
|
|
|
55
38
|
// A picture the text names by its file name is shown where the text puts it; the rest of
|
|
56
39
|
// the files follow the text, pictures in place and everything else as a link.
|
|
57
40
|
const named = (source: string): string | undefined => {
|
|
58
|
-
const name =
|
|
41
|
+
const name = namedFile(source);
|
|
59
42
|
const file = comment.files.find(
|
|
60
43
|
(candidate) => candidate.file.name === name && isPicture(candidate.file.contentType),
|
|
61
44
|
);
|
|
@@ -141,23 +124,6 @@ export interface CommentFormProps {
|
|
|
141
124
|
readonly busy?: boolean | undefined;
|
|
142
125
|
}
|
|
143
126
|
|
|
144
|
-
/** A name for a file among those chosen already: the same, or with a count before its extension. */
|
|
145
|
-
function unusedName(name: string, taken: readonly File[]): string {
|
|
146
|
-
const names = new Set(taken.map((file) => file.name));
|
|
147
|
-
if (!names.has(name)) {
|
|
148
|
-
return name;
|
|
149
|
-
}
|
|
150
|
-
const dot = name.lastIndexOf(".");
|
|
151
|
-
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
152
|
-
const extension = dot > 0 ? name.slice(dot) : "";
|
|
153
|
-
for (let n = 2; ; n += 1) {
|
|
154
|
-
const candidate = `${stem}-${n}${extension}`;
|
|
155
|
-
if (!names.has(candidate)) {
|
|
156
|
-
return candidate;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
127
|
export function CommentForm(props: CommentFormProps) {
|
|
162
128
|
const words = useWords().comments;
|
|
163
129
|
const [body, setBody] = useState("");
|
|
@@ -171,7 +137,10 @@ export function CommentForm(props: CommentFormProps) {
|
|
|
171
137
|
if (held.length >= MAX_FILES_PER_COMMENT) {
|
|
172
138
|
break;
|
|
173
139
|
}
|
|
174
|
-
const name = unusedName(
|
|
140
|
+
const name = unusedName(
|
|
141
|
+
file.name,
|
|
142
|
+
held.map((kept) => kept.name),
|
|
143
|
+
);
|
|
175
144
|
const kept = name === file.name ? file : new File([file], name, { type: file.type });
|
|
176
145
|
held = [...held, kept];
|
|
177
146
|
added.push(kept);
|
|
@@ -189,9 +158,11 @@ export function CommentForm(props: CommentFormProps) {
|
|
|
189
158
|
event.preventDefault();
|
|
190
159
|
const added = add(pasted);
|
|
191
160
|
if (added.length > 0) {
|
|
192
|
-
const lines = added.map((file) => ``).join("\n");
|
|
193
161
|
setBody((current) =>
|
|
194
|
-
|
|
162
|
+
placing(
|
|
163
|
+
current,
|
|
164
|
+
added.map((file) => file.name),
|
|
165
|
+
),
|
|
195
166
|
);
|
|
196
167
|
}
|
|
197
168
|
};
|