@braincrew-lab/langchain-canvas 0.7.1 → 0.7.4

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/README.md CHANGED
@@ -18,13 +18,14 @@ const { sendMessage, messages, canvas } = useCanvasStream({ endpoint: "/api/chat
18
18
  npm i @braincrew-lab/langchain-canvas
19
19
  ```
20
20
 
21
- Export/import for Office formats uses optional peers — install only what you need:
21
+ Office formats use optional peers — install only what you need:
22
22
 
23
23
  ```bash
24
- npm i exceljs docx pptxgenjs fast-formula-parser
24
+ npm i exceljs docx pptxgenjs fast-formula-parser # export / import
25
+ npm i docx-preview # read an uploaded .docx on the canvas
25
26
  ```
26
27
 
27
- If a format's package isn't installed, that one export just tells the user what to add; everything else keeps working.
28
+ If a format's package isn't installed, that one feature says what to add and the rest keeps working: an export tells the user, and a Word upload falls back to the file card it showed before.
28
29
 
29
30
  ## Mount it
30
31
 
@@ -0,0 +1,272 @@
1
+ import { loadOptional } from './chunk-YZZSJJMQ.js';
2
+ import { useCanvasStore } from './chunk-RAPTQ6VH.js';
3
+ import { useRef, useState, useEffect } from 'react';
4
+ import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
5
+
6
+ // src/io/docxAddress.ts
7
+ var DOCX_ADDRESS_ATTRIBUTE = "data-canvas-docx";
8
+ var BODY_SELECTOR = "section.docx";
9
+ function bodyOf(section) {
10
+ return section.querySelector(":scope > article") ?? section;
11
+ }
12
+ function pages(root) {
13
+ const sections = Array.from(root.querySelectorAll(BODY_SELECTOR));
14
+ return sections.length > 0 ? sections : [root];
15
+ }
16
+ function stampDocxAddresses(root) {
17
+ const blocks = [];
18
+ let paragraphs = 0;
19
+ let tables = 0;
20
+ for (const page of pages(root)) {
21
+ for (const element of Array.from(bodyOf(page).children)) {
22
+ const tag = element.tagName.toLowerCase();
23
+ if (tag !== "p" && tag !== "table") continue;
24
+ const kind = tag === "table" ? "table" : "paragraph";
25
+ const index = kind === "table" ? tables++ : paragraphs++;
26
+ const address = `${kind === "table" ? "t" : "p"}${index}`;
27
+ element.setAttribute(DOCX_ADDRESS_ATTRIBUTE, address);
28
+ blocks.push({ kind, index, address, element });
29
+ }
30
+ }
31
+ return blocks;
32
+ }
33
+ function addressedAncestor(root, node) {
34
+ let current = node;
35
+ while (current && current !== root) {
36
+ if (current instanceof Element && current.hasAttribute(DOCX_ADDRESS_ATTRIBUTE)) {
37
+ return current;
38
+ }
39
+ current = current.parentNode;
40
+ }
41
+ return null;
42
+ }
43
+ function pickOf(element, literal) {
44
+ const address = element.getAttribute(DOCX_ADDRESS_ATTRIBUTE) ?? "";
45
+ const kind = address.startsWith("t") ? "table" : "paragraph";
46
+ const text = (element.textContent ?? "").replace(/\s+/g, " ").trim();
47
+ return {
48
+ address,
49
+ kind,
50
+ label: literal ? `\u201C${literal}\u201D` : `[${address}]`,
51
+ text,
52
+ ...literal ? { literal } : {}
53
+ };
54
+ }
55
+ function pickFromNode(root, node) {
56
+ const element = addressedAncestor(root, node);
57
+ return element ? pickOf(element) : null;
58
+ }
59
+ function pickFromSelection(root, selection) {
60
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0)
61
+ return null;
62
+ const literal = selection.toString().replace(/\s+/g, " ").trim();
63
+ if (!literal) return null;
64
+ const element = addressedAncestor(
65
+ root,
66
+ selection.getRangeAt(0).startContainer
67
+ );
68
+ return element ? pickOf(element, literal) : null;
69
+ }
70
+ var CJK = /[ㄱ-ㆎ가-힣぀-ヿ一-鿿]/gu;
71
+ var LATIN_WORD = /[A-Za-z0-9][A-Za-z0-9'’-]*/gu;
72
+ function countWords(text) {
73
+ return (text.match(LATIN_WORD)?.length ?? 0) + (text.match(CJK)?.length ?? 0);
74
+ }
75
+ function fitToWidth(root, available) {
76
+ const wrapper = root.querySelector(".docx-wrapper");
77
+ const page = root.querySelector(BODY_SELECTOR);
78
+ if (!wrapper || !page || available <= 0) return 1;
79
+ wrapper.style.zoom = "";
80
+ const width = Math.max(root.scrollWidth, page.getBoundingClientRect().width);
81
+ if (width <= 0) return 1;
82
+ const scale = Math.min(1, available / width);
83
+ wrapper.style.zoom = scale < 1 ? String(scale) : "";
84
+ return scale;
85
+ }
86
+ function declaredFontFamilies(root) {
87
+ const declarations = [];
88
+ for (const sheet of Array.from(root.querySelectorAll("style"))) {
89
+ for (const match of (sheet.textContent ?? "").matchAll(
90
+ /font-family:\s*([^;}]+)/g
91
+ )) {
92
+ declarations.push(match[1]);
93
+ }
94
+ }
95
+ for (const element of Array.from(
96
+ root.querySelectorAll("[style]")
97
+ )) {
98
+ if (element.style?.fontFamily) declarations.push(element.style.fontFamily);
99
+ }
100
+ const families = /* @__PURE__ */ new Set();
101
+ for (const declaration of declarations) {
102
+ for (const part of declaration.split(",")) {
103
+ const family = part.trim().replace(/^['"]|['"]$/g, "");
104
+ if (!family || family.startsWith("var(")) continue;
105
+ if (/^(serif|sans-serif|monospace|cursive|fantasy|inherit|initial|unset)$/i.test(
106
+ family
107
+ ))
108
+ continue;
109
+ families.add(family);
110
+ }
111
+ }
112
+ return Array.from(families).sort();
113
+ }
114
+ function substitutedFonts(root) {
115
+ const families = declaredFontFamilies(root);
116
+ if (families.length === 0) return [];
117
+ let context = null;
118
+ try {
119
+ context = document.createElement("canvas").getContext("2d");
120
+ } catch {
121
+ return [];
122
+ }
123
+ if (!context) return [];
124
+ const measured = context;
125
+ const probe = "\uAC00\uB098\uB2E4 Handgloves 0123";
126
+ const width = (font) => {
127
+ measured.font = `24px ${font}`;
128
+ return measured.measureText(probe).width;
129
+ };
130
+ const control = width("monospace");
131
+ return families.filter(
132
+ (family) => width(`"${family}", monospace`) === control
133
+ );
134
+ }
135
+ function docxStats(root) {
136
+ const bodies = pages(root).map((page) => bodyOf(page).textContent ?? "");
137
+ return {
138
+ words: countWords(bodies.join(" ")),
139
+ pagesDrawn: root.querySelectorAll(BODY_SELECTOR).length,
140
+ paginates: false,
141
+ substitutedFonts: substitutedFonts(root)
142
+ };
143
+ }
144
+ var BANNER = "Preview only \u2014 to change it, ask in chat or select some text.";
145
+ function DocxPreview({
146
+ artifactId,
147
+ href,
148
+ name,
149
+ fallback
150
+ }) {
151
+ const hostRef = useRef(null);
152
+ const [status, setStatus] = useState("loading");
153
+ const [stats, setStats] = useState(null);
154
+ const [picked, setPicked] = useState(null);
155
+ const setSelections = useCanvasStore((s) => s.setSelections);
156
+ useEffect(() => {
157
+ let live = true;
158
+ let observer = null;
159
+ const host = hostRef.current;
160
+ if (!host) return;
161
+ setStatus("loading");
162
+ setStats(null);
163
+ setPicked(null);
164
+ (async () => {
165
+ const { renderAsync } = await loadOptional(
166
+ "docx-preview",
167
+ () => import('docx-preview')
168
+ );
169
+ const response = await fetch(href);
170
+ if (!response.ok)
171
+ throw new Error(`${response.status} ${response.statusText}`);
172
+ const data = await response.arrayBuffer();
173
+ if (!live) return;
174
+ host.replaceChildren();
175
+ await renderAsync(data, host, void 0, {
176
+ inWrapper: true,
177
+ breakPages: true,
178
+ renderHeaders: true,
179
+ renderFooters: true,
180
+ useBase64URL: true
181
+ });
182
+ if (!live) return;
183
+ stampDocxAddresses(host);
184
+ setStats(docxStats(host));
185
+ setStatus("ready");
186
+ let fittedFor = host.clientWidth;
187
+ fitToWidth(host, fittedFor);
188
+ if (typeof ResizeObserver !== "undefined") {
189
+ observer = new ResizeObserver(() => {
190
+ if (Math.abs(host.clientWidth - fittedFor) < 2) return;
191
+ fittedFor = host.clientWidth;
192
+ fitToWidth(host, fittedFor);
193
+ });
194
+ observer.observe(host);
195
+ }
196
+ })().catch(() => {
197
+ if (live) setStatus("unavailable");
198
+ });
199
+ return () => {
200
+ live = false;
201
+ observer?.disconnect();
202
+ };
203
+ }, [href]);
204
+ const choose = (pick) => {
205
+ const host = hostRef.current;
206
+ if (!host) return;
207
+ for (const marked of Array.from(host.querySelectorAll(".is-picked"))) {
208
+ marked.classList.remove("is-picked");
209
+ }
210
+ if (!pick) {
211
+ setPicked(null);
212
+ setSelections([]);
213
+ return;
214
+ }
215
+ host.querySelector(`[${DOCX_ADDRESS_ATTRIBUTE}="${pick.address}"]`)?.classList.add("is-picked");
216
+ setPicked(pick.address);
217
+ setSelections([
218
+ {
219
+ artifactId,
220
+ cid: pick.address,
221
+ selector: pick.label,
222
+ tag: pick.kind === "table" ? "table" : "p",
223
+ text: pick.literal ?? pick.text
224
+ }
225
+ ]);
226
+ };
227
+ const onMouseUp = (event) => {
228
+ const host = hostRef.current;
229
+ if (!host || status !== "ready") return;
230
+ const dragged = pickFromSelection(host, window.getSelection?.() ?? null);
231
+ choose(dragged ?? pickFromNode(host, event.target));
232
+ };
233
+ if (status === "unavailable") return /* @__PURE__ */ jsx(Fragment, { children: fallback });
234
+ return /* @__PURE__ */ jsxs("div", { className: "cv-docx", children: [
235
+ /* @__PURE__ */ jsx("div", { className: "cv-docx__banner", role: "note", children: BANNER }),
236
+ status === "loading" && /* @__PURE__ */ jsx("div", { className: "cv-docx__loading", children: fallback }),
237
+ /* @__PURE__ */ jsx(
238
+ "div",
239
+ {
240
+ ref: hostRef,
241
+ className: "cv-docx__page",
242
+ onMouseUp,
243
+ "aria-label": `${name} preview`,
244
+ hidden: status !== "ready"
245
+ }
246
+ ),
247
+ stats && /* @__PURE__ */ jsxs(
248
+ "div",
249
+ {
250
+ className: "cv-docx__status",
251
+ title: "The preview keeps the document's own page breaks; it does not repaginate, so it states no page number.",
252
+ children: [
253
+ /* @__PURE__ */ jsxs("span", { children: [
254
+ stats.words.toLocaleString(),
255
+ " words"
256
+ ] }),
257
+ picked && /* @__PURE__ */ jsxs("span", { className: "cv-docx__picked", children: [
258
+ "pointing at [",
259
+ picked,
260
+ "]"
261
+ ] }),
262
+ stats.substitutedFonts.length > 0 && /* @__PURE__ */ jsxs("span", { className: "cv-docx__fonts", children: [
263
+ "substituted: ",
264
+ stats.substitutedFonts.join(", ")
265
+ ] })
266
+ ]
267
+ }
268
+ )
269
+ ] });
270
+ }
271
+
272
+ export { DocxPreview };
@@ -1,5 +1,6 @@
1
1
  import { isAssetReference, resolveAssetUrl } from './chunk-7T5DRR3F.js';
2
2
  import { useCanvasStore } from './chunk-RAPTQ6VH.js';
3
+ import { lazy, Suspense } from 'react';
3
4
  import { jsxs, jsx } from 'react/jsx-runtime';
4
5
 
5
6
  function formatSize(size) {
@@ -8,6 +9,9 @@ function formatSize(size) {
8
9
  if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
9
10
  return `${(size / (1024 * 1024)).toFixed(1)} MB`;
10
11
  }
12
+ var DocxPreview = lazy(
13
+ () => import('./DocxPreview-QK4K5427.js').then((m) => ({ default: m.DocxPreview }))
14
+ );
11
15
  function iconFor(mediaType, name) {
12
16
  if (mediaType?.startsWith("image/")) return "\u{1F5BC}\uFE0F";
13
17
  if (mediaType === "application/pdf") return "\u{1F4D5}";
@@ -22,9 +26,26 @@ function FileRenderer({ artifact }) {
22
26
  const assetBaseUrl = useCanvasStore((s) => s.assetBaseUrl);
23
27
  const href = assetBaseUrl && isAssetReference(path) ? resolveAssetUrl(path, assetBaseUrl) : null;
24
28
  const isImage = Boolean(mediaType?.startsWith("image/"));
29
+ const isWord = `${path} ${name}`.toLowerCase().includes(".docx");
25
30
  const facts = [mediaType, formatSize(size), detail].filter(Boolean).join(" \xB7 ");
31
+ const preview = isImage && href ? /* @__PURE__ */ jsx("img", { className: "cv-file__image", src: href, alt: name }) : cover ? /* @__PURE__ */ jsx(
32
+ "img",
33
+ {
34
+ className: "cv-file__cover",
35
+ src: cover,
36
+ alt: `${name} \u2014 first page`
37
+ }
38
+ ) : excerpt ? /* @__PURE__ */ jsx("pre", { className: "cv-file__excerpt", children: excerpt }) : null;
26
39
  return /* @__PURE__ */ jsxs("div", { className: "cv-file", children: [
27
- isImage && href ? /* @__PURE__ */ jsx("img", { className: "cv-file__image", src: href, alt: name }) : cover ? /* @__PURE__ */ jsx("img", { className: "cv-file__cover", src: cover, alt: `${name} \u2014 first page` }) : excerpt ? /* @__PURE__ */ jsx("pre", { className: "cv-file__excerpt", children: excerpt }) : null,
40
+ isWord && href ? /* @__PURE__ */ jsx(Suspense, { fallback: preview, children: /* @__PURE__ */ jsx(
41
+ DocxPreview,
42
+ {
43
+ artifactId: artifact.id,
44
+ href,
45
+ name,
46
+ fallback: preview
47
+ }
48
+ ) }) : preview,
28
49
  /* @__PURE__ */ jsxs("div", { className: "cv-file__card", children: [
29
50
  /* @__PURE__ */ jsx("span", { className: "cv-file__icon", "aria-hidden": true, children: iconFor(mediaType, name) }),
30
51
  /* @__PURE__ */ jsxs("span", { className: "cv-file__meta", children: [
package/dist/index.js CHANGED
@@ -2259,7 +2259,7 @@ var ChartRenderer = lazy(() => import('./ChartRenderer-TBTWNPV7.js').then((m) =>
2259
2259
  var DocumentRenderer = lazy(() => import('./DocumentRenderer-ER5TSNA6.js').then((m) => ({ default: m.DocumentRenderer })));
2260
2260
  var TableRenderer = lazy(() => import('./TableRenderer-YTOF2ZIC.js').then((m) => ({ default: m.TableRenderer })));
2261
2261
  var SlidesRenderer = lazy(() => import('./SlidesRenderer-AHE5ANBZ.js').then((m) => ({ default: m.SlidesRenderer })));
2262
- var FileRenderer = lazy(() => import('./FileRenderer-2CDJYZ7I.js').then((m) => ({ default: m.FileRenderer })));
2262
+ var FileRenderer = lazy(() => import('./FileRenderer-W2CR7BI2.js').then((m) => ({ default: m.FileRenderer })));
2263
2263
  var builtinRenderers = {
2264
2264
  html: HtmlRenderer,
2265
2265
  document: DocumentRenderer,
@@ -2310,6 +2310,7 @@ var dataExporters = {
2310
2310
  { label: "PowerPoint", extension: "pptx", mime: MIME.pptx, build: (a) => slidesToPptx(a.data, a.title) }
2311
2311
  ]
2312
2312
  };
2313
+ var PRINT_COLOR_CSS = "*{-webkit-print-color-adjust:exact;print-color-adjust:exact}";
2313
2314
  function toStandaloneHtml(title, renderedHtml) {
2314
2315
  return `<!doctype html>
2315
2316
  <html lang="en">
@@ -2317,7 +2318,8 @@ function toStandaloneHtml(title, renderedHtml) {
2317
2318
  <meta charset="utf-8" />
2318
2319
  <meta name="viewport" content="width=device-width, initial-scale=1" />
2319
2320
  <title>${escapeHtml(title)}</title>
2320
- <style>${EXPORT_CSS}</style>
2321
+ <style>${EXPORT_CSS}
2322
+ ${PRINT_COLOR_CSS}</style>
2321
2323
  </head>
2322
2324
  <body>
2323
2325
  <main class="export">
@@ -2472,6 +2474,7 @@ function slidesToPrintHtml(data, title) {
2472
2474
  return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeXml(title)}</title><style>
2473
2475
  @page { size: ${pw}px ${ph}px; margin: 0; }
2474
2476
  * { margin: 0; box-sizing: border-box; }
2477
+ ${PRINT_COLOR_CSS}
2475
2478
  body { font-family: Inter, Arial, sans-serif; }
2476
2479
  .slide { position: relative; width: ${pw}px; height: ${ph}px; overflow: hidden; page-break-after: always; }
2477
2480
  .el { position: absolute; overflow: hidden; line-height: 1.25; }
@@ -2481,7 +2484,7 @@ function slidesToPrintHtml(data, title) {
2481
2484
  function htmlSlideToPrintHtml(html, ratio) {
2482
2485
  const w = ratio === "4:3" ? 960 : 1280;
2483
2486
  const h = 720;
2484
- const style = `<style>@page{size:${w}px ${h}px;margin:0}html,body{margin:0!important;padding:0!important;background:#fff}.slide-container{width:${w}px!important;height:${h}px!important;box-shadow:none!important;border-radius:0!important;overflow:hidden!important;page-break-after:avoid}</style>`;
2487
+ const style = `<style>@page{size:${w}px ${h}px;margin:0}${PRINT_COLOR_CSS}html,body{margin:0!important;padding:0!important;background:#fff}.slide-container{width:${w}px!important;height:${h}px!important;box-shadow:none!important;border-radius:0!important;overflow:hidden!important;page-break-after:avoid}</style>`;
2485
2488
  const i = html.toLowerCase().lastIndexOf("</head>");
2486
2489
  return i === -1 ? style + html : html.slice(0, i) + style + html.slice(i);
2487
2490
  }
package/dist/styles.css CHANGED
@@ -1268,6 +1268,48 @@
1268
1268
  }
1269
1269
  .cv-file__download:hover { border-color: var(--cv-accent); }
1270
1270
 
1271
+ /* Word preview — a page the user reads and points at, never types into.
1272
+ The renderer writes the document's own colours and fonts inline, so this
1273
+ only frames it: the banner that says it is read-only, the pointing
1274
+ highlight, and the status line under it. */
1275
+ .cv-docx {
1276
+ display: flex;
1277
+ flex-direction: column;
1278
+ gap: 8px;
1279
+ width: 100%;
1280
+ }
1281
+ .cv-docx__banner {
1282
+ padding: 7px 12px;
1283
+ border: 1px solid var(--cv-border);
1284
+ border-radius: 10px;
1285
+ background: var(--cv-bg);
1286
+ color: var(--cv-muted);
1287
+ font-size: 12px;
1288
+ }
1289
+ .cv-docx__page {
1290
+ max-height: 68vh;
1291
+ overflow: auto;
1292
+ border: 1px solid var(--cv-border);
1293
+ border-radius: 12px;
1294
+ background: var(--cv-bg);
1295
+ }
1296
+ .cv-docx__page [data-canvas-docx] { cursor: pointer; }
1297
+ .cv-docx__page [data-canvas-docx]:hover { outline: 1px dashed var(--cv-border); outline-offset: 2px; }
1298
+ .cv-docx__page .is-picked {
1299
+ outline: 2px solid var(--cv-accent);
1300
+ outline-offset: 2px;
1301
+ border-radius: 3px;
1302
+ }
1303
+ .cv-docx__status {
1304
+ display: flex;
1305
+ flex-wrap: wrap;
1306
+ gap: 10px;
1307
+ font-size: 11px;
1308
+ color: var(--cv-muted);
1309
+ }
1310
+ .cv-docx__picked { color: var(--cv-accent); }
1311
+ .cv-docx__fonts { color: var(--cv-warn, #b45309); }
1312
+
1271
1313
  /* Tablet / narrow desktop: tighten paddings, let toolbars wrap. */
1272
1314
  @media (max-width: 900px) {
1273
1315
  .cv-body { padding: 16px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braincrew-lab/langchain-canvas",
3
- "version": "0.7.1",
3
+ "version": "0.7.4",
4
4
  "description": "A live canvas for LangChain agents — stream documents, charts, and rich artifacts into a React panel.",
5
5
  "license": "MIT",
6
6
  "author": "Brain Crew (https://github.com/braincrew-lab)",
@@ -69,6 +69,7 @@
69
69
  },
70
70
  "optionalDependencies": {
71
71
  "docx": "^9.0.2",
72
+ "docx-preview": "^0.4.0",
72
73
  "exceljs": "^4.4.0",
73
74
  "fast-formula-parser": "^1.0.19",
74
75
  "pptxgenjs": "^3.12.0"