diff --git a/apps/web/src/icon-tooltip.tsx b/apps/web/src/icon-tooltip.tsx index e7739bd2..2e9ee287 100644 --- a/apps/web/src/icon-tooltip.tsx +++ b/apps/web/src/icon-tooltip.tsx @@ -25,7 +25,7 @@ function iconButton(target: EventTarget | null): HTMLElement | null { if ( !button || button.disabled || button.closest("[inert]") || button.matches( - ".sr-only, .navigation-drawer-backdrop, .navigation-modal-backdrop, .plan-comment-button", + ".sr-only, .navigation-drawer-backdrop, .navigation-modal-backdrop, .plan-comment-button, .plan-decision-marker", ) ) { return null; diff --git a/e2e/decision-marker.e2e.ts b/e2e/decision-marker.e2e.ts new file mode 100644 index 00000000..356f86ed --- /dev/null +++ b/e2e/decision-marker.e2e.ts @@ -0,0 +1,476 @@ +/** + * A resolved decision is a margin marker beside the prose it produced. + * + * Geometry, hover, pinning, scrolling and the highlight registry are browser + * behaviour, so this runs in Chromium; the pure pieces (which options were not + * chosen, how hover and pin combine, where things sit) are in + * `packages/editor/src/resolved.test.ts`. + * + * Anchors are seeded with a stale epoch and the block's digest, which is how + * the server finds a block again after its history is gone. + */ + +import { createHash } from "node:crypto"; + +import { authenticate, content, expect, roomPath, test } from "./room"; + +import type { Locator, Page } from "@playwright/test"; + +const FIRST = "The rollout goes team by team, starting with the docs team."; +const SECOND = "After two weeks we review the pilot and decide whether to widen it."; +const WIDGET_A = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; +const WIDGET_B = "01K0N4TR8K7JGM4R1J7PW4R8YK"; +const QUESTION_A = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; +const QUESTION_B = "01K0N4V4E7Y6P4MJ5WD8XZF3B3"; +const AT = "2026-09-23T15:13:00.000Z"; + +function digest(text: string): string { + return `sha256:${createHash("sha256").update(`${text}\n`).digest("hex")}`; +} + +/** A ULID, which is what the dialect insists an option id is. */ +function optionId(question: string, index: number): string { + return `${question.slice(0, 22)}${question.slice(-2)}0${index}`; +} + +function options(question: string, labels: string[]): string { + return labels.map((label, index) => + `` + ) + .join("\n"); +} + +function questionnaire( + widget: string, + question: string, + prompt: string, + labels: string[], + answer: string, +) { + return `\n` + + `\n` + + `${options(question, labels)}\n` + + `\n` + + `\n\n`; +} + +const LABELS_A = ["All at once", "Team by team", "Phased over a quarter"]; +const LABELS_B = ["Two weeks", "A month"]; + +const SOURCE = `${FIRST}\n\n` + + questionnaire(WIDGET_A, QUESTION_A, "How should we roll this out?", LABELS_A, "Team by team") + + `\n${SECOND}\n\n` + + questionnaire(WIDGET_B, QUESTION_B, "How long is the pilot?", LABELS_B, "Two weeks") + + `\n${"Padding paragraph.\n\n".repeat(60)}`; + +function record( + widget: string, + question: string, + prompt: string, + labels: string[], + answer: string, + prose: string, +) { + let definition = { + questions: [{ + id: question, + header: "Rollout", + question: prompt, + multiple: false, + options: labels.map((label, index) => ({ + id: optionId(question, index), + label, + description: "", + })), + }], + }; + return { + id: widget, + status: "answered", + definition, + answers: { [question]: answer }, + resolver: "ana", + at: Date.parse(AT) / 1_000, + anchors: { + widget, + questions: { + [question]: { + anchors: [{ epoch: "stale", position: "", digest: digest(prose) }], + pending: false, + }, + }, + }, + }; +} + +const STATE = { + revision: 1, + questions: [ + record(WIDGET_A, QUESTION_A, "How should we roll this out?", LABELS_A, "Team by team", FIRST), + record(WIDGET_B, QUESTION_B, "How long is the pilot?", LABELS_B, "Two weeks", SECOND), + ], +}; + +function marker(page: Page, answer = "Team by team") { + return page.getByRole("button", { name: `Decision: ${answer}` }); +} + +function washed(page: Page, name = "plan-decision"): Promise { + return page.evaluate(name => CSS.highlights.get(name)?.size ?? 0, name); +} + +function prose(page: Page, text: string) { + return content(page).getByText(text, { exact: true }); +} + +async function open(page: Page, answer = "Team by team") { + await page.setViewportSize({ width: 1_440, height: 900 }); + await page.getByRole("button", { name: "Hide chat pane" }).click(); + await page.getByRole("button", { name: "Collapse Projects sidebar" }).click(); + // The panes animate; a marker under a still-moving pointer would be left behind. + let last = ""; + let steady = 0; + await expect.poll(async () => { + let now = JSON.stringify(await marker(page, answer).boundingBox()); + steady = now === last ? steady + 1 : 0; + last = now; + return steady; + }, { intervals: [200] }).toBeGreaterThanOrEqual(3); +} + +test("a linked decision is a gutter marker on its first line, with no card in the plan", async ({ join, seed }) => { + await seed(SOURCE, STATE); + let page = await join("ana"); + await open(page); + + await expect(marker(page)).toBeVisible(); + await expect( + page.locator( + `[data-document-view="plan"] article[data-plan-sidecar-questionnaire="${WIDGET_A}"]`, + ), + ).toHaveCount(0); + + let line = (await prose(page, FIRST).boundingBox())!; + let box = (await marker(page).boundingBox())!; + expect(box.x + box.width).toBeLessThanOrEqual(line.x); + let lineCentre = line.y + 16; + expect(Math.abs(box.y + box.height / 2 - lineCentre)).toBeLessThan(14); + expect(box.width).toBe(20); +}); + +test("hovering the marker washes the prose and previews the decision", async ({ join, seed }) => { + await seed(SOURCE, STATE); + let page = await join("ana"); + await open(page); + + await marker(page).hover(); + let preview = page.getByRole("tooltip"); + await expect(preview).toContainText("How should we roll this out?"); + await expect(preview).toContainText("Team by team"); + await expect(preview).toContainText("ana"); + // Folded away until pinned. + expect( + await preview.locator(".plan-decision-fold").evaluate(element => + element.getBoundingClientRect().height + ), + ).toBe(0); + await expect.poll(() => washed(page)).toBeGreaterThan(0); + + await page.mouse.move(5, 5); + await expect(page.getByRole("tooltip")).toHaveCount(0); + await expect.poll(() => washed(page)).toBe(0); +}); + +test("hovering the anchored prose previews it without taking the text selection", async ({ join, seed }) => { + await seed(SOURCE, STATE); + let page = await join("ana"); + await open(page); + + await prose(page, FIRST).hover(); + await expect(page.getByRole("tooltip")).toContainText("How should we roll this out?"); + await expect.poll(() => washed(page)).toBeGreaterThan(0); +}); + +test("pressing the marker pins the popover, which shows what was not chosen and dismisses", async ({ join, seed }) => { + await seed(SOURCE, STATE); + let page = await join("ana"); + await open(page); + + await marker(page).click(); + let dialog = page.getByRole("dialog", { name: "Decision" }); + await expect(dialog).toBeVisible(); + await expect(marker(page)).toHaveAttribute("aria-expanded", "true"); + await expect(dialog.getByText("All at once")).toBeVisible(); + await expect(dialog.getByText("Phased over a quarter")).toBeVisible(); + + // Leaving the marker does not let go of a pin, or of its wash. + await page.mouse.move(5, 5); + await expect(dialog).toBeVisible(); + await expect.poll(() => washed(page)).toBeGreaterThan(0); + + await dialog.getByRole("button", { name: "Close" }).click(); + await expect(page.getByRole("dialog", { name: "Decision" })).toHaveCount(0); + await expect.poll(() => washed(page)).toBe(0); + await expect(marker(page)).toBeFocused(); + + await marker(page).click(); + await expect(dialog).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog", { name: "Decision" })).toHaveCount(0); + await expect(marker(page)).toBeFocused(); + await expect.poll(() => washed(page)).toBe(0); + + await marker(page).click(); + await expect(dialog).toBeVisible(); + await page.mouse.click(5, 400); + await expect(page.getByRole("dialog", { name: "Decision" })).toHaveCount(0); +}); + +test("clicking the prose pins, and only one decision is pinned at a time", async ({ join, seed }) => { + await seed(SOURCE, STATE); + let page = await join("ana"); + await open(page); + + await prose(page, FIRST).click(); + await expect(page.getByRole("dialog", { name: "Decision" })).toContainText("How should we roll"); + // The same prose again is not a dismissal. + await prose(page, FIRST).click(); + await expect(page.getByRole("dialog", { name: "Decision" })).toHaveCount(1); + + // The popover covers the prose below it, so the next decision is reached by its marker. + await marker(page, "Two weeks").click(); + let dialog = page.getByRole("dialog", { name: "Decision" }); + await expect(dialog).toHaveCount(1); + await expect(dialog).toContainText("How long is the pilot?"); + await expect(marker(page)).toHaveAttribute("aria-expanded", "false"); + await expect(marker(page, "Two weeks")).toHaveAttribute("aria-expanded", "true"); +}); + +test("the marker is a keyboard control and its popover is reachable from it", async ({ join, seed }) => { + await seed(SOURCE, STATE); + let page = await join("ana"); + await open(page); + + await marker(page).focus(); + await expect(page.getByRole("tooltip")).toContainText("Team by team"); + await page.keyboard.press("Enter"); + let dialog = page.getByRole("dialog", { name: "Decision" }); + await expect(dialog).toBeVisible(); + // Pinning by keyboard moves focus into the popover, which is not next in tab order. + await expect(dialog.getByRole("button", { name: "Close" })).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("dialog", { name: "Decision" })).toHaveCount(0); + await expect(marker(page)).toBeFocused(); +}); + +test("the marker follows its prose as the document scrolls", async ({ join, seed }) => { + await seed(SOURCE, STATE); + let page = await join("ana"); + await open(page); + + let before = (await marker(page, "Two weeks").boundingBox())!; + await page.locator("[data-plan-scroll]").evaluate(element => element.scrollBy(0, 40)); + await expect.poll(async () => (await marker(page, "Two weeks").boundingBox())!.y) + .toBeCloseTo(before.y - 40, 0); +}); + +test("a narrow document keeps its marker on screen and opens the popover within it", async ({ join, seed }) => { + await seed(SOURCE, STATE); + let page = await join("ana", { viewport: { width: 390, height: 844 } }); + + let target = marker(page); + await expect(target).toBeVisible(); + let box = (await target.boundingBox())!; + expect(box.x).toBeGreaterThanOrEqual(0); + + await prose(page, FIRST).click(); + let dialog = page.getByRole("dialog", { name: "Decision" }); + await expect(dialog).toBeVisible(); + let pop = (await dialog.boundingBox())!; + expect(pop.x).toBeGreaterThanOrEqual(0); + expect(pop.x + pop.width).toBeLessThanOrEqual(390); +}); + +test("a touch marker has a 44px target", async ({ baseURL, browser, room, seed }) => { + await seed(SOURCE, STATE); + let context = await browser.newContext({ + baseURL, + hasTouch: true, + isMobile: true, + viewport: { width: 390, height: 844 }, + }); + try { + let page = await context.newPage(); + await authenticate(page, "ana", baseURL!); + await page.goto(roomPath(room)); + let target = marker(page); + await expect(target).toBeVisible(); + let box = (await target.boundingBox())!; + expect(box.width).toBeGreaterThanOrEqual(44); + expect(box.height).toBeGreaterThanOrEqual(44); + await target.tap(); + await expect(page.getByRole("dialog", { name: "Decision" })).toBeVisible(); + } finally { + await context.close(); + } +}); + +test("a decision with no prose keeps a compact card instead of a marker", async ({ join, seed }) => { + let orphan = { + ...STATE.questions[0]!, + anchors: { widget: WIDGET_A, questions: { [QUESTION_A]: { anchors: [], pending: false } } }, + }; + await seed(SOURCE, { revision: 1, questions: [orphan, STATE.questions[1]] }); + let page = await join("ana"); + await open(page, "Two weeks"); + + await expect(marker(page)).toHaveCount(0); + await expect( + page.locator( + `[data-document-view="plan"] article[data-plan-sidecar-questionnaire="${WIDGET_A}"]`, + ), + ).toBeVisible(); + await expect(marker(page, "Two weeks")).toBeVisible(); +}); + +test("the marker follows an edit above its prose", async ({ join, seed }) => { + await seed(SOURCE, STATE); + let page = await join("ana"); + await open(page); + + let before = (await marker(page, "Two weeks").boundingBox())!; + await prose(page, FIRST).click(); + await page.keyboard.press("Home"); + for (let line = 0; line < 3; line++) await page.keyboard.press("Enter"); + await expect.poll(async () => (await marker(page, "Two weeks").boundingBox())!.y) + .toBeGreaterThan(before.y + 20); + let line = (await prose(page, SECOND).boundingBox())!; + let box = (await marker(page, "Two weeks").boundingBox())!; + expect(Math.abs(box.y + box.height / 2 - (line.y + 16))).toBeLessThan(14); +}); + +/** Home and End do not move the caret on macOS, so put it at the edge with the pointer. */ +async function clickEdge(block: Locator, edge: "start" | "end") { + let box = (await block.boundingBox())!; + await block.click({ position: { x: edge === "start" ? 1 : box.width - 1, y: 8 } }); +} + +function paragraph(page: Page, text: string) { + return content(page).locator("p").filter({ hasText: text }); +} + +test("arrowing down past a collapsed decision lands in the next paragraph and types there", async ({ join, seed }) => { + await seed(SOURCE, STATE); + let page = await join("ana"); + await open(page); + + await clickEdge(prose(page, FIRST), "end"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.type("xyz"); + await expect(content(page)).not.toContainText("zyx"); + // The column is kept, so the text lands inside the paragraph after the hidden card. + let landed = paragraph(page, "xyz"); + await expect(landed).toHaveCount(1); + await expect(landed).toContainText("After two weeks"); + await expect(landed).toContainText("widen it."); + await expect(prose(page, FIRST)).toBeVisible(); +}); + +test("arrowing up past a collapsed decision lands in the previous paragraph", async ({ join, seed }) => { + await seed(SOURCE, STATE); + let page = await join("ana"); + await open(page); + + await clickEdge(prose(page, SECOND), "start"); + await page.keyboard.press("ArrowUp"); + await page.keyboard.type("xyz"); + await expect(paragraph(page, FIRST)).toContainText("xyz"); + await expect(paragraph(page, SECOND)).not.toContainText("xyz"); +}); + +test("arrowing right or left across a collapsed decision crosses it", async ({ join, seed }) => { + await seed(SOURCE, STATE); + let page = await join("ana"); + await open(page); + + await clickEdge(prose(page, FIRST), "end"); + await page.keyboard.press("ArrowRight"); + await page.keyboard.type("xyz"); + await expect(paragraph(page, "xyzAfter two weeks")).toHaveCount(1); + // Three characters back to the start of the paragraph, one more across the card. + for (let press = 0; press < 4; press++) await page.keyboard.press("ArrowLeft"); + await page.keyboard.type("Q"); + await expect(paragraph(page, `${FIRST}Q`)).toHaveCount(1); + await expect(content(page)).not.toContainText("zyx"); +}); + +test("typing with a visible decision card selected never lands at the document start", async ({ join, seed }) => { + let orphan = { + ...STATE.questions[0]!, + anchors: { widget: WIDGET_A, questions: { [QUESTION_A]: { anchors: [], pending: false } } }, + }; + await seed(SOURCE, { revision: 1, questions: [orphan, STATE.questions[1]] }); + let page = await join("ana"); + await open(page, "Two weeks"); + + await clickEdge(prose(page, FIRST), "end"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.type("xyz"); + await expect(content(page)).not.toContainText("zyx"); + await expect(content(page).locator("p").first()).toHaveText(FIRST); +}); + +const hiddenCard = (page: Page, widget: string) => + page.locator(`[data-plan-collapsed][data-plan-sidecar-questionnaire="${widget}"]`); + +test("Backspace beside a collapsed decision joins the text blocks and keeps it", async ({ join, seed }) => { + await seed(SOURCE, STATE); + let page = await join("ana"); + await open(page); + + await clickEdge(prose(page, SECOND), "start"); + await page.keyboard.press("Backspace"); + await expect(paragraph(page, `${FIRST}${SECOND}`)).toHaveCount(1); + await expect(hiddenCard(page, WIDGET_A)).toHaveCount(1); + // The caret is at the join, so typing lands there. + await page.keyboard.type("Q"); + await expect(paragraph(page, `${FIRST}Q${SECOND}`)).toHaveCount(1); + await expect(hiddenCard(page, WIDGET_A)).toHaveCount(1); +}); + +test("Delete at the end of the block before a collapsed decision joins the next block", async ({ join, seed }) => { + await seed(SOURCE, STATE); + let page = await join("ana"); + await open(page); + + await clickEdge(prose(page, FIRST), "end"); + await page.keyboard.press("Delete"); + await expect(paragraph(page, `${FIRST}${SECOND}`)).toHaveCount(1); + await expect(hiddenCard(page, WIDGET_A)).toHaveCount(1); +}); + +test("arrowing down onto a visible card that follows a collapsed one selects the visible card", async ({ join, seed }) => { + let orphan = { + ...STATE.questions[1]!, + anchors: { widget: WIDGET_B, questions: { [QUESTION_B]: { anchors: [], pending: false } } }, + }; + let source = `${FIRST}\n\n` + + questionnaire(WIDGET_A, QUESTION_A, "How should we roll this out?", LABELS_A, "Team by team") + + `\n` + + questionnaire(WIDGET_B, QUESTION_B, "How long is the pilot?", LABELS_B, "Two weeks") + + `\n${SECOND}\n\n${"Padding paragraph.\n\n".repeat(60)}`; + await seed(source, { revision: 1, questions: [STATE.questions[0], orphan] }); + let page = await join("ana"); + await open(page); + + await clickEdge(prose(page, FIRST), "end"); + await page.keyboard.press("ArrowDown"); + // Deleting the selection removes whatever it is: only the visible card may go. + await page.keyboard.press("Backspace"); + await expect( + page.locator( + `[data-document-view="plan"] article[data-plan-sidecar-questionnaire="${WIDGET_B}"]`, + ), + ).toHaveCount(0); + await expect(hiddenCard(page, WIDGET_A)).toHaveCount(1); +}); diff --git a/packages/editor/src/marks.ts b/packages/editor/src/marks.ts index a4c55dd4..87572690 100644 --- a/packages/editor/src/marks.ts +++ b/packages/editor/src/marks.ts @@ -57,6 +57,8 @@ import type { Points } from "./passage"; export type Owner = "questions" | "comments"; const NAME = "plan-related"; +/** Prose a decision produced is washed in the decision's own (success) tone. */ +const DECISION_NAME = "plan-decision"; /** * How long a pin stays up. @@ -83,8 +85,14 @@ let lapsing: ReturnType | undefined; * it, are the parts of this worth testing and the parts that need no browser. */ export function union(): Points[] { - let hover = [...wanted.values()].flat(); - return hover.length > 0 ? hover : pinned?.places ?? []; + return layers().flatMap(([, places]) => places); +} + +/** `union`, still divided by the store that asked, so each can keep its own tone. */ +function layers(): [Owner, Points[]][] { + let hover = [...wanted].filter(([, places]) => places.length > 0); + if (hover.length > 0) return hover; + return pinned ? [[pinned.owner, pinned.places]] : []; } function available(): boolean { @@ -193,7 +201,10 @@ export function unpin(editor?: LexicalEditor, owner?: Owner): void { export function clear(editor?: LexicalEditor): void { wanted.clear(); release(); - if (available()) CSS.highlights.delete(NAME); + if (available()) { + CSS.highlights.delete(NAME); + CSS.highlights.delete(DECISION_NAME); + } if (editor) outline(editor, []); } @@ -207,16 +218,20 @@ function release(): void { function render(editor: LexicalEditor): void { if (!available()) return fallback(editor); - let ranges: Range[] = []; + let ranges: { [name: string]: Range[] } = { [NAME]: [], [DECISION_NAME]: [] }; editor.getEditorState().read(() => { - for (let points of union()) { - let range = $rangeOf(editor, points); - if (range) ranges.push(range); + for (let [owner, places] of layers()) { + for (let points of places) { + let range = $rangeOf(editor, points); + if (range) ranges[owner === "questions" ? DECISION_NAME : NAME]!.push(range); + } } }); - if (ranges.length === 0) CSS.highlights.delete(NAME); - else CSS.highlights.set(NAME, new Highlight(...ranges)); + for (let [name, found] of Object.entries(ranges)) { + if (found.length === 0) CSS.highlights.delete(name); + else CSS.highlights.set(name, new Highlight(...found)); + } } /** Blocks currently outlined by the fallback, so they can be un-outlined. */ diff --git a/packages/editor/src/questionnaires.ts b/packages/editor/src/questionnaires.ts index 481abf8e..5234a0fe 100644 --- a/packages/editor/src/questionnaires.ts +++ b/packages/editor/src/questionnaires.ts @@ -65,6 +65,8 @@ export class QuestionnaireStore { #binding: Binding | undefined; #editor: LexicalEditor | undefined; #related: Related[] = []; + #relations = 0; + #relationListeners = new Set<() => void>(); /** * Which decision the reader last asked to be taken to, and how far along * it. Never cleared: whether it is still live is the pin's answer. @@ -125,7 +127,23 @@ export class QuestionnaireStore { console.error("[plan] could not resolve what decisions relate to:", err); return; } + this.#relations++; for (let listener of this.#listeners) listener(); + for (let listener of this.#relationListeners) listener(); + } + + /** Changes whenever a new snapshot of where decisions live has been resolved. */ + relationsSnapshot = (): number => this.#relations; + + subscribeRelations = (listener: () => void): () => void => { + this.#relationListeners.add(listener); + return () => this.#relationListeners.delete(listener); + }; + + /** The node keys of the blocks a decision resolves to, or none if it names none. */ + blocks(widget: string, question: string): string[] { + let found = this.#related.find(item => item.widget === widget && item.question === question); + return !found || found.pending ? [] : found.keys; } /** How much prose each of a questionnaire's decisions resolves to. */ @@ -242,6 +260,16 @@ export function useQuestionnaires(store: QuestionnaireStore): QuestionnaireEntry return useSyncExternalStore(subscribe, store.snapshot, store.snapshot); } +/** Re-renders when where decisions live has been re-resolved. */ +export function useRelations(store: QuestionnaireStore | undefined): number { + let subscribe = useCallback( + (listener: () => void) => store ? store.subscribeRelations(listener) : () => {}, + [store], + ); + let snapshot = useCallback(() => store?.relationsSnapshot() ?? 0, [store]); + return useSyncExternalStore(subscribe, snapshot, snapshot); +} + export function useHasPlanContent(store: QuestionnaireStore): boolean { let subscribe = useCallback((listener: () => void) => store.subscribe(listener), [store]); return useSyncExternalStore(subscribe, store.contentSnapshot, store.contentSnapshot); diff --git a/packages/editor/src/resolved-layer.tsx b/packages/editor/src/resolved-layer.tsx new file mode 100644 index 00000000..bfcedf8a --- /dev/null +++ b/packages/editor/src/resolved-layer.tsx @@ -0,0 +1,528 @@ +/** + * A resolved decision, as a marker beside the prose it produced. + * + * Reader-local chrome, like the comment layer it is modelled on: it is drawn + * over the document from measurements and never put in it. Hovering the marker + * or the prose washes the passage and previews the decision; pressing either + * pins the same popover open, where the options not chosen and the close + * button appear. What each of those says and where it sits is `resolved.ts`. + */ + +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { CheckIcon, ClockIcon, CloseIcon, DecisionIcon } from "@chopin/icons"; +import { useCellValue } from "@mdxeditor/gurx"; + +import { when } from "./card"; +import { containsHit, passageHits } from "./comment-hits"; +import { Face } from "./face"; +import { COARSE_POINTER_QUERY } from "./pointer"; +import { + COMPACT_GUTTER, + keyOf, + MARKER_SIZE, + MARKER_TOUCH_SIZE, + markerPoint, + point, + popoverBelow, + shown, + unchosen, +} from "./resolved"; +import { useQuestionnaires, useRelations } from "./questionnaires"; +import { blockElement } from "./scroll"; +import { useTransitionPresence } from "./transition-presence"; +import { widgets$ } from "./widget-options"; + +import type { CSSProperties } from "react"; +import type { Rect } from "./comment-geometry"; +import type { MarkerPlace } from "./resolved"; +import type { PassageHit } from "./comment-hits"; +import type { QuestionnaireStore } from "./questionnaires"; + +/** One answered question that has prose to sit beside. */ +type Decision = { + key: string; + widget: string; + question: string; + prompt: string; + answer: string; + /** Undefined when the answer cannot be read back against the options. */ + others?: string[]; + by?: string; + at?: string; + keys: string[]; +}; + +type Placed = { + decision: Decision; + marker: MarkerPlace; + lineHeight: number; + anchor: Rect; + hits: PassageHit[]; +}; + +const POPOVER_WIDTH = 336; + +function rect(value: DOMRect): Rect { + return { + top: value.top, + right: value.right, + bottom: value.bottom, + left: value.left, + width: value.width, + height: value.height, + }; +} + +function lineHeightOf(element: HTMLElement): number { + let style = getComputedStyle(element); + let lineHeight = Number.parseFloat(style.lineHeight); + return Number.isFinite(lineHeight) ? lineHeight : Number.parseFloat(style.fontSize) * 1.6; +} + +/** + * The marker's box. A disc is padded out to the hit size around its centre; a + * slim bar keeps its place in the gutter and only grows to a touch target + * toward the prose side, where the bar is drawn at its left edge. + */ +function markerStyle( + marker: MarkerPlace, + lineHeight: number, + hit: number, + inset: number, +): CSSProperties { + if (!marker.compact) return { top: marker.top - inset, left: marker.left - inset }; + let coarse = hit > MARKER_SIZE; + let height = coarse ? Math.max(hit, lineHeight) : lineHeight; + return { + top: marker.top - (height - lineHeight) / 2, + left: coarse ? 0 : marker.left, + width: coarse ? hit : COMPACT_GUTTER, + height, + "--plan-decision-bar": `${coarse ? marker.left + 4 : 4}px`, + "--plan-decision-line": `${lineHeight}px`, + } as CSSProperties; +} + +type PopoverValue = { + decision: Decision; + pinned: boolean; + style: CSSProperties; +}; + +function Popover( + { close, value }: { close: () => void; value: PopoverValue }, +) { + let { decision, pinned } = value; + let others = decision.others ?? []; + return ( + <> + + {decision.prompt} + + + + + + + + + {decision.answer} + + {others.length > 0 && ( + + + {others.map(label => ( + + + {label} + + ))} + + + )} + {(decision.by || decision.at) && ( + + {decision.by && ( + + + {decision.by} + + )} + {decision.at && when(decision.at) && ( + + + {when(decision.at)} + + )} + + )} + > + ); +} + +function Surface( + { close, id, immediately, onMeasure, value }: { + close: () => void; + id: string; + immediately: boolean; + onMeasure: (height: number) => void; + value?: PopoverValue; + }, +) { + let presence = useTransitionPresence(value, 150, immediately); + let element = useRef(null); + let present = presence.phase !== "closed"; + useEffect(() => { + let node = element.current; + if (!node || !present) return; + let observe = new ResizeObserver(() => onMeasure(node.offsetHeight)); + observe.observe(node); + onMeasure(node.offsetHeight); + return () => observe.disconnect(); + }, [onMeasure, present]); + if (presence.phase === "closed") return null; + let active = presence.phase !== "closing"; + let current = presence.value; + return ( + + + + ); +} + +export function ResolvedLayer({ store }: { store: QuestionnaireStore }) { + let [editor] = useLexicalComposerContext(); + let options = useCellValue(widgets$); + let entries = useQuestionnaires(store); + let relations = useRelations(store); + let [host, setHost] = useState(); + let [placed, setPlaced] = useState([]); + let [pointer, act] = useReducer(point, {}); + let [coarse, setCoarse] = useState(false); + let [height, setHeight] = useState(0); + let root = useRef(null); + let placedRef = useRef([]); + let leaving = useRef | undefined>(undefined); + let press = useRef<{ left: number; top: number; moved: boolean } | undefined>(undefined); + let painted = useRef(false); + let restoring = useRef(false); + let enterPopover = useRef(false); + let origin = useRef(undefined); + + let decisions = useMemo(() => { + let out: Decision[] = []; + for (let entry of entries) { + for (let question of entry.value.questions) { + if (question.answer === undefined) continue; + let keys = store.blocks(entry.id, question.id); + if (keys.length === 0) continue; + out.push({ + key: keyOf({ widget: entry.id, question: question.id }), + widget: entry.id, + question: question.id, + prompt: question.prompt, + answer: question.answer, + others: unchosen(question), + by: entry.value.by, + at: entry.value.at, + keys, + }); + } + } + return out; + // `relations` is what says the blocks behind a decision were re-resolved. + }, [entries, relations, store]); + let decisionsRef = useRef(decisions); + decisionsRef.current = decisions; + + useEffect(() => { + return editor.registerRootListener(element => { + setHost(element?.closest(".plan-document") ?? undefined); + }); + }, [editor]); + + useEffect(() => { + let query = matchMedia(COARSE_POINTER_QUERY); + let update = () => setCoarse(query.matches); + update(); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + + let measure = useCallback(() => { + if (!host) return; + let page = rect(host.getBoundingClientRect()); + let next: Placed[] = []; + for (let decision of decisionsRef.current) { + try { + let elements = [ + ...new Set( + decision.keys.map(key => blockElement(editor, key)).filter( + (element): element is HTMLElement => !!element?.isConnected, + ), + ), + ]; + let rects = elements.map(element => rect(element.getBoundingClientRect())).filter(value => + value.width > 0 && value.height > 0 + ); + if (rects.length === 0) continue; + let first = rects.reduce((top, value) => value.top < top.top ? value : top); + let element = elements[rects.indexOf(first)]!; + let lineHeight = lineHeightOf(element); + let marker = markerPoint(first, lineHeight, page); + next.push({ decision, marker, lineHeight, anchor: first, hits: passageHits(page, rects) }); + } catch (error) { + // A bad anchor must not break Lexical's update listener. + console.error(`[plan] could not place decision ${decision.key}:`, error); + } + } + placedRef.current = next; + setPlaced(next); + }, [editor, host]); + + useLayoutEffect(() => { + measure(); + }, [measure, decisions, coarse]); + + useEffect(() => { + if (!host) return; + let off = editor.registerUpdateListener(measure); + host.addEventListener("scroll", measure, true); + let observer = new ResizeObserver(measure); + observer.observe(host); + return () => { + off(); + host.removeEventListener("scroll", measure, true); + observer.disconnect(); + }; + }, [editor, host, measure]); + + useEffect(() => { + act({ type: "prune", live: new Set(decisions.map(decision => decision.key)) }); + }, [decisions]); + + let enter = useCallback((key: string) => { + clearTimeout(leaving.current); + act({ type: "enter", key }); + }, []); + let leave = useCallback((key: string) => { + clearTimeout(leaving.current); + leaving.current = setTimeout(() => act({ type: "leave", key }), 100); + }, []); + useEffect(() => () => clearTimeout(leaving.current), []); + + let restoreFocus = useCallback(() => { + let target = origin.current; + if (!target?.isConnected) return; + if ( + root.current?.contains(document.activeElement) || document.activeElement === document.body + ) { + // Giving focus back is not a request for the preview. + restoring.current = true; + target.focus(); + restoring.current = false; + } + }, []); + let dismiss = useCallback(() => { + act({ type: "dismiss" }); + restoreFocus(); + }, [restoreFocus]); + + useEffect(() => { + if (!host) return; + let over = (event: MouseEvent): Placed | undefined => { + let page = host.getBoundingClientRect(); + let at = { top: event.clientY - page.top, left: event.clientX - page.left }; + return placedRef.current.find(entry => containsHit(entry.hits, at)); + }; + let inProse = (target: EventTarget | null) => + !!target && !!editor.getRootElement()?.contains(target as Node); + let down = (event: PointerEvent) => { + press.current = { left: event.clientX, top: event.clientY, moved: false }; + }; + let move = (event: PointerEvent) => { + let pending = press.current; + if (pending && Math.hypot(event.clientX - pending.left, event.clientY - pending.top) > 3) { + pending.moved = true; + } + if (root.current?.contains(event.target as Node)) return; + let entry = inProse(event.target) ? over(event) : undefined; + if (entry) enter(entry.decision.key); + else for (let item of placedRef.current) leave(item.decision.key); + }; + let click = (event: MouseEvent) => { + let pending = press.current; + press.current = undefined; + if (!pending || pending.moved || !inProse(event.target)) return; + let entry = over(event); + let selection = getSelection(); + if (!entry || (selection && !selection.isCollapsed)) return; + origin.current = root.current?.querySelector( + `[data-plan-decision-marker="${entry.decision.key}"]`, + ) ?? undefined; + act({ type: "pin", key: entry.decision.key }); + }; + let out = () => { + for (let item of placedRef.current) leave(item.decision.key); + }; + host.addEventListener("pointerdown", down); + host.addEventListener("pointermove", move); + host.addEventListener("click", click); + host.addEventListener("pointerleave", out); + return () => { + host.removeEventListener("pointerdown", down); + host.removeEventListener("pointermove", move); + host.removeEventListener("click", click); + host.removeEventListener("pointerleave", out); + }; + }, [editor, enter, host, leave]); + + useEffect(() => { + if (!pointer.pinned || !enterPopover.current) return; + enterPopover.current = false; + root.current?.querySelector("[data-plan-decision-close]")?.focus(); + }, [pointer.pinned]); + + let view = shown(pointer); + let open = view ? placed.find(entry => entry.decision.key === view.key) : undefined; + + // One wash at a time, and only the one this layer put up is taken down. + let openKey = open?.decision.key; + useEffect(() => { + let decision = decisions.find(item => item.key === openKey); + if (!decision) return; + store.highlight(decision.widget, decision.question); + painted.current = true; + return () => { + if (!painted.current) return; + painted.current = false; + store.clear(); + }; + }, [decisions, openKey, store]); + + useEffect(() => { + if (!pointer.pinned && !pointer.hover) return; + let outside = (event: PointerEvent) => { + if (!pointer.pinned) return; + let target = event.target as Node; + if (root.current?.contains(target)) return; + // A press on the pinned prose is the click that pins it, not a dismissal. + let page = host?.getBoundingClientRect(); + let entry = placedRef.current.find(item => item.decision.key === pointer.pinned); + if ( + page && entry + && containsHit(entry.hits, { + top: event.clientY - page.top, + left: event.clientX - page.left, + }) + ) return; + act({ type: "dismiss" }); + }; + let escape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + // A hover preview goes quietly; only a pinned popover owns the key, so + // Escape still reaches whatever else (a child document) it would close. + if (pointer.pinned) event.preventDefault(); + dismiss(); + }; + document.addEventListener("pointerdown", outside); + document.addEventListener("keydown", escape); + return () => { + document.removeEventListener("pointerdown", outside); + document.removeEventListener("keydown", escape); + }; + }, [dismiss, host, pointer.hover, pointer.pinned]); + + if (!host) return null; + + let page = rect(host.getBoundingClientRect()); + let hit = coarse ? MARKER_TOUCH_SIZE : MARKER_SIZE; + let inset = (hit - MARKER_SIZE) / 2; + let width = Math.min(POPOVER_WIDTH, host.clientWidth - 16); + let value: PopoverValue | undefined; + if (open && view) { + let at = popoverBelow(open.anchor, page, width, height); + value = { + decision: open.decision, + pinned: view.pinned, + style: { ...at, width }, + }; + } + let popoverId = "plan-decision-pop"; + let immediately = options.motionImmediately?.() ?? false; + + return createPortal( + + {placed.map(({ decision, lineHeight, marker }) => { + let isPinned = pointer.pinned === decision.key; + let previewing = view?.key === decision.key && !view.pinned; + return ( + leave(decision.key)} + onClick={event => { + origin.current = event.currentTarget; + // Detail 0 is a key press: the popover is not next in tab order, + // so focus goes to it rather than leaving the reader to find it. + enterPopover.current = event.detail === 0 && !isPinned; + act({ type: "toggle", key: decision.key }); + }} + onFocus={() => !restoring.current && enter(decision.key)} + onMouseEnter={() => enter(decision.key)} + onMouseLeave={() => leave(decision.key)} + data-compact={marker.compact || undefined} + style={markerStyle(marker, lineHeight, hit, inset)} + type="button" + > + + + + + ); + })} + + , + host, + ); +} diff --git a/packages/editor/src/resolved.test.ts b/packages/editor/src/resolved.test.ts new file mode 100644 index 00000000..31aa500a --- /dev/null +++ b/packages/editor/src/resolved.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "bun:test"; + +import { carriedByMarkers } from "./widgets/questionnaire"; +import { keyOf, markerPoint, point, popoverBelow, prune, shown, unchosen } from "./resolved"; + +import type { Question, Questionnaire } from "@chopin/dialect"; +import type { Rect } from "./comment-geometry"; +import type { PointerState } from "./resolved"; + +function question( + answer: string | undefined, + multiple = false, + labels = ["All at once", "Team by team", "Phased"], +): Question { + return { + id: "q", + header: "Rollout", + prompt: "How?", + multiple, + options: labels.map((label, index) => ({ id: `o${index}`, label })), + ...(answer === undefined ? {} : { answer }), + }; +} + +function box(left: number, top: number, width: number, height: number): Rect { + return { left, top, width, height, right: left + width, bottom: top + height }; +} + +describe("unchosen options", () => { + it("lists the other options in definition order", () => { + expect(unchosen(question("Team by team"))).toEqual(["All at once", "Phased"]); + }); + + it("reads a multiple choice back out of its joined answer", () => { + expect(unchosen(question("Phased, All at once", true))).toEqual(["Team by team"]); + }); + + it("matches whole labels even when a label contains the separator", () => { + let labels = ["Ship, then measure", "Ship", "Wait"]; + expect(unchosen(question("Ship, then measure", false, labels))).toEqual(["Ship", "Wait"]); + expect(unchosen(question("Ship, Wait", true, labels))).toEqual(["Ship, then measure"]); + }); + + it("says nothing for a custom answer", () => { + expect(unchosen(question("Something else entirely"))).toBeUndefined(); + expect(unchosen(question("Team by team, plus more", true))).toBeUndefined(); + }); + + it("says nothing when it cannot match exactly", () => { + expect(unchosen(question("team by team"))).toBeUndefined(); + expect(unchosen(question("Team by team, Phased"))).toBeUndefined(); + expect(unchosen(question(undefined))).toBeUndefined(); + expect(unchosen(question("", false))).toBeUndefined(); + expect(unchosen(question("A", false, []))).toBeUndefined(); + }); + + it("does not count one option twice", () => { + expect(unchosen(question("Phased, Phased", true))).toBeUndefined(); + }); +}); + +describe("hover and pin", () => { + let a = keyOf({ widget: "w", question: "a" }); + let b = keyOf({ widget: "w", question: "b" }); + + it("shows a hover and lets go of it", () => { + let state = point({}, { type: "enter", key: a }); + expect(shown(state)).toEqual({ key: a, pinned: false }); + expect(shown(point(state, { type: "leave", key: a }))).toBeUndefined(); + }); + + it("keeps a pin after the pointer leaves", () => { + let state = point(point({}, { type: "enter", key: a }), { type: "toggle", key: a }); + state = point(state, { type: "leave", key: a }); + expect(shown(state)).toEqual({ key: a, pinned: true }); + }); + + it("toggles off from the marker but not from the prose", () => { + let pinned = point({}, { type: "toggle", key: a }); + expect(point(pinned, { type: "toggle", key: a }).pinned).toBeUndefined(); + expect(point(pinned, { type: "pin", key: a }).pinned).toBe(a); + }); + + it("holds one pin: pinning another replaces it", () => { + let state = point(point({}, { type: "pin", key: a }), { type: "pin", key: b }); + expect(state.pinned).toBe(b); + }); + + it("lets a hover borrow the popover from the pin and give it back", () => { + let state: PointerState = { pinned: a }; + state = point(state, { type: "enter", key: b }); + expect(shown(state)).toEqual({ key: b, pinned: false }); + state = point(state, { type: "leave", key: b }); + expect(shown(state)).toEqual({ key: a, pinned: true }); + }); + + it("ignores the leave of something that is not hovered", () => { + let state: PointerState = { hover: a }; + expect(point(state, { type: "leave", key: b })).toBe(state); + }); + + it("dismisses both", () => { + expect(point({ hover: a, pinned: b }, { type: "dismiss" })).toEqual({}); + }); + + it("forgets decisions that lost their prose", () => { + expect(prune({ hover: a, pinned: b }, new Set([b]))).toEqual({ hover: undefined, pinned: b }); + let same: PointerState = { pinned: a }; + expect(point(same, { type: "prune", live: new Set([a]) })).toBe(same); + }); +}); + +describe("marker and popover placement", () => { + let host = box(0, 0, 800, 600); + + it("sits in the gutter, centred on the first line", () => { + expect(markerPoint(box(100, 50, 600, 80), 32, host)).toEqual({ + top: 56, + left: 72, + compact: false, + }); + }); + + it("becomes a slim bar on the first line when the gutter is too narrow", () => { + let narrow = markerPoint(box(16, 50, 300, 80), 32, box(0, 0, 360, 600)); + expect(narrow).toEqual({ top: 50, left: 4, compact: true }); + expect(markerPoint(box(8, 50, 300, 80), 32, box(0, 0, 360, 600)).left).toBe(0); + }); + + it("never leaves the host", () => { + expect(markerPoint(box(1000, 50, 100, 20), 20, host).left).toBeLessThanOrEqual(780); + expect(markerPoint(box(2, 50, 100, 20), 20, host).left).toBeGreaterThanOrEqual(0); + }); + + it("opens under the block, aligned to its start, and flips at the bottom", () => { + expect(popoverBelow(box(100, 50, 600, 80), host, 336, 120)).toEqual({ top: 138, left: 100 }); + expect(popoverBelow(box(100, 500, 600, 60), host, 336, 120).top).toBe(372); + expect(popoverBelow(box(700, 50, 90, 20), host, 336, 100).left).toBe(464); + }); +}); + +describe("a card carried by markers", () => { + function value(...answers: (string | undefined)[]): Questionnaire { + return { + id: "w", + questions: answers.map((answer, index) => ({ ...question(answer), id: `q${index}` })), + }; + } + + it("collapses only when every question is answered and linked", () => { + expect(carriedByMarkers(value("A"), { q0: 1 })).toBe(true); + expect(carriedByMarkers(value("A", "B"), { q0: 1, q1: 2 })).toBe(true); + }); + + it("stays a compact card when pending, empty, orphaned, or partly linked", () => { + expect(carriedByMarkers(value("A"), { q0: 0 })).toBe(false); + expect(carriedByMarkers(value("A"), {})).toBe(false); + expect(carriedByMarkers(value("A", "B"), { q0: 1, q1: 0 })).toBe(false); + expect(carriedByMarkers(value("A"), undefined)).toBe(false); + expect(carriedByMarkers(value(undefined), { q0: 1 })).toBe(false); + }); +}); diff --git a/packages/editor/src/resolved.ts b/packages/editor/src/resolved.ts new file mode 100644 index 00000000..25e663e7 --- /dev/null +++ b/packages/editor/src/resolved.ts @@ -0,0 +1,162 @@ +/** + * The pure half of a resolved decision's margin marker. + * + * What the popover says, where the marker and popover sit, and which of hover + * and pin is showing are all decidable without a browser, so they live here + * and are tested directly. Measuring the prose and painting the marker is + * `resolved-layer.tsx`, which can only be tested in Chromium. + */ + +import type { Question } from "@chopin/dialect"; +import type { Point, Rect } from "./comment-geometry"; + +/** + * The options a question's answer did not pick, in definition order. + * + * The plan keeps only the answer as one joined string ("A, B" for a multiple + * choice), so this has to read the choices back out of it. Undefined whenever + * that cannot be done exactly — a custom answer, a label that has since + * changed, anything left over — because listing "also considered" options + * against an answer that may have picked one of them would be a false claim. + * Labels may themselves contain ", ", so matching is by whole label. + */ +export function unchosen(question: Question): string[] | undefined { + let answer = question.answer; + if (!answer || question.options.length === 0) return undefined; + + let chosen = new Set(); + let rest = answer; + while (rest.length > 0) { + let best = -1; + question.options.forEach((option, index) => { + if (chosen.has(index) || !option.label) return; + let fits = rest === option.label || rest.startsWith(`${option.label}, `); + if (fits && (best < 0 || option.label.length > question.options[best]!.label.length)) { + best = index; + } + }); + if (best < 0) return undefined; + // A single choice never joins anything, so a remainder is not a choice. + if (!question.multiple && rest !== question.options[best]!.label) return undefined; + chosen.add(best); + let label = question.options[best]!.label; + rest = rest === label ? "" : rest.slice(label.length + 2); + } + + return question.options.filter((_, index) => !chosen.has(index)).map(option => option.label); +} + +/** Identity of one resolved decision in the document. */ +export type DecisionKey = { widget: string; question: string }; + +export function keyOf(key: DecisionKey): string { + return `${key.widget}/${key.question}`; +} + +/** + * What the reader is pointing at. + * + * `pinned` outlives the pointer; `hover` does not. There is at most one pin, + * because a reader has one pointer and two open popovers cannot both be the + * place they were sent. + */ +export type PointerState = { hover?: string; pinned?: string }; + +export type PointerAction = + | { type: "enter"; key: string } + | { type: "leave"; key: string } + /** The marker: pressing it again lets go. */ + | { type: "toggle"; key: string } + /** The prose: pressing it never lets go, so a second click is not a dismissal. */ + | { type: "pin"; key: string } + /** Escape, the close button, or a press outside. */ + | { type: "dismiss" } + /** The document moved: drop whatever no longer has prose to point at. */ + | { type: "prune"; live: ReadonlySet }; + +export function point(state: PointerState, event: PointerAction): PointerState { + switch (event.type) { + case "prune": + return prune(state, event.live); + case "enter": + return state.hover === event.key ? state : { ...state, hover: event.key }; + case "leave": + return state.hover === event.key ? { pinned: state.pinned } : state; + case "toggle": + return state.pinned === event.key + ? { hover: state.hover } + : { hover: state.hover, pinned: event.key }; + case "pin": + return state.pinned === event.key ? state : { hover: state.hover, pinned: event.key }; + case "dismiss": + return {}; + } +} + +/** + * The one popover to show. A hover on a different decision borrows the surface + * from the pin and gives it back, and only the pinned one is acted in. + */ +export function shown(state: PointerState): { key: string; pinned: boolean } | undefined { + let key = state.hover ?? state.pinned; + return key === undefined ? undefined : { key, pinned: key === state.pinned }; +} + +/** Forget a pin or hover whose decision no longer has any prose. */ +export function prune(state: PointerState, live: ReadonlySet): PointerState { + let hover = state.hover !== undefined && live.has(state.hover) ? state.hover : undefined; + let pinned = state.pinned !== undefined && live.has(state.pinned) ? state.pinned : undefined; + return hover === state.hover && pinned === state.pinned ? state : { hover, pinned }; +} + +export const MARKER_SIZE = 20; +/** The same hit size as the comment button on a touch screen. */ +export const MARKER_TOUCH_SIZE = 44; +const GAP = 8; +/** What a slim marker needs beside the prose: its bar and a little air. */ +export const COMPACT_GUTTER = 12; + +export type MarkerPlace = Point & { + /** True when the gutter is too narrow for a disc, so the marker is a slim bar. */ + compact: boolean; +}; + +/** + * Where the marker sits: in the gutter left of the first anchored block, + * centred on its first line. + * + * When the gutter cannot hold the disc and its gap — a phone, or a split + * pane — the marker becomes a slim bar the height of the first line, hugging + * the prose instead of covering the start of it. A tap on the prose opens the + * same popover, so nothing depends on the bar being easy to hit. + */ +export function markerPoint( + block: Rect, + lineHeight: number, + host: Rect, + size = MARKER_SIZE, +): MarkerPlace { + let gutter = block.left - host.left; + let top = block.top - host.top; + if (gutter < size + GAP / 2) { + return { top, left: Math.max(0, gutter - COMPACT_GUTTER), compact: true }; + } + return { + top: top + Math.max(0, (lineHeight - size) / 2), + left: Math.min(Math.max(0, gutter - size - GAP), Math.max(0, host.width - size)), + compact: false, + }; +} + +/** + * The popover, under the first anchored block and aligned to its start, so it + * reads as a note on the passage rather than covering it. Flips above when the + * document ends before it does. + */ +export function popoverBelow(block: Rect, host: Rect, width: number, height: number): Point { + let left = Math.min(Math.max(0, block.left - host.left), Math.max(0, host.width - width)); + let below = block.bottom - host.top + GAP; + let above = block.top - host.top - GAP - height; + let top = below + height > host.height && above >= 0 ? above : below; + return { top, left }; +} diff --git a/packages/editor/src/styles.css b/packages/editor/src/styles.css index f83cf221..2e507a9c 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -2031,3 +2031,213 @@ color: var(--color-text-tertiary); font-variant-numeric: tabular-nums; } + +/* + * A resolved decision: a margin marker beside the prose it produced. + * + * Reader-local chrome drawn over the document like the comment layer, and + * for the same reason: nothing here may move a collaborative line box. + */ +::highlight(plan-decision) { + background-color: var(--color-success-wash); +} + +/* A card carried by markers takes no room in the plan. */ +.plan .plan-content [data-plan-questionnaire]:has([data-plan-collapsed]) { + margin-block: 0; +} + +.plan-decision-layer { + pointer-events: none; + position: absolute; + inset: 0; + z-index: 2; +} + +.plan-decision-marker { + --plan-decision-hit: 1.25rem; + pointer-events: auto; + position: absolute; + display: grid; + place-items: center; + inline-size: var(--plan-decision-hit); + block-size: var(--plan-decision-hit); + padding: 0; + border-radius: var(--radius-full); +} + +/* A touch screen gets the comment button's 44px target around the same disc. */ +:root[data-plan-coarse-pointer] .plan-decision-marker { + --plan-decision-hit: 2.75rem; +} + +.plan-decision-disc { + display: grid; + place-items: center; + inline-size: 1.25rem; + block-size: 1.25rem; + border-radius: var(--radius-full); + background: var(--color-success-wash); + color: var(--color-success); +} + +/* Too narrow a gutter for a disc: a slim bar the height of the first line. */ +.plan-decision-marker[data-compact] { + place-items: center start; + border-radius: 0; +} + +.plan-decision-marker[data-compact] .plan-decision-disc { + inline-size: 0.25rem; + block-size: var(--plan-decision-line, 1lh); + margin-inline-start: var(--plan-decision-bar, 0px); + background: var(--color-success); +} + +.plan-decision-marker[data-compact] svg { + display: none; +} + +.plan-decision-marker:hover .plan-decision-disc, +.plan-decision-marker[aria-expanded="true"] .plan-decision-disc { + outline: var(--edge-width) solid var(--color-success); +} + +.plan-decision-pop { + max-width: none; + padding: 0.75rem; + border-radius: var(--radius-lg); + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); +} + +.plan-decision-pop[data-pinned] { + pointer-events: auto; +} + +.plan-decision-pop p { + margin: 0; +} + +.plan-decision-head { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.plan-decision-question { + flex: 1; + line-height: var(--text-xs--line-height); + font-weight: 500; + color: var(--color-text-secondary); +} + +.plan-decision-pop .plan-decision-tools { + display: flex; + margin: -0.125rem -0.25rem 0 0; + opacity: 0; + pointer-events: none; + transition: opacity var(--duration-fast) var(--ease-out); +} + +/* The close button is a compact 24px, like the comment card's. */ +.plan-decision-tools .btn-icon { + inline-size: 1.5rem; + block-size: 1.5rem; + min-inline-size: 1.5rem; + min-block-size: 1.5rem; + padding: 0; +} + +.plan-decision-pop[data-pinned] .plan-decision-tools { + opacity: 1; + pointer-events: auto; +} + +.plan-decision-pop .plan-decision-answer { + display: flex; + align-items: center; + gap: 0.375rem; + margin-block-start: 0.375rem; + font-size: var(--text-sm); + font-weight: 500; + color: var(--color-success); +} + +.plan-decision-pop .plan-decision-answer > span { + display: inline; + margin: 0; + color: var(--color-text-primary); +} + +/* What was not chosen: same size, lighter weight, a grey cross in the check's place. */ +.plan-decision-pop .plan-decision-answer[data-rejected] { + margin-block-start: 0.25rem; + font-weight: 400; + color: var(--color-text-quaternary); +} + +.plan-decision-pop .plan-decision-answer[data-rejected] > span { + color: var(--color-text-secondary); +} + +.plan-decision-meta { + display: grid; + gap: 0.75rem; + /* A clear space separates what was decided from who and when. */ + margin-block-start: 1rem; + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); + color: var(--color-text-tertiary); +} + +.plan-decision-pop .plan-decision-meta > p { + display: flex; + align-items: flex-start; + gap: 0.375rem; +} + +.plan-decision-meta > p > :first-child { + flex: none; +} + +.plan-decision-meta > p > svg { + margin-block: calc((1lh - 0.875rem) / 2); +} + +.plan-decision-meta > p > :is(img, [role="img"]) { + margin-block: calc((1lh - 1.125rem) / 2); +} + +.plan-decision-pop .plan-decision-meta span { + display: inline; + margin: 0; + color: inherit; +} + +.plan-decision-meta strong { + font-weight: 500; + color: var(--color-text-secondary); +} + +.plan-decision-fold { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows var(--duration-base) var(--ease-out); +} + +.plan-decision-fold > div { + overflow: hidden; +} + +.plan-decision-pop[data-pinned] .plan-decision-fold { + grid-template-rows: 1fr; +} + +@media (prefers-reduced-motion: reduce) { + .plan-decision-fold, + .plan-decision-tools, + .plan-decision-disc { + transition: none; + } +} diff --git a/packages/editor/src/widgets-plugin.tsx b/packages/editor/src/widgets-plugin.tsx index 0a829106..c91e21e9 100644 --- a/packages/editor/src/widgets-plugin.tsx +++ b/packages/editor/src/widgets-plugin.tsx @@ -19,11 +19,13 @@ import { addComposerChild$, realmPlugin } from "@mdxeditor/editor"; import { ChangeObserver } from "./changes-observer"; import { CommentLayer } from "./comment-layer"; import { QuestionnaireObserver } from "./questionnaires"; +import { ResolvedLayer } from "./resolved-layer"; import { TableChrome } from "./table/chrome"; import { ThreadObserver } from "./threads"; import { Toolbar } from "./toolbar"; import { CalloutPlugin, + DecoratorSelectionPlugin, EnterPlugin, PreviewPlugin, ResearchDeletionPlugin, @@ -80,6 +82,7 @@ export const widgetsPlugin = realmPlugin({ if (params?.questions) { let store = params.questions; realm.pub(addComposerChild$, () => ); + realm.pub(addComposerChild$, () => ); } if (params?.threads) { let store = params.threads; @@ -94,6 +97,7 @@ export const widgetsPlugin = realmPlugin({ realm.pub(addComposerChild$, PreviewPlugin); realm.pub(addComposerChild$, CalloutPlugin); realm.pub(addComposerChild$, EnterPlugin); + realm.pub(addComposerChild$, DecoratorSelectionPlugin); realm.pub(addComposerChild$, ResearchDeletionPlugin); // Link nodes live inside Lexical's contenteditable root, where a normal // browser click changes the selection instead of following the anchor. diff --git a/packages/editor/src/widgets/decorator-selection.test.ts b/packages/editor/src/widgets/decorator-selection.test.ts new file mode 100644 index 00000000..f45e714c --- /dev/null +++ b/packages/editor/src/widgets/decorator-selection.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from "bun:test"; +import { createHeadlessEditor } from "@lexical/headless"; +import { + $createNodeSelection, + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isElementNode, + $isNodeSelection, + $isRangeSelection, + $isTextNode, + $setSelection, +} from "lexical"; + +import { $createQuestionnaireNode, registry } from "@chopin/dialect"; + +import { $deleteAcross, $releaseHidden, $skipHidden, skip } from "./decorator-selection"; + +import type { LexicalEditor } from "lexical"; + +test("skip steps over a run of hidden blocks and stops at the first visible one", () => { + let blocks = ["text", "hidden", "hidden", "text", "hidden"]; + let hidden = (block: string) => block === "hidden"; + expect(skip(blocks, 0, "next", hidden)).toEqual({ at: 3 }); + expect(skip(blocks, 3, "previous", hidden)).toEqual({ at: 0 }); + // Nothing adjacent to skip, or nothing beyond it. + expect(skip(blocks, 2, "next", hidden)).toBeUndefined(); + expect(skip(blocks, 3, "next", hidden)).toBeUndefined(); + expect(skip(blocks, 3, "previous", block => block === "x")).toBeUndefined(); +}); + +function build(): LexicalEditor { + let editor = createHeadlessEditor({ + nodes: registry().nodes, + onError(error) { + throw error; + }, + }); + editor.update(() => { + let before = $createParagraphNode().append($createTextNode("Before")); + let card = $createQuestionnaireNode({ id: "01K0N4TR8K7JGM4R1J7PW4R8YJ", questions: [] }); + $getRoot().append(before, card, $createParagraphNode().append($createTextNode("After"))); + before.selectEnd(); + }, { discrete: true }); + return editor; +} + +function press( + editor: LexicalEditor, + direction: "next" | "previous", + vertical: boolean, + hidden: boolean, +) { + let outcome: ReturnType; + editor.update(() => { + outcome = $skipHidden(direction, vertical, () => hidden); + }, { discrete: true }); + return outcome; +} + +function caret(editor: LexicalEditor) { + return editor.read(() => { + let selection = $getSelection(); + if (!$isRangeSelection(selection)) return undefined; + return { text: selection.anchor.getNode().getTextContent(), offset: selection.anchor.offset }; + }); +} + +test("horizontal arrows place the caret across a hidden card and never select it", () => { + let editor = build(); + expect(press(editor, "next", false, true)).toBe("moved"); + expect(caret(editor)).toEqual({ text: "After", offset: 0 }); + expect(press(editor, "previous", false, true)).toBe("moved"); + expect(caret(editor)).toEqual({ text: "Before", offset: 6 }); +}); + +test("vertical arrows are left to the browser, and only beside a hidden card", () => { + let editor = build(); + expect(press(editor, "next", true, true)).toBe("native"); + expect(press(editor, "next", true, false)).toBeUndefined(); + editor.read(() => expect($isNodeSelection($getSelection())).toBe(false)); +}); + +test("a visible card is left to Lexical, and a mid-block caret is untouched", () => { + let editor = build(); + expect(press(editor, "next", false, false)).toBeUndefined(); + editor.update(() => { + let text = $getRoot().getFirstChild(); + let child = $isElementNode(text) ? text.getFirstChild() : null; + if ($isTextNode(child)) child.select(2, 2); + }, { discrete: true }); + expect(press(editor, "next", false, true)).toBeUndefined(); + expect(caret(editor)).toEqual({ text: "Before", offset: 2 }); +}); + +function types(editor: LexicalEditor): string[] { + return editor.read(() => $getRoot().getChildren().map(node => node.getType())); +} + +test("Backspace at the start of the block after a hidden card joins the paragraphs around it", () => { + let editor = build(); + editor.update(() => { + let after = $getRoot().getLastChild(); + if ($isElementNode(after)) after.selectStart(); + }, { discrete: true }); + editor.update(() => { + expect($deleteAcross("previous", () => true)).toBe(true); + }, { discrete: true }); + expect(types(editor)).toEqual(["paragraph", "plan-questionnaire"]); + expect(editor.read(() => $getRoot().getFirstChild()?.getTextContent())).toBe("BeforeAfter"); + // The caret is at the join, inside the merged text. + expect(caret(editor)).toEqual({ text: "BeforeAfter", offset: 6 }); +}); + +test("Delete at the end of the block before a hidden card joins the next paragraph", () => { + let editor = build(); + editor.update(() => { + expect($deleteAcross("next", () => true)).toBe(true); + }, { discrete: true }); + expect(types(editor)).toEqual(["paragraph", "plan-questionnaire"]); + expect(editor.read(() => $getRoot().getFirstChild()?.getTextContent())).toBe("BeforeAfter"); +}); + +test("a visible card is left to Lexical for Backspace and Delete", () => { + let editor = build(); + editor.update(() => { + expect($deleteAcross("next", () => false)).toBe(false); + }, { discrete: true }); + expect(types(editor)).toEqual(["paragraph", "plan-questionnaire", "paragraph"]); +}); + +test("a hidden card in a node selection is handed on to the next text block", () => { + let editor = build(); + editor.update(() => { + let card = $getRoot().getChildren()[1]!; + let nodes = $createNodeSelection(); + nodes.add(card.getKey()); + $setSelection(nodes); + expect($releaseHidden("next", () => true)).toBe(true); + }, { discrete: true }); + expect(caret(editor)).toEqual({ text: "After", offset: 0 }); + editor.update(() => { + let card = $getRoot().getChildren()[1]!; + let nodes = $createNodeSelection(); + nodes.add(card.getKey()); + $setSelection(nodes); + // A visible card is never released. + expect($releaseHidden("next", () => false)).toBe(false); + }, { discrete: true }); +}); diff --git a/packages/editor/src/widgets/decorator-selection.tsx b/packages/editor/src/widgets/decorator-selection.tsx new file mode 100644 index 00000000..d15c4eb9 --- /dev/null +++ b/packages/editor/src/widgets/decorator-selection.tsx @@ -0,0 +1,301 @@ +/** + * Keeping the caret, and the keyboard, away from decorators that cannot take + * them. + * + * Arrowing onto a decorator block gives Lexical a node selection, and Lexical + * then declines to handle `beforeinput` for it without cancelling the event. + * The browser has no text caret to insert at, so it writes into the root at + * its start, and because the editor's own selection never moves, every further + * character goes in front of the last: typing "abc" gives "cba" at the top of + * the document. `BEFORE_INPUT_COMMAND` therefore cancels insertion while a node + * is selected. + * + * A resolved decision that a margin marker carries renders as a hidden + * placeholder. Selecting it is invisible, so ArrowUp, ArrowDown, ArrowLeft and + * ArrowRight step over it to the next block of text instead, as if it were not + * there. Backspace and Delete next to it are still Lexical's. + */ + +import { useEffect } from "react"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { + $createNodeSelection, + $getRoot, + $getSelection, + $isDecoratorNode, + $isElementNode, + $isNodeSelection, + $isParagraphNode, + $isRangeSelection, + $isRootNode, + $isTextNode, + $setSelection, + BEFORE_INPUT_COMMAND, + COMMAND_PRIORITY_HIGH, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, + KEY_ARROW_UP_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + mergeRegister, +} from "lexical"; + +import type { + ElementNode, + LexicalCommand, + LexicalEditor, + LexicalNode, + RangeSelection, +} from "lexical"; + +type Direction = "next" | "previous"; + +/** + * Where an arrow key from beside a run of hidden blocks ends up. + * + * `blocks` are the siblings in document order; the hidden ones are skipped. A + * visible block of text takes the caret, any other visible block is selected + * like Lexical would, and running out of siblings leaves the key to Lexical. + */ +export function skip( + blocks: readonly T[], + from: number, + direction: Direction, + hidden: (block: T) => boolean, +): { at: number } | undefined { + let step = direction === "next" ? 1 : -1; + let at = from + step; + if (blocks[at] === undefined || !hidden(blocks[at]!)) return undefined; + while (blocks[at] !== undefined && hidden(blocks[at]!)) at += step; + return blocks[at] === undefined ? undefined : { at }; +} + +/** Whether the caret has nowhere further to go inside its block. */ +function $atEdge(selection: RangeSelection, block: ElementNode, direction: Direction): boolean { + let { key, offset, type } = selection.focus; + let node = selection.focus.getNode(); + if (type === "text" && $isTextNode(node)) { + let end = direction === "next" ? offset === node.getTextContentSize() : offset === 0; + let last = direction === "next" ? block.getLastDescendant() : block.getFirstDescendant(); + return end && (last === null || last.getKey() === key); + } + if (!$isElementNode(node)) return false; + if (direction === "previous") return offset === 0 && (node.is(block) || node.isEmpty()); + return offset === node.getChildrenSize() && (node.is(block) || node.isEmpty()); +} + +type Hidden = (key: string) => boolean; + +function $isHiddenBlock(node: LexicalNode, hidden: Hidden): boolean { + return $isDecoratorNode(node) && !node.isInline() && hidden(node.getKey()); +} + +/** + * The hidden blocks beside a collapsed caret, and the visible block past them. + * + * Without `vertical` the caret must also be at the edge of its block, since a + * horizontal key only crosses a block boundary from there. + */ +function $across(direction: Direction, vertical: boolean, hidden: Hidden) { + let selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return undefined; + + let focus = selection.focus.getNode(); + let root = $isRootNode(focus); + let block = root ? null : focus.getTopLevelElement(); + if (!root && block === null) return undefined; + + let siblings: LexicalNode[] = $getRoot().getChildren(); + + let from: number; + if (root) { + // The caret sits between root children; an offset `n` is before child `n`. + let offset = selection.focus.offset; + from = direction === "next" ? offset - 1 : offset; + } else { + if (!vertical && !$atEdge(selection, block!, direction)) return undefined; + from = siblings.findIndex(sibling => sibling.is(block)); + } + + let target = skip(siblings, from, direction, node => $isHiddenBlock(node, hidden)); + if (!target) return undefined; + return { block, landing: siblings[target.at]! }; +} + +/** + * Step over the hidden blocks next to the caret, if there are any. + * + * Vertical movement is left to the browser, which keeps the caret's column and + * has nothing to land on in a block that is not displayed; all that has to be + * done is to stop Lexical selecting the block first. Horizontal movement has no + * such fallback, so it places the caret itself. A visible decorator past the + * hidden blocks is selected by `$releaseHidden` once Lexical has selected the + * hidden one in front of it. + */ +export function $skipHidden( + direction: Direction, + vertical: boolean, + hidden: Hidden, +): "native" | "moved" | undefined { + let found = $across(direction, vertical, hidden); + if (!found) return undefined; + let { landing } = found; + if ($isDecoratorNode(landing)) { + if (vertical) return undefined; + $selectOnly(landing); + return "moved"; + } + if (vertical) return "native"; + if ($isElementNode(landing)) { + if (direction === "next") landing.selectStart(); + else landing.selectEnd(); + return "moved"; + } + return undefined; +} + +function $selectOnly(node: LexicalNode): void { + let nodes = $createNodeSelection(); + nodes.add(node.getKey()); + $setSelection(nodes); +} + +/** + * Backspace or Delete beside a hidden block, as if it were not there. + * + * Lexical would select the placeholder, and a second press would delete a + * decision nobody can see. Two paragraphs join around it; any other pair just + * gets the caret, and the placeholder stays where it is. + */ +export function $deleteAcross(direction: Direction, hidden: Hidden): boolean { + let found = $across(direction, false, hidden); + if (!found?.block) return false; + let { block, landing } = found; + if (!$isElementNode(landing)) return false; + + if ($isParagraphNode(block) && $isParagraphNode(landing)) { + if (direction === "previous") { + // A text point, because an element offset would be read as the end of + // the merged text once adjacent text nodes are normalised into one. + let tail = landing.getLastDescendant(); + let end = landing.getChildrenSize(); + landing.append(...block.getChildren()); + block.remove(); + if ($isTextNode(tail)) tail.select(tail.getTextContentSize(), tail.getTextContentSize()); + else landing.select(end, end); + } else { + let tail = block.getLastDescendant(); + let end = block.getChildrenSize(); + block.append(...landing.getChildren()); + landing.remove(); + if ($isTextNode(tail)) tail.select(tail.getTextContentSize(), tail.getTextContentSize()); + else block.select(end, end); + } + } else if (direction === "previous") landing.selectEnd(); + else landing.selectStart(); + return true; +} + +/** + * Whatever put a hidden block in a node selection, take it back out. + * + * Moves on in the direction the last key was heading: to a visible decorator + * if one is next, otherwise to text, and failing that back the other way. + */ +export function $releaseHidden(direction: Direction, hidden: Hidden): boolean { + let selection = $getSelection(); + if (!$isNodeSelection(selection)) return false; + let nodes = selection.getNodes(); + if (nodes.length === 0 || !nodes.every(node => $isHiddenBlock(node, hidden))) return false; + + let siblings: LexicalNode[] = $getRoot().getChildren(); + let edge = nodes.map(node => siblings.findIndex(sibling => sibling.is(node))); + for (let way of [direction, direction === "next" ? "previous" : "next"] as Direction[]) { + let step = way === "next" ? 1 : -1; + let at = (way === "next" ? Math.max(...edge) : Math.min(...edge)) + step; + while (siblings[at] && $isHiddenBlock(siblings[at]!, hidden)) at += step; + let landing = siblings[at]; + if (!landing) continue; + if ($isDecoratorNode(landing)) $selectOnly(landing); + else if ($isElementNode(landing)) { + if (way === "next") landing.selectStart(); + else landing.selectEnd(); + } else continue; + return true; + } + return false; +} + +export function registerDecoratorSelection( + editor: LexicalEditor, + hidden: Hidden = key => isHidden(editor, key), +): () => void { + let heading: Direction = "next"; + let remove = (command: LexicalCommand, direction: Direction) => + editor.registerCommand( + command, + event => { + heading = direction; + if (event?.shiftKey || event?.altKey || event?.metaKey || event?.ctrlKey) return false; + if (!$deleteAcross(direction, hidden)) return false; + event?.preventDefault(); + return true; + }, + COMMAND_PRIORITY_HIGH, + ); + let arrow = (command: LexicalCommand, direction: Direction, vertical: boolean) => + editor.registerCommand( + command, + event => { + heading = direction; + if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) return false; + let outcome = $skipHidden(direction, vertical, hidden); + if (outcome === undefined) return false; + if (outcome === "moved") event.preventDefault(); + return true; + }, + COMMAND_PRIORITY_HIGH, + ); + + return mergeRegister( + editor.registerCommand( + BEFORE_INPUT_COMMAND, + event => { + if (!event.inputType.startsWith("insert") || !$isNodeSelection($getSelection())) { + return false; + } + event.preventDefault(); + return true; + }, + COMMAND_PRIORITY_HIGH, + ), + arrow(KEY_ARROW_UP_COMMAND, "previous", true), + arrow(KEY_ARROW_DOWN_COMMAND, "next", true), + arrow(KEY_ARROW_LEFT_COMMAND, "previous", false), + arrow(KEY_ARROW_RIGHT_COMMAND, "next", false), + remove(KEY_BACKSPACE_COMMAND, "previous"), + remove(KEY_DELETE_COMMAND, "next"), + editor.registerUpdateListener(({ editorState }) => { + if (!editorState.read(() => $releaseNeeded(hidden))) return; + editor.update(() => void $releaseHidden(heading, hidden), { tag: "history-merge" }); + }), + ); +} + +function $releaseNeeded(hidden: Hidden): boolean { + let selection = $getSelection(); + return $isNodeSelection(selection) + && selection.getNodes().every(node => $isHiddenBlock(node, hidden)); +} + +/** A collapsed decision renders `data-plan-collapsed`; see `InlineQuestionnaire`. */ +function isHidden(editor: LexicalEditor, key: string): boolean { + return editor.getElementByKey(key)?.querySelector("[data-plan-collapsed]") != null; +} + +export function DecoratorSelectionPlugin() { + let [editor] = useLexicalComposerContext(); + useEffect(() => registerDecoratorSelection(editor), [editor]); + return null; +} diff --git a/packages/editor/src/widgets/index.ts b/packages/editor/src/widgets/index.ts index e64920bf..d6a4fa66 100644 --- a/packages/editor/src/widgets/index.ts +++ b/packages/editor/src/widgets/index.ts @@ -29,6 +29,7 @@ export function register(): void { } export { CalloutPlugin } from "./callout"; +export { DecoratorSelectionPlugin } from "./decorator-selection"; export { EnterPlugin } from "./enter"; export { QuestionnaireCard } from "./questionnaire"; export type { QuestionnaireCardProps } from "./questionnaire"; diff --git a/packages/editor/src/widgets/questionnaire.tsx b/packages/editor/src/widgets/questionnaire.tsx index af2c2ebe..c250b2a0 100644 --- a/packages/editor/src/widgets/questionnaire.tsx +++ b/packages/editor/src/widgets/questionnaire.tsx @@ -14,6 +14,7 @@ import { useCellValue } from "@mdxeditor/gurx"; import { Provenance, SidecarCard } from "../card"; import { ContentSwapLayer } from "../content-swap"; import { PresenceFaces } from "../presence-faces"; +import { useRelations } from "../questionnaires"; import { widgets$ } from "../widget-options"; import type { ReactNode } from "react"; @@ -233,8 +234,32 @@ function Decided( ); } +/** + * Whether a resolved decision is carried entirely by a margin marker. + * + * Only when every question has prose to sit beside. Pending, deliberately + * empty and orphaned decisions have nowhere to put a marker, so they keep a + * compact card rather than vanishing from the plan. + */ +export function carriedByMarkers( + value: Questionnaire, + places: { [question: string]: number } | undefined, +): boolean { + return !!places + && value.questions.length > 0 + && value.questions.every(question => + question.answer !== undefined && (places[question.id] ?? 0) > 0 + ); +} + function InlineQuestionnaire({ value }: { value: Questionnaire }) { let options = useCellValue(widgets$); + // Re-render when anchors arrive: whether the card collapses depends on them. + useRelations(options.questions); + let places = options.questions?.counts(value.id); + if (carriedByMarkers(value, places)) { + return ; + } return ( options.questions?.highlight(value.id, question)} onQuestionLeave={() => options.questions?.clear()} onQuestionSelect={question => options.questions?.reveal(value.id, question)} - places={options.questions?.counts(value.id)} + places={places} value={value} wire={options.wire} /> diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 00599a65..cd66751f 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -3,12 +3,14 @@ export { ArrowUpIcon, CheckIcon, ChevronIcon, + ClockIcon, CloseIcon, CodeIcon, DecisionIcon, InfoIcon, LightbulbIcon, LinkPlusIcon, + MessageForwardIcon, MessageIcon, MessagePlusIcon, PlusIcon, diff --git a/packages/icons/src/line.tsx b/packages/icons/src/line.tsx index 8cb78bd6..b7f0c1e1 100644 --- a/packages/icons/src/line.tsx +++ b/packages/icons/src/line.tsx @@ -165,3 +165,22 @@ export function MessagePlusIcon(props: IconProps) { ); } + +export function ClockIcon(props: IconProps) { + return ( + + + + + ); +} + +/** Reserved for "Show in chat" on the resolved-decision popover; no data drives it yet. */ +export function MessageForwardIcon(props: IconProps) { + return ( + + + + + ); +} diff --git a/scripts/design-contract/exceptions/dynamic-editor.json b/scripts/design-contract/exceptions/dynamic-editor.json index 2354b950..c8dfd085 100644 --- a/scripts/design-contract/exceptions/dynamic-editor.json +++ b/scripts/design-contract/exceptions/dynamic-editor.json @@ -237,6 +237,41 @@ ], "sourceHash": "c60690040f4b0911e23027b7b975596e0db27e2d4e20c4ec4ce3f1955c5b81e8" }, + { + "file": "packages/editor/src/resolved-layer.tsx", + "reason": "Decision marker and popover style is measured anchor geometry, sized from the first anchored block; browser positioning remains outside static token evaluation.", + "cases": [ + [ + "dynamic", + "*", + "current.style", + "unresolved style > Surface > ", + 1 + ], + [ + "dynamic", + "*", + "markerStyle(marker, lineHeight, hit, inset)", + "unresolved style > ResolvedLayer > ", + 1 + ] + ], + "sourceHash": "75db0a6e19ecf2fa73d55b0082f84e85da2c51d5fac157f75d4111335e00dc3b" + }, + { + "file": "packages/editor/src/resolved-layer.tsx", + "reason": "Motion/presence contract returns finite semantic state classes (is-open, is-closing); imported function evaluation is outside this static scanner. Review the producer when changing this boundary.", + "cases": [ + [ + "dynamic", + "class", + "presence.className", + "class > Surface > ", + 1 + ] + ], + "sourceHash": "75db0a6e19ecf2fa73d55b0082f84e85da2c51d5fac157f75d4111335e00dc3b" + }, { "file": "packages/editor/src/table/rails.tsx", "reason": "Table rail style is computed viewport geometry (position, size, transform); token policy deliberately does not ban measured dimensions.", @@ -389,7 +424,7 @@ 1 ] ], - "sourceHash": "0f70c895629e5bbbbbb90fdcf48fbd28287c74fff034b6e9834a26a7c423127e" + "sourceHash": "57762a66f2046b26f1c87547bf45bb21e73eb1c37deda3dcb376ba7056f10b10" }, { "file": "packages/editor/src/widgets/code-view.tsx", diff --git a/scripts/design-contract/exceptions/dynamic-packages.json b/scripts/design-contract/exceptions/dynamic-packages.json index 24d76085..491286a0 100644 --- a/scripts/design-contract/exceptions/dynamic-packages.json +++ b/scripts/design-contract/exceptions/dynamic-packages.json @@ -198,9 +198,23 @@ "props", "unresolved JSX props > MessagePlusIcon > ", 1 + ], + [ + "dynamic", + "*", + "props", + "unresolved JSX props > ClockIcon > ", + 1 + ], + [ + "dynamic", + "*", + "props", + "unresolved JSX props > MessageForwardIcon > ", + 1 ] ], - "sourceHash": "395805db3113280090d3c6c652cfb28e2ff1f7ff12ec613cfdefe1b3d24e972a" + "sourceHash": "3f0551a94613d444137252e24f2e69af45d333a1a28aba384158b669cf3ca040" }, { "file": "packages/visuals/src/ui/mini-bars.tsx",
{decision.prompt}
+ + {decision.answer} +
+ + {label} +
+ + {decision.by} +
+ + {when(decision.at)} +