@ai-matrx/rich-content 0.2.25 → 0.2.28

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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.28
4
+
5
+ - `BlockRenderer` and `ConfigurableMarkdownContent` suspend on the lazy host like the other entry points (plain text meanwhile); a test lists every gated entry point.
6
+ - The "message" markdown preset drops `<script>` elements and inline `on*` handlers after `rehype-raw`, so an answer never hands React a script tag.
7
+
8
+ **Consumer action:** none.
9
+
10
+ ## 0.2.27
11
+
12
+ - ONE copy truth (Arman, 2026-10-04: "one click to get either the markdown version or the no-markup version, everywhere"). `copyRichContent(md, "default")` now writes EXACTLY the person's flavor (`copy.default_flavor`, default markdown) — the same bytes as that flavor's menu row; it no longer adds an HTML flavor (formatted copies live in the Alchemy palette). Every flavor is clean: `<artifact …>` wrappers are unwrapped to their body (`cleanCopyMarkdown`, `richCopyPlainText`).
13
+ - New `copy/CopySplitButton`: `[Copy | ⌄]` — the main part copies the default flavor, the chevron offers "Copy markdown" and "Copy plain text" (and, with `mountMore`, the Alchemy palette below them). Main and rows run the same `copy(flavor)`.
14
+ - The rich-document action bar leads with the split Copy; it runs the registry's `copy-markdown` / `copy-plain-text` actions (the ⋯ "Copy as" rows), so the button and the menu cannot disagree. The Alchemy palette (formatted, download, AI) moved under the chevron; its captured text is unwrapped too.
15
+ - The selection toolbar's FIRST control is the split Copy (copies the selection; markdown and plain text one click away), on every selection — read-only answers included (the copy rows were missing from the strip). `copySelection(target, flavor)` is the one selection copy; the strip no longer repeats the copy rows.
16
+
17
+ ### Consumer action
18
+
19
+ - matrx-frontend: none required; the bar and the selection toolbar pick it up. A host with its own copy control renders `CopySplitButton` with a `copy` that ends in `copyRichContent`.
20
+
21
+ ## 0.2.26
22
+
23
+ - The SVG and chart-source copies pass their failure words to the kit (`copyText(text, undefined, failureMessage)`, kit 0.30) instead of a second toast in the `false` branch: a refused copy shows ONE toast.
24
+
25
+ **Consumer action:** none.
26
+
3
27
  ## 0.2.25
4
28
 
