Skip to content

Commit efabace

Browse files
committed
Let the Checkbox extension own its text-card adornment
The second contribution point from ADR 008: a text-card adornment draws content before the card's text and may name a text state the card styles. The Checkbox extension now owns its tick box, its styles and its "done" text state; the Text Card only hosts registered adornments (none on mind-map nodes). The card view carries the installed extensions instead of a checked flag, and the toggle goes through the shared ExtensionCommands port, which moves to its own module now that two contribution points use it. Computed styles of the cards are unchanged unchecked, hovered and checked.
1 parent ee23841 commit efabace

21 files changed

Lines changed: 213 additions & 113 deletions

‎docs/REFACTOR-STATE.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,11 @@
5353
the definitions are registered in `src/extensions/architectureRegistry.ts`. Header controls are
5454
migrated: Lock, Privacy, Extra colors, Counter and Copy/Paste JSON own their header control in
5555
their module (`headerControlRegistry.ts`), the container and text-block headers host them through
56-
`useElementHeaderExtensions`, and commands go through one `ExtensionCommands` port. Next: the
57-
text-card checkbox as an element adornment, extension menu items, then behavior (lock, search,
58-
privacy, auto checkboxes, inherit card color, JSON copy/paste) out of `App.tsx`.
56+
`useElementHeaderExtensions`, and commands go through one `ExtensionCommands` port. The
57+
Checkbox is a text-card adornment (`cardAdornmentRegistry.ts`): it draws its tick box and marks
58+
the card's text state; the Text Card only hosts adornments. Next: extension menu items, then
59+
behavior (lock, search, privacy, auto checkboxes, inherit card color, JSON copy/paste) out of
60+
`App.tsx`.
5961
Phase 5 leftovers, not blocking: Text Card editing state (draft, editing id) is still in
6062
`App.tsx`; element registration in `src/elements/registry.ts` waits until rendering dispatches
6163
through the registry; loose text cards and mind-map nodes still position with left/top rather

‎docs/decisions/008-extension-ui-contributions.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ An extension owns its UI and reaches the application through a command port. Ele
3232
closing.
3333
- Commands go through one `ExtensionCommands` port supplied by the application (today `App.tsx`
3434
over the retained callbacks). Elements forward the port without inspecting it.
35-
- Further contribution points (element adornments such as the text-card checkbox, menu items,
36-
and behavioral effects such as lock, search filtering and privacy) follow the same pattern as
37-
their extensions migrate.
35+
- The second point is the **text-card adornment** (`cardAdornment.ts`,
36+
`cardAdornmentRegistry.ts`): content drawn before the card's text, plus a named text state
37+
(`done`) that the card styles. Mind-map nodes, which share the Text Card renderer, take none.
38+
- Further contribution points (menu items, and behavioral effects such as lock, search filtering
39+
and privacy) follow the same pattern as their extensions migrate.
3840

3941
## Consequences
4042

‎src/App.tsx‎

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { IconRotateClockwise } from "@tabler/icons-react";
2020
import { CanvasContextMenu, ContainerContentContextMenu } from "./components/ContextMenus";
2121
import { ContainerRenderer } from "./elements/container/ContainerRenderer";
2222
import type { ContainerActions } from "./elements/container/containerView";
23-
import type { ExtensionCommands } from "./extensions/headerControl";
23+
import type { ExtensionCommands } from "./extensions/extensionCommands";
2424
import { ContainerMenu, type ContainerMenuActions } from "./elements/container/ContainerMenu";
2525
import { asContainerDocumentElement } from "./elements/container/containerViewProjection";
2626
import { ContainerJsonEditorWindow } from "./components/ContainerJsonEditorWindow";
@@ -308,12 +308,6 @@ const createAppMetadata = (data: AppData): AppData => ({
308308

309309
type CallbackMap = Record<string, (...args: never[]) => unknown>;
310310

311-
/** Null when no checkbox extension is installed on the card. */
312-
const textCardChecked = (card: TextCardElement) => {
313-
const checkbox = card.extensions?.checkbox;
314-
return checkbox ? Boolean(checkbox.checked) : null;
315-
};
316-
317311
const useStableCallbacks = <T extends CallbackMap>(callbacks: T): T => {
318312
const callbacksRef = useRef<T | null>(callbacks);
319313
const stableCallbacksRef = useRef<T | null>(null);
@@ -6436,7 +6430,6 @@ function App({
64366430
onCancel: canvasNodeActions.cancelTextCardEdit,
64376431
onStartMove: canvasNodeActions.startTextCardMove,
64386432
onOpenMenu: canvasNodeActions.openTextCardMenu,
6439-
onToggleCheckbox: canvasNodeActions.toggleTextCardCheckbox,
64406433
onSizeChange: rememberTextCardSize,
64416434
}),
64426435
[canvasNodeActions, rememberTextCardSize],
@@ -6465,8 +6458,11 @@ function App({
64656458
if (textBlock) action(textBlock);
64666459
};
64676460
const extensionCommands: ExtensionCommands = useStableCallbacks({
6468-
toggle: (extension: "lock" | "privacy", elementId: string) =>
6469-
extension === "lock" ? toggleLockExtension(elementId) : togglePrivacyExtension(elementId),
6461+
toggle: (extension: "lock" | "privacy" | "checkbox", elementId: string) => {
6462+
if (extension === "lock") toggleLockExtension(elementId);
6463+
else if (extension === "privacy") togglePrivacyExtension(elementId);
6464+
else toggleTextCardCheckbox(elementId);
6465+
},
64706466
updateAccent: (elementId: string, accent: string) =>
64716467
containersById.has(elementId)
64726468
? updateContainerAccent(elementId, accent)
@@ -7166,9 +7162,10 @@ function App({
71667162
key={card.id}
71677163
element={cardElement}
71687164
actions={textCardActions}
7165+
extensionCommands={extensionCommands}
71697166
view={{
71707167
layer: card.layer ?? 0,
7171-
checked: textCardChecked(card),
7168+
extensions: card.extensions,
71727169
editing: editingTextCardId === card.id,
71737170
draft: editingTextCardId === card.id ? textCardDraft : "",
71747171
position,
@@ -7250,9 +7247,10 @@ function App({
72507247
key={card.id}
72517248
element={cardElement}
72527249
actions={textCardActions}
7250+
extensionCommands={extensionCommands}
72537251
view={{
72547252
layer: card.layer ?? 0,
7255-
checked: textCardChecked(card),
7253+
extensions: card.extensions,
72567254
editing: editingTextCardId === card.id,
72577255
draft: editingTextCardId === card.id ? textCardDraft : "",
72587256
position: getTextCardRenderPosition(card),
@@ -7348,9 +7346,10 @@ function App({
73487346
key={`drag-overlay-${id}`}
73497347
element={cardElement}
73507348
actions={textCardActions}
7349+
extensionCommands={extensionCommands}
73517350
view={{
73527351
layer: card.layer ?? 0,
7353-
checked: textCardChecked(card),
7352+
extensions: card.extensions,
73547353
editing: false,
73557354
draft: "",
73567355
position,
@@ -7392,9 +7391,10 @@ function App({
73927391
key={`release-overlay-${card.id}`}
73937392
element={cardElement}
73947393
actions={textCardActions}
7394+
extensionCommands={extensionCommands}
73957395
view={{
73967396
layer: card.layer ?? 0,
7397-
checked: textCardChecked(card),
7397+
extensions: card.extensions,
73987398
editing: false,
73997399
draft: "",
74007400
position: textCardInteractionSnapshot.release?.active ? to : from,

‎src/elements/container/ContainerHeader.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { IconBox, IconDotsVertical, IconSearch, IconX } from "@tabler/icons-react";
22
import { useRef } from "react";
33
import type { SyntheticEvent } from "react";
4-
import type { ExtensionCommands } from "../../extensions/headerControl";
4+
import type { ExtensionCommands } from "../../extensions/extensionCommands";
55
import type { HeaderLayoutMetrics } from "../useHeaderExtensionLayout";
66
import { useElementHeaderExtensions } from "../useElementHeaderExtensions";
77
import type { ContainerDocumentElement } from "./containerModel";

‎src/elements/container/ContainerRenderer.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
33
import { asEntityId } from "../../domain/ids/entityIds";
44
import type { ContainerDocumentElement } from "./containerModel";
55
import { ContainerRenderer } from "./ContainerRenderer";
6-
import type { ExtensionCommands } from "../../extensions/headerControl";
6+
import type { ExtensionCommands } from "../../extensions/extensionCommands";
77
import type { ContainerActions, ContainerViewState } from "./containerView";
88

99
vi.stubGlobal(

‎src/elements/container/ContainerRenderer.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { IconArrowDownRight } from "@tabler/icons-react";
22
import { memo, useState } from "react";
33
import type { ReactNode } from "react";
4-
import type { ExtensionCommands } from "../../extensions/headerControl";
4+
import type { ExtensionCommands } from "../../extensions/extensionCommands";
55
import { placementStyle, shallowEqual } from "../elementPlacement";
66
import { ContainerHeader } from "./ContainerHeader";
77
import type { ContainerDocumentElement } from "./containerModel";

‎src/elements/text-block/TextBlockHeader.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { IconDotsVertical, IconNotes } from "@tabler/icons-react";
22
import { useRef } from "react";
33
import type { SyntheticEvent } from "react";
4-
import type { ExtensionCommands } from "../../extensions/headerControl";
4+
import type { ExtensionCommands } from "../../extensions/extensionCommands";
55
import type { HeaderLayoutMetrics } from "../useHeaderExtensionLayout";
66
import { useElementHeaderExtensions } from "../useElementHeaderExtensions";
77
import type { TextBlockDocumentElement } from "./textBlockModel";

‎src/elements/text-block/TextBlockRenderer.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
33
import { asEntityId } from "../../domain/ids/entityIds";
44
import type { TextBlockDocumentElement } from "./textBlockModel";
55
import { TextBlockRenderer } from "./TextBlockRenderer";
6-
import type { ExtensionCommands } from "../../extensions/headerControl";
6+
import type { ExtensionCommands } from "../../extensions/extensionCommands";
77
import type { TextBlockActions, TextBlockViewState } from "./textBlockView";
88

99
vi.stubGlobal(

‎src/elements/text-block/TextBlockRenderer.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { IconArrowDownRight } from "@tabler/icons-react";
22
import { Suspense, lazy, memo, useEffect, useRef, useState } from "react";
33
import type { WheelEvent } from "react";
4-
import type { ExtensionCommands } from "../../extensions/headerControl";
4+
import type { ExtensionCommands } from "../../extensions/extensionCommands";
55
import { placementStyle, shallowEqual } from "../elementPlacement";
66
import { TextBlockHeader } from "./TextBlockHeader";
77
import type { TextBlockDocumentElement } from "./textBlockModel";

‎src/elements/text-card/TextCardRenderer.test.tsx‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
33
import { asEntityId } from "../../domain/ids/entityIds";
44
import type { MindMapNodeDocumentElement } from "../mind-map/mindMapModel";
55
import type { TextCardDocumentElement } from "./textCardModel";
6+
import type { ExtensionCommands } from "../../extensions/extensionCommands";
67
import {
78
TextCardRenderer,
89
type TextCardActions,
@@ -53,16 +54,25 @@ function createActions(): { [Key in keyof TextCardActions]: ReturnType<typeof vi
5354
onCancel: vi.fn(),
5455
onStartMove: vi.fn(),
5556
onOpenMenu: vi.fn(),
56-
onToggleCheckbox: vi.fn(),
5757
onSizeChange: vi.fn(),
5858
};
5959
}
6060

61+
const extensionCommands = {
62+
toggle: vi.fn(),
63+
updateAccent: vi.fn(),
64+
rememberRecentColor: vi.fn(),
65+
copyJsonForAi: vi.fn(async () => undefined),
66+
pasteJsonFromAi: vi.fn(async () => undefined),
67+
openJsonEditor: vi.fn(),
68+
} satisfies ExtensionCommands;
69+
6170
function renderCard(element: TextCardRendererElement, view: Partial<TextCardViewState> = {}) {
6271
const actions = createActions();
72+
extensionCommands.toggle.mockClear();
6373
const fullView: TextCardViewState = {
6474
layer: 0,
65-
checked: null,
75+
extensions: undefined,
6676
editing: false,
6777
draft: "",
6878
shadowsUnderElements: true,
@@ -73,6 +83,7 @@ function renderCard(element: TextCardRendererElement, view: Partial<TextCardView
7383
element={element}
7484
view={fullView}
7585
actions={actions as unknown as TextCardActions}
86+
extensionCommands={extensionCommands}
7687
/>,
7788
);
7889
const card = result.container.querySelector<HTMLElement>(`[data-text-card-id='${CARD_ID}']`)!;
@@ -177,15 +188,20 @@ describe("TextCardRenderer", () => {
177188
});
178189

179190
it("toggles an installed checkbox without starting a move, and strikes the text through", () => {
180-
const { actions, card } = renderCard(textCard(), { checked: true });
191+
const { actions, card } = renderCard(textCard(), {
192+
extensions: { checkbox: { checked: true } },
193+
});
181194
const checkbox = screen.getByRole("button", { pressed: true });
182195

183196
fireEvent.pointerDown(checkbox);
184197
fireEvent.click(checkbox);
185198

186-
expect(actions.onToggleCheckbox).toHaveBeenCalledWith(CARD_ID);
199+
expect(extensionCommands.toggle).toHaveBeenCalledWith("checkbox", CARD_ID);
187200
expect(actions.onStartMove).not.toHaveBeenCalled();
188-
expect(card.querySelector(".taskmap-text-card__text")).toHaveAttribute("data-checked", "true");
201+
expect(card.querySelector(".taskmap-text-card__text")).toHaveAttribute(
202+
"data-text-state",
203+
"done",
204+
);
189205
});
190206

191207
it("shows no checkbox when none is installed", () => {
@@ -203,6 +219,7 @@ describe("TextCardRenderer", () => {
203219
element={nextElement}
204220
view={next}
205221
actions={actions as unknown as TextCardActions}
222+
extensionCommands={extensionCommands}
206223
/>,
207224
);
208225

@@ -250,7 +267,7 @@ describe("TextCardRenderer mind-map variant", () => {
250267
});
251268

252269
it("never renders a checkbox", () => {
253-
renderCard(mindMapNode(), { checked: false });
270+
renderCard(mindMapNode(), { extensions: { checkbox: { checked: false } } });
254271

255272
expect(screen.queryByRole("button")).not.toBeInTheDocument();
256273
});

0 commit comments

Comments
 (0)