pi-weave 0.2.2 → 0.2.4

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.
@@ -8,8 +8,8 @@
8
8
  *
9
9
  * ## The editor
10
10
  *
11
- * Clicking the prose swaps it for a `<textarea>`; `draft` is `null` in read
12
- * mode, so text and mode cannot disagree. No reducer — the 686-line one that
11
+ * The explicit Edit control swaps the body for a `<textarea>`; `draft` is
12
+ * `null` in read mode, so text and mode cannot disagree. No reducer — the 686-line one that
13
13
  * stood here resolved conflicts against a revision the server no longer
14
14
  * issues. Being component state, the draft is out of the 2 s poll's reach;
15
15
  * the shell guards it through the `editor` slot below.
@@ -23,7 +23,7 @@
23
23
  * ## Wikilink preview (P6.3)
24
24
  *
25
25
  * Hovering or focusing a wikilink opens a small card for its target. The
26
- * delegated pattern the click handler established extends to the card for
26
+ * body's delegated handlers extend to the card for
27
27
  * free — `mouseover`, `focusin` and `keydown` are read through the same
28
28
  * ancestor walk (`previewAnchorOf`), the state machine is `reducePreview`,
29
29
  * and this file's only contribution is forwarding the pointer's viewport
@@ -46,17 +46,18 @@ import {
46
46
  artifactKeyOfNode,
47
47
  DISCARD_PROMPT,
48
48
  DONE_LABEL,
49
+ EDIT_LABEL,
49
50
  EDITED_WORD,
50
51
  EDITOR_ARIA_LABEL,
51
52
  EMPTY_PREVIEW,
52
53
  PREVIEW_ID,
53
54
  SAVE_LABEL,
54
55
  SAVING_LABEL,
56
+ TASK_CHECKBOX_ATTR,
55
57
  WIKILINK_ATTR,
56
58
  draftDirty,
57
59
  draftMoved,
58
60
  hasTextSelection,
59
- noteClickAction,
60
61
  saveLanded,
61
62
  noteEmptyMessage,
62
63
  noteHeader,
@@ -67,6 +68,9 @@ import {
67
68
  renderNote,
68
69
  selectedArtifactPath,
69
70
  tagLabel,
71
+ taskCheckboxIndexOf,
72
+ taskCheckboxLabel,
73
+ toggleTaskCheckbox,
70
74
  wikiIndex,
71
75
  wikilinkTargetOf,
72
76
  } from "./note.model";
@@ -117,30 +121,28 @@ function Header({
117
121
  open,
118
122
  editing,
119
123
  saving,
120
- onDone,
124
+ onToggle,
121
125
  onSave,
122
126
  }: {
123
127
  view: NoteHeaderView;
124
128
  open: () => void;
125
129
  editing: boolean;
126
130
  saving: boolean;
127
- onDone: () => void;
131
+ onToggle: () => void;
128
132
  onSave: () => void;
129
133
  }) {
130
134
  return (
131
135
  <header class="weave-note-head">
132
136
  <h3 class="weave-note-title">{view.title}</h3>
133
137
  <p class="weave-note-meta">
134
- {/* Nothing in read mode: the prose is the way in. The way out needs a
135
- control, because "click the text" cannot also mean "stop". */}
136
138
  {editing ? (
137
139
  <>
138
140
  <button type="button" class="weave-note-save" disabled={saving} onClick={onSave}>
139
141
  {saving ? SAVING_LABEL : SAVE_LABEL}
140
142
  </button>
141
- <button type="button" class="weave-note-edit" onClick={onDone}>{DONE_LABEL}</button>
143
+ <button type="button" class="weave-note-edit" onClick={onToggle}>{DONE_LABEL}</button>
142
144
  </>
143
- ) : null}
145
+ ) : <button type="button" class="weave-note-edit" onClick={onToggle}>{EDIT_LABEL}</button>}
144
146
  <button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
145
147
  <span class="weave-note-open-mark" aria-hidden="true">↗</span>
146
148
  </button>
@@ -190,6 +192,12 @@ export function Note(props: NoteProps) {
190
192
  const dispatch = (event: PreviewEvent): void => void sendPreview((state) => reducePreview(state, event));
191
193
  const bodyRef = useRef<HTMLDivElement | null>(null);
192
194
  const cardRef = useRef<HTMLDivElement | null>(null);
195
+ const taskSaving = useRef(false);
196
+ const taskSource = useRef<{ slug: string; serverBody: string; body: string } | null>(null);
197
+ if (note === null) taskSource.current = null;
198
+ else if (taskSource.current === null || taskSource.current.slug !== note.slug || taskSource.current.serverBody !== note.body) {
199
+ taskSource.current = { slug: note.slug, serverBody: note.body, body: note.body };
200
+ }
193
201
 
194
202
  const [draft, setDraft] = useState<string | null>(null);
195
203
  const [saving, setSaving] = useState(false);
@@ -234,6 +242,33 @@ export function Note(props: NoteProps) {
234
242
  else close();
235
243
  };
236
244
 
245
+ const saveTask = async (checkbox: HTMLInputElement, taskIndex: number): Promise<void> => {
246
+ const source = taskSource.current;
247
+ const restore = (): void => {
248
+ checkbox.checked = !checkbox.checked;
249
+ checkbox.setAttribute("aria-label", taskCheckboxLabel(checkbox.checked));
250
+ };
251
+ if (source === null || taskSaving.current) {
252
+ restore();
253
+ return;
254
+ }
255
+ const body = toggleTaskCheckbox(source.body, taskIndex);
256
+ if (body === null) {
257
+ restore();
258
+ return;
259
+ }
260
+
261
+ const inputs = [...(bodyRef.current?.querySelectorAll<HTMLInputElement>(`input[${TASK_CHECKBOX_ATTR}]`) ?? [])];
262
+ taskSaving.current = true;
263
+ checkbox.setAttribute("aria-label", taskCheckboxLabel(checkbox.checked));
264
+ for (const input of inputs) input.disabled = true;
265
+ const ok = await props.onSave(source.slug, body);
266
+ if (ok) source.body = body;
267
+ else restore();
268
+ for (const input of inputs) input.disabled = false;
269
+ taskSaving.current = false;
270
+ };
271
+
237
272
  const toggleEdit = (): void => {
238
273
  if (note === null) return;
239
274
  if (draft === null) open(note.body);
@@ -326,7 +361,7 @@ export function Note(props: NoteProps) {
326
361
  open={() => props.onOpen(note.slug)}
327
362
  editing={draft !== null}
328
363
  saving={saving}
329
- onDone={toggleEdit}
364
+ onToggle={toggleEdit}
330
365
  onSave={() => void save()}
331
366
  />
332
367
  {draft !== null ? (
@@ -381,16 +416,20 @@ export function Note(props: NoteProps) {
381
416
  }}
382
417
  onBlur={() => dispatch({ type: "hide" })}
383
418
  onClick={(event) => {
384
- // Three gestures, one element; `noteClickAction` owns the order.
419
+ const checkbox = event.target as HTMLInputElement;
420
+ const taskIndex = taskCheckboxIndexOf(checkbox);
421
+ if (taskIndex !== null) {
422
+ void saveTask(checkbox, taskIndex);
423
+ return;
424
+ }
385
425
  const selection =
386
426
  typeof window !== "undefined" && typeof window.getSelection === "function"
387
427
  ? window.getSelection()
388
428
  : null;
429
+ if (hasTextSelection(selection)) return;
389
430
  const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
390
- const action = noteClickAction(hasTextSelection(selection), target);
391
431
  // A wikilink carries no href, so route it onto the context bus.
392
- if (action === "navigate" && target !== null) props.onSelect(target);
393
- else if (action === "edit") open(note.body);
432
+ if (target !== null) props.onSelect(target);
394
433
  }}
395
434
  onKeyDown={(event) => {
396
435
  // Escape is first so the card closes on the gesture a keyboard
@@ -405,11 +444,9 @@ export function Note(props: NoteProps) {
405
444
  }
406
445
  if (event.key !== "Enter" && event.key !== " ") return;
407
446
  const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
447
+ if (target === null) return;
408
448
  event.preventDefault();
409
- // The keyboard's half of the click: without it, editing would be
410
- // mouse-only now that the Edit button is gone.
411
- if (target !== null) props.onSelect(target);
412
- else open(note.body);
449
+ props.onSelect(target);
413
450
  }}
414
451
  // Sanitised by `renderNote`'s three layers — see note.model.ts.
415
452
  dangerouslySetInnerHTML={{ __html: html }}
@@ -67,7 +67,7 @@
67
67
  * `tsconfig.json` project compile the tests.
68
68
  */
69
69
 
70
- import { Marked } from "marked";
70
+ import { Lexer, Marked } from "marked";
71
71
  import type { TokenizerAndRendererExtension, Tokens } from "marked";
72
72
  import { relTime } from "../../shared/view";
73
73
  import type { GraphPayload, ViewNote, WireGraphNode, WireNoteSource } from "../../shared/wire";
@@ -197,6 +197,14 @@ export function safeUrl(raw: string): string | null {
197
197
  */
198
198
  export const WIKILINK_ATTR = "data-weave-target";
199
199
 
200
+ /** The rendered task checkbox's zero-based source-order index. */
201
+ export const TASK_CHECKBOX_ATTR = "data-weave-task";
202
+
203
+ /** The action a task checkbox offers from its current state. */
204
+ export function taskCheckboxLabel(checked: boolean): string {
205
+ return checked ? "Mark task incomplete" : "Mark task complete";
206
+ }
207
+
200
208
  /** Prefix core's graph builder gives note nodes. Mirrors `noteSlug`. */
201
209
  const NOTE_PREFIX = "note:";
202
210
 
@@ -465,11 +473,16 @@ export function renderWikilink(index: WikiIndex, target: string, alias: string):
465
473
  * rather than `string | Promise<string>`; nothing here is asynchronous.
466
474
  */
467
475
  export function markdownRenderer(index: WikiIndex): Marked {
476
+ let taskIndex = 0;
468
477
  return new Marked({
469
478
  gfm: true,
470
479
  async: false,
471
480
  extensions: [wikilinkExtension(index)],
472
481
  renderer: {
482
+ checkbox({ checked }) {
483
+ const state = checked ? ' checked=""' : "";
484
+ return `<input type="checkbox"${state} ${TASK_CHECKBOX_ATTR}="${taskIndex++}" aria-label="${taskCheckboxLabel(checked)}"> `;
485
+ },
473
486
  /**
474
487
  * Layer 1. Raw HTML in a note body is rendered as **text**.
475
488
  *
@@ -551,7 +564,6 @@ export interface SanitizeConfig {
551
564
  readonly ALLOW_DATA_ATTR: boolean;
552
565
  readonly ALLOW_ARIA_ATTR: boolean;
553
566
  readonly ALLOW_UNKNOWN_PROTOCOLS: boolean;
554
- readonly USE_PROFILES: { readonly html: true };
555
567
  }
556
568
 
557
569
  /**
@@ -575,9 +587,9 @@ export interface SanitizeConfig {
575
587
  * and keeps the element. It also contains no `style`, so a CSS-based overlay
576
588
  * attack has no attribute to live in.
577
589
  *
578
- * `ALLOW_DATA_ATTR: false` with {@link WIKILINK_ATTR} named explicitly is
590
+ * `ALLOW_DATA_ATTR: false` with the two renderer-owned data attributes named explicitly is
579
591
  * narrower than leaving data attributes on: the only `data-` attribute that
580
- * may exist in rendered output is the one this module writes.
592
+ * may exist in rendered output is one this module writes.
581
593
  * `ADD_URI_SAFE_ATTR` then tells DOMPurify that the attribute's value is not a
582
594
  * URL and must not be run through `IS_ALLOWED_URI` — without it a slug like
583
595
  * `note-2` is fine but the check is being applied to a value that was never a
@@ -644,15 +656,15 @@ export const SANITIZE_CONFIG: SanitizeConfig = frozen({
644
656
  "align",
645
657
  "type",
646
658
  "checked",
647
- "disabled",
659
+ "aria-label",
648
660
  "start",
649
661
  WIKILINK_ATTR,
662
+ TASK_CHECKBOX_ATTR,
650
663
  ]),
651
- ADD_URI_SAFE_ATTR: frozen([WIKILINK_ATTR]),
664
+ ADD_URI_SAFE_ATTR: frozen([WIKILINK_ATTR, TASK_CHECKBOX_ATTR]),
652
665
  ALLOW_DATA_ATTR: false,
653
666
  ALLOW_ARIA_ATTR: false,
654
667
  ALLOW_UNKNOWN_PROTOCOLS: false,
655
- USE_PROFILES: frozen({ html: true as const }),
656
668
  });
657
669
 
658
670
  /**
@@ -671,12 +683,92 @@ export function renderNote(purify: Purifier, body: string, index: WikiIndex): st
671
683
 
672
684
  // --- reading a click back out of the DOM --------------------------------------------------
673
685
 
686
+ const TASK_LINE_RE = /^((?: {0,3}>[ \t]?)*[ \t]*(?:[*+-]|\d{1,9}[.)])[ \t]+\[)([ xX])(\] +\S)/;
687
+
688
+ /** Source offsets for the task markers marked will render, in render order. */
689
+ function taskMarkerOffsets(body: string): Array<number | null> {
690
+ const candidates: number[] = [];
691
+ const source = body.replace(/\r\n?/g, "\n");
692
+ let sourceOffset = 0;
693
+
694
+ for (const token of Lexer.lex(source, { gfm: true })) {
695
+ const tokenOffset = source.indexOf(token.raw, sourceOffset);
696
+ if (tokenOffset === -1) continue;
697
+ sourceOffset = tokenOffset + token.raw.length;
698
+ if (token.type !== "list" && token.type !== "blockquote") continue;
699
+
700
+ let lineOffset = 0;
701
+ while (lineOffset < token.raw.length) {
702
+ const newline = token.raw.indexOf("\n", lineOffset);
703
+ const lineEnd = newline === -1 ? token.raw.length : newline;
704
+ const line = token.raw.slice(lineOffset, lineEnd).replace(/\r$/, "");
705
+ const task = TASK_LINE_RE.exec(line);
706
+ if (task !== null) candidates.push(tokenOffset + lineOffset + (task[1] as string).length);
707
+ lineOffset = newline === -1 ? token.raw.length : newline + 1;
708
+ }
709
+ }
710
+
711
+ const walker = new Marked();
712
+ const states = (markdown: string): boolean[] => {
713
+ const checked: boolean[] = [];
714
+ walker.walkTokens(Lexer.lex(markdown, { gfm: true }), (token) => {
715
+ if (token.type === "checkbox") checked.push(token.checked);
716
+ });
717
+ return checked;
718
+ };
719
+ const baseline = states(source);
720
+ const offsets: Array<number | null> = baseline.map(() => null);
721
+ // ponytail: re-lex each candidate so Marked remains the sole judge of
722
+ // Markdown structure; use token source positions if Marked adds them.
723
+ for (const offset of candidates) {
724
+ const toggled = `${source.slice(0, offset)}${source[offset] === " " ? "x" : " "}${source.slice(offset + 1)}`;
725
+ const next = states(toggled);
726
+ if (next.length !== baseline.length) continue;
727
+ let changed = -1;
728
+ for (let index = 0; index < baseline.length; index += 1) {
729
+ if (next[index] === baseline[index]) continue;
730
+ if (changed !== -1) {
731
+ changed = -1;
732
+ break;
733
+ }
734
+ changed = index;
735
+ }
736
+ if (changed !== -1) offsets[changed] = offset;
737
+ }
738
+
739
+ return offsets.map((offset) => {
740
+ if (offset === null) return null;
741
+ let original = 0;
742
+ let normalized = 0;
743
+ while (normalized < offset) {
744
+ original += body[original] === "\r" && body[original + 1] === "\n" ? 2 : 1;
745
+ normalized += 1;
746
+ }
747
+ return original;
748
+ });
749
+ }
750
+
751
+ /** Flip one rendered GFM task marker without changing any other source byte. */
752
+ export function toggleTaskCheckbox(body: string, index: number): string | null {
753
+ if (!Number.isSafeInteger(index) || index < 0) return null;
754
+ const offset = taskMarkerOffsets(body)[index];
755
+ if (offset === undefined || offset === null) return null;
756
+ return `${body.slice(0, offset)}${body[offset] === " " ? "x" : " "}${body.slice(offset + 1)}`;
757
+ }
758
+
674
759
  /** The slice of `Element` {@link wikilinkTargetOf} walks. `HTMLElement` satisfies it. */
675
760
  export interface ClosestElement {
676
761
  getAttribute(name: string): string | null;
677
762
  readonly parentElement: ClosestElement | null;
678
763
  }
679
764
 
765
+ /** The task index carried by a rendered checkbox, if the target is one. */
766
+ export function taskCheckboxIndexOf(element: ClosestElement | null): number | null {
767
+ const raw = element?.getAttribute(TASK_CHECKBOX_ATTR);
768
+ if (raw === null || raw === undefined || !/^(?:0|[1-9]\d*)$/.test(raw)) return null;
769
+ return Number(raw);
770
+ }
771
+
680
772
  /**
681
773
  * Depth limit for the ancestor walk.
682
774
  *
@@ -747,11 +839,8 @@ export const CREATED_WORD = "created";
747
839
 
748
840
  // --- the editor -------------------------------------------------------------------------
749
841
 
750
- /**
751
- * The editor's strings. No "Edit" label — clicking the prose opens it (see
752
- * {@link noteClickAction}); "Done", because leaving a clean editor cancels
753
- * nothing.
754
- */
842
+ /** The editor's controls and prompts. */
843
+ export const EDIT_LABEL = "Edit";
755
844
  export const DONE_LABEL = "Done";
756
845
  export const SAVE_LABEL = "Save";
757
846
  export const SAVING_LABEL = "Saving…";
@@ -767,18 +856,6 @@ export function draftDirty(draft: string, body: string): boolean {
767
856
  return draft !== body;
768
857
  }
769
858
 
770
- /** What a click on the rendered body means. */
771
- export type NoteClickAction = "ignore" | "navigate" | "edit";
772
-
773
- /**
774
- * Resolve a click on the prose. Selection wins (a drag to copy ends in a
775
- * click, and 865da37 is that bug), then wikilinks, then editing.
776
- */
777
- export function noteClickAction(hasSelection: boolean, wikilinkTarget: string | null): NoteClickAction {
778
- if (hasSelection) return "ignore";
779
- return wikilinkTarget === null ? "edit" : "navigate";
780
- }
781
-
782
859
  /**
783
860
  * Whether a save's reply still belongs to the editor that issued it. A
784
861
  * counter, not the slug: reopening the same note is a new session too.
@@ -441,11 +441,6 @@ body{font-size:var(--weave-px-base)}
441
441
  chrome's 1.6: the extra leading is what a page of running text needs that a
442
442
  status bar does not. Code stays mono deliberately. */
443
443
  .weave-note-body{padding:12px var(--weave-note-gutter) 28px;font-size:var(--weave-px-body);line-height:1.7;color:var(--weave-fg)}
444
- /* The prose is the edit affordance: a caret and a faint wash, not a border.
445
- This column is read far more often than edited. */
446
- .weave-note-body{cursor:text}
447
- .weave-note-body:hover{background:var(--weave-panel)}
448
- @media (prefers-reduced-motion: no-preference){.weave-note-body{transition:background 120ms ease}}
449
444
  .weave-note-body>*:first-child{margin-top:0}
450
445
  .weave-note-body h1,.weave-note-body h2,.weave-note-body h3,
451
446
  .weave-note-body h4,.weave-note-body h5,.weave-note-body h6{
@@ -462,6 +457,8 @@ body{font-size:var(--weave-px-base)}
462
457
  .weave-note-body p,.weave-note-body ul,.weave-note-body ol,.weave-note-body blockquote{margin:0 0 12px}
463
458
  .weave-note-body ul,.weave-note-body ol{padding-left:20px}
464
459
  .weave-note-body li{margin:4px 0}
460
+ .weave-note-body input[type="checkbox"]{accent-color:var(--weave-accent);cursor:pointer}
461
+ .weave-note-body input[type="checkbox"]:disabled{cursor:wait}
465
462
  .weave-note-body blockquote{
466
463
  padding-left:10px;border-left:2px solid var(--weave-line-strong);color:var(--weave-dim);
467
464
  }