@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.
Files changed (69) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +6 -0
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/cli/help.d.ts.map +1 -1
  6. package/dist/cli/help.js +6 -8
  7. package/dist/cli/help.js.map +1 -1
  8. package/dist/components/board.d.ts +19 -9
  9. package/dist/components/board.d.ts.map +1 -1
  10. package/dist/components/board.js +210 -28
  11. package/dist/components/board.js.map +1 -1
  12. package/dist/components/comments.d.ts +0 -2
  13. package/dist/components/comments.d.ts.map +1 -1
  14. package/dist/components/comments.js +4 -39
  15. package/dist/components/comments.js.map +1 -1
  16. package/dist/components/documents.d.ts +2 -0
  17. package/dist/components/documents.d.ts.map +1 -1
  18. package/dist/components/documents.js +51 -5
  19. package/dist/components/documents.js.map +1 -1
  20. package/dist/components/files.d.ts +11 -0
  21. package/dist/components/files.d.ts.map +1 -0
  22. package/dist/components/files.js +44 -0
  23. package/dist/components/files.js.map +1 -0
  24. package/dist/components/runs.d.ts.map +1 -1
  25. package/dist/components/runs.js +34 -7
  26. package/dist/components/runs.js.map +1 -1
  27. package/dist/components/task-view.d.ts.map +1 -1
  28. package/dist/components/task-view.js +2 -4
  29. package/dist/components/task-view.js.map +1 -1
  30. package/dist/console.css +144 -80
  31. package/dist/console.d.ts.map +1 -1
  32. package/dist/console.js +3 -3
  33. package/dist/console.js.map +1 -1
  34. package/dist/docs-page.d.ts.map +1 -1
  35. package/dist/docs-page.js +14 -1
  36. package/dist/docs-page.js.map +1 -1
  37. package/dist/i18n/en.d.ts +6 -3
  38. package/dist/i18n/en.d.ts.map +1 -1
  39. package/dist/i18n/en.js +7 -4
  40. package/dist/i18n/en.js.map +1 -1
  41. package/dist/i18n/ko.d.ts.map +1 -1
  42. package/dist/i18n/ko.js +7 -4
  43. package/dist/i18n/ko.js.map +1 -1
  44. package/dist/index.d.ts +2 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/rules.d.ts.map +1 -1
  49. package/dist/rules.js +2 -1
  50. package/dist/rules.js.map +1 -1
  51. package/docs/specs/cli.md +4 -4
  52. package/docs/specs/rest.md +3 -4
  53. package/package.json +1 -1
  54. package/skills/working-the-board/SKILL.md +4 -4
  55. package/src/cli/cli.ts +6 -0
  56. package/src/cli/help.ts +6 -8
  57. package/src/components/board.tsx +357 -77
  58. package/src/components/comments.tsx +10 -39
  59. package/src/components/documents.tsx +87 -4
  60. package/src/components/files.ts +49 -0
  61. package/src/components/runs.tsx +73 -15
  62. package/src/components/task-view.tsx +10 -12
  63. package/src/console.tsx +20 -8
  64. package/src/docs-page.tsx +18 -1
  65. package/src/i18n/en.ts +8 -4
  66. package/src/i18n/ko.ts +8 -4
  67. package/src/index.ts +1 -1
  68. package/src/rules.ts +2 -1
  69. package/src/styles/console.css +144 -80
@@ -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 its grip, with a mouse or a finger, and by the select on it with the
35
- // keyboard; either way the move goes back up and the board is told. It takes its data as props
36
- // and nothing from the network; what it is told on a click goes back up.
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 board is read-only. */
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 the tasks put away are (ADR-0029); given, the done and dropped columns lead there. */
50
- readonly archiveHref?: string | undefined;
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 pointer travels on the grip before the card is dragged, and not pressed. */
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: { readonly x: number; readonly y: number };
89
+ readonly from: Point;
68
90
  readonly x: number;
69
91
  readonly y: number;
70
- /** Whether the pointer has travelled far enough for this to be a drag. */
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 function TaskCard(props: {
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's grip is pressed, to drag it to another column; left out, the card has no grip. */
83
- readonly onGrip?: ((task: RestTask, event: ReactPointerEvent<HTMLElement>) => void) | undefined;
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("sc-card", props.dragging === true && "sc-card-dragging")}
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.onGrip !== undefined && (
95
- <button
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>
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.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) {
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 grip = useCallback((task: RestTask, event: ReactPointerEvent<HTMLElement>) => {
175
- if (event.pointerType === "mouse" && event.button !== 0) {
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
- // The press is the drag's beginning and nothing else: no text is selected on the way.
179
- event.preventDefault();
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({ task, from, x: from.x, y: from.y, moving: false, over: undefined });
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 moving =
199
- before.moving || Math.hypot(x - before.from.x, y - before.from.y) > DRAG_SLACK;
200
- return {
201
- ...before,
202
- x,
203
- y,
204
- moving,
205
- over: moving ? columnStateOf(under?.dataset.column) : undefined,
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 release = () => {
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 (last?.moving === true && last.over !== undefined && last.over !== last.task.state) {
221
- onMove?.(last.task, last.over);
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 pointer has travelled; nothing while a card is merely pressed. */
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 ref={board} className={cx("sc-board", dragging !== null && "sc-board-dragging")}>
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-meta">
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
- onGrip={onMove === undefined ? undefined : grip}
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={{ transform: `translate(${dragging.x + 12}px, ${dragging.y + 12}px)` }}
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
- <span className="sc-card-number">#{dragging.task.number}</span> {dragging.task.title}
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>