@ai-matrx/rich-content 0.2.10 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/copy/copy-commands.d.ts +42 -0
  2. package/dist/copy/copy-commands.js +119 -0
  3. package/dist/copy/markdown-copy-html.d.ts +3 -0
  4. package/dist/copy/markdown-copy-html.js +8 -0
  5. package/dist/copy/markdown-readable-text.d.ts +4 -0
  6. package/dist/copy/markdown-readable-text.js +62 -0
  7. package/dist/display/chat-markdown/block-registry/BlockComponentRegistry.js +2 -0
  8. package/dist/display/chat-markdown/block-registry/block-dispatch.js +2 -0
  9. package/dist/host/app-bindings.d.ts +30 -0
  10. package/dist/host/app-bindings.js +38 -0
  11. package/dist/host/feedback-subject.d.ts +16 -0
  12. package/dist/host/feedback-subject.js +0 -0
  13. package/dist/host/manual-copy.d.ts +7 -0
  14. package/dist/host/manual-copy.js +11 -0
  15. package/dist/host/registry.d.ts +2 -0
  16. package/dist/host/registry.js +10 -0
  17. package/dist/host/session-knob.d.ts +5 -0
  18. package/dist/host/session-knob.js +7 -0
  19. package/dist/host/types.d.ts +24 -0
  20. package/dist/rich-document/RegistryContextMenu.d.ts +29 -0
  21. package/dist/rich-document/RegistryContextMenu.js +40 -0
  22. package/dist/rich-document/RichDocument.d.ts +78 -0
  23. package/dist/rich-document/RichDocument.js +218 -0
  24. package/dist/rich-document/RichDocumentActionProvider.d.ts +18 -0
  25. package/dist/rich-document/RichDocumentActionProvider.js +18 -0
  26. package/dist/rich-document/RichDocumentActionSurface.d.ts +11 -0
  27. package/dist/rich-document/RichDocumentActionSurface.js +59 -0
  28. package/dist/rich-document/RichDocumentActions.d.ts +12 -0
  29. package/dist/rich-document/RichDocumentActions.js +29 -0
  30. package/dist/rich-document/actions/hostActions.d.ts +5 -0
  31. package/dist/rich-document/actions/hostActions.js +31 -0
  32. package/dist/rich-document/actions/labels.d.ts +9 -0
  33. package/dist/rich-document/actions/labels.js +8 -0
  34. package/dist/rich-document/actions/markdownTable.d.ts +9 -0
  35. package/dist/rich-document/actions/markdownTable.js +39 -0
  36. package/dist/rich-document/actions/provider.d.ts +58 -0
  37. package/dist/rich-document/actions/provider.js +245 -0
  38. package/dist/rich-document/actions/resumePendingAuthAction.d.ts +3 -0
  39. package/dist/rich-document/actions/resumePendingAuthAction.js +31 -0
  40. package/dist/rich-document/actions/sources/index.d.ts +9 -0
  41. package/dist/rich-document/actions/sources/index.js +22 -0
  42. package/dist/rich-document/actions/sources/raw.d.ts +4 -0
  43. package/dist/rich-document/actions/sources/raw.js +35 -0
  44. package/dist/rich-document/actions/useRichDocumentProvider.d.ts +1 -0
  45. package/dist/rich-document/actions/useRichDocumentProvider.js +13 -0
  46. package/dist/rich-document/host-types.d.ts +51 -0
  47. package/dist/rich-document/host-types.js +0 -0
  48. package/dist/rich-document/registrations.d.ts +1 -0
  49. package/dist/rich-document/registrations.js +13 -0
  50. package/dist/rich-document/review/applyTargets.d.ts +16 -0
  51. package/dist/rich-document/review/applyTargets.js +32 -0
  52. package/dist/rich-document/review/proposedEdit.d.ts +22 -0
  53. package/dist/rich-document/review/proposedEdit.js +48 -0
  54. package/dist/rich-document/review/protectedSpans.d.ts +14 -0
  55. package/dist/rich-document/review/protectedSpans.js +61 -0
  56. package/dist/rich-document/runtime/actionSurfacesStore.d.ts +30 -0
  57. package/dist/rich-document/runtime/actionSurfacesStore.js +79 -0
  58. package/dist/rich-document/runtime/contextMenuFor.d.ts +6 -0
  59. package/dist/rich-document/runtime/contextMenuFor.js +7 -0
  60. package/dist/rich-document/runtime/providerBridge.d.ts +20 -0
  61. package/dist/rich-document/runtime/providerBridge.js +23 -0
  62. package/dist/rich-document/runtime/useActionSurfaceProvider.d.ts +33 -0
  63. package/dist/rich-document/runtime/useActionSurfaceProvider.js +214 -0
  64. package/dist/rich-document/search/ContentFindControl.d.ts +8 -0
  65. package/dist/rich-document/search/ContentFindControl.js +46 -0
  66. package/dist/rich-document/search/RenderedFindBar.d.ts +8 -0
  67. package/dist/rich-document/search/RenderedFindBar.js +110 -0
  68. package/dist/rich-document/search/renderedFind.d.ts +1 -0
  69. package/dist/rich-document/search/renderedFind.js +1 -0
  70. package/dist/rich-document/types.d.ts +384 -0
  71. package/dist/rich-document/types.js +0 -0
  72. package/dist/rich-document/variants/ActionBar.d.ts +17 -0
  73. package/dist/rich-document/variants/ActionBar.js +48 -0
  74. package/dist/rich-document/variants/MenuVariant.d.ts +15 -0
  75. package/dist/rich-document/variants/MenuVariant.js +17 -0
  76. package/dist/rich-document/variants/MiniActionBar.d.ts +5 -0
  77. package/dist/rich-document/variants/MiniActionBar.js +11 -0
  78. package/dist/rich-document/variants/RegistryActionList.d.ts +17 -0
  79. package/dist/rich-document/variants/RegistryActionList.js +94 -0
  80. package/dist/rich-document/variants/RegistryActionMenu.d.ts +24 -0
  81. package/dist/rich-document/variants/RegistryActionMenu.js +74 -0
  82. package/dist/rich-document/variants/shared/AlchemyDocumentMenu.d.ts +7 -0
  83. package/dist/rich-document/variants/shared/AlchemyDocumentMenu.js +83 -0
  84. package/dist/rich-document/variants/shared/OpenOneMenuButton.d.ts +9 -0
  85. package/dist/rich-document/variants/shared/OpenOneMenuButton.js +70 -0
  86. package/dist/rich-document/variants/shared/StandaloneOneMenu.d.ts +8 -0
  87. package/dist/rich-document/variants/shared/StandaloneOneMenu.js +32 -0
  88. package/dist/rich-document/variants/shared/categories.d.ts +4 -0
  89. package/dist/rich-document/variants/shared/categories.js +31 -0
  90. package/dist/rich-document/variants/shared/iconMap.d.ts +2 -0
  91. package/dist/rich-document/variants/shared/iconMap.js +73 -0
  92. package/dist/rich-document/variants/shared/menuStructure.d.ts +81 -0
  93. package/dist/rich-document/variants/shared/menuStructure.js +268 -0
  94. package/dist/rich-document/variants/shared/runAction.d.ts +17 -0
  95. package/dist/rich-document/variants/shared/runAction.js +24 -0
  96. package/dist/selection-toolbar/SelectionToolbarFrame.d.ts +64 -0
  97. package/dist/selection-toolbar/SelectionToolbarFrame.js +191 -0
  98. package/dist/selection-toolbar/common-actions.d.ts +2 -0
  99. package/dist/selection-toolbar/common-actions.js +103 -0
  100. package/dist/selection-toolbar/selection-actions.d.ts +126 -0
  101. package/dist/selection-toolbar/selection-actions.js +240 -0
  102. package/dist/selection-toolbar/selection-copy.d.ts +19 -0
  103. package/dist/selection-toolbar/selection-copy.js +98 -0
  104. package/dist/selection-toolbar/selection-shape.d.ts +1 -0
  105. package/dist/selection-toolbar/selection-shape.js +15 -0
  106. package/dist/selection-toolbar/selection-zones.d.ts +90 -0
  107. package/dist/selection-toolbar/selection-zones.js +85 -0
  108. package/dist/selection-toolbar/textarea-caret.d.ts +8 -0
  109. package/dist/selection-toolbar/textarea-caret.js +69 -0
  110. package/dist/utils/alchemy-icon-keys.d.ts +18 -0
  111. package/dist/utils/alchemy-icon-keys.js +29 -0
  112. package/dist/utils/rendered-find.d.ts +9 -0
  113. package/dist/utils/rendered-find.js +68 -0
  114. package/package.json +22 -6
@@ -0,0 +1,268 @@
1
+ import {
2
+ Save,
3
+ Copy,
4
+ Share2,
5
+ Edit,
6
+ BarChart3,
7
+ Settings,
8
+ GitCompareArrows,
9
+ MessagesSquare
10
+ } from "lucide-react";
11
+ import { AGENT_ICON } from "@ai-matrx/icons/domain";
12
+ const AI_SUBMENU_LABEL = "AI Actions";
13
+ const CONVERSATION_SUBMENU_LABEL = "Conversation";
14
+ const MENU_STRUCTURE = [
15
+ {
16
+ // The doors a reader must see without scrolling or guessing (defect D6:
17
+ // "Add to Rulebook" once sat twenty rows below the fold and read as
18
+ // missing). Every family of near-identical variants is ONE submenu row.
19
+ submenu: null,
20
+ actionIds: [
21
+ // A bar's primary buttons, for the menu-only hosts that have no bar
22
+ // (icon-only, menu variant). A menu under a bar never lists them.
23
+ "thumbs-up",
24
+ "thumbs-down",
25
+ "tts-play",
26
+ "continue-in-chat",
27
+ // Send THIS message onward — one family, adjacent, no heading (no
28
+ // approved name fits; "Ask in chat" was coined 2026-09-25).
29
+ "send-to-agent",
30
+ // An answer in a "Custom agent…" window goes back into the text it came from.
31
+ "apply-to-source",
32
+ // A good "Custom agent…" run, kept: as a shortcut or bound to the page.
33
+ "save-run-as-shortcut",
34
+ "bind-run-to-page",
35
+ "ask-followup",
36
+ "quote-into-chat",
37
+ "add-to-rulebook",
38
+ "save-to-task",
39
+ "pin-message",
40
+ // The record's Notes & comments dock (present only when it holds something).
41
+ "notes-and-comments",
42
+ "save-to-notes",
43
+ "summarize-and-listen",
44
+ "summarize-for-listening"
45
+ ]
46
+ },
47
+ {
48
+ // Everything that acts on the WHOLE conversation this message belongs to
49
+ // — the same verbs as the chat header's conversation menu, one row deep.
50
+ // Present only when the source carries a conversationId.
51
+ submenu: CONVERSATION_SUBMENU_LABEL,
52
+ icon: MessagesSquare,
53
+ target: true,
54
+ // Its rows mix find/ask/share/edit/export; "save" seats the section in
55
+ // the document group, right after the promoted rows and before "Save".
56
+ layoutCategory: "save",
57
+ actionIds: [
58
+ "conversation-find",
59
+ "conversation-pinned-only",
60
+ "conversation-share",
61
+ "conversation-copy-link",
62
+ "conversation-rename",
63
+ "conversation-duplicate"
64
+ // THE full Alchemy transfer set over the whole conversation (Arman,
65
+ // 2026-09-26) joins here from the host: extendMenuSection(CONVERSATION_SUBMENU_LABEL, ids).
66
+ ]
67
+ },
68
+ {
69
+ // Review-and-apply AI powers (a text field promotes these to the top —
70
+ // RegistryActionList; a document keeps them one row deep).
71
+ submenu: AI_SUBMENU_LABEL,
72
+ icon: AGENT_ICON,
73
+ inline: true,
74
+ actionIds: ["text-cleanup", "text-help", "text-custom-agent"]
75
+ },
76
+ {
77
+ // "Save" — the classic name (e5b8d01229, 2026-05-19); "Save as" was coined 2026-09-24.
78
+ submenu: "Save",
79
+ icon: Save,
80
+ actionIds: [
81
+ "save-as-message-template",
82
+ "save-as-flashcard",
83
+ // Flashcards / quiz from this message — the same family as the row
84
+ // above (folded 2026-09-26 to keep the menu within the 17-row D6 bound).
85
+ "convert-to-study",
86
+ "save-table-as-data",
87
+ "save-to-scratch",
88
+ "add-to-docs",
89
+ "save-as-file",
90
+ "save-to-code",
91
+ "save-to-files",
92
+ "save-code-to-scratch",
93
+ "save-as-pdf",
94
+ "save-shape-instance",
95
+ "save-to-contact",
96
+ "set-context-value"
97
+ ]
98
+ },
99
+ {
100
+ submenu: "Copy as",
101
+ icon: Copy,
102
+ actionIds: [
103
+ // The plain copy leads its own family (it sat as a loose top-level row
104
+ // beside "Copy as › plain text"; folded 2026-09-26 for the D6 bound).
105
+ "copy",
106
+ "copy-markdown",
107
+ "copy-plain-text",
108
+ "copy-with-thinking",
109
+ "copy-table-tsv",
110
+ "copy-table-csv",
111
+ "copy-html-source",
112
+ "copy-html-page"
113
+ ]
114
+ },
115
+ {
116
+ // "Export" — the classic name (e5b8d01229, 2026-05-19); "Share & export" was coined 2026-09-24.
117
+ submenu: "Export",
118
+ icon: Share2,
119
+ actionIds: [
120
+ "html-preview",
121
+ "share-webpage",
122
+ "send-google-doc",
123
+ "email-to-me",
124
+ "download-pdf",
125
+ "download-docx",
126
+ "download-html",
127
+ "print",
128
+ "full-print"
129
+ ]
130
+ },
131
+ {
132
+ submenu: "Compare",
133
+ icon: GitCompareArrows,
134
+ actionIds: [
135
+ "compare-with-clipboard",
136
+ "set-compare-base",
137
+ "compare-with-base"
138
+ ]
139
+ },
140
+ {
141
+ submenu: "Edit",
142
+ icon: Edit,
143
+ actionIds: [
144
+ "regenerate-response",
145
+ "regenerate-latest",
146
+ "edit",
147
+ "edit-and-resubmit",
148
+ "open-fullscreen-editor",
149
+ "edit-history",
150
+ "fork-at-message",
151
+ "fork-and-regenerate",
152
+ "delete-message"
153
+ ]
154
+ },
155
+ {
156
+ // Creator/admin power tools, one row: the analysis doors and the
157
+ // server-API test verbs (a separate "Server API (test)" row pushed the
158
+ // admin menu past the 17-row D6 bound).
159
+ submenu: "Creator tools",
160
+ icon: BarChart3,
161
+ actionIds: [
162
+ "analyze-response",
163
+ "debug-stream",
164
+ "server-api-admin-fork-at",
165
+ "server-api-admin-fork-before",
166
+ "server-api-admin-hide-from-model",
167
+ "server-api-admin-delete-this",
168
+ "server-api-admin-delete-from-here",
169
+ "server-api-admin-delete-dryrun",
170
+ "server-api-admin-replace-with-summary",
171
+ "server-api-admin-restore-compaction"
172
+ ]
173
+ },
174
+ {
175
+ // App doors that act on no content: feedback, and the read-aloud voice
176
+ // setting (the Listen button's settings door; a loose top-level row it
177
+ // was the 18th, below the fold — D6).
178
+ submenu: "App",
179
+ icon: Settings,
180
+ actionIds: ["submit-feedback", "tts-voice-settings"]
181
+ }
182
+ ];
183
+ const PLACED_IDS = new Set(
184
+ MENU_STRUCTURE.flatMap((section) => section.actionIds)
185
+ );
186
+ const MENU_ALIASES = {
187
+ "regenerate-latest": "regenerate-response"
188
+ };
189
+ const LONE_AI_ROW_JOINS = "Edit";
190
+ function buildMenuTree(input, options = {}) {
191
+ const present = new Set(input.map((a) => a.id));
192
+ const actions = input.filter((a) => {
193
+ const canonical = MENU_ALIASES[a.id];
194
+ return !(canonical && present.has(canonical));
195
+ });
196
+ const byId = /* @__PURE__ */ new Map();
197
+ for (const action of actions) byId.set(action.id, action);
198
+ let topLevel = [];
199
+ const submenus = [];
200
+ for (const section of MENU_STRUCTURE) {
201
+ const sectionActions = section.actionIds.map((id) => byId.get(id)).filter((a) => Boolean(a));
202
+ if (sectionActions.length === 0) continue;
203
+ if (section.submenu === null) {
204
+ topLevel = sectionActions;
205
+ } else {
206
+ submenus.push({
207
+ label: section.submenu,
208
+ icon: section.icon,
209
+ actions: sectionActions
210
+ });
211
+ }
212
+ }
213
+ const ai = submenus.find((s) => s.label === AI_SUBMENU_LABEL);
214
+ const home = submenus.find((s) => s.label === LONE_AI_ROW_JOINS);
215
+ if (!options.aiSlot && ai && ai.actions.length === 1 && home) {
216
+ home.actions = [...home.actions, ...ai.actions];
217
+ submenus.splice(submenus.indexOf(ai), 1);
218
+ }
219
+ const extras = actions.filter((a) => !PLACED_IDS.has(a.id));
220
+ return { topLevel, submenus, extras };
221
+ }
222
+ function registryMenuActions(actions) {
223
+ return actions.filter((a) => (a.renderSlot ?? "overflow") !== "primary");
224
+ }
225
+ function withAiSlot(submenus, hasSlot) {
226
+ if (!hasSlot || submenus.some((s) => s.label === AI_SUBMENU_LABEL)) return submenus;
227
+ const ai = {
228
+ label: AI_SUBMENU_LABEL,
229
+ icon: MENU_STRUCTURE.find((s) => s.submenu === AI_SUBMENU_LABEL)?.icon,
230
+ actions: []
231
+ };
232
+ const order = (label) => MENU_STRUCTURE.findIndex((s) => s.submenu === label);
233
+ const aiAt = order(AI_SUBMENU_LABEL);
234
+ const index = submenus.findIndex((s) => {
235
+ const at = order(s.label);
236
+ return at === -1 || at > aiAt;
237
+ });
238
+ if (index === -1) return [...submenus, ai];
239
+ return [...submenus.slice(0, index), ai, ...submenus.slice(index)];
240
+ }
241
+ function flattenMenuTreeIds(tree) {
242
+ return [
243
+ ...tree.topLevel.map((a) => a.id),
244
+ ...tree.submenus.flatMap((s) => [`submenu:${s.label}`, ...s.actions.map((a) => a.id)]),
245
+ ...tree.extras.map((a) => a.id)
246
+ ];
247
+ }
248
+ let structureVersion = 0;
249
+ function menuStructureVersion() {
250
+ return structureVersion;
251
+ }
252
+ function extendMenuSection(submenu, actionIds) {
253
+ structureVersion++;
254
+ const section = MENU_STRUCTURE.find((s) => s.submenu === submenu);
255
+ if (!section) throw new Error(`[rich-document] no menu section named "${submenu}"`);
256
+ for (const id of actionIds) if (!section.actionIds.includes(id)) section.actionIds.push(id);
257
+ }
258
+ export {
259
+ AI_SUBMENU_LABEL,
260
+ CONVERSATION_SUBMENU_LABEL,
261
+ MENU_STRUCTURE,
262
+ buildMenuTree,
263
+ extendMenuSection,
264
+ flattenMenuTreeIds,
265
+ menuStructureVersion,
266
+ registryMenuActions,
267
+ withAiSlot
268
+ };
@@ -0,0 +1,17 @@
1
+ import type { LucideIcon } from "lucide-react";
2
+ import type { RichDocumentAction, RichDocumentActionContext } from "../../types.js";
3
+ /**
4
+ * Fire an action's handler. Builds a fresh context via getCtx() at click
5
+ * time (so handlers see live content), then runs the handler fire-and-forget
6
+ * — handlers own their toasts/dialogs.
7
+ */
8
+ export declare function runAction(action: RichDocumentAction, getCtx: () => RichDocumentActionContext): void;
9
+ export interface ActionDisplay {
10
+ label: string;
11
+ Icon: LucideIcon;
12
+ iconColor?: string;
13
+ isDisabled: boolean;
14
+ disabledReason?: string;
15
+ }
16
+ /** Resolve an action's label / disabled-state against a context snapshot. */
17
+ export declare function resolveActionDisplay(action: RichDocumentAction, ctx: RichDocumentActionContext): ActionDisplay;
@@ -0,0 +1,24 @@
1
+ import { resolveActionLabel } from "../../actions/labels.js";
2
+ import { holdDeliberateIntent } from "../../../host/app-bindings.js";
3
+ function runAction(action, getCtx) {
4
+ const ctx = getCtx();
5
+ void holdDeliberateIntent(() => action.run(ctx)).catch((err) => {
6
+ console.error(`[RichDocument] action ${action.id} threw`, err);
7
+ });
8
+ }
9
+ function resolveActionDisplay(action, ctx) {
10
+ const disabledResult = action.disabled?.(ctx);
11
+ const isDisabled = disabledResult === true || typeof disabledResult === "object" && disabledResult !== null;
12
+ const disabledReason = typeof disabledResult === "object" && disabledResult !== null ? disabledResult.reason : void 0;
13
+ return {
14
+ label: resolveActionLabel(action.label, ctx),
15
+ Icon: action.stateIcon?.(ctx)?.icon ?? action.icon,
16
+ iconColor: action.iconColor,
17
+ isDisabled,
18
+ disabledReason
19
+ };
20
+ }
21
+ export {
22
+ resolveActionDisplay,
23
+ runAction
24
+ };
@@ -0,0 +1,64 @@
1
+ import * as React from "react";
2
+ import type { ClickTarget } from "@ai-matrx/alchemy/actions";
3
+ import type { SelectionMode } from "./selection-zones.js";
4
+ export interface Rect {
5
+ left: number;
6
+ top: number;
7
+ bottom: number;
8
+ width: number;
9
+ }
10
+ /** The nearest ancestor that scrolls (or the viewport): the box the toolbar lives in. */
11
+ export declare function scrollBoundsOf(node: Node | null): {
12
+ top: number;
13
+ bottom: number;
14
+ left: number;
15
+ right: number;
16
+ };
17
+ type Box = {
18
+ top: number;
19
+ bottom: number;
20
+ left: number;
21
+ right: number;
22
+ };
23
+ /**
24
+ * The scroll box minus whatever pins itself over its top edge (a sticky page
25
+ * header, a fixed bar): a scroll area that runs UNDER a header has a top edge
26
+ * the person cannot see, and a toolbar placed "above the selection" there is
27
+ * clipped by it. Sampled on demand at the box's top edge, never cached.
28
+ */
29
+ export declare function visibleBoxOf(box: Box, anchor: Node | null, at: {
30
+ left: number;
31
+ width: number;
32
+ }): Box;
33
+ /** Where the frame goes for an anchor inside a box (pure; unit-tested). */
34
+ export declare function placeFrame(at: Rect, size: {
35
+ w: number;
36
+ h: number;
37
+ }, box: {
38
+ top: number;
39
+ bottom: number;
40
+ left: number;
41
+ right: number;
42
+ }, viewport: {
43
+ w: number;
44
+ h: number;
45
+ }): {
46
+ left: number;
47
+ top: number;
48
+ hidden: boolean;
49
+ };
50
+ export default function SelectionToolbarFrame({ seq, mode, target, text, range, rect, anchor, docked, focusSignal, panel, }: {
51
+ seq: number;
52
+ mode: SelectionMode;
53
+ target: ClickTarget;
54
+ text: string;
55
+ range: Range | null;
56
+ rect: Rect;
57
+ /** The node the selection lives in — its scroll container bounds the toolbar. */
58
+ anchor: Node | null;
59
+ docked: boolean;
60
+ /** Changes on every keyboard open (Ctrl/Cmd+Alt+M): focus the first control. */
61
+ focusSignal: number;
62
+ panel: React.ReactNode | null;
63
+ }): React.ReactElement | null;
64
+ export {};
@@ -0,0 +1,191 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { SelectionToolbar } from "@ai-matrx/alchemy/react/selection";
6
+ const GAP = 8;
7
+ const EDGE = 8;
8
+ function scrollBoundsOf(node) {
9
+ const view = { top: 0, bottom: window.innerHeight, left: 0, right: window.innerWidth };
10
+ let el = node instanceof Element ? node : node?.parentElement ?? null;
11
+ while (el && el !== document.body && el !== document.documentElement) {
12
+ const style = getComputedStyle(el);
13
+ if (/(auto|scroll|hidden)/.test(style.overflowY) && el.scrollHeight > el.clientHeight + 1) {
14
+ const r = el.getBoundingClientRect();
15
+ return {
16
+ top: Math.max(view.top, r.top),
17
+ bottom: Math.min(view.bottom, r.bottom),
18
+ left: Math.max(view.left, r.left),
19
+ right: Math.min(view.right, r.right)
20
+ };
21
+ }
22
+ el = el.parentElement;
23
+ }
24
+ return view;
25
+ }
26
+ function visibleBoxOf(box, anchor, at) {
27
+ if (typeof document.elementFromPoint !== "function") return box;
28
+ const x = Math.max(box.left + 1, Math.min(box.right - 1, at.left + at.width / 2));
29
+ const y = Math.max(0, box.top) + 1;
30
+ let el = document.elementFromPoint(x, y);
31
+ let inset = box.top;
32
+ while (el && el !== document.body && el !== document.documentElement) {
33
+ const position = getComputedStyle(el).position;
34
+ const owns = anchor ? el.contains(anchor) : false;
35
+ if ((position === "fixed" || position === "sticky") && !owns && !el.closest("[data-selection-toolbar]")) {
36
+ inset = Math.max(inset, el.getBoundingClientRect().bottom);
37
+ }
38
+ el = el.parentElement;
39
+ }
40
+ return { ...box, top: Math.min(inset, box.bottom) };
41
+ }
42
+ function placeFrame(at, size, box, viewport) {
43
+ const hidden = at.bottom <= box.top || at.top >= box.bottom;
44
+ let top = at.top - size.h - GAP;
45
+ if (top < box.top + EDGE) top = at.bottom + GAP;
46
+ top = Math.max(box.top + EDGE, Math.min(Math.min(box.bottom, viewport.h) - size.h - EDGE, top));
47
+ const left = Math.max(EDGE, Math.min(viewport.w - size.w - EDGE, at.left + at.width / 2 - size.w / 2));
48
+ return { left, top, hidden };
49
+ }
50
+ function SelectionToolbarFrame({
51
+ seq,
52
+ mode,
53
+ target,
54
+ text,
55
+ range,
56
+ rect,
57
+ anchor,
58
+ docked,
59
+ focusSignal,
60
+ panel
61
+ }) {
62
+ const frameRef = React.useRef(null);
63
+ const current = React.useRef(0);
64
+ const [position, setPosition] = React.useState(null);
65
+ const place = React.useCallback(() => {
66
+ const frame = frameRef.current;
67
+ if (!frame || frame.offsetHeight === 0) return;
68
+ let at = rect;
69
+ if (range) {
70
+ const live = range.getBoundingClientRect();
71
+ if (live.width || live.height) at = { left: live.left, top: live.top, bottom: live.bottom, width: live.width };
72
+ }
73
+ const next = placeFrame(
74
+ at,
75
+ { w: frame.offsetWidth, h: frame.offsetHeight },
76
+ visibleBoxOf(scrollBoundsOf(anchor), anchor, at),
77
+ { w: window.innerWidth, h: window.innerHeight }
78
+ );
79
+ setPosition((p) => p && p.left === next.left && p.top === next.top && p.hidden === next.hidden ? p : next);
80
+ }, [rect, range, anchor]);
81
+ React.useLayoutEffect(() => {
82
+ if (docked) return;
83
+ place();
84
+ const frame = frameRef.current;
85
+ const ro = frame && typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => place()) : null;
86
+ if (frame) ro?.observe(frame);
87
+ const mo = frame && typeof MutationObserver !== "undefined" ? new MutationObserver(() => place()) : null;
88
+ if (frame) mo?.observe(frame, { childList: true, subtree: true });
89
+ window.addEventListener("scroll", place, true);
90
+ window.addEventListener("resize", place);
91
+ return () => {
92
+ ro?.disconnect();
93
+ mo?.disconnect();
94
+ window.removeEventListener("scroll", place, true);
95
+ window.removeEventListener("resize", place);
96
+ };
97
+ }, [docked, place, panel]);
98
+ const buttons = () => [...frameRef.current?.querySelectorAll("[role=toolbar] button:not([disabled])") ?? []];
99
+ React.useEffect(() => {
100
+ const frame = frameRef.current;
101
+ if (!frame || panel) return;
102
+ const apply = () => {
103
+ const list = buttons();
104
+ if (list.length === 0) return;
105
+ if (current.current >= list.length) current.current = 0;
106
+ list.forEach((b, i) => {
107
+ const want = i === current.current ? "0" : "-1";
108
+ if (b.getAttribute("tabindex") !== want) b.setAttribute("tabindex", want);
109
+ });
110
+ };
111
+ apply();
112
+ const mo = new MutationObserver(apply);
113
+ mo.observe(frame, { childList: true, subtree: true });
114
+ const onFocusIn = (e) => {
115
+ const at = buttons().indexOf(e.target);
116
+ if (at >= 0 && at !== current.current) {
117
+ current.current = at;
118
+ apply();
119
+ }
120
+ };
121
+ frame.addEventListener("focusin", onFocusIn);
122
+ return () => {
123
+ mo.disconnect();
124
+ frame.removeEventListener("focusin", onFocusIn);
125
+ };
126
+ }, [seq, panel]);
127
+ React.useEffect(() => {
128
+ if (!focusSignal) return;
129
+ let tries = 0;
130
+ let id = 0;
131
+ const focusFirst = () => {
132
+ const first = buttons()[0];
133
+ if (first) {
134
+ current.current = 0;
135
+ first.focus();
136
+ } else if (tries++ < 20) id = requestAnimationFrame(focusFirst);
137
+ };
138
+ id = requestAnimationFrame(focusFirst);
139
+ return () => cancelAnimationFrame(id);
140
+ }, [focusSignal]);
141
+ const onKeyDown = (e) => {
142
+ if (panel) return;
143
+ const keys = ["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End"];
144
+ if (!keys.includes(e.key)) return;
145
+ const list = buttons();
146
+ if (list.length === 0) return;
147
+ e.preventDefault();
148
+ const at = list.indexOf(document.activeElement);
149
+ const next = e.key === "Home" ? 0 : e.key === "End" ? list.length - 1 : e.key === "ArrowRight" || e.key === "ArrowDown" ? (at + 1) % list.length : (at - 1 + list.length) % list.length;
150
+ current.current = next;
151
+ list.forEach((b, i) => b.setAttribute("tabindex", i === next ? "0" : "-1"));
152
+ list[next].focus();
153
+ };
154
+ if (typeof document === "undefined") return null;
155
+ return createPortal(
156
+ /* @__PURE__ */ jsx(
157
+ "div",
158
+ {
159
+ ref: frameRef,
160
+ "data-selection-toolbar": docked ? "docked" : "floating",
161
+ "data-selection-mode": mode,
162
+ onKeyDown,
163
+ className: docked ? "fixed inset-x-2 z-[9999] flex justify-center" : "fixed z-[9999]",
164
+ style: docked ? { bottom: "calc(env(safe-area-inset-bottom, 0px) + 8px)" } : {
165
+ left: position?.left ?? -1e4,
166
+ top: position?.top ?? -1e4,
167
+ ...position && !position.hidden ? {} : { visibility: "hidden" }
168
+ },
169
+ children: panel ? (
170
+ // A panel that draws in its own layer (the record sheet) leaves this
171
+ // wrapper empty — then it takes no space and shows no chrome.
172
+ /* @__PURE__ */ jsx(
173
+ "div",
174
+ {
175
+ "data-selection-panel": "",
176
+ className: "w-[min(340px,calc(100vw-16px))] rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg empty:hidden",
177
+ children: panel
178
+ }
179
+ )
180
+ ) : /* @__PURE__ */ jsx(SelectionToolbar, { target, content: text, className: docked ? "max-w-full" : void 0 }, seq)
181
+ }
182
+ ),
183
+ document.body
184
+ );
185
+ }
186
+ export {
187
+ SelectionToolbarFrame as default,
188
+ placeFrame,
189
+ scrollBoundsOf,
190
+ visibleBoxOf
191
+ };
@@ -0,0 +1,2 @@
1
+ import type { ActionProvider } from "@ai-matrx/alchemy/actions";
2
+ export declare const selectionCommonProvider: ActionProvider;
@@ -0,0 +1,103 @@
1
+ import { ClipboardCopy, NotebookPen, Table2 } from "lucide-react";
2
+ import { hasTableShape } from "@ai-matrx/records-ui/table-shape";
3
+ import { registerAlchemyIcon } from "../utils/alchemy-icon-keys.js";
4
+ import {
5
+ SELECTION_COMMON_HOST_KEY,
6
+ declareSelectionProvider,
7
+ hostHalf,
8
+ placeSelectionActions,
9
+ selectionToolbarHostOf,
10
+ shownInSelectionMode
11
+ } from "./selection-actions.js";
12
+ function commonOf(target) {
13
+ const half = hostHalf(target, SELECTION_COMMON_HOST_KEY);
14
+ return half?.kind === "selection-common" && half.text ? half : null;
15
+ }
16
+ function rowsTextOf(common) {
17
+ if (common.shapeText && hasTableShape(common.shapeText)) return common.shapeText;
18
+ return common.text;
19
+ }
20
+ function copyEligible(id) {
21
+ return (t) => commonOf(t) && shownInSelectionMode(id, t) ? { status: "available" } : { status: "absent" };
22
+ }
23
+ function eligible(id) {
24
+ return (t) => commonOf(t) && !hostHalf(t, "annotation") && shownInSelectionMode(id, t) ? { status: "available" } : { status: "absent" };
25
+ }
26
+ const COPY_FLAVORS = [
27
+ { id: "selection:copy", label: "Copy", flavor: "default", order: 0, placement: "primary" },
28
+ { id: "selection:copy-markdown", label: "Copy markdown", flavor: "markdown", order: 1, placement: "overflow" },
29
+ { id: "selection:copy-text", label: "Copy text", flavor: "text", order: 2, placement: "overflow" }
30
+ ];
31
+ const ACTIONS = [
32
+ // THE one copy module (copy-commands.ts, selection-copy.ts): Copy writes
33
+ // the formatted selection AND the knob's plain flavor (markdown by default);
34
+ // Copy markdown and Copy text are the explicit choices, under More.
35
+ ...COPY_FLAVORS.map(
36
+ ({ id, label, flavor, order, placement }) => ({
37
+ id,
38
+ label,
39
+ icon: registerAlchemyIcon(ClipboardCopy),
40
+ category: "copy",
41
+ order,
42
+ placement,
43
+ preserveSelection: true,
44
+ eligible: copyEligible(id),
45
+ run: async (t) => {
46
+ const common = commonOf(t);
47
+ if (!common) return;
48
+ const { copyRenderedSelection, renderedSelectionRange } = await import("./selection-copy.js");
49
+ const { copyRichContent } = await import("../copy/copy-commands.js");
50
+ const ok = renderedSelectionRange() ? await copyRenderedSelection(flavor, common.shapeText ?? common.text) : await copyRichContent(common.text, flavor, { toast: false });
51
+ if (ok) (await import("../host/toast.js")).toast.success(flavor === "default" ? "Copied" : flavor === "markdown" ? "Markdown copied" : "Text copied");
52
+ }
53
+ })
54
+ ),
55
+ {
56
+ id: "selection:save-to-notes",
57
+ label: "Save to notes",
58
+ icon: registerAlchemyIcon(NotebookPen),
59
+ category: "save",
60
+ order: 0,
61
+ placement: "primary",
62
+ preserveSelection: true,
63
+ eligible: eligible("selection:save-to-notes"),
64
+ run: (t) => {
65
+ const common = commonOf(t);
66
+ if (!common) return;
67
+ selectionToolbarHostOf(t)?.ui.close();
68
+ common.saveToNotes(common.text);
69
+ }
70
+ },
71
+ {
72
+ // THE ONE "Save to a table" (SAVE-AS-TABLE-EVERYWHERE, 2026-09-29): offered only when the
73
+ // selected text reads as rows — a table, a list or a few bullets, Key: value lines, CSV/TSV.
74
+ id: "selection:save-to-table",
75
+ label: "Save to a table",
76
+ icon: registerAlchemyIcon(Table2),
77
+ category: "save",
78
+ order: 1,
79
+ placement: "overflow",
80
+ preserveSelection: true,
81
+ // Offered in every mode the table allows, Read (annotated) included — only the words decide.
82
+ eligible: (t) => {
83
+ const common = commonOf(t);
84
+ return common && shownInSelectionMode("selection:save-to-table", t) && hasTableShape(rowsTextOf(common)) ? { status: "available" } : { status: "absent" };
85
+ },
86
+ run: (t) => {
87
+ const common = commonOf(t);
88
+ if (!common) return;
89
+ selectionToolbarHostOf(t)?.ui.close();
90
+ common.saveToTable(rowsTextOf(common));
91
+ }
92
+ }
93
+ ];
94
+ const selectionCommonProvider = {
95
+ id: "selection-common",
96
+ tier: "T0",
97
+ declaredIds: () => ACTIONS.map((a) => a.id),
98
+ actions: (target) => commonOf(target) ? placeSelectionActions(ACTIONS, target) : []
99
+ };
100
+ declareSelectionProvider(selectionCommonProvider);
101
+ export {
102
+ selectionCommonProvider
103
+ };