aac-board-viewer 0.1.1 → 0.1.3

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
@@ -40,24 +40,10 @@ yarn add aac-board-viewer
40
40
 
41
41
  ## Quick Start
42
42
 
43
- ### Client-Side Usage
44
-
45
- ```tsx
46
- import { BoardViewer, useAACFile } from 'aac-board-viewer';
47
- import 'aac-board-viewer/styles';
48
-
49
- function MyViewer() {
50
- const { tree, loading, error } = useAACFile('/path/to/file.sps');
51
-
52
- if (loading) return <div>Loading...</div>;
53
- if (error) return <div>Error: {error.message}</div>;
54
-
55
- return <BoardViewer tree={tree} />;
56
- }
57
- ```
58
-
59
43
  ### Server-Side / API Usage
60
44
 
45
+ > Important: `@willwade/aac-processors` is a Node-only dependency (uses native modules like `better-sqlite3`). File parsing must happen server-side. For client apps, call an API that returns the parsed tree and pass that to `BoardViewer`.
46
+
61
47
  ```tsx
62
48
  import { BoardViewer } from 'aac-board-viewer';
63
49
  import 'aac-board-viewer/styles';
@@ -179,18 +165,6 @@ interface AACTree {
179
165
 
180
166
  The library provides utilities for loading AAC files:
181
167
 
182
- ### Client-Side Loading
183
-
184
- ```tsx
185
- import { useAACFile } from 'aac-board-viewer';
186
-
187
- function Viewer({ fileUrl }) {
188
- const { tree, loading, error } = useAACFile(fileUrl);
189
-
190
- // ...
191
- }
192
- ```
193
-
194
168
  ### Programmatic Loading
195
169
 
196
170
  ```tsx
package/dist/index.js CHANGED
@@ -47,6 +47,53 @@ module.exports = __toCommonJS(index_exports);
47
47
  // src/components/BoardViewer.tsx
48
48
  var import_react = __toESM(require("react"));
49
49
  var import_jsx_runtime = require("react/jsx-runtime");
50
+ function PredictionsTooltip({ predictions, label, position, onClose }) {
51
+ import_react.default.useEffect(() => {
52
+ const handleClickOutside = (e) => {
53
+ if (e.target instanceof HTMLElement && !e.target.closest(".predictions-tooltip")) {
54
+ onClose();
55
+ }
56
+ };
57
+ document.addEventListener("mousedown", handleClickOutside);
58
+ return () => document.removeEventListener("mousedown", handleClickOutside);
59
+ }, [onClose]);
60
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
61
+ "div",
62
+ {
63
+ className: "predictions-tooltip fixed z-50 bg-white dark:bg-gray-800 rounded-lg shadow-xl border-2 border-purple-500 p-3 max-w-xs",
64
+ style: {
65
+ left: `${Math.min(position.x, window.innerWidth - 200)}px`,
66
+ top: `${Math.min(position.y, window.innerHeight - 150)}px`
67
+ },
68
+ children: [
69
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "flex items-center justify-between mb-2", children: [
70
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("h4", { className: "text-sm font-semibold text-gray-900 dark:text-white", children: [
71
+ 'Word forms for "',
72
+ label,
73
+ '"'
74
+ ] }),
75
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
76
+ "button",
77
+ {
78
+ onClick: onClose,
79
+ className: "p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded",
80
+ "aria-label": "Close",
81
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { className: "h-4 w-4 text-gray-600 dark:text-gray-400", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) })
82
+ }
83
+ )
84
+ ] }),
85
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "flex flex-wrap gap-1", children: predictions.map((word, idx) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
86
+ "span",
87
+ {
88
+ className: "px-2 py-1 bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded text-xs font-medium",
89
+ children: word
90
+ },
91
+ idx
92
+ )) })
93
+ ]
94
+ }
95
+ );
96
+ }
50
97
  function BoardViewer({
51
98
  tree,
52
99
  buttonMetrics,
@@ -72,6 +119,12 @@ function BoardViewer({
72
119
  return tree.rootId;
73
120
  }
74
121
  }
122
+ const startPage = Object.values(tree.pages).find(
123
+ (p) => p.name.toLowerCase() === "start"
124
+ );
125
+ if (startPage) {
126
+ return startPage.id;
127
+ }
75
128
  const nonToolbarPage = Object.values(tree.pages).find(
76
129
  (p) => !p.name.toLowerCase().includes("toolbar") && !p.name.toLowerCase().includes("tool bar")
77
130
  );
@@ -86,6 +139,7 @@ function BoardViewer({
86
139
  const [message, setMessage] = (0, import_react.useState)("");
87
140
  const [currentWordCount, setCurrentWordCount] = (0, import_react.useState)(0);
88
141
  const [currentEffort, setCurrentEffort] = (0, import_react.useState)(0);
142
+ const [predictionsTooltip, setPredictionsTooltip] = (0, import_react.useState)(null);
89
143
  const buttonMetricsLookup = (0, import_react.useMemo)(() => {
90
144
  if (!buttonMetrics) return {};
91
145
  const lookup = {};
@@ -95,6 +149,17 @@ function BoardViewer({
95
149
  return lookup;
96
150
  }, [buttonMetrics]);
97
151
  const currentPage = currentPageId ? tree.pages[currentPageId] : null;
152
+ const goToPage = (targetPageId) => {
153
+ if (!targetPageId || !tree.pages[targetPageId]) return false;
154
+ if (currentPage) {
155
+ setPageHistory((prev) => [...prev, currentPage]);
156
+ }
157
+ setCurrentPageId(targetPageId);
158
+ if (onPageChange) {
159
+ onPageChange(targetPageId);
160
+ }
161
+ return true;
162
+ };
98
163
  import_react.default.useEffect(() => {
99
164
  setCurrentPageId(resolveInitialPageId());
100
165
  setPageHistory([]);
@@ -107,24 +172,62 @@ function BoardViewer({
107
172
  if (onButtonClick) {
108
173
  onButtonClick(button);
109
174
  }
175
+ const intent = button.semanticAction?.intent ? String(button.semanticAction.intent) : void 0;
110
176
  const targetPageId = button.targetPageId || button.semanticAction?.targetId;
111
- if (targetPageId && tree.pages[targetPageId]) {
112
- if (currentPage) {
113
- setPageHistory((prev) => [...prev, currentPage]);
114
- }
115
- setCurrentPageId(targetPageId);
116
- if (onPageChange) {
117
- onPageChange(targetPageId);
118
- }
177
+ const effort = buttonMetricsLookup[button.id]?.effort || 1;
178
+ const textValue = button.semanticAction?.text || button.message || button.label || "";
179
+ const deleteLastWord = () => {
180
+ setMessage((prev) => {
181
+ const parts = prev.trim().split(/\s+/);
182
+ parts.pop();
183
+ const newMsg = parts.join(" ");
184
+ return newMsg;
185
+ });
186
+ };
187
+ const deleteLastCharacter = () => {
188
+ setMessage((prev) => prev.slice(0, -1));
189
+ };
190
+ const appendText = (word) => {
191
+ const trimmed = word || button.label || "";
192
+ setMessage((prev) => {
193
+ const newMessage = trimmed ? prev + (prev ? " " : "") + trimmed : prev;
194
+ if (trimmed) {
195
+ updateStats(trimmed, effort);
196
+ }
197
+ return newMessage;
198
+ });
199
+ };
200
+ if (intent === "NAVIGATE_TO" && goToPage(targetPageId)) {
119
201
  return;
120
202
  }
121
- const word = button.message || button.label;
122
- const effort = buttonMetricsLookup[button.id]?.effort || 1;
123
- setMessage((prev) => {
124
- const newMessage = prev + (prev ? " " : "") + word;
125
- updateStats(word, effort);
126
- return newMessage;
127
- });
203
+ switch (intent) {
204
+ case "GO_BACK":
205
+ handleBack();
206
+ return;
207
+ case "GO_HOME":
208
+ if (tree.rootId && goToPage(tree.rootId)) return;
209
+ break;
210
+ case "DELETE_WORD":
211
+ deleteLastWord();
212
+ return;
213
+ case "DELETE_CHARACTER":
214
+ deleteLastCharacter();
215
+ return;
216
+ case "CLEAR_TEXT":
217
+ clearMessage();
218
+ return;
219
+ case "SPEAK_IMMEDIATE":
220
+ case "SPEAK_TEXT":
221
+ case "INSERT_TEXT":
222
+ appendText(textValue);
223
+ return;
224
+ default:
225
+ break;
226
+ }
227
+ if (targetPageId && goToPage(targetPageId)) {
228
+ return;
229
+ }
230
+ appendText(textValue);
128
231
  };
129
232
  const handleBack = () => {
130
233
  if (pageHistory.length > 0) {
@@ -141,6 +244,17 @@ function BoardViewer({
141
244
  setCurrentWordCount(0);
142
245
  setCurrentEffort(0);
143
246
  };
247
+ const handleShowPredictions = (button, event) => {
248
+ event.stopPropagation();
249
+ const predictions = button.predictions || button.parameters?.predictions;
250
+ if (predictions && predictions.length > 0) {
251
+ setPredictionsTooltip({
252
+ predictions,
253
+ label: button.label,
254
+ position: { x: event.clientX, y: event.clientY }
255
+ });
256
+ }
257
+ };
144
258
  const getTextColor = (backgroundColor) => {
145
259
  if (!backgroundColor) return "text-gray-900 dark:text-gray-100";
146
260
  const hex = backgroundColor.replace("#", "");
@@ -292,52 +406,109 @@ ${button.message || ""}`,
292
406
  display: "grid",
293
407
  gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`
294
408
  },
295
- children: currentPage.grid.map(
296
- (row, rowIndex) => row.map((button, colIndex) => {
297
- if (!button) {
298
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "aspect-square" }, `empty-${rowIndex}-${colIndex}`);
299
- }
300
- const buttonMetric = buttonMetricsLookup[button.id];
301
- const effort = buttonMetric?.effort || 0;
302
- const targetPageId = button.targetPageId || button.semanticAction?.targetId;
303
- const hasLink = targetPageId && tree.pages[targetPageId];
304
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
305
- "button",
306
- {
307
- onClick: () => handleButtonClick(button),
308
- className: "relative aspect-square p-2 rounded-lg border-2 transition flex flex-col items-center justify-center gap-1 hover:opacity-80 hover:scale-105 active:scale-95",
309
- style: {
310
- backgroundColor: button.style?.backgroundColor || "#f3f4f6",
311
- borderColor: button.style?.borderColor || "#e5e7eb",
312
- color: button.style?.fontColor || void 0
409
+ children: (() => {
410
+ const rendered = /* @__PURE__ */ new Set();
411
+ return currentPage.grid.flatMap(
412
+ (row, rowIndex) => row.map((button, colIndex) => {
413
+ if (!button) {
414
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "aspect-square" }, `empty-${rowIndex}-${colIndex}`);
415
+ }
416
+ if (rendered.has(button.id)) {
417
+ return null;
418
+ }
419
+ rendered.add(button.id);
420
+ const buttonMetric = buttonMetricsLookup[button.id];
421
+ const effort = buttonMetric?.effort || 0;
422
+ const targetPageId = button.targetPageId || button.semanticAction?.targetId;
423
+ const hasLink = targetPageId && tree.pages[targetPageId];
424
+ const colSpan = button.columnSpan || 1;
425
+ const rowSpan = button.rowSpan || 1;
426
+ const predictions = button.predictions || button.parameters?.predictions;
427
+ const hasPredictions = predictions && predictions.length > 0;
428
+ const isPredictionCell = button.contentType === "AutoContent" && (button.contentSubType || "").toLowerCase() === "prediction";
429
+ const isWorkspace = button.contentType === "Workspace";
430
+ const imageSrc = (button.resolvedImageEntry && !String(button.resolvedImageEntry).startsWith("[") ? button.resolvedImageEntry : null) || (button.image && !String(button.image).startsWith("[") ? button.image : null);
431
+ if (isWorkspace) {
432
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
433
+ "div",
434
+ {
435
+ className: "relative p-3 rounded-lg border-2 bg-gray-50 text-gray-800 flex items-center gap-2",
436
+ style: {
437
+ borderColor: button.style?.borderColor || "#e5e7eb",
438
+ gridColumn: `${colIndex + 1} / span ${colSpan}`,
439
+ gridRow: `${rowIndex + 1} / span ${rowSpan}`
440
+ },
441
+ children: [
442
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "font-semibold text-sm", children: button.label || "Workspace" }),
443
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "text-xs text-gray-500 truncate", children: message || "Chat writing area" })
444
+ ]
445
+ },
446
+ button.id
447
+ );
448
+ }
449
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
450
+ "button",
451
+ {
452
+ onClick: () => handleButtonClick(button),
453
+ className: "relative aspect-square p-2 rounded-lg border-2 transition flex flex-col items-center justify-center gap-1 hover:opacity-80 hover:scale-105 active:scale-95",
454
+ style: {
455
+ backgroundColor: button.style?.backgroundColor || "#f3f4f6",
456
+ borderColor: button.style?.borderColor || "#e5e7eb",
457
+ color: button.style?.fontColor || void 0,
458
+ gridColumn: `${colIndex + 1} / span ${colSpan}`,
459
+ gridRow: `${rowIndex + 1} / span ${rowSpan}`
460
+ },
461
+ children: [
462
+ buttonMetric && showEffortBadges && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "absolute top-1 right-1 px-1.5 py-0.5 text-xs font-semibold rounded bg-blue-600 text-white shadow-sm", children: effort.toFixed(1) }),
463
+ hasLink && showLinkIndicators && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "absolute top-1 left-1 w-2 h-2 bg-green-500 rounded-full shadow-sm" }),
464
+ hasPredictions && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
465
+ "div",
466
+ {
467
+ onClick: (e) => handleShowPredictions(button, e),
468
+ className: "absolute bottom-1 right-1 px-1.5 py-0.5 text-xs font-semibold rounded bg-purple-600 text-white shadow-sm cursor-pointer hover:bg-purple-700 transition",
469
+ title: `Has ${predictions?.length} word form${predictions && predictions.length > 1 ? "s" : ""}`,
470
+ children: predictions?.length
471
+ }
472
+ ),
473
+ imageSrc && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
474
+ "img",
475
+ {
476
+ src: imageSrc,
477
+ alt: button.label,
478
+ className: "max-h-12 object-contain"
479
+ }
480
+ ),
481
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "flex flex-col items-center justify-center", children: [
482
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
483
+ "span",
484
+ {
485
+ className: `text-xs sm:text-sm text-center font-medium leading-tight line-clamp-3 ${getTextColor(
486
+ button.style?.backgroundColor
487
+ )}`,
488
+ children: button.label
489
+ }
490
+ ),
491
+ isPredictionCell && predictions && predictions.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "mt-1 text-[10px] sm:text-xs text-center opacity-80 space-y-0.5", children: [
492
+ predictions.slice(0, 3).map((p, idx) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { children: p }, `${button.id}-pred-${idx}`)),
493
+ predictions.length > 3 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { children: "\u2026" })
494
+ ] })
495
+ ] }),
496
+ button.message && button.message !== button.label && !isPredictionCell && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
497
+ "span",
498
+ {
499
+ className: `text-[10px] sm:text-xs text-center opacity-75 line-clamp-2 ${getTextColor(
500
+ button.style?.backgroundColor
501
+ )}`,
502
+ children: button.message
503
+ }
504
+ )
505
+ ]
313
506
  },
314
- children: [
315
- buttonMetric && showEffortBadges && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "absolute top-1 right-1 px-1.5 py-0.5 text-xs font-semibold rounded bg-blue-600 text-white shadow-sm", children: effort.toFixed(1) }),
316
- hasLink && showLinkIndicators && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "absolute top-1 left-1 w-2 h-2 bg-green-500 rounded-full shadow-sm" }),
317
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
318
- "span",
319
- {
320
- className: `text-xs sm:text-sm text-center font-medium leading-tight line-clamp-3 ${getTextColor(
321
- button.style?.backgroundColor
322
- )}`,
323
- children: button.label
324
- }
325
- ),
326
- button.message && button.message !== button.label && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
327
- "span",
328
- {
329
- className: `text-[10px] sm:text-xs text-center opacity-75 line-clamp-2 ${getTextColor(
330
- button.style?.backgroundColor
331
- )}`,
332
- children: button.message
333
- }
334
- )
335
- ]
336
- },
337
- button.id
338
- );
339
- })
340
- )
507
+ button.id
508
+ );
509
+ })
510
+ );
511
+ })()
341
512
  }
342
513
  ),
343
514
  Object.keys(tree.pages).length > 1 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "p-4 border-t border-gray-200 dark:border-gray-700", children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { className: "text-sm text-gray-600 dark:text-gray-400 mb-2", children: [
@@ -345,7 +516,16 @@ ${button.message || ""}`,
345
516
  " pages in this vocabulary"
346
517
  ] }) })
347
518
  ] })
348
- ] })
519
+ ] }),
520
+ predictionsTooltip && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
521
+ PredictionsTooltip,
522
+ {
523
+ predictions: predictionsTooltip.predictions,
524
+ label: predictionsTooltip.label,
525
+ position: predictionsTooltip.position,
526
+ onClose: () => setPredictionsTooltip(null)
527
+ }
528
+ )
349
529
  ] });
350
530
  }
351
531