5
29
  - The server level no longer calls a client reference: `RichContentServer`, the inline/prose element maps and `DetailsElements` import `cn` from `@ai-matrx/design-system/cn` (server-safe) instead of the design-system root, whose `"use client"` stamp made `cn()` throw "Attempted to call cn() from the server" in every Server Component render (it failed matrx-frontend's production build at "Collecting page data").
@@ -0,0 +1,18 @@
1
+ import * as React from "react";
2
+ export type SplitCopyFlavor = "markdown" | "text";
3
+ export interface CopySplitButtonProps {
4
+ /** Copy in one flavor. Resolve true (or nothing) when the clipboard holds it, false when it did not. */
5
+ copy: (flavor: SplitCopyFlavor) => Promise<boolean | void> | boolean | void;
6
+ /** What is copied, for the button's name ("Copy answer"). Default "Copy". */
7
+ label?: string;
8
+ /** "sm" (32px, an action bar) or "xs" (28px, a dense strip). */
9
+ size?: "sm" | "xs";
10
+ /** Mount more copy options (the Alchemy palette) under the two rows; called with null on close. */
11
+ mountMore?: (element: HTMLElement | null, close: () => void) => void;
12
+ /** Keep the page's text selection when pressed (the selection toolbar). */
13
+ preserveSelection?: boolean;
14
+ disabled?: boolean;
15
+ className?: string;
16
+ }
17
+ export declare function CopySplitButton({ copy, label, size, mountMore, preserveSelection, disabled, className, }: CopySplitButtonProps): React.ReactElement;
18
+ export default CopySplitButton;
@@ -0,0 +1,148 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { Check, ChevronDown, ClipboardCopy, Copy, FileCode2, Type } from "lucide-react";
5
+ import { Popover, PopoverContent, PopoverTrigger, cn } from "@ai-matrx/design-system";
6
+ import { defaultCopyFlavor } from "./copy-commands.js";
7
+ const ROWS = [
8
+ { flavor: "markdown", label: "Copy markdown", icon: FileCode2 },
9
+ { flavor: "text", label: "Copy plain text", icon: Type }
10
+ ];
11
+ function CopySplitButton({
12
+ copy,
13
+ label = "Copy",
14
+ size = "sm",
15
+ mountMore,
16
+ preserveSelection = false,
17
+ disabled = false,
18
+ className
19
+ }) {
20
+ const [open, setOpen] = React.useState(false);
21
+ const [copied, setCopied] = React.useState(null);
22
+ const timer = React.useRef(null);
23
+ React.useEffect(() => () => {
24
+ if (timer.current) clearTimeout(timer.current);
25
+ }, []);
26
+ const flavor = defaultCopyFlavor();
27
+ const run = async (which) => {
28
+ const ok = await copy(which);
29
+ if (ok === false) return;
30
+ setCopied(which);
31
+ if (timer.current) clearTimeout(timer.current);
32
+ timer.current = setTimeout(() => setCopied(null), 1600);
33
+ };
34
+ const close = React.useCallback(() => setOpen(false), []);
35
+ const keep = preserveSelection ? (event) => event.preventDefault() : void 0;
36
+ const mainName = `${label} (${flavor === "text" ? "plain text" : "markdown"})`;
37
+ const box = size === "xs" ? "h-7" : "h-8";
38
+ const MainIcon = copied ? Check : size === "xs" ? ClipboardCopy : Copy;
39
+ return /* @__PURE__ */ jsxs(
40
+ "div",
41
+ {
42
+ "data-copy-split": "",
43
+ className: cn(
44
+ "inline-flex shrink-0 items-center rounded-md text-muted-foreground hover:bg-accent/40",
45
+ disabled && "pointer-events-none opacity-50",
46
+ className
47
+ ),
48
+ children: [
49
+ /* @__PURE__ */ jsx(
50
+ "button",
51
+ {
52
+ type: "button",
53
+ "aria-label": mainName,
54
+ title: mainName,
55
+ "data-copy-split-main": flavor,
56
+ "data-matrx-button": "",
57
+ "data-tap-floor": "",
58
+ "data-tap-floor-square": "",
59
+ disabled,
60
+ onMouseDown: keep,
61
+ onClick: () => void run(flavor),
62
+ className: cn(
63
+ "inline-flex items-center justify-center rounded-l-md hover:bg-accent hover:text-foreground pointer-coarse:h-11 pointer-coarse:min-w-11",
64
+ box,
65
+ size === "xs" ? "min-w-7" : "min-w-8",
66
+ copied && "text-green-600 dark:text-green-400"
67
+ ),
68
+ children: /* @__PURE__ */ jsx(MainIcon, { className: size === "xs" ? "h-3.5 w-3.5" : "h-4 w-4" })
69
+ }
70
+ ),
71
+ /* @__PURE__ */ jsxs(Popover, { open, onOpenChange: setOpen, children: [
72
+ /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx(
73
+ "button",
74
+ {
75
+ type: "button",
76
+ "aria-label": "More ways to copy",
77
+ title: "Copy markdown or plain text",
78
+ "data-copy-split-more": "",
79
+ "data-matrx-button": "",
80
+ disabled,
81
+ onMouseDown: keep,
82
+ className: cn(
83
+ "inline-flex w-4 items-center justify-center rounded-r-md hover:bg-accent hover:text-foreground pointer-coarse:h-11 pointer-coarse:w-6",
84
+ box
85
+ ),
86
+ children: /* @__PURE__ */ jsx(ChevronDown, { className: "h-3 w-3" })
87
+ }
88
+ ) }),
89
+ /* @__PURE__ */ jsxs(
90
+ PopoverContent,
91
+ {
92
+ align: "start",
93
+ className: "w-60 p-1",
94
+ onOpenAutoFocus: preserveSelection ? (event) => event.preventDefault() : void 0,
95
+ "data-copy-split-menu": "",
96
+ children: [
97
+ /* @__PURE__ */ jsx("div", { role: "menu", "aria-label": "Copy as", children: ROWS.map(({ flavor: rowFlavor, label: rowLabel, icon: Icon }) => /* @__PURE__ */ jsxs(
98
+ "button",
99
+ {
100
+ type: "button",
101
+ role: "menuitem",
102
+ "data-copy-flavor": rowFlavor,
103
+ onMouseDown: keep,
104
+ onClick: () => {
105
+ setOpen(false);
106
+ void run(rowFlavor);
107
+ },
108
+ className: "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent",
109
+ children: [
110
+ /* @__PURE__ */ jsx(Icon, { className: "h-4 w-4 shrink-0 text-muted-foreground" }),
111
+ /* @__PURE__ */ jsx("span", { className: "flex-1", children: rowLabel }),
112
+ rowFlavor === flavor ? /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: "Default" }) : null
113
+ ]
114
+ },
115
+ rowFlavor
116
+ )) }),
117
+ mountMore ? /* @__PURE__ */ jsx(MoreSlot, { mount: mountMore, close }) : null
118
+ ]
119
+ }
120
+ )
121
+ ] })
122
+ ]
123
+ }
124
+ );
125
+ }
126
+ function MoreSlot({ mount, close }) {
127
+ const ref = React.useRef(null);
128
+ React.useEffect(() => {
129
+ const element = ref.current;
130
+ try {
131
+ mount(element, close);
132
+ } catch (error) {
133
+ console.error("[copy] more copy options could not open:", error);
134
+ }
135
+ return () => {
136
+ try {
137
+ mount(null, close);
138
+ } catch {
139
+ }
140
+ };
141
+ }, [mount, close]);
142
+ return /* @__PURE__ */ jsx("div", { ref, "data-copy-split-more-slot": "", className: "mt-1 border-t border-border pt-1 empty:hidden" });
143
+ }
144
+ var CopySplitButton_default = CopySplitButton;
145
+ export {
146
+ CopySplitButton,
147
+ CopySplitButton_default as default
148
+ };
@@ -30,7 +30,11 @@ export interface RichCopyOptions {
30
30
  /** Toast on success: a label, or false for none. Default: the flavor's label. */
31
31
  toast?: string | false;
32
32
  }
33
- /** The bytes a flavor writes — pure enough to test; the HTML is loaded at click time. */
33
+ /** The markdown a person copies: no reasoning blocks (unless asked), no `<artifact>` wrappers. */
34
+ export declare function cleanCopyMarkdown(markdown: string, options?: {
35
+ includeThinking?: boolean;
36
+ }): string;
37
+ /** The bytes a flavor writes — pure, the one place both flavors are decided. */
34
38
  export declare function richCopyPlainText(markdown: string, flavor: CopyFlavor, defaultFlavor?: "markdown" | "text"): string;
35
39
  /**
36
40
  * Write the plain flavor, or html + plain in one clipboard item (kit's `copyRich`; `html` may be a
@@ -11,7 +11,8 @@ import { showManualCopy } from "../host/manual-copy.js";
11
11
  import { toast } from "../host/toast.js";
12
12
  import { getSessionKnob } from "../host/session-knob.js";
13
13
  import { markdownToReadableText } from "./markdown-readable-text.js";
14
- import { formattedCopyHtml, removeThinkingContent } from "./markdown-copy-html.js";
14
+ import { unwrapKindEnvelopes } from "@ai-matrx/content-ir/source";
15
+ import { removeThinkingContent } from "./markdown-copy-html.js";
15
16
  async function copyContent(content, options = {}) {
16
17
  let capturedText;
17
18
  try {
@@ -86,11 +87,16 @@ function defaultCopyFlavor() {
86
87
  const FLAVOR_TOAST = {
87
88
  default: "Copied",
88
89
  markdown: "Markdown copied",
89
- text: "Text copied"
90
+ text: "Plain text copied"
90
91
  };
92
+ function cleanCopyMarkdown(markdown, options = {}) {
93
+ const source = options.includeThinking ? markdown : removeThinkingContent(markdown);
94
+ return unwrapKindEnvelopes(source);
95
+ }
91
96
  function richCopyPlainText(markdown, flavor, defaultFlavor = "markdown") {
92
97
  const plainFlavor = flavor === "default" ? defaultFlavor : flavor;
93
- return plainFlavor === "text" ? markdownToReadableText(markdown) : markdown;
98
+ const clean = unwrapKindEnvelopes(markdown);
99
+ return plainFlavor === "text" ? markdownToReadableText(clean) : clean;
94
100
  }
95
101
  async function writeClipboardFlavors(plain, html) {
96
102
  const landed = html === void 0 ? await copyText(plain) : await copyRich(
@@ -101,15 +107,16 @@ async function writeClipboardFlavors(plain, html) {
101
107
  return landed;
102
108
  }
103
109
  async function copyRichContent(markdown, flavor = "default", options = {}) {
104
- const source = options.includeThinking ? markdown : removeThinkingContent(markdown);
105
- const plain = richCopyPlainText(source, flavor, defaultCopyFlavor());
106
- const ok = await writeClipboardFlavors(plain, flavor === "default" ? formattedCopyHtml(source) : void 0);
107
- const label = options.toast === void 0 ? FLAVOR_TOAST[flavor] : options.toast;
110
+ const source = cleanCopyMarkdown(markdown, options);
111
+ const resolved = flavor === "default" ? defaultCopyFlavor() : flavor;
112
+ const ok = await writeClipboardFlavors(richCopyPlainText(source, resolved));
113
+ const label = options.toast === void 0 ? FLAVOR_TOAST[resolved] : options.toast;
108
114
  if (ok && label) toast.success(label);
109
115
  return ok;
110
116
  }
111
117
  export {
112
118
  COPY_DEFAULT_FLAVOR_KNOB,
119
+ cleanCopyMarkdown,
113
120
  copyContent,
114
121
  copyRichContent,
115
122
  defaultCopyFlavor,
@@ -37,10 +37,7 @@ const ChartBlock = ({ content = "", isStreamActive = false, className }) => {
37
37
  const presentation = useCanvasPresentation();
38
38
  const paneLayout = chartPaneLayout(presentation, spec?.type ?? "");
39
39
  const handleCopy = async () => {
40
- if (!await copyText(source)) {
41
- toast.error("Couldn't copy the chart source");
42
- return;
43
- }
40
+ if (!await copyText(source, void 0, "Couldn't copy the chart source")) return;
44
41
  setCopied(true);
45
42
  setTimeout(() => setCopied(false), 1500);
46
43
  };
@@ -40,10 +40,7 @@ const SvgBlock = ({ content = "", isStreamActive = false, className }) => {
40
40
  const [copied, setCopied] = useState(false);
41
41
  const [fullscreen, setFullscreen] = useState(false);
42
42
  const handleCopy = async () => {
43
- if (!await copyText(svg)) {
44
- toast.error("Couldn't copy the SVG");
45
- return;
46
- }
43
+ if (!await copyText(svg, void 0, "Couldn't copy the SVG")) return;
47
44
  setCopied(true);
48
45
  setTimeout(() => setCopied(false), 1500);
49
46
  };
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import React from "react";
4
+ import { RichContentHostGate } from "../../host/RichContentHostGate.js";
4
5
  import MarkdownCore from "../../markdown-core/MarkdownCore.js";
5
6
  import { cn } from "@ai-matrx/design-system";
6
7
  import { Checkbox } from "@ai-matrx/design-system";
@@ -123,6 +124,13 @@ function resolveConfig(partial) {
123
124
  }
124
125
  const getDirectionClasses = (direction) => direction === "rtl" ? "text-right rtl" : "text-left ltr";
125
126
  const ConfigurableMarkdownContent = (props) => /* @__PURE__ */ jsx(
127
+ RichContentHostGate,
128
+ {
129
+ fallback: /* @__PURE__ */ jsx("div", { "data-rich-content-host-loading": true, style: { whiteSpace: "pre-wrap" }, children: props.content }),
130
+ children: /* @__PURE__ */ jsx(ConfigurableMarkdownContentKindGated, { ...props })
131
+ }
132
+ );
133
+ const ConfigurableMarkdownContentKindGated = (props) => /* @__PURE__ */ jsx(
126
134
  KindTextGate,
127
135
  {
128
136
  component: "ConfigurableMarkdownContent",
@@ -2,6 +2,7 @@
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import { durableRecordId } from "@ai-matrx/kit/ids";
4
4
  import React, { useCallback } from "react";
5
+ import { RichContentHostGate } from "../../../host/RichContentHostGate.js";
5
6
  import { BlockComponents, LoadingComponents } from "./BlockComponentRegistry.js";
6
7
  import { resolveArtifactDef } from "../../../host/app-bindings.js";
7
8
  import {
@@ -262,7 +263,14 @@ const PendingStructuredBlock = ({
262
263
  value: envelope.root.value ?? null
263
264
  });
264
265
  };
265
- const BlockRenderer = ({
266
+ const BlockRenderer = (props) => /* @__PURE__ */ jsx(
267
+ RichContentHostGate,
268
+ {
269
+ fallback: typeof props.block.content === "string" ? /* @__PURE__ */ jsx("div", { "data-rich-content-host-loading": true, style: { whiteSpace: "pre-wrap" }, children: props.block.content }) : null,
270
+ children: /* @__PURE__ */ jsx(BlockRendererGated, { ...props })
271
+ }
272
+ );
273
+ const BlockRendererGated = ({
266
274
  requestId,
267
275
  block: inputBlock,
268
276
  index,
@@ -5,6 +5,7 @@ import remarkBreaks from "remark-breaks";
5
5
  import remarkMath from "./math/vendor/remark-math.js";
6
6
  import rehypeKatex from "./math/vendor/rehype-katex.js";
7
7
  import rehypeRaw from "rehype-raw";
8
+ import rehypeDropScripts from "./rehype-drop-scripts.js";
8
9
  import remarkMatrxVariable from "../display/chat-markdown/matrx-variables/remarkMatrxVariable.js";
9
10
  import remarkMatrxCite from "../display/chat-markdown/citations/remarkMatrxCite.js";
10
11
  import rehypeSafeRawHtml from "../display/chat-markdown/rehypeSafeRawHtml.js";
@@ -88,7 +89,7 @@ const MARKDOWN_PRESETS = {
88
89
  },
89
90
  message: {
90
91
  remark: [GFM, MATH, remarkBreaks, ...SYNTAX_PARSE, PAGE_BREAK, ...SYNTAX_TRANSFORM],
91
- rehype: [rehypeRaw, ...SYNTAX_REHYPE, KATEX],
92
+ rehype: [rehypeRaw, rehypeDropScripts, ...SYNTAX_REHYPE, KATEX],
92
93
  math: true
93
94
  }
94
95
  };
@@ -0,0 +1,7 @@
1
+ import type { Root } from "hast";
2
+ /**
3
+ * After `rehype-raw` the "message" preset holds whatever HTML the text carried — `<script>` too. React
4
+ * refuses to render a script element ("Encountered a script tag while rendering React component") and
5
+ * an answer must never carry one, so scripts (and inline event handlers) are removed from the tree.
6
+ */
7
+ export default function rehypeDropScripts(): (tree: Root) => void;
@@ -0,0 +1,18 @@
1
+ function rehypeDropScripts() {
2
+ return (tree) => {
3
+ const walk = (node) => {
4
+ node.children = node.children.filter((child) => !(child.type === "element" && child.tagName === "script"));
5
+ for (const child of node.children) {
6
+ if (child.type !== "element") continue;
7
+ for (const key of Object.keys(child.properties ?? {})) {
8
+ if (/^on[A-Za-z]/.test(key)) delete child.properties[key];
9
+ }
10
+ walk(child);
11
+ }
12
+ };
13
+ walk(tree);
14
+ };
15
+ }
16
+ export {
17
+ rehypeDropScripts as default
18
+ };
@@ -1,5 +1,6 @@
1
1
  import * as React from "react";
2
2
  import type { ClickTarget } from "@ai-matrx/alchemy/actions";
3
+ import { type SplitCopyFlavor } from "../../copy/CopySplitButton.js";
3
4
  import type { RichDocumentAction, RichDocumentActionContext } from "../types.js";
4
5
  export interface ActionBarProps {
5
6
  actions: RichDocumentAction[];
@@ -13,5 +14,9 @@ export interface ActionBarProps {
13
14
  /** Condensed presentation (the "mini-bar" variant). */
14
15
  mini?: boolean;
15
16
  }
17
+ /** The registry rows the split Copy runs, by flavor (features/rich-document/actions/handlers/transfer.ts). */
18
+ export declare const SPLIT_COPY_ACTION_IDS: Readonly<Record<SplitCopyFlavor, string>>;
19
+ /** Copy the document in one flavor through the SAME action its ⋯ "Copy as" row runs. */
20
+ export declare function runDocumentCopy(actions: readonly RichDocumentAction[], getCtx: () => RichDocumentActionContext, flavor: SplitCopyFlavor): Promise<boolean | void>;
16
21
  export declare function ActionBar(props: ActionBarProps): React.ReactElement;
17
22
  export default ActionBar;
@@ -1,17 +1,34 @@
1
1
  "use client";
2
- import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
3
4
  import { ActionBar as AlchemyActionBar } from "@ai-matrx/alchemy/react/bar";
4
5
  import { useRichDocumentProvider } from "../actions/useRichDocumentProvider.js";
5
6
  import { cn } from "@ai-matrx/design-system";
6
7
  import { AlchemyDocumentMenu } from "./shared/AlchemyDocumentMenu.js";
8
+ import { CopySplitButton } from "../../copy/CopySplitButton.js";
9
+ import { copyRichContent } from "../../copy/copy-commands.js";
7
10
  import { OpenOneMenuButton, useOneMenuFor } from "./shared/OpenOneMenuButton.js";
8
11
  import { StandaloneOneMenu } from "./shared/StandaloneOneMenu.js";
9
12
  const BAR_RESTRICT = { exclude: ["copy"] };
13
+ const SPLIT_COPY_ACTION_IDS = {
14
+ markdown: "copy-markdown",
15
+ text: "copy-plain-text"
16
+ };
17
+ async function runDocumentCopy(actions, getCtx, flavor) {
18
+ const action = actions.find((a) => a.id === SPLIT_COPY_ACTION_IDS[flavor]);
19
+ if (action) return action.run(getCtx());
20
+ return copyRichContent(getCtx().content, flavor);
21
+ }
10
22
  function ActionBar(props) {
11
23
  const { actions, getCtx, target, sourceId, className, hideOverflow = false, mini = false } = props;
12
24
  useRichDocumentProvider();
13
25
  const oneMenu = useOneMenuFor(getCtx().source);
14
26
  const hasCopy = actions.some((action) => action.category === "copy");
27
+ const palette = React.useRef(null);
28
+ const copy = React.useCallback((flavor) => runDocumentCopy(actions, getCtx, flavor), [actions, getCtx]);
29
+ const mountMore = React.useCallback((element, close) => {
30
+ palette.current?.mountPalette(element, close);
31
+ }, []);
15
32
  return /* @__PURE__ */ jsxs(
16
33
  "div",
17
34
  {
@@ -25,7 +42,21 @@ function ActionBar(props) {
25
42
  className
26
43
  ),
27
44
  children: [
28
- hasCopy ? /* @__PURE__ */ jsx(AlchemyDocumentMenu, { getCtx, sourceId, size: mini ? "xs" : "sm" }, sourceId) : null,
45
+ hasCopy ? /* @__PURE__ */ jsxs(Fragment, { children: [
46
+ /* @__PURE__ */ jsx(CopySplitButton, { copy, size: mini ? "xs" : "sm", mountMore }),
47
+ /* @__PURE__ */ jsx(
48
+ AlchemyDocumentMenu,
49
+ {
50
+ getCtx,
51
+ sourceId,
52
+ size: mini ? "xs" : "sm",
53
+ triggerHidden: true,
54
+ controllerRef: palette,
55
+ richCopy: copy
56
+ },
57
+ sourceId
58
+ )
59
+ ] }) : null,
29
60
  /* @__PURE__ */ jsx(
30
61
  AlchemyActionBar,
31
62
  {
@@ -44,5 +75,7 @@ function ActionBar(props) {
44
75
  var ActionBar_default = ActionBar;
45
76
  export {
46
77
  ActionBar,
47
- ActionBar_default as default
78
+ SPLIT_COPY_ACTION_IDS,
79
+ ActionBar_default as default,
80
+ runDocumentCopy
48
81
  };
@@ -1,7 +1,13 @@
1
1
  import * as React from "react";
2
+ import { type ContentTransferController } from "@ai-matrx/alchemy/react/workspace";
2
3
  import type { RichDocumentActionContext } from "../../types.js";
3
- export declare function AlchemyDocumentMenu({ getCtx, sourceId, size, }: {
4
+ export declare function AlchemyDocumentMenu({ getCtx, sourceId, size, triggerHidden, controllerRef, richCopy, }: {
4
5
  getCtx: () => RichDocumentActionContext;
5
6
  sourceId: string;
6
7
  size?: "sm" | "xs";
8
+ /** No trigger of its own: the host mounts the palette (the split Copy's chevron). */
9
+ triggerHidden?: boolean;
10
+ controllerRef?: React.Ref<ContentTransferController>;
11
+ /** THE copy of this document, by flavor — the palette's copy tiles run it (never the raw text). */
12
+ richCopy?: (flavor: "markdown" | "text") => Promise<boolean | void> | boolean | void;
7
13
  }): React.JSX.Element;
@@ -3,12 +3,16 @@ import { jsx } from "react/jsx-runtime";
3
3
  import { useAlchemyDisclosure } from "../../../host/app-bindings.js";
4
4
  import * as React from "react";
5
5
  import { ContentTransferMenu } from "@ai-matrx/alchemy/react/workspace";
6
+ import { unwrapKindEnvelopes } from "@ai-matrx/content-ir/source";
6
7
  import { directSource } from "@ai-matrx/alchemy/operate";
7
8
  import { removeThinkingContent } from "@ai-matrx/print/markdown";
8
9
  function AlchemyDocumentMenu({
9
10
  getCtx,
10
11
  sourceId,
11
- size = "sm"
12
+ size = "sm",
13
+ triggerHidden = false,
14
+ controllerRef,
15
+ richCopy
12
16
  }) {
13
17
  useAlchemyDisclosure();
14
18
  const [hasThinking, setHasThinking] = React.useState(false);
@@ -24,8 +28,9 @@ function AlchemyDocumentMenu({
24
28
  throw new Error(
25
29
  "The document changed. Reopen Alchemy Menu for the current document."
26
30
  );
27
- const text = removeThinkingContent(ctx.content);
28
- setHasThinking(text !== ctx.content);
31
+ const unthought = removeThinkingContent(ctx.content);
32
+ setHasThinking(unthought !== ctx.content);
33
+ const text = unwrapKindEnvelopes(unthought);
29
34
  return directSource(
30
35
  { kind: "markdown", text },
31
36
  {
@@ -65,6 +70,13 @@ function AlchemyDocumentMenu({
65
70
  source,
66
71
  label: "Document",
67
72
  className: size === "xs" ? "matrx-alchemy-xs" : void 0,
73
+ ...triggerHidden ? { triggerHidden: true } : {},
74
+ ...controllerRef ? { controllerRef } : {},
75
+ ...richCopy ? {
76
+ // The split button carries Copy markdown / Copy plain text; the palette keeps the rest.
77
+ richCopy: (flavor) => richCopy(flavor === "text" ? "text" : "markdown"),
78
+ richCopyFlavors: []
79
+ } : {},
68
80
  variants: hasThinking ? [
69
81
  {
70
82
  id: "include-thinking",
@@ -1,8 +1,12 @@
1
1
  "use client";
2
- import { jsx } from "react/jsx-runtime";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { createPortal } from "react-dom";
5
+ import { cn } from "@ai-matrx/design-system";
5
6
  import { SelectionToolbar } from "@ai-matrx/alchemy/react/selection";
7
+ import { CopySplitButton } from "../copy/CopySplitButton.js";
8
+ import { SELECTION_COPY_IDS, canCopySelection, copySelection } from "./common-actions.js";
9
+ const STRIP_RESTRICT = { exclude: [...SELECTION_COPY_IDS] };
6
10
  const GAP = 8;
7
11
  const EDGE = 8;
8
12
  function scrollBoundsOf(node) {
@@ -177,7 +181,40 @@ function SelectionToolbarFrame({
177
181
  children: panel
178
182
  }
179
183
  )
180
- ) : /* @__PURE__ */ jsx(SelectionToolbar, { target, content: text, className: docked ? "max-w-full" : void 0 }, seq)
184
+ ) : (
185
+ // ONE row: the split Copy first (copies the selection; markdown and
186
+ // plain text one click away), then the Alchemy strip (formatting while
187
+ // editing, highlight / comment / AI while reading).
188
+ /* @__PURE__ */ jsxs(
189
+ "div",
190
+ {
191
+ role: "toolbar",
192
+ "aria-label": "Selection actions",
193
+ "data-selection-strip": "",
194
+ className: cn(
195
+ "flex items-center gap-0.5 rounded-lg border border-border bg-popover p-0.5 shadow-md",
196
+ "[&>[data-alchemy-layout]]:border-0 [&>[data-alchemy-layout]]:bg-transparent [&>[data-alchemy-layout]]:p-0 [&>[data-alchemy-layout]]:shadow-none",
197
+ docked && "max-w-full"
198
+ ),
199
+ children: [
200
+ canCopySelection(target) ? /* @__PURE__ */ jsxs(Fragment, { children: [
201
+ /* @__PURE__ */ jsx(
202
+ CopySplitButton,
203
+ {
204
+ size: "xs",
205
+ label: "Copy selection",
206
+ preserveSelection: true,
207
+ copy: (flavor) => copySelection(target, flavor)
208
+ },
209
+ seq
210
+ ),
211
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "mx-0.5 h-4 w-px shrink-0 bg-border" })
212
+ ] }) : null,
213
+ /* @__PURE__ */ jsx(SelectionToolbar, { target, content: text, restrict: STRIP_RESTRICT, className: docked ? "min-w-0" : void 0 }, seq)
214
+ ]
215
+ }
216
+ )
217
+ )
181
218
  }
182
219
  ),
183
220
  document.body
@@ -1,2 +1,12 @@
1
- import type { ActionProvider } from "@ai-matrx/alchemy/actions";
1
+ import type { ActionProvider, ClickTarget } from "@ai-matrx/alchemy/actions";
2
+ /** The ids of the selection's copy rows (the toolbar's split Copy button stands for them). */
3
+ export declare const SELECTION_COPY_IDS: readonly ["selection:copy", "selection:copy-markdown", "selection:copy-text"];
4
+ /**
5
+ * Copy the selection in one flavor — THE selection copy: the toolbar's split
6
+ * button and the copy rows (right-click, More) all run this, so they write the
7
+ * same bytes. "default" is the person's flavor (`copy.default_flavor`).
8
+ */
9
+ export declare function copySelection(target: ClickTarget, flavor: "default" | "markdown" | "text"): Promise<boolean>;
10
+ /** Is there a selection this target can copy? */
11
+ export declare function canCopySelection(target: ClickTarget): boolean;
2
12
  export declare const selectionCommonProvider: ActionProvider;
@@ -23,10 +23,24 @@ function copyEligible(id) {
23
23
  function eligible(id) {
24
24
  return (t) => commonOf(t) && !hostHalf(t, "annotation") && shownInSelectionMode(id, t) ? { status: "available" } : { status: "absent" };
25
25
  }
26
+ const SELECTION_COPY_IDS = ["selection:copy", "selection:copy-markdown", "selection:copy-text"];
27
+ async function copySelection(target, flavor) {
28
+ const common = commonOf(target);
29
+ if (!common) return false;
30
+ const { copyRenderedSelection, renderedSelectionRange } = await import("./selection-copy.js");
31
+ const { copyRichContent, defaultCopyFlavor } = await import("../copy/copy-commands.js");
32
+ const resolved = flavor === "default" ? defaultCopyFlavor() : flavor;
33
+ const ok = renderedSelectionRange() ? await copyRenderedSelection(resolved, common.shapeText ?? common.text) : await copyRichContent(common.text, resolved, { toast: false });
34
+ if (ok) (await import("../host/toast.js")).toast.success(resolved === "markdown" ? "Markdown copied" : "Plain text copied");
35
+ return ok;
36
+ }
37
+ function canCopySelection(target) {
38
+ return Boolean(commonOf(target));
39
+ }
26
40
  const COPY_FLAVORS = [
27
41
  { id: "selection:copy", label: "Copy", flavor: "default", order: 0, placement: "primary" },
28
42
  { 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" }
43
+ { id: "selection:copy-text", label: "Copy plain text", flavor: "text", order: 2, placement: "overflow" }
30
44
  ];
31
45
  const ACTIONS = [
32
46
  // THE one copy module (copy-commands.ts, selection-copy.ts): Copy writes
@@ -43,12 +57,7 @@ const ACTIONS = [
43
57
  preserveSelection: true,
44
58
  eligible: copyEligible(id),
45
59
  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");
60
+ await copySelection(t, flavor);
52
61
  }
53
62
  })
54
63
  ),
@@ -99,5 +108,8 @@ const selectionCommonProvider = {
99
108
  };
100
109
  declareSelectionProvider(selectionCommonProvider);
101
110
  export {
111
+ SELECTION_COPY_IDS,
112
+ canCopySelection,
113
+ copySelection,
102
114
  selectionCommonProvider
103
115
  };
@@ -49,9 +49,6 @@ async function copyRenderedSelection(flavor, fallbackText) {
49
49
  const range = renderedSelectionRange();
50
50
  const html = range ? rangeHtml(range) : null;
51
51
  const markdown = (html ? await rangeMarkdown(html) : null) ?? fallbackText;
52
- if (flavor === "default" && html) {
53
- return writeClipboardFlavors(richCopyPlainText(markdown, "default", defaultCopyFlavor()), html);
54
- }
55
52
  return copyRichContent(markdown, flavor, { toast: false });
56
53
  }
57
54
  function installRichCopyKeys() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/rich-content",
3
- "version": "0.2.25",
3
+ "version": "0.2.28",
4
4
  "description": "The AI Matrx formatted-text engine: the one markdown renderer, streaming markdown, the block splitter and renderer, the generic blocks, <RichContent> levels, the rich-document action registry and menus, copy, and the selection toolbar — as portable React.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -133,18 +133,18 @@
133
133
  "@ai-matrx/agents": "latest",
134
134
  "@ai-matrx/alchemy": "latest",
135
135
  "@ai-matrx/canvas": "latest",
136
- "@ai-matrx/content-ir": "latest",
137
136
  "@ai-matrx/content-ir-react": "latest",
138
137
  "@ai-matrx/data": "latest",
139
- "@ai-matrx/design-system": "latest",
138
+ "@ai-matrx/content-ir": "latest",
140
139
  "@ai-matrx/diff": "latest",
141
140
  "@ai-matrx/icons": "latest",
142
141
  "@ai-matrx/kit": "latest",
143
- "@ai-matrx/print": "latest",
144
142
  "@ai-matrx/media": "latest",
145
143
  "@ai-matrx/records": "latest",
144
+ "@ai-matrx/print": "latest",
146
145
  "@ai-matrx/records-ui": "latest",
147
- "@ai-matrx/tap-target": "latest"
146
+ "@ai-matrx/tap-target": "latest",
147
+ "@ai-matrx/design-system": "latest"
148
148
  },
149
149
  "peerDependencies": {
150
150
  "react": ">=19.0.0",