@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +32 -24
- package/src/App.autoclose.test.tsx +137 -0
- package/src/App.diff.test.tsx +158 -22
- package/src/App.export.test.tsx +44 -37
- package/src/App.plan-review.test.tsx +632 -0
- package/src/App.readonly.test.tsx +60 -36
- package/src/App.rendering.test.tsx +132 -0
- package/src/App.review-panel.test.tsx +136 -0
- package/src/App.submit-confirm.test.tsx +220 -0
- package/src/App.test.tsx +128 -69
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +288 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +38 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +30 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +228 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +83 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +59 -0
- package/src/components/ConfirmCard.stories.tsx +56 -0
- package/src/components/ConfirmCard.tsx +135 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +355 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +62 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +58 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +74 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +389 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +78 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +101 -0
- package/src/components/ReviewRail.stories.tsx +220 -0
- package/src/components/ReviewRail.tsx +316 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +141 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +108 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +21 -0
- package/src/components/agent-launcher.tsx +123 -0
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +48 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +64 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +98 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +136 -0
- package/src/components/stories-app.tsx +123 -0
- package/src/components/stories.test.tsx +62 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +69 -0
- package/src/components/syntax-highlight.ts +92 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +36 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +430 -31
- package/src/config.ts +304 -26
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +143 -0
- package/src/editor.ts +123 -9
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +27 -0
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +30 -0
- package/src/intent-dispatch.test.ts +492 -0
- package/src/intent-dispatch.ts +428 -0
- package/src/key-bindings.test.ts +152 -0
- package/src/key-bindings.ts +424 -0
- package/src/keymap.test.ts +622 -0
- package/src/keymap.ts +339 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +24 -9
- package/src/serve.ts +7 -7
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +309 -0
- package/src/session-controller.ts +900 -0
- package/src/share.ts +113 -0
- package/src/test-support.test.ts +55 -0
- package/src/test-support.ts +187 -0
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +36 -15
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +34 -23
- package/src/view-plan.test.ts +294 -0
- package/src/view-plan.ts +408 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +70 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { MenuBar } from "./MenuBar";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "MenuBar" };
|
|
7
|
+
|
|
8
|
+
const callbacks = { onToggle: () => {}, onSettings: () => {}, onKeybinds: () => {} };
|
|
9
|
+
|
|
10
|
+
function BottomFrame({ children }: { children: React.ReactNode }): React.ReactNode {
|
|
11
|
+
return (
|
|
12
|
+
<box
|
|
13
|
+
style={{ flexDirection: "column", width: "100%", height: "100%", justifyContent: "flex-end" }}
|
|
14
|
+
>
|
|
15
|
+
{children}
|
|
16
|
+
</box>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const Closed: Story = {
|
|
21
|
+
render: () => (
|
|
22
|
+
<BottomFrame>
|
|
23
|
+
<MenuBar open={false} version="0.1.0-alpha.32" {...callbacks} />
|
|
24
|
+
</BottomFrame>
|
|
25
|
+
),
|
|
26
|
+
size: { width: 60, height: 8 },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const Open: Story = {
|
|
30
|
+
render: () => (
|
|
31
|
+
<BottomFrame>
|
|
32
|
+
<MenuBar open version="0.1.0-alpha.32" {...callbacks} />
|
|
33
|
+
</BottomFrame>
|
|
34
|
+
),
|
|
35
|
+
expectedColors: [DARK.text],
|
|
36
|
+
size: { width: 60, height: 8 },
|
|
37
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
3
|
+
import type { Theme } from "../theme";
|
|
4
|
+
import { useComponentTheme } from "./theme-context";
|
|
5
|
+
import { FRAME_BORDER_STYLE } from "./primitives/frame";
|
|
6
|
+
|
|
7
|
+
export interface MenuBarProps {
|
|
8
|
+
open: boolean;
|
|
9
|
+
version: string;
|
|
10
|
+
/** The transient status line, shown between the menu and the version. */
|
|
11
|
+
status?: string;
|
|
12
|
+
onToggle: () => void;
|
|
13
|
+
onSettings: () => void;
|
|
14
|
+
onKeybinds: () => void;
|
|
15
|
+
theme?: Theme;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DROPUP_OPTION_ROWS = 2;
|
|
19
|
+
|
|
20
|
+
export function MenuBar({
|
|
21
|
+
open,
|
|
22
|
+
version,
|
|
23
|
+
status,
|
|
24
|
+
onToggle,
|
|
25
|
+
onSettings,
|
|
26
|
+
onKeybinds,
|
|
27
|
+
theme,
|
|
28
|
+
}: MenuBarProps): React.ReactNode {
|
|
29
|
+
const tokens = useComponentTheme(theme);
|
|
30
|
+
const { height: terminalHeight } = useTerminalDimensions();
|
|
31
|
+
const dropupHeight = DROPUP_OPTION_ROWS + 2;
|
|
32
|
+
return (
|
|
33
|
+
<>
|
|
34
|
+
{open ? (
|
|
35
|
+
<box
|
|
36
|
+
style={{
|
|
37
|
+
position: "absolute",
|
|
38
|
+
left: 1,
|
|
39
|
+
top: Math.max(0, terminalHeight - 1 - dropupHeight),
|
|
40
|
+
width: 16,
|
|
41
|
+
height: dropupHeight,
|
|
42
|
+
border: true,
|
|
43
|
+
borderStyle: FRAME_BORDER_STYLE,
|
|
44
|
+
borderColor: tokens.text,
|
|
45
|
+
flexDirection: "column",
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
<box onMouseUp={onSettings}>
|
|
49
|
+
<text fg={tokens.text}> Settings</text>
|
|
50
|
+
</box>
|
|
51
|
+
<box onMouseUp={onKeybinds}>
|
|
52
|
+
<text fg={tokens.text}> Keybinds</text>
|
|
53
|
+
</box>
|
|
54
|
+
</box>
|
|
55
|
+
) : null}
|
|
56
|
+
<box
|
|
57
|
+
style={{
|
|
58
|
+
flexDirection: "row",
|
|
59
|
+
height: 1,
|
|
60
|
+
backgroundColor: tokens.panel,
|
|
61
|
+
paddingLeft: 1,
|
|
62
|
+
paddingRight: 1,
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<box onMouseUp={onToggle}>
|
|
66
|
+
<text fg={tokens.textDim}>menu</text>
|
|
67
|
+
</box>
|
|
68
|
+
{status ? <text fg={tokens.accent}>{` ${status}`}</text> : null}
|
|
69
|
+
<box style={{ flexGrow: 1 }} />
|
|
70
|
+
<text fg={tokens.textDim}>v{version}</text>
|
|
71
|
+
</box>
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { buildDisplay } from "../view-plan";
|
|
5
|
+
import { PlanSheet } from "./PlanSheet";
|
|
6
|
+
import { FIXTURE_PLAN, fixtureDisplay, fixtureMarks, fixturePlanSession } from "./story-fixtures";
|
|
7
|
+
|
|
8
|
+
export const meta: StoryMeta = { title: "PlanSheet" };
|
|
9
|
+
|
|
10
|
+
const callbacks = { onLineActivate: () => {} };
|
|
11
|
+
|
|
12
|
+
// a working copy with the context paragraph cut, so it renders as a del block
|
|
13
|
+
const CUT_WORKING = FIXTURE_PLAN.replace("The daemon persists sessions to disk atomically.\n", "");
|
|
14
|
+
|
|
15
|
+
export const AnnotatedPlan: Story = {
|
|
16
|
+
render: () => (
|
|
17
|
+
<PlanSheet
|
|
18
|
+
session={fixturePlanSession()}
|
|
19
|
+
display={fixtureDisplay()}
|
|
20
|
+
marks={fixtureMarks()}
|
|
21
|
+
cursor={2}
|
|
22
|
+
activeSpan={null}
|
|
23
|
+
compose={null}
|
|
24
|
+
popover={null}
|
|
25
|
+
editOrphanCount={0}
|
|
26
|
+
{...callbacks}
|
|
27
|
+
/>
|
|
28
|
+
),
|
|
29
|
+
expectedColors: [DARK.markCommentBackground, DARK.cursorBackground],
|
|
30
|
+
size: { width: 90, height: 28 },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const SpanSelection: Story = {
|
|
34
|
+
render: () => (
|
|
35
|
+
<PlanSheet
|
|
36
|
+
session={fixturePlanSession({ annotations: [] })}
|
|
37
|
+
display={fixtureDisplay()}
|
|
38
|
+
marks={new Map()}
|
|
39
|
+
cursor={2}
|
|
40
|
+
activeSpan={{ displayIndex: 2, start: 0, end: 10 }}
|
|
41
|
+
compose={null}
|
|
42
|
+
popover={null}
|
|
43
|
+
editOrphanCount={0}
|
|
44
|
+
{...callbacks}
|
|
45
|
+
/>
|
|
46
|
+
),
|
|
47
|
+
expectedColors: [DARK.accent],
|
|
48
|
+
size: { width: 90, height: 28 },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const InlineCompose: Story = {
|
|
52
|
+
render: () => (
|
|
53
|
+
<PlanSheet
|
|
54
|
+
session={fixturePlanSession({ annotations: [] })}
|
|
55
|
+
display={fixtureDisplay()}
|
|
56
|
+
marks={new Map()}
|
|
57
|
+
cursor={2}
|
|
58
|
+
activeSpan={{ displayIndex: 2, start: 0, end: 10 }}
|
|
59
|
+
compose={{
|
|
60
|
+
kind: "comment",
|
|
61
|
+
displayIndex: 2,
|
|
62
|
+
quote: "The daemon",
|
|
63
|
+
draft: { text: "Which daemon?", onInput: () => {}, onSave: () => {}, onCancel: () => {} },
|
|
64
|
+
}}
|
|
65
|
+
popover={null}
|
|
66
|
+
editOrphanCount={0}
|
|
67
|
+
{...callbacks}
|
|
68
|
+
/>
|
|
69
|
+
),
|
|
70
|
+
size: { width: 90, height: 30 },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Span mode shows the marker-actions toolbar inline below the marked block. */
|
|
74
|
+
// The marker popover floats as an absolute overlay; the virtual terminal used
|
|
75
|
+
// for story snapshots cannot composite it, so it is covered by the standalone
|
|
76
|
+
// MarkerPopover stories and a functional App test rather than a PlanSheet snapshot.
|
|
77
|
+
|
|
78
|
+
/** A cut block renders struck-through and grayed inline - no red, no [cut] tag. */
|
|
79
|
+
export const WithCutBlock: Story = {
|
|
80
|
+
render: () => (
|
|
81
|
+
<PlanSheet
|
|
82
|
+
session={fixturePlanSession()}
|
|
83
|
+
display={buildDisplay(FIXTURE_PLAN, CUT_WORKING)}
|
|
84
|
+
marks={new Map()}
|
|
85
|
+
cursor={0}
|
|
86
|
+
activeSpan={null}
|
|
87
|
+
compose={null}
|
|
88
|
+
popover={null}
|
|
89
|
+
editOrphanCount={0}
|
|
90
|
+
{...callbacks}
|
|
91
|
+
/>
|
|
92
|
+
),
|
|
93
|
+
expectedColors: [DARK.textDim],
|
|
94
|
+
size: { width: 90, height: 28 },
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const OrphanBanner: Story = {
|
|
98
|
+
render: () => (
|
|
99
|
+
<PlanSheet
|
|
100
|
+
session={fixturePlanSession()}
|
|
101
|
+
display={fixtureDisplay()}
|
|
102
|
+
marks={new Map()}
|
|
103
|
+
cursor={0}
|
|
104
|
+
activeSpan={null}
|
|
105
|
+
compose={null}
|
|
106
|
+
popover={null}
|
|
107
|
+
editOrphanCount={1}
|
|
108
|
+
{...callbacks}
|
|
109
|
+
/>
|
|
110
|
+
),
|
|
111
|
+
expectedColors: [DARK.red],
|
|
112
|
+
size: { width: 90, height: 28 },
|
|
113
|
+
};
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan document sheet: header chrome, the reconciliation banner, and the
|
|
3
|
+
* scrollable block flow. Each prose block renders as one natively wrapped
|
|
4
|
+
* text (wrapMode "word") beside a marker gutter, so wrapping is the
|
|
5
|
+
* renderer's job; the sheet keeps the rendered/work offset mapping per block
|
|
6
|
+
* and exposes an imperative selection surface - the keyboard span drives the
|
|
7
|
+
* native selection, and a mouse drag reads back as an exact work-text range,
|
|
8
|
+
* which keeps quote anchors char-precise.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
12
|
+
import { useRenderer } from "@opentui/react";
|
|
13
|
+
import { createTextAttributes, type ScrollBoxRenderable, type TextRenderable } from "@opentui/core";
|
|
14
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
15
|
+
import {
|
|
16
|
+
blockRuns,
|
|
17
|
+
displayText,
|
|
18
|
+
overlayMarks,
|
|
19
|
+
renderedOffsetFor,
|
|
20
|
+
workRangeForRendered,
|
|
21
|
+
type DisplayBlock,
|
|
22
|
+
type Mark,
|
|
23
|
+
type SpanState,
|
|
24
|
+
type StyleRun,
|
|
25
|
+
} from "../view-plan";
|
|
26
|
+
import type { Theme } from "../theme";
|
|
27
|
+
import { useComponentTheme } from "./theme-context";
|
|
28
|
+
import { CodeBlock } from "./CodeBlock";
|
|
29
|
+
import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
|
|
30
|
+
import { MarkerPopover, type MarkerPopoverProps } from "./MarkerPopover";
|
|
31
|
+
import { FRAME_BORDER_STYLE } from "./primitives/frame";
|
|
32
|
+
|
|
33
|
+
/** A cut block reads as removed: struck through and grayed, never red. */
|
|
34
|
+
const CUT_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: true });
|
|
35
|
+
|
|
36
|
+
export interface PlanSelection {
|
|
37
|
+
displayIndex: number;
|
|
38
|
+
start: number;
|
|
39
|
+
end: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PlanSheetHandle {
|
|
43
|
+
/** Work-text range of the current native (mouse) selection, if any. */
|
|
44
|
+
/** The current native selection; `preferredIndex` (the mouse-up block) re-anchors a fresh drag. */
|
|
45
|
+
readSelection(preferredIndex?: number): PlanSelection | null;
|
|
46
|
+
/** Anchor/extend the renderer's native selection from keyboard span offsets. */
|
|
47
|
+
driveSpanSelection(span: SpanState): void;
|
|
48
|
+
clearSelection(): void;
|
|
49
|
+
revealBlock(displayIndex: number): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PlanComposeState {
|
|
53
|
+
kind: "comment";
|
|
54
|
+
displayIndex: number;
|
|
55
|
+
quote: string;
|
|
56
|
+
draft: AnnotationDraft;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The marker-actions popover, rendered inline at its block while span mode is live. */
|
|
60
|
+
export interface PlanPopoverState extends MarkerPopoverProps {
|
|
61
|
+
displayIndex: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PlanSheetProps {
|
|
65
|
+
session: ReviewSession;
|
|
66
|
+
display: DisplayBlock[];
|
|
67
|
+
marks: Map<number, Mark[]>;
|
|
68
|
+
cursor: number;
|
|
69
|
+
/** Extra selection-style paint on one block (keyboard span or compose anchor). */
|
|
70
|
+
activeSpan: { displayIndex: number; start: number; end: number } | null;
|
|
71
|
+
compose: PlanComposeState | null;
|
|
72
|
+
/** The marker-actions popover for the span's block; null when not in span mode. */
|
|
73
|
+
popover: PlanPopoverState | null;
|
|
74
|
+
editOrphanCount: number;
|
|
75
|
+
onLineActivate: (displayIndex: number) => void;
|
|
76
|
+
theme?: Theme;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface BlockRef {
|
|
80
|
+
renderable: TextRenderable;
|
|
81
|
+
runs: StyleRun[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Screen position of a rendered-text offset inside a wrapped text. */
|
|
85
|
+
function positionOfRenderedOffset(
|
|
86
|
+
renderable: TextRenderable,
|
|
87
|
+
renderedOffset: number,
|
|
88
|
+
): { x: number; y: number } {
|
|
89
|
+
const info = renderable.lineInfo;
|
|
90
|
+
let lineIndex = 0;
|
|
91
|
+
for (let index = 0; index < info.lineStartCols.length; index++) {
|
|
92
|
+
if (info.lineStartCols[index]! <= renderedOffset) lineIndex = index;
|
|
93
|
+
else break;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
x: renderable.x + renderedOffset - (info.lineStartCols[lineIndex] ?? 0),
|
|
97
|
+
y: renderable.y + lineIndex,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function PlanSheet(
|
|
102
|
+
{
|
|
103
|
+
display,
|
|
104
|
+
marks,
|
|
105
|
+
cursor,
|
|
106
|
+
activeSpan,
|
|
107
|
+
compose,
|
|
108
|
+
popover,
|
|
109
|
+
editOrphanCount,
|
|
110
|
+
onLineActivate,
|
|
111
|
+
theme,
|
|
112
|
+
}: PlanSheetProps,
|
|
113
|
+
handleRef,
|
|
114
|
+
): React.ReactNode {
|
|
115
|
+
const tokens = useComponentTheme(theme);
|
|
116
|
+
const renderer = useRenderer();
|
|
117
|
+
const blockRefs = useRef(new Map<number, BlockRef>());
|
|
118
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
119
|
+
|
|
120
|
+
useImperativeHandle(handleRef, () => ({
|
|
121
|
+
readSelection: (preferredIndex?: number): PlanSelection | null => {
|
|
122
|
+
if (!renderer?.hasSelection) return null;
|
|
123
|
+
const readBlock = (displayIndex: number): PlanSelection | null => {
|
|
124
|
+
const blockRef = blockRefs.current.get(displayIndex);
|
|
125
|
+
if (!blockRef) return null;
|
|
126
|
+
const selection = blockRef.renderable.getSelection();
|
|
127
|
+
if (!selection || selection.end <= selection.start) return null;
|
|
128
|
+
const range = workRangeForRendered(blockRef.runs, selection.start, selection.end);
|
|
129
|
+
return range ? { displayIndex, ...range } : null;
|
|
130
|
+
};
|
|
131
|
+
// the block the mouse released on wins, so a fresh drag re-anchors the span
|
|
132
|
+
// (one marker at a time) instead of sticking to the topmost prior selection
|
|
133
|
+
if (preferredIndex !== undefined) {
|
|
134
|
+
const preferred = readBlock(preferredIndex);
|
|
135
|
+
if (preferred) return preferred;
|
|
136
|
+
}
|
|
137
|
+
for (const displayIndex of [...blockRefs.current.keys()].sort(
|
|
138
|
+
(left, right) => left - right,
|
|
139
|
+
)) {
|
|
140
|
+
const found = readBlock(displayIndex);
|
|
141
|
+
if (found) return found;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
},
|
|
145
|
+
driveSpanSelection: (span: SpanState): void => {
|
|
146
|
+
if (!renderer) return;
|
|
147
|
+
const blockRef = blockRefs.current.get(span.displayIndex);
|
|
148
|
+
if (!blockRef) return;
|
|
149
|
+
const renderedStart = renderedOffsetFor(blockRef.runs, span.start);
|
|
150
|
+
const renderedEnd = renderedOffsetFor(blockRef.runs, span.end - 1);
|
|
151
|
+
if (renderedStart === null || renderedEnd === null) return;
|
|
152
|
+
const startPosition = positionOfRenderedOffset(blockRef.renderable, renderedStart);
|
|
153
|
+
const endPosition = positionOfRenderedOffset(blockRef.renderable, renderedEnd);
|
|
154
|
+
renderer.startSelection(blockRef.renderable, startPosition.x, startPosition.y);
|
|
155
|
+
renderer.updateSelection(blockRef.renderable, endPosition.x + 1, endPosition.y);
|
|
156
|
+
},
|
|
157
|
+
clearSelection: (): void => {
|
|
158
|
+
renderer?.clearSelection();
|
|
159
|
+
},
|
|
160
|
+
revealBlock: (displayIndex: number): void => {
|
|
161
|
+
try {
|
|
162
|
+
scrollRef.current?.scrollChildIntoView(`plan-block-${displayIndex}`);
|
|
163
|
+
} catch {
|
|
164
|
+
// reveal is best-effort; selection state is already correct
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
// the popover floats above its block, anchored to the selection start; flip
|
|
170
|
+
// below when the block sits too near the viewport top to fit above it
|
|
171
|
+
const [popoverFlipBelow, setPopoverFlipBelow] = useState(false);
|
|
172
|
+
const [popoverLeft, setPopoverLeft] = useState(2);
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (!popover) return;
|
|
175
|
+
const blockRef = blockRefs.current.get(popover.displayIndex);
|
|
176
|
+
const scrollbox = scrollRef.current;
|
|
177
|
+
if (!blockRef || !scrollbox) return;
|
|
178
|
+
// only the toolbar (3 rows) + gap must fit above; the dropdown flows down
|
|
179
|
+
setPopoverFlipBelow(blockRef.renderable.y - scrollbox.y < 4);
|
|
180
|
+
// left-anchor to the start of the selection: gutter width + its column
|
|
181
|
+
const startColumn =
|
|
182
|
+
activeSpan && activeSpan.displayIndex === popover.displayIndex
|
|
183
|
+
? (renderedOffsetFor(blockRef.runs, activeSpan.start) ?? 0)
|
|
184
|
+
: 0;
|
|
185
|
+
setPopoverLeft(2 + startColumn);
|
|
186
|
+
}, [popover, activeSpan, display, cursor]);
|
|
187
|
+
|
|
188
|
+
const registerBlock = (
|
|
189
|
+
displayIndex: number,
|
|
190
|
+
renderable: TextRenderable | null,
|
|
191
|
+
runs: StyleRun[],
|
|
192
|
+
): void => {
|
|
193
|
+
if (renderable) blockRefs.current.set(displayIndex, { renderable, runs });
|
|
194
|
+
else blockRefs.current.delete(displayIndex);
|
|
195
|
+
};
|
|
196
|
+
// stale refs must not survive a shrinking display list
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
for (const displayIndex of blockRefs.current.keys()) {
|
|
199
|
+
if (displayIndex >= display.length) blockRefs.current.delete(displayIndex);
|
|
200
|
+
}
|
|
201
|
+
}, [display]);
|
|
202
|
+
|
|
203
|
+
const children: React.ReactNode[] = [];
|
|
204
|
+
for (let displayIndex = 0; displayIndex < display.length; displayIndex++) {
|
|
205
|
+
const block = display[displayIndex]!;
|
|
206
|
+
const isCursor = displayIndex === cursor;
|
|
207
|
+
const gap = topGap(display[displayIndex - 1], block);
|
|
208
|
+
if (block.kind === "code") {
|
|
209
|
+
children.push(
|
|
210
|
+
<CodeBlock
|
|
211
|
+
key={displayIndex}
|
|
212
|
+
id={`plan-block-${displayIndex}`}
|
|
213
|
+
language={(block.work ?? block.base)?.lang}
|
|
214
|
+
content={displayText(block)}
|
|
215
|
+
isCursor={isCursor}
|
|
216
|
+
marginTop={gap}
|
|
217
|
+
isAnnotated={(marks.get(displayIndex) ?? []).length > 0}
|
|
218
|
+
changeTag={showsChangeTag(block) ? tagLabel(block) : undefined}
|
|
219
|
+
cut={block.type === "del"}
|
|
220
|
+
theme={theme}
|
|
221
|
+
/>,
|
|
222
|
+
);
|
|
223
|
+
} else {
|
|
224
|
+
const blockMarks = [...(marks.get(displayIndex) ?? [])];
|
|
225
|
+
if (activeSpan && activeSpan.displayIndex === displayIndex) {
|
|
226
|
+
blockMarks.push({ start: activeSpan.start, end: activeSpan.end, role: "kspan" });
|
|
227
|
+
}
|
|
228
|
+
const runs = overlayMarks(blockRuns(block, true), blockMarks);
|
|
229
|
+
// the change tag participates in the rendered text but carries no offsets;
|
|
230
|
+
// a cut block shows no tag (its strikethrough already reads as removed)
|
|
231
|
+
const mappedRuns: StyleRun[] = showsChangeTag(block)
|
|
232
|
+
? [...runs, { text: ` [${tagLabel(block)}]`, role: "plain", start: null }]
|
|
233
|
+
: runs;
|
|
234
|
+
children.push(
|
|
235
|
+
<box
|
|
236
|
+
key={displayIndex}
|
|
237
|
+
id={`plan-block-${displayIndex}`}
|
|
238
|
+
// raise the block holding the popover so its overlay paints over the
|
|
239
|
+
// later blocks it floats across (siblings paint in z-index order)
|
|
240
|
+
style={{
|
|
241
|
+
flexDirection: "row",
|
|
242
|
+
marginTop: gap,
|
|
243
|
+
zIndex: popover?.displayIndex === displayIndex ? 10 : undefined,
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
<text selectable={false}>
|
|
247
|
+
<span fg={isCursor ? tokens.accent : tokens.textDim}>{isCursor ? "▎ " : " "}</span>
|
|
248
|
+
<span fg={tokens.textDim}>{marker(block)}</span>
|
|
249
|
+
</text>
|
|
250
|
+
<text
|
|
251
|
+
bg={isCursor ? tokens.cursorBackground : undefined}
|
|
252
|
+
selectable
|
|
253
|
+
selectionBg={tokens.accent}
|
|
254
|
+
selectionFg={tokens.accentInk}
|
|
255
|
+
style={{ wrapMode: "word", flexGrow: 1, flexShrink: 1 }}
|
|
256
|
+
ref={(renderable: TextRenderable | null) =>
|
|
257
|
+
registerBlock(displayIndex, renderable, mappedRuns)
|
|
258
|
+
}
|
|
259
|
+
onMouseUp={() => onLineActivate(displayIndex)}
|
|
260
|
+
>
|
|
261
|
+
{runs.map((run, runIndex) => (
|
|
262
|
+
<span key={runIndex} {...runStyle(run, block, tokens)}>
|
|
263
|
+
{run.text}
|
|
264
|
+
</span>
|
|
265
|
+
))}
|
|
266
|
+
{showsChangeTag(block) ? (
|
|
267
|
+
<span fg={tagColor(block, tokens)}> [{tagLabel(block)}]</span>
|
|
268
|
+
) : null}
|
|
269
|
+
</text>
|
|
270
|
+
{popover && popover.displayIndex === displayIndex ? (
|
|
271
|
+
// float the card over the block, centered, with a one-row gap; flip
|
|
272
|
+
// below only when there is no room above (near the viewport top)
|
|
273
|
+
<box
|
|
274
|
+
style={{
|
|
275
|
+
position: "absolute",
|
|
276
|
+
left: popoverLeft,
|
|
277
|
+
// fixed offset: the toolbar sits one row above the selection and
|
|
278
|
+
// stays put; the dropdown flows down from it, over the selection
|
|
279
|
+
top: popoverFlipBelow ? 1 : -4,
|
|
280
|
+
flexDirection: "column",
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
<MarkerPopover {...popover} theme={theme} />
|
|
284
|
+
</box>
|
|
285
|
+
) : null}
|
|
286
|
+
</box>,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if (compose && compose.displayIndex === displayIndex) {
|
|
290
|
+
children.push(
|
|
291
|
+
<AnnotationCard
|
|
292
|
+
key={`compose-${displayIndex}`}
|
|
293
|
+
kind={compose.kind}
|
|
294
|
+
quote={compose.quote}
|
|
295
|
+
draft={compose.draft}
|
|
296
|
+
theme={theme}
|
|
297
|
+
/>,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
304
|
+
<box
|
|
305
|
+
style={{
|
|
306
|
+
flexGrow: 1,
|
|
307
|
+
flexDirection: "column",
|
|
308
|
+
border: true,
|
|
309
|
+
borderStyle: FRAME_BORDER_STYLE,
|
|
310
|
+
borderColor: tokens.text,
|
|
311
|
+
}}
|
|
312
|
+
>
|
|
313
|
+
{editOrphanCount > 0 ? (
|
|
314
|
+
<box style={{ height: 1, backgroundColor: tokens.markCommentBackground, paddingLeft: 1 }}>
|
|
315
|
+
<text fg={tokens.red}>
|
|
316
|
+
{editOrphanCount} annotation{editOrphanCount === 1 ? "" : "s"} no longer match - the
|
|
317
|
+
passage was removed.
|
|
318
|
+
</text>
|
|
319
|
+
</box>
|
|
320
|
+
) : null}
|
|
321
|
+
<scrollbox
|
|
322
|
+
ref={scrollRef}
|
|
323
|
+
style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 0 }}
|
|
324
|
+
focused={false}
|
|
325
|
+
>
|
|
326
|
+
{children}
|
|
327
|
+
</scrollbox>
|
|
328
|
+
</box>
|
|
329
|
+
</box>
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
/** Vertical rhythm: gaps live ABOVE blocks so boundaries never collapse. */
|
|
334
|
+
function topGap(previous: DisplayBlock | undefined, current: DisplayBlock): number {
|
|
335
|
+
if (!previous) return 0;
|
|
336
|
+
const tightPair =
|
|
337
|
+
(current.kind === "li" && previous.kind === "li") ||
|
|
338
|
+
(current.kind === "oli" && previous.kind === "oli");
|
|
339
|
+
return tightPair ? 0 : 1;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function marker(block: DisplayBlock): string {
|
|
343
|
+
if (block.kind === "li") return "- ";
|
|
344
|
+
if (block.kind === "oli") return `${block.orderedItemNumber ?? 1}. `;
|
|
345
|
+
if (block.kind === "quote") return "▏ ";
|
|
346
|
+
return "";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** An added or edited block earns a tag; a cut block relies on its strikethrough. */
|
|
350
|
+
function showsChangeTag(block: DisplayBlock): boolean {
|
|
351
|
+
return block.type === "add" || block.type === "mod";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function tagLabel(block: DisplayBlock): "new" | "edited" {
|
|
355
|
+
return block.type === "add" ? "new" : "edited";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function tagColor(block: DisplayBlock, tokens: Theme): string {
|
|
359
|
+
return block.type === "add" ? tokens.green : tokens.accent;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function runStyle(
|
|
363
|
+
run: StyleRun,
|
|
364
|
+
block: DisplayBlock,
|
|
365
|
+
tokens: Theme,
|
|
366
|
+
): { fg?: string; bg?: string; attributes?: number } {
|
|
367
|
+
// a cut block reads as removed: every run struck through and grayed, never red
|
|
368
|
+
if (block.type === "del") return { fg: tokens.textDim, attributes: CUT_ATTRIBUTES };
|
|
369
|
+
const headingFg =
|
|
370
|
+
block.kind === "h1"
|
|
371
|
+
? tokens.text
|
|
372
|
+
: block.kind === "h2" || block.kind === "h3"
|
|
373
|
+
? tokens.accent
|
|
374
|
+
: undefined;
|
|
375
|
+
switch (run.role) {
|
|
376
|
+
case "ins":
|
|
377
|
+
return { fg: tokens.insertedForeground };
|
|
378
|
+
case "del":
|
|
379
|
+
return { fg: tokens.deletedForeground };
|
|
380
|
+
case "mark-comment":
|
|
381
|
+
return { fg: tokens.text, bg: tokens.markCommentBackground };
|
|
382
|
+
case "mark-focus":
|
|
383
|
+
return { fg: tokens.accentInk, bg: tokens.accent };
|
|
384
|
+
case "kspan":
|
|
385
|
+
return { fg: tokens.accentInk, bg: tokens.accent };
|
|
386
|
+
default:
|
|
387
|
+
return { fg: headingFg ?? tokens.text };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { PromptDialog } from "./PromptDialog";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "PromptDialog" };
|
|
7
|
+
|
|
8
|
+
export const RenameAuthor: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<PromptDialog
|
|
11
|
+
isOpen
|
|
12
|
+
title=" Rename author "
|
|
13
|
+
label="Display name for this collaborator:"
|
|
14
|
+
value="Robin"
|
|
15
|
+
placeholder="their name"
|
|
16
|
+
onInput={() => {}}
|
|
17
|
+
/>
|
|
18
|
+
),
|
|
19
|
+
expectedColors: [DARK.elevated, DARK.accent],
|
|
20
|
+
size: { width: 64, height: 16 },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const NameYourself: Story = {
|
|
24
|
+
render: () => (
|
|
25
|
+
<PromptDialog
|
|
26
|
+
isOpen
|
|
27
|
+
title=" Welcome "
|
|
28
|
+
label="Your name (optional) - it attributes the notes you leave:"
|
|
29
|
+
value=""
|
|
30
|
+
placeholder="your name"
|
|
31
|
+
onInput={() => {}}
|
|
32
|
+
/>
|
|
33
|
+
),
|
|
34
|
+
expectedColors: [DARK.elevated],
|
|
35
|
+
size: { width: 64, height: 16 },
|
|
36
|
+
};
|