@skillcdn/console 0.1.19 → 0.1.21
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/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +6 -0
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +6 -8
- package/dist/cli/help.js.map +1 -1
- package/dist/components/board.d.ts +19 -9
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +210 -28
- 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/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +2 -4
- package/dist/components/task-view.js.map +1 -1
- package/dist/console.css +144 -80
- 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 +7 -4
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +7 -4
- 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/dist/rules.d.ts.map +1 -1
- package/dist/rules.js +2 -1
- package/dist/rules.js.map +1 -1
- package/docs/specs/cli.md +4 -4
- package/docs/specs/rest.md +3 -4
- package/package.json +1 -1
- package/skills/working-the-board/SKILL.md +4 -4
- package/src/cli/cli.ts +6 -0
- package/src/cli/help.ts +6 -8
- package/src/components/board.tsx +357 -77
- 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/components/task-view.tsx +10 -12
- package/src/console.tsx +20 -8
- package/src/docs-page.tsx +18 -1
- package/src/i18n/en.ts +8 -4
- package/src/i18n/ko.ts +8 -4
- package/src/index.ts +1 -1
- package/src/rules.ts +2 -1
- package/src/styles/console.css +144 -80
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,7 @@ import {
|
|
|
16
18
|
} from "../api.js";
|
|
17
19
|
import { useWords } from "../i18n/index.js";
|
|
18
20
|
import type { BoardFilter } from "../router.js";
|
|
21
|
+
import { isPlainClick } from "./shell.js";
|
|
19
22
|
import {
|
|
20
23
|
Avatar,
|
|
21
24
|
Badge,
|
|
@@ -31,27 +34,41 @@ import {
|
|
|
31
34
|
// A card says what a person needs at a glance and nothing more: the number and the title, the
|
|
32
35
|
// category, what is due, who is on it, and what blocks it. Whether an agent is at it is not
|
|
33
36
|
// 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
|
-
//
|
|
37
|
+
// to another column by hand: pressed anywhere but on its menu, its title included, then dragged
|
|
38
|
+
// whole, with a mouse at once and under a finger once it has rested a moment, so that a finger
|
|
39
|
+
// that moves at once scrolls as it always did; a click on the title opens the task as long as the
|
|
40
|
+
// card was not dragged. The card's menu holds the rest: the way to the task's form, and the way to
|
|
41
|
+
// put the task away (ADR-0029, ADR-0030). The keyboard changes a state on the task's page. The
|
|
42
|
+
// board takes its data as props and nothing from the network; what it is told on a click goes
|
|
43
|
+
// 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 is put away from its card's menu (ADR-0029, ADR-0030). 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;
|
|
@@ -61,53 +78,74 @@ export function columnStateOf(value: string | null | undefined): TaskState | und
|
|
|
61
78
|
return TASK_STATES.find((state) => state === value);
|
|
62
79
|
}
|
|
63
80
|
|
|
81
|
+
interface Point {
|
|
82
|
+
readonly x: number;
|
|
83
|
+
readonly y: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
/** A card on its way to another column. */
|
|
65
87
|
interface Drag {
|
|
66
88
|
readonly task: RestTask;
|
|
67
|
-
readonly from:
|
|
89
|
+
readonly from: Point;
|
|
68
90
|
readonly x: number;
|
|
69
91
|
readonly y: number;
|
|
70
|
-
/**
|
|
92
|
+
/** Where on the card it was taken hold of, so that the card moves whole under the pointer. */
|
|
93
|
+
readonly grip: Point;
|
|
94
|
+
/** How wide the card is where it stands, so that the one in hand is the same. */
|
|
95
|
+
readonly width: number;
|
|
96
|
+
/** Whether the card has come loose: the mouse has travelled, or the finger has rested. */
|
|
71
97
|
readonly moving: boolean;
|
|
98
|
+
/** Whether the press is a finger's, which must rest before the card comes loose. */
|
|
99
|
+
readonly hold: boolean;
|
|
72
100
|
/** The column under the pointer, or none. */
|
|
73
101
|
readonly over: TaskState | undefined;
|
|
74
102
|
}
|
|
75
103
|
|
|
76
|
-
export
|
|
104
|
+
export interface TaskCardProps {
|
|
77
105
|
readonly task: RestTask;
|
|
78
106
|
readonly href: string;
|
|
79
107
|
readonly onOpen: (task: RestTask) => void;
|
|
80
|
-
readonly onMove?: ((task: RestTask, state: TaskState) => void) | undefined;
|
|
81
108
|
readonly today?: string | undefined;
|
|
82
|
-
/** Called when the card
|
|
83
|
-
readonly
|
|
84
|
-
/** Whether the card is on its way to another column. */
|
|
109
|
+
/** Called when the card is pressed anywhere but on its menu, to drag it to another column; left out, the card stays where it is. */
|
|
110
|
+
readonly onPress?: ((task: RestTask, event: ReactPointerEvent<HTMLElement>) => void) | undefined;
|
|
111
|
+
/** Whether the card is on its way to another column: the one left in its place is faded. */
|
|
85
112
|
readonly dragging?: boolean | undefined;
|
|
86
|
-
|
|
113
|
+
/** Where the task is edited; given, the card's menu leads there. */
|
|
114
|
+
readonly editHref?: string | undefined;
|
|
115
|
+
/** Called when the menu's edit is chosen, to show the form without leaving the page; left out, the link is followed. */
|
|
116
|
+
readonly onEdit?: ((task: RestTask) => void) | undefined;
|
|
117
|
+
/** Called when the task is put away from the card's menu, whatever its state (ADR-0030). */
|
|
118
|
+
readonly onArchive?: ((task: RestTask) => void) | undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function TaskCard(props: TaskCardProps) {
|
|
87
122
|
const words = useWords();
|
|
88
|
-
const { task } = props;
|
|
123
|
+
const { task, onPress } = props;
|
|
89
124
|
return (
|
|
90
125
|
<article
|
|
91
|
-
className={cx(
|
|
126
|
+
className={cx(
|
|
127
|
+
"sc-card",
|
|
128
|
+
onPress !== undefined && "sc-card-movable",
|
|
129
|
+
props.dragging === true && "sc-card-dragging",
|
|
130
|
+
)}
|
|
92
131
|
aria-label={`#${task.number} ${task.title}`}
|
|
132
|
+
onPointerDown={onPress === undefined ? undefined : (event) => onPress(task, event)}
|
|
93
133
|
>
|
|
94
|
-
{props.
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
onPointerDown={(event) => props.onGrip?.(task, event)}
|
|
102
|
-
>
|
|
103
|
-
⋮⋮
|
|
104
|
-
</button>
|
|
134
|
+
{(props.editHref !== undefined || props.onArchive !== undefined) && (
|
|
135
|
+
<CardMenu
|
|
136
|
+
task={task}
|
|
137
|
+
editHref={props.editHref}
|
|
138
|
+
onEdit={props.onEdit}
|
|
139
|
+
onArchive={props.onArchive}
|
|
140
|
+
/>
|
|
105
141
|
)}
|
|
106
142
|
<a
|
|
107
143
|
className="sc-card-title"
|
|
108
144
|
href={props.href}
|
|
145
|
+
// The card is dragged by our own hand, the title included; the browser's drag of a link is not it.
|
|
146
|
+
draggable={false}
|
|
109
147
|
onClick={(event) => {
|
|
110
|
-
if (event
|
|
148
|
+
if (isPlainClick(event)) {
|
|
111
149
|
event.preventDefault();
|
|
112
150
|
props.onOpen(task);
|
|
113
151
|
}
|
|
@@ -129,25 +167,182 @@ export function TaskCard(props: {
|
|
|
129
167
|
<span className="sc-card-spacer" />
|
|
130
168
|
{task.assignee !== null && <Avatar person={task.assignee} size="sm" />}
|
|
131
169
|
</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
170
|
</article>
|
|
148
171
|
);
|
|
149
172
|
}
|
|
150
173
|
|
|
174
|
+
/** Where the menu stands: from the top and the right edge of the window, over everything. */
|
|
175
|
+
interface MenuPlace {
|
|
176
|
+
readonly top: number;
|
|
177
|
+
readonly right: number;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const MENU_GAP = 4;
|
|
181
|
+
const MENU_MARGIN = 8;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The card's menu: three dots in its corner, which open the ways a card has beyond being moved,
|
|
185
|
+
* each given by the composition. The menu is rendered over the document, so that no column
|
|
186
|
+
* clips it; a press elsewhere, the escape key, a scroll or a change of the window closes it, and
|
|
187
|
+
* so does the button that opened it.
|
|
188
|
+
*/
|
|
189
|
+
function CardMenu(props: {
|
|
190
|
+
readonly task: RestTask;
|
|
191
|
+
readonly editHref: string | undefined;
|
|
192
|
+
readonly onEdit: ((task: RestTask) => void) | undefined;
|
|
193
|
+
readonly onArchive: ((task: RestTask) => void) | undefined;
|
|
194
|
+
}) {
|
|
195
|
+
const words = useWords();
|
|
196
|
+
const [place, setPlace] = useState<MenuPlace | null>(null);
|
|
197
|
+
const button = useRef<HTMLButtonElement>(null);
|
|
198
|
+
const panel = useRef<HTMLDivElement>(null);
|
|
199
|
+
const open = place !== null;
|
|
200
|
+
const close = useCallback(() => setPlace(null), []);
|
|
201
|
+
const toggle = () => {
|
|
202
|
+
const rect = button.current?.getBoundingClientRect();
|
|
203
|
+
if (open || rect === undefined) {
|
|
204
|
+
close();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
setPlace({
|
|
208
|
+
top: rect.bottom + MENU_GAP,
|
|
209
|
+
right: Math.max(MENU_MARGIN, window.innerWidth - rect.right),
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
/** Whether the focus, or a press, is on the menu or on its button: the menu's own. */
|
|
213
|
+
const own = useCallback(
|
|
214
|
+
(target: EventTarget | null): boolean =>
|
|
215
|
+
target instanceof Node &&
|
|
216
|
+
(panel.current?.contains(target) === true || button.current?.contains(target) === true),
|
|
217
|
+
[],
|
|
218
|
+
);
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (!open) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// Opened near the bottom of the window, the menu stands above its button instead.
|
|
224
|
+
const shown = panel.current?.getBoundingClientRect();
|
|
225
|
+
const anchor = button.current?.getBoundingClientRect();
|
|
226
|
+
if (
|
|
227
|
+
shown !== undefined &&
|
|
228
|
+
anchor !== undefined &&
|
|
229
|
+
shown.bottom > window.innerHeight - MENU_MARGIN
|
|
230
|
+
) {
|
|
231
|
+
setPlace((current) =>
|
|
232
|
+
current === null
|
|
233
|
+
? current
|
|
234
|
+
: { ...current, top: Math.max(MENU_MARGIN, anchor.top - MENU_GAP - shown.height) },
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
panel.current?.querySelector<HTMLElement>("[role='menuitem']")?.focus({ preventScroll: true });
|
|
238
|
+
const away = (event: PointerEvent) => {
|
|
239
|
+
if (!own(event.target)) {
|
|
240
|
+
close();
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
const key = (event: KeyboardEvent) => {
|
|
244
|
+
if (event.key === "Escape") {
|
|
245
|
+
close();
|
|
246
|
+
button.current?.focus();
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
document.addEventListener("pointerdown", away);
|
|
250
|
+
document.addEventListener("keydown", key);
|
|
251
|
+
window.addEventListener("scroll", close, true);
|
|
252
|
+
window.addEventListener("resize", close);
|
|
253
|
+
return () => {
|
|
254
|
+
document.removeEventListener("pointerdown", away);
|
|
255
|
+
document.removeEventListener("keydown", key);
|
|
256
|
+
window.removeEventListener("scroll", close, true);
|
|
257
|
+
window.removeEventListener("resize", close);
|
|
258
|
+
};
|
|
259
|
+
}, [open, close, own]);
|
|
260
|
+
// The arrow keys walk the items, around the ends.
|
|
261
|
+
const walk = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
262
|
+
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
event.preventDefault();
|
|
266
|
+
const items = [...(panel.current?.querySelectorAll<HTMLElement>("[role='menuitem']") ?? [])];
|
|
267
|
+
if (items.length === 0) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const active = document.activeElement;
|
|
271
|
+
const at = active instanceof HTMLElement ? items.indexOf(active) : -1;
|
|
272
|
+
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
273
|
+
items[(at + step + items.length) % items.length]?.focus();
|
|
274
|
+
};
|
|
275
|
+
const { task } = props;
|
|
276
|
+
return (
|
|
277
|
+
<>
|
|
278
|
+
<button
|
|
279
|
+
type="button"
|
|
280
|
+
ref={button}
|
|
281
|
+
className="sc-card-menu-button"
|
|
282
|
+
aria-label={words.board.menu}
|
|
283
|
+
aria-haspopup="menu"
|
|
284
|
+
aria-expanded={open}
|
|
285
|
+
onClick={toggle}
|
|
286
|
+
>
|
|
287
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
288
|
+
<circle cx="8" cy="3" r="1.5" fill="currentColor" />
|
|
289
|
+
<circle cx="8" cy="8" r="1.5" fill="currentColor" />
|
|
290
|
+
<circle cx="8" cy="13" r="1.5" fill="currentColor" />
|
|
291
|
+
</svg>
|
|
292
|
+
</button>
|
|
293
|
+
{place !== null &&
|
|
294
|
+
typeof document !== "undefined" &&
|
|
295
|
+
createPortal(
|
|
296
|
+
<div
|
|
297
|
+
ref={panel}
|
|
298
|
+
className="sc-card-menu"
|
|
299
|
+
role="menu"
|
|
300
|
+
aria-label={`#${task.number} ${task.title}`}
|
|
301
|
+
style={{ top: place.top, right: place.right }}
|
|
302
|
+
onKeyDown={walk}
|
|
303
|
+
// The focus leaving for anywhere but the menu's own button closes it; the button closes it itself.
|
|
304
|
+
onBlur={(event) => {
|
|
305
|
+
if (!own(event.relatedTarget)) {
|
|
306
|
+
close();
|
|
307
|
+
}
|
|
308
|
+
}}
|
|
309
|
+
>
|
|
310
|
+
{props.editHref !== undefined && (
|
|
311
|
+
<a
|
|
312
|
+
role="menuitem"
|
|
313
|
+
className="sc-menu-link"
|
|
314
|
+
href={props.editHref}
|
|
315
|
+
onClick={(event) => {
|
|
316
|
+
close();
|
|
317
|
+
if (props.onEdit !== undefined && isPlainClick(event)) {
|
|
318
|
+
event.preventDefault();
|
|
319
|
+
props.onEdit(task);
|
|
320
|
+
}
|
|
321
|
+
}}
|
|
322
|
+
>
|
|
323
|
+
{words.common.edit}
|
|
324
|
+
</a>
|
|
325
|
+
)}
|
|
326
|
+
{props.onArchive !== undefined && (
|
|
327
|
+
<button
|
|
328
|
+
type="button"
|
|
329
|
+
role="menuitem"
|
|
330
|
+
className="sc-menu-link"
|
|
331
|
+
onClick={() => {
|
|
332
|
+
close();
|
|
333
|
+
props.onArchive?.(task);
|
|
334
|
+
}}
|
|
335
|
+
>
|
|
336
|
+
{words.task.archive}
|
|
337
|
+
</button>
|
|
338
|
+
)}
|
|
339
|
+
</div>,
|
|
340
|
+
document.body,
|
|
341
|
+
)}
|
|
342
|
+
</>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
151
346
|
/** The order a column reads in: what is due soonest first, what has no day after, the newest first among those. */
|
|
152
347
|
export function byDue(a: RestTask, b: RestTask): number {
|
|
153
348
|
if (a.dueOn !== b.dueOn) {
|
|
@@ -162,6 +357,8 @@ export function byDue(a: RestTask, b: RestTask): number {
|
|
|
162
357
|
return b.number - a.number;
|
|
163
358
|
}
|
|
164
359
|
|
|
360
|
+
const nothing = () => undefined;
|
|
361
|
+
|
|
165
362
|
export function Board(props: BoardProps) {
|
|
166
363
|
const words = useWords();
|
|
167
364
|
const { onMove } = props;
|
|
@@ -170,43 +367,74 @@ export function Board(props: BoardProps) {
|
|
|
170
367
|
// What the pointer's release reads, so that the move is made outside a state update.
|
|
171
368
|
const current = useRef<Drag | null>(null);
|
|
172
369
|
current.current = drag;
|
|
370
|
+
// Whether the held card has come loose, kept outside the state so that the touch that follows
|
|
371
|
+
// the hold at once is answered before the state has rendered.
|
|
372
|
+
const loose = useRef(false);
|
|
173
373
|
|
|
174
|
-
const
|
|
175
|
-
|
|
374
|
+
const press = useCallback((task: RestTask, event: ReactPointerEvent<HTMLElement>) => {
|
|
375
|
+
// The card's menu is pressed for itself; everything else on the card takes hold of it.
|
|
376
|
+
if (
|
|
377
|
+
event.button !== 0 ||
|
|
378
|
+
(event.target instanceof Element && event.target.closest("button") !== null)
|
|
379
|
+
) {
|
|
176
380
|
return;
|
|
177
381
|
}
|
|
178
|
-
|
|
179
|
-
|
|
382
|
+
const hold = event.pointerType !== "mouse";
|
|
383
|
+
if (!hold) {
|
|
384
|
+
// The press is the drag's beginning and nothing else: no text is selected on the way.
|
|
385
|
+
event.preventDefault();
|
|
386
|
+
}
|
|
387
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
180
388
|
const from = { x: event.clientX, y: event.clientY };
|
|
181
|
-
setDrag({
|
|
389
|
+
setDrag({
|
|
390
|
+
task,
|
|
391
|
+
from,
|
|
392
|
+
x: from.x,
|
|
393
|
+
y: from.y,
|
|
394
|
+
grip: { x: from.x - rect.left, y: from.y - rect.top },
|
|
395
|
+
width: rect.width,
|
|
396
|
+
moving: false,
|
|
397
|
+
hold,
|
|
398
|
+
over: undefined,
|
|
399
|
+
});
|
|
182
400
|
}, []);
|
|
183
401
|
|
|
184
402
|
// While a card is held, the pointer is followed over the whole document: the column under it
|
|
185
403
|
// is marked, the board scrolls when the pointer nears its edge, and the release moves the task.
|
|
404
|
+
// A finger's hold is timed here, and once the card has come loose the page no longer scrolls
|
|
405
|
+
// under the finger, nor asks for the menu a long press would otherwise bring up; the click that
|
|
406
|
+
// follows a drag opens nothing.
|
|
186
407
|
const held = drag !== null;
|
|
187
408
|
useEffect(() => {
|
|
188
409
|
if (!held) {
|
|
189
410
|
return;
|
|
190
411
|
}
|
|
412
|
+
const columnUnder = (x: number, y: number) =>
|
|
413
|
+
columnStateOf(
|
|
414
|
+
document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-column]")?.dataset.column,
|
|
415
|
+
);
|
|
191
416
|
const follow = (event: PointerEvent) => {
|
|
192
417
|
const { clientX: x, clientY: y } = event;
|
|
193
|
-
const under = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-column]");
|
|
194
418
|
setDrag((before) => {
|
|
195
419
|
if (before === null) {
|
|
196
420
|
return before;
|
|
197
421
|
}
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
422
|
+
const travelled = Math.hypot(x - before.from.x, y - before.from.y);
|
|
423
|
+
if (!before.moving) {
|
|
424
|
+
// A finger that wanders before it has rested is scrolling, and lets the card go; a
|
|
425
|
+
// mouse that has barely moved is still only pressing.
|
|
426
|
+
if (before.hold) {
|
|
427
|
+
return travelled > HOLD_SLACK ? null : { ...before, x, y };
|
|
428
|
+
}
|
|
429
|
+
if (travelled <= DRAG_SLACK) {
|
|
430
|
+
return { ...before, x, y };
|
|
431
|
+
}
|
|
432
|
+
loose.current = true;
|
|
433
|
+
}
|
|
434
|
+
return { ...before, x, y, moving: true, over: columnUnder(x, y) };
|
|
207
435
|
});
|
|
208
436
|
const element = board.current;
|
|
209
|
-
if (element !== null) {
|
|
437
|
+
if (element !== null && loose.current) {
|
|
210
438
|
const rect = element.getBoundingClientRect();
|
|
211
439
|
if (x < rect.left + EDGE) {
|
|
212
440
|
element.scrollLeft -= EDGE_STEP;
|
|
@@ -215,27 +443,72 @@ export function Board(props: BoardProps) {
|
|
|
215
443
|
}
|
|
216
444
|
}
|
|
217
445
|
};
|
|
218
|
-
const
|
|
446
|
+
const loosen = () => {
|
|
447
|
+
setDrag((before) => {
|
|
448
|
+
if (before === null || before.moving) {
|
|
449
|
+
return before;
|
|
450
|
+
}
|
|
451
|
+
loose.current = true;
|
|
452
|
+
return { ...before, moving: true, over: columnUnder(before.x, before.y) };
|
|
453
|
+
});
|
|
454
|
+
};
|
|
455
|
+
const swallow = (event: MouseEvent) => {
|
|
456
|
+
event.preventDefault();
|
|
457
|
+
event.stopPropagation();
|
|
458
|
+
};
|
|
459
|
+
const release = (event: PointerEvent) => {
|
|
219
460
|
const last = current.current;
|
|
220
|
-
if (
|
|
221
|
-
|
|
461
|
+
if (loose.current) {
|
|
462
|
+
// The click the browser sends after the pointer is let go would open the title's task:
|
|
463
|
+
// it is swallowed, and the ear for it taken down once that moment has passed.
|
|
464
|
+
window.addEventListener("click", swallow, true);
|
|
465
|
+
window.setTimeout(() => window.removeEventListener("click", swallow, true), 0);
|
|
466
|
+
}
|
|
467
|
+
if (last?.moving === true && event.type === "pointerup") {
|
|
468
|
+
const over = columnUnder(event.clientX, event.clientY);
|
|
469
|
+
if (over !== undefined && over !== last.task.state) {
|
|
470
|
+
onMove?.(last.task, over);
|
|
471
|
+
}
|
|
222
472
|
}
|
|
473
|
+
loose.current = false;
|
|
223
474
|
setDrag(null);
|
|
224
475
|
};
|
|
476
|
+
const block = (event: TouchEvent) => {
|
|
477
|
+
if (loose.current) {
|
|
478
|
+
event.preventDefault();
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
const quiet = (event: Event) => event.preventDefault();
|
|
482
|
+
const timer = current.current?.hold === true ? window.setTimeout(loosen, HOLD_MS) : undefined;
|
|
225
483
|
window.addEventListener("pointermove", follow);
|
|
226
484
|
window.addEventListener("pointerup", release);
|
|
227
485
|
window.addEventListener("pointercancel", release);
|
|
486
|
+
document.addEventListener("touchmove", block, { passive: false });
|
|
487
|
+
document.addEventListener("contextmenu", quiet);
|
|
228
488
|
return () => {
|
|
489
|
+
if (timer !== undefined) {
|
|
490
|
+
window.clearTimeout(timer);
|
|
491
|
+
}
|
|
229
492
|
window.removeEventListener("pointermove", follow);
|
|
230
493
|
window.removeEventListener("pointerup", release);
|
|
231
494
|
window.removeEventListener("pointercancel", release);
|
|
495
|
+
document.removeEventListener("touchmove", block);
|
|
496
|
+
document.removeEventListener("contextmenu", quiet);
|
|
497
|
+
loose.current = false;
|
|
232
498
|
};
|
|
233
499
|
}, [held, onMove]);
|
|
234
500
|
|
|
235
|
-
/** The drag under way, once the
|
|
501
|
+
/** The drag under way, once the card has come loose; nothing while a card is merely pressed. */
|
|
236
502
|
const dragging = drag?.moving === true ? drag : null;
|
|
237
503
|
return (
|
|
238
|
-
<div
|
|
504
|
+
<div
|
|
505
|
+
ref={board}
|
|
506
|
+
className={cx(
|
|
507
|
+
"sc-board",
|
|
508
|
+
onMove !== undefined && "sc-board-movable",
|
|
509
|
+
dragging !== null && "sc-board-dragging",
|
|
510
|
+
)}
|
|
511
|
+
>
|
|
239
512
|
{TASK_STATES.map((state) => {
|
|
240
513
|
const tasks = props.tasks.filter((task) => task.state === state).sort(byDue);
|
|
241
514
|
return (
|
|
@@ -251,14 +524,7 @@ export function Board(props: BoardProps) {
|
|
|
251
524
|
>
|
|
252
525
|
<header className="sc-column-header">
|
|
253
526
|
<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>
|
|
527
|
+
<span className="sc-column-count">{tasks.length}</span>
|
|
262
528
|
</header>
|
|
263
529
|
<div className="sc-column-body">
|
|
264
530
|
{tasks.map((task) => (
|
|
@@ -267,10 +533,12 @@ export function Board(props: BoardProps) {
|
|
|
267
533
|
task={task}
|
|
268
534
|
href={props.taskHref(task)}
|
|
269
535
|
onOpen={props.onOpen}
|
|
270
|
-
onMove={onMove}
|
|
271
536
|
today={props.today}
|
|
272
|
-
|
|
537
|
+
onPress={onMove === undefined ? undefined : press}
|
|
273
538
|
dragging={dragging?.task.id === task.id}
|
|
539
|
+
editHref={props.editHref?.(task)}
|
|
540
|
+
onEdit={props.onEdit}
|
|
541
|
+
onArchive={props.onArchive}
|
|
274
542
|
/>
|
|
275
543
|
))}
|
|
276
544
|
{tasks.length === 0 && state === "idea" && props.empty}
|
|
@@ -279,12 +547,24 @@ export function Board(props: BoardProps) {
|
|
|
279
547
|
);
|
|
280
548
|
})}
|
|
281
549
|
{dragging !== null && (
|
|
550
|
+
// The card in hand: the whole of it, as wide as where it stands, under the pointer where it was taken hold of.
|
|
282
551
|
<div
|
|
283
552
|
className="sc-drag-ghost"
|
|
284
553
|
aria-hidden="true"
|
|
285
|
-
style={{
|
|
554
|
+
style={{
|
|
555
|
+
width: dragging.width,
|
|
556
|
+
transform: `translate(${dragging.x - dragging.grip.x}px, ${dragging.y - dragging.grip.y}px)`,
|
|
557
|
+
}}
|
|
286
558
|
>
|
|
287
|
-
<
|
|
559
|
+
<TaskCard
|
|
560
|
+
task={dragging.task}
|
|
561
|
+
href={props.taskHref(dragging.task)}
|
|
562
|
+
onOpen={nothing}
|
|
563
|
+
today={props.today}
|
|
564
|
+
// The menu too, inert under the pointer, so that the card in hand keeps the shape of the one in its place.
|
|
565
|
+
editHref={props.editHref?.(dragging.task)}
|
|
566
|
+
onArchive={props.onArchive}
|
|
567
|
+
/>
|
|
288
568
|
</div>
|
|
289
569
|
)}
|
|
290
570
|
</div>
|