@pie-players/pie-assessment-toolkit 0.2.9 → 0.2.10

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 (96) hide show
  1. package/dist/components/ItemToolBar.custom-element.js +413 -88
  2. package/dist/components/PieAssessmentToolkit.custom-element.js +128 -41
  3. package/dist/components/SectionToolBar.custom-element.js +162 -0
  4. package/dist/components/section-toolbar-element.d.ts +3 -0
  5. package/dist/components/section-toolbar-element.d.ts.map +1 -0
  6. package/dist/components/section-toolbar-element.js +2 -0
  7. package/dist/components/section-toolbar-element.js.map +1 -0
  8. package/dist/context/assessment-toolkit-context.d.ts +1 -1
  9. package/dist/context/assessment-toolkit-context.d.ts.map +1 -1
  10. package/dist/index.d.ts +4 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/runtime/SectionRuntimeEngine.d.ts +1 -1
  15. package/dist/runtime/SectionRuntimeEngine.d.ts.map +1 -1
  16. package/dist/services/HighlightCoordinator.d.ts +9 -0
  17. package/dist/services/HighlightCoordinator.d.ts.map +1 -1
  18. package/dist/services/HighlightCoordinator.js +196 -55
  19. package/dist/services/HighlightCoordinator.js.map +1 -1
  20. package/dist/services/TTSService.d.ts +18 -0
  21. package/dist/services/TTSService.d.ts.map +1 -1
  22. package/dist/services/TTSService.js +343 -105
  23. package/dist/services/TTSService.js.map +1 -1
  24. package/dist/services/ToolRegistry.d.ts +14 -2
  25. package/dist/services/ToolRegistry.d.ts.map +1 -1
  26. package/dist/services/ToolRegistry.js +2 -1
  27. package/dist/services/ToolRegistry.js.map +1 -1
  28. package/dist/services/ToolkitCoordinator.d.ts +21 -1
  29. package/dist/services/ToolkitCoordinator.d.ts.map +1 -1
  30. package/dist/services/ToolkitCoordinator.js +106 -2
  31. package/dist/services/ToolkitCoordinator.js.map +1 -1
  32. package/dist/services/createDefaultToolRegistry.d.ts +5 -11
  33. package/dist/services/createDefaultToolRegistry.d.ts.map +1 -1
  34. package/dist/services/createDefaultToolRegistry.js +5 -48
  35. package/dist/services/createDefaultToolRegistry.js.map +1 -1
  36. package/dist/services/interfaces.d.ts +1 -1
  37. package/dist/services/interfaces.d.ts.map +1 -1
  38. package/dist/services/pnp-standard-features.d.ts +1 -1
  39. package/dist/services/section-controller-types.d.ts +5 -0
  40. package/dist/services/section-controller-types.d.ts.map +1 -1
  41. package/dist/services/tool-config-defaults.d.ts +14 -0
  42. package/dist/services/tool-config-defaults.d.ts.map +1 -0
  43. package/dist/services/tool-config-defaults.js +23 -0
  44. package/dist/services/tool-config-defaults.js.map +1 -0
  45. package/dist/services/tool-context.d.ts.map +1 -1
  46. package/dist/services/tool-context.js +50 -3
  47. package/dist/services/tool-context.js.map +1 -1
  48. package/dist/services/tool-instance-id.d.ts +1 -3
  49. package/dist/services/tool-instance-id.d.ts.map +1 -1
  50. package/dist/services/tool-instance-id.js +4 -10
  51. package/dist/services/tool-instance-id.js.map +1 -1
  52. package/dist/services/toolbar-items.d.ts +26 -0
  53. package/dist/services/toolbar-items.d.ts.map +1 -0
  54. package/dist/services/toolbar-items.js +10 -0
  55. package/dist/services/toolbar-items.js.map +1 -0
  56. package/dist/services/tools-config-normalizer.d.ts.map +1 -1
  57. package/dist/services/tools-config-normalizer.js +5 -6
  58. package/dist/services/tools-config-normalizer.js.map +1 -1
  59. package/dist/services/tts/browser-provider.d.ts.map +1 -1
  60. package/dist/services/tts/browser-provider.js +201 -34
  61. package/dist/services/tts/browser-provider.js.map +1 -1
  62. package/dist/services/tts/text-processing.d.ts +17 -0
  63. package/dist/services/tts/text-processing.d.ts.map +1 -0
  64. package/dist/services/tts/text-processing.js +144 -0
  65. package/dist/services/tts/text-processing.js.map +1 -0
  66. package/dist/tools/default-tool-module-loaders.d.ts.map +1 -1
  67. package/dist/tools/default-tool-module-loaders.js +1 -7
  68. package/dist/tools/default-tool-module-loaders.js.map +1 -1
  69. package/dist/tools/registrations/accessibility-tools.d.ts +7 -2
  70. package/dist/tools/registrations/accessibility-tools.d.ts.map +1 -1
  71. package/dist/tools/registrations/accessibility-tools.js +21 -15
  72. package/dist/tools/registrations/accessibility-tools.js.map +1 -1
  73. package/dist/tools/registrations/calculator.d.ts.map +1 -1
  74. package/dist/tools/registrations/calculator.js +18 -10
  75. package/dist/tools/registrations/calculator.js.map +1 -1
  76. package/dist/tools/registrations/interaction-tools.js +4 -4
  77. package/dist/tools/registrations/interaction-tools.js.map +1 -1
  78. package/dist/tools/registrations/measurement-tools.js +4 -4
  79. package/dist/tools/registrations/measurement-tools.js.map +1 -1
  80. package/dist/tools/registrations/subject-specific-tools.js +4 -4
  81. package/dist/tools/registrations/subject-specific-tools.js.map +1 -1
  82. package/dist/tools/registrations/tts.d.ts.map +1 -1
  83. package/dist/tools/registrations/tts.js +161 -17
  84. package/dist/tools/registrations/tts.js.map +1 -1
  85. package/dist/tools/tool-tag-map.d.ts.map +1 -1
  86. package/dist/tools/tool-tag-map.js +2 -1
  87. package/dist/tools/tool-tag-map.js.map +1 -1
  88. package/package.json +11 -7
  89. package/src/components/ItemToolBar.svelte +254 -42
  90. package/src/components/PieAssessmentToolkit.svelte +139 -46
  91. package/src/components/SectionToolBar.svelte +58 -0
  92. package/dist/services/tts/provider-interface.d.ts +0 -131
  93. package/dist/services/tts/provider-interface.d.ts.map +0 -1
  94. package/dist/services/tts/provider-interface.js +0 -10
  95. package/dist/services/tts/provider-interface.js.map +0 -1
  96. package/src/components/PNPProvenanceViewer.svelte +0 -270
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pie-players/pie-assessment-toolkit",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "type": "module",
5
5
  "description": "PIE assessment toolkit: composable services + reference implementation for assessment players and tool coordination",
6
6
  "license": "MIT",
@@ -33,6 +33,10 @@
33
33
  "types": "./dist/components/pie-assessment-toolkit-element.d.ts",
34
34
  "import": "./dist/components/pie-assessment-toolkit-element.js"
35
35
  },
36
+ "./components/section-toolbar-element": {
37
+ "types": "./dist/components/section-toolbar-element.d.ts",
38
+ "import": "./dist/components/section-toolbar-element.js"
39
+ },
36
40
  "./services/pnp-standard-features": {
37
41
  "types": "./dist/services/pnp-standard-features.d.ts",
38
42
  "import": "./dist/services/pnp-standard-features.js"
@@ -60,12 +64,12 @@
60
64
  "test": "bun test"
61
65
  },
62
66
  "dependencies": {
63
- "@pie-players/pie-calculator": "0.1.4",
64
- "@pie-players/pie-calculator-desmos": "0.1.5",
65
- "@pie-players/pie-context": "0.1.1",
66
- "@pie-players/pie-players-shared": "0.2.5",
67
- "@pie-players/pie-tts": "0.1.4",
68
- "@pie-players/tts-client-server": "0.2.4",
67
+ "@pie-players/pie-calculator": "0.1.5",
68
+ "@pie-players/pie-calculator-desmos": "0.1.6",
69
+ "@pie-players/pie-context": "0.1.2",
70
+ "@pie-players/pie-players-shared": "0.2.6",
71
+ "@pie-players/pie-tts": "0.1.5",
72
+ "@pie-players/tts-client-server": "0.2.5",
69
73
  "daisyui": "^5.5.18"
70
74
  },
71
75
  "peerDependencies": {
@@ -3,10 +3,14 @@
3
3
  tag: 'pie-item-toolbar',
4
4
  shadow: 'open',
5
5
  props: {
6
+ level: { type: 'String', attribute: 'level' },
7
+ scopeId: { type: 'String', attribute: 'scope-id' },
6
8
  itemId: { type: 'String', attribute: 'item-id' },
9
+ sectionId: { type: 'String', attribute: 'section-id' },
7
10
  catalogId: { type: 'String', attribute: 'catalog-id' },
8
11
  tools: { type: 'String', attribute: 'tools' },
9
12
  contentKind: { type: 'String', attribute: 'content-kind' },
13
+ position: { type: 'String', attribute: 'position' },
10
14
  size: { type: 'String', attribute: 'size' },
11
15
  language: { type: 'String', attribute: 'language' },
12
16
 
@@ -16,7 +20,8 @@
16
20
  pnpResolver: { type: 'Object', reflect: false },
17
21
  assessment: { type: 'Object', reflect: false },
18
22
  itemRef: { type: 'Object', reflect: false },
19
- item: { type: 'Object', reflect: false }
23
+ item: { type: 'Object', reflect: false },
24
+ hostButtons: { type: 'Object', reflect: false }
20
25
  }
21
26
  }}
22
27
  />
@@ -38,6 +43,12 @@
38
43
  connectAssessmentToolkitShellContext,
39
44
  } from '../context/runtime-context-consumer.js';
40
45
  import type { ToolRegistry, ToolToolbarRenderResult, ToolbarContext } from '../services/ToolRegistry.js';
46
+ import {
47
+ isExternalIconUrl,
48
+ isInlineSvgIcon,
49
+ isToolbarLinkItem,
50
+ type ToolbarItem
51
+ } from '../services/toolbar-items.js';
41
52
  import type { PNPToolResolver } from '../services/PNPToolResolver.js';
42
53
  import { createDefaultToolRegistry } from '../services/createDefaultToolRegistry.js';
43
54
  import { DEFAULT_TOOL_MODULE_LOADERS } from '../tools/default-tool-module-loaders.js';
@@ -48,7 +59,7 @@
48
59
  } from '../services/tools-config-normalizer.js';
49
60
  import { createScopedToolId, parseScopedToolId } from '../services/tool-instance-id.js';
50
61
  import type { AssessmentEntity, AssessmentItemRef, ItemEntity } from '@pie-players/pie-players-shared/types';
51
- import type { ElementToolContext, ItemToolContext } from '../services/tool-context.js';
62
+ import type { ElementToolContext, ItemToolContext, ToolLevel, ToolContext } from '../services/tool-context.js';
52
63
 
53
64
  const isBrowser = typeof window !== 'undefined';
54
65
  const fallbackToolRegistry = createDefaultToolRegistry({
@@ -59,30 +70,40 @@
59
70
 
60
71
  // Props
61
72
  let {
73
+ level = 'item' as ToolLevel,
74
+ scopeId = '',
62
75
  itemId = '',
76
+ sectionId = '',
63
77
  catalogId = '',
64
78
  tools = 'calculator,textToSpeech,answerEliminator',
65
79
  contentKind = 'assessment-item',
80
+ position = 'bottom' as 'top' | 'right' | 'bottom' | 'left' | 'none',
66
81
  scopeElement = null,
67
82
  toolRegistry = null,
68
83
  pnpResolver = null,
69
84
  assessment = null,
70
85
  itemRef = null,
71
86
  item = null,
87
+ hostButtons = [] as ToolbarItem[],
72
88
  class: className = '',
73
89
  size = 'md' as 'sm' | 'md' | 'lg',
74
90
  language = 'en-US'
75
91
  }: {
92
+ level?: ToolLevel;
93
+ scopeId?: string;
76
94
  itemId?: string;
95
+ sectionId?: string;
77
96
  catalogId?: string;
78
97
  tools?: string;
79
98
  contentKind?: string;
99
+ position?: 'top' | 'right' | 'bottom' | 'left' | 'none';
80
100
  scopeElement?: HTMLElement | null;
81
101
  toolRegistry?: ToolRegistry | null;
82
102
  pnpResolver?: PNPToolResolver | null;
83
103
  assessment?: AssessmentEntity | null;
84
104
  itemRef?: AssessmentItemRef | null;
85
105
  item?: ItemEntity | null;
106
+ hostButtons?: ToolbarItem[];
86
107
  class?: string;
87
108
  size?: 'sm' | 'md' | 'lg';
88
109
  language?: string;
@@ -111,11 +132,22 @@
111
132
  const effectiveTTSService = $derived(runtimeContext?.ttsService);
112
133
  const effectiveElementToolStateStore = $derived(runtimeContext?.elementToolStateStore);
113
134
  const effectiveAssessmentId = $derived(runtimeContext?.assessmentId ?? '');
114
- const effectiveSectionId = $derived(runtimeContext?.sectionId ?? '');
115
135
  const effectiveItemId = $derived(itemId || shellContext?.itemId || '');
116
136
  const effectiveCanonicalItemId = $derived(shellContext?.canonicalItemId || effectiveItemId);
117
- const effectiveCatalogId = $derived(catalogId || effectiveItemId);
118
- const effectiveContentKind = $derived(contentKind || shellContext?.contentKind || 'assessment-item');
137
+ const effectiveSectionId = $derived(sectionId || runtimeContext?.sectionId || '');
138
+ const effectiveContentKind = $derived(contentKind || shellContext?.contentKind || (level === 'section' ? 'section' : 'assessment-item'));
139
+ const effectiveLevel = $derived.by((): ToolLevel => {
140
+ if (level && level !== 'item') return level;
141
+ if (effectiveContentKind === 'rubric-block-stimulus') return 'passage';
142
+ return 'item';
143
+ });
144
+ const effectiveScopeId = $derived.by(() => {
145
+ if (scopeId) return scopeId;
146
+ if (effectiveLevel === 'section') return effectiveSectionId || runtimeContext?.sectionId || 'default-section';
147
+ if (effectiveLevel === 'assessment') return runtimeContext?.assessmentId || 'default-assessment';
148
+ return effectiveCanonicalItemId || effectiveItemId || 'default-item';
149
+ });
150
+ const effectiveCatalogId = $derived(catalogId || effectiveScopeId);
119
151
  const effectiveItem = $derived((item as ItemEntity | null) || (shellContext?.item as ItemEntity | null) || null);
120
152
 
121
153
  // Effective registry for visibility and metadata ownership.
@@ -129,9 +161,11 @@
129
161
  const coordinatorConfig = runtimeContext?.toolkitCoordinator?.config?.tools as any;
130
162
  return normalizeToolsConfig(coordinatorConfig || {});
131
163
  });
132
- const placementLevel = $derived.by(() =>
133
- effectiveContentKind === 'rubric-block-stimulus' ? 'passage' : 'item'
134
- );
164
+ const placementLevel = $derived.by(() => {
165
+ if (effectiveLevel === 'section') return 'section';
166
+ if (effectiveLevel === 'passage' || effectiveContentKind === 'rubric-block-stimulus') return 'passage';
167
+ return 'item';
168
+ });
135
169
  const placementAllowedToolIds = $derived.by(() => {
136
170
  const resolved = resolveToolsForLevel(effectiveToolsConfig, placementLevel);
137
171
  return effectiveToolRegistry.normalizeToolIds(resolved).filter(Boolean);
@@ -150,33 +184,56 @@
150
184
  });
151
185
 
152
186
  const contentReady = $derived.by(() => {
187
+ if (effectiveLevel === 'section' || effectiveLevel === 'assessment') return true;
153
188
  const config = (effectiveItem as ItemEntity | null)?.config;
154
189
  return !!(effectiveItem && config && typeof config === 'object');
155
190
  });
156
191
 
157
192
  const toolContext = $derived.by((): ItemToolContext | null => {
193
+ if (effectiveLevel === 'section') {
194
+ return {
195
+ level: 'section',
196
+ assessment: (assessment || {}) as AssessmentEntity,
197
+ section: {} as any
198
+ } as ToolContext as ItemToolContext;
199
+ }
200
+ if (effectiveLevel === 'assessment') {
201
+ return {
202
+ level: 'assessment',
203
+ assessment: (assessment || {}) as AssessmentEntity
204
+ } as ToolContext as ItemToolContext;
205
+ }
158
206
  if (!contentReady || !effectiveItem) {
159
207
  return null;
160
208
  }
209
+ if (effectiveLevel === 'passage') {
210
+ return {
211
+ level: 'passage',
212
+ assessment: (assessment || {}) as AssessmentEntity,
213
+ itemRef: (itemRef || ({ id: effectiveCanonicalItemId } as AssessmentItemRef)) as AssessmentItemRef,
214
+ passage: effectiveItem as any
215
+ } as ToolContext as ItemToolContext;
216
+ }
161
217
  return {
162
218
  level: 'item',
163
219
  assessment: (assessment || {}) as AssessmentEntity,
164
220
  itemRef: (itemRef || ({ id: effectiveCanonicalItemId } as AssessmentItemRef)) as AssessmentItemRef,
165
221
  item: effectiveItem as ItemEntity
166
- };
222
+ } as ToolContext as ItemToolContext;
167
223
  });
168
224
 
169
- const renderContext = $derived.by((): ItemToolContext => {
225
+ const renderContext = $derived.by((): ToolContext => {
170
226
  if (toolContext) return toolContext;
171
227
  return {
172
228
  level: 'item',
173
229
  assessment: (assessment || {}) as AssessmentEntity,
174
230
  itemRef: (itemRef || ({ id: effectiveCanonicalItemId } as AssessmentItemRef)) as AssessmentItemRef,
175
231
  item: ((effectiveItem as ItemEntity | null) || ({ id: effectiveCanonicalItemId, config: {} } as ItemEntity)) as ItemEntity
176
- };
232
+ } as ToolContext;
177
233
  });
178
234
 
179
235
  const elementContexts = $derived.by((): ElementToolContext[] => {
236
+ if (effectiveLevel !== 'item' && effectiveLevel !== 'passage') return [];
180
237
  if (!contentReady || !effectiveItem) return [];
181
238
  const modelsRaw = (effectiveItem as any)?.config?.models;
182
239
  const models = Array.isArray(modelsRaw)
@@ -197,20 +254,33 @@
197
254
 
198
255
  // Pass 2: tool-owned context filtering (item + element aggregation)
199
256
  const visibleToolIds = $derived.by(() => {
257
+ const levelCompatibleToolIds = allowedToolIds.filter((toolId) => {
258
+ const registration = effectiveToolRegistry.get(toolId);
259
+ return registration ? registration.supportedLevels.includes(effectiveLevel) : false;
260
+ });
261
+ if (effectiveLevel === 'section') {
262
+ // Section toolbars are orchestrator-driven. Tool-level relevance is often
263
+ // item-content dependent. Keep pass-1 as the source of truth here.
264
+ return allowedToolIds;
265
+ }
200
266
  if (!contentReady) {
201
267
  // Stage 1: orchestrator-level allow-list only.
202
- return allowedToolIds;
268
+ return levelCompatibleToolIds;
203
269
  }
204
270
  if (!toolContext && elementContexts.length === 0) {
205
- return allowedToolIds;
271
+ return levelCompatibleToolIds;
206
272
  }
207
273
  const visible = new Set<string>();
208
274
  if (toolContext) {
209
- effectiveToolRegistry.filterVisibleInContext(allowedToolIds, toolContext).forEach((tool) => visible.add(tool.toolId));
275
+ effectiveToolRegistry
276
+ .filterVisibleInContext(levelCompatibleToolIds, toolContext)
277
+ .forEach((tool) => visible.add(tool.toolId));
210
278
  }
211
279
 
212
280
  for (const context of elementContexts) {
213
- effectiveToolRegistry.filterVisibleInContext(allowedToolIds, context).forEach((tool) => visible.add(tool.toolId));
281
+ effectiveToolRegistry
282
+ .filterVisibleInContext(levelCompatibleToolIds, context)
283
+ .forEach((tool) => visible.add(tool.toolId));
214
284
  }
215
285
 
216
286
  return Array.from(visible);
@@ -257,10 +327,19 @@
257
327
  // This keeps toolbar contracts stable across mixed registration implementations.
258
328
  return parseScopedToolId(toolId)
259
329
  ? toolId
260
- : createScopedToolId(toolId, 'item', effectiveCanonicalItemId);
330
+ : createScopedToolId(toolId, effectiveLevel, effectiveScopeId);
261
331
  };
262
332
  return {
263
- itemId: effectiveCanonicalItemId,
333
+ scope: {
334
+ level: effectiveLevel,
335
+ scopeId: effectiveScopeId,
336
+ assessmentId: runtimeContext?.assessmentId,
337
+ sectionId: effectiveSectionId,
338
+ itemId: effectiveItemId,
339
+ canonicalItemId: effectiveCanonicalItemId,
340
+ contentKind: effectiveContentKind
341
+ },
342
+ itemId: effectiveScopeId,
264
343
  catalogId: effectiveCatalogId,
265
344
  language,
266
345
  ui: {
@@ -274,7 +353,13 @@
274
353
  elementToolStateStore: effectiveElementToolStateStore || null,
275
354
  toggleTool: (toolId: string) => {
276
355
  if (!effectiveToolCoordinator) return;
277
- effectiveToolCoordinator.toggleTool(toInstanceToolId(toolId));
356
+ const instanceToolId = toInstanceToolId(toolId);
357
+ // Some tools only self-register after first visibility sync.
358
+ // Seed a placeholder registration so first toggle always works.
359
+ if (!effectiveToolCoordinator.getToolState(instanceToolId)) {
360
+ effectiveToolCoordinator.registerTool(instanceToolId, toolId);
361
+ }
362
+ effectiveToolCoordinator.toggleTool(instanceToolId);
278
363
  },
279
364
  isToolVisible: (toolId: string) => {
280
365
  if (!effectiveToolCoordinator) return false;
@@ -300,6 +385,80 @@
300
385
  }
301
386
  return rendered;
302
387
  });
388
+ const normalizedHostButtons = $derived.by((): ToolbarItem[] =>
389
+ Array.isArray(hostButtons)
390
+ ? hostButtons.filter((item): item is ToolbarItem => !!item && typeof item.id === 'string')
391
+ : []
392
+ );
393
+ const nativeToolbarItems = $derived.by((): ToolbarItem[] =>
394
+ renderedTools
395
+ .filter((renderedTool) => !!renderedTool.button)
396
+ .map((renderedTool) => {
397
+ const button = renderedTool.button!;
398
+ return {
399
+ id: renderedTool.toolId,
400
+ label: button.label,
401
+ ariaLabel: button.ariaLabel || button.label,
402
+ icon: button.icon,
403
+ tooltip: button.tooltip || button.label,
404
+ disabled: button.disabled,
405
+ active: activeToolState[renderedTool.toolId] ?? button.active ?? false,
406
+ onClick: () => {
407
+ button.onClick();
408
+ syncRenderedToolsState();
409
+ },
410
+ } satisfies ToolbarItem;
411
+ })
412
+ );
413
+ const toolbarItems = $derived.by((): ToolbarItem[] => [
414
+ ...nativeToolbarItems,
415
+ ...normalizedHostButtons
416
+ ]);
417
+ const mountedElementsBeforeButtons = $derived.by(() =>
418
+ renderedTools.flatMap((renderedTool) =>
419
+ (renderedTool.elements || [])
420
+ .filter((entry) => entry.mount === 'before-buttons')
421
+ .map((entry) => entry.element)
422
+ .filter((entry): entry is HTMLElement => Boolean(entry))
423
+ )
424
+ );
425
+ const mountedElementsAfterButtons = $derived.by(() =>
426
+ renderedTools.flatMap((renderedTool) =>
427
+ (renderedTool.elements || [])
428
+ .filter((entry) => entry.mount === 'after-buttons')
429
+ .map((entry) => entry.element)
430
+ .filter((entry): entry is HTMLElement => Boolean(entry))
431
+ )
432
+ );
433
+
434
+ function isToolbarItemActive(item: ToolbarItem): boolean {
435
+ if (item.id in activeToolState) {
436
+ return activeToolState[item.id] === true;
437
+ }
438
+ return item.active === true;
439
+ }
440
+
441
+ function getFallbackIconSvg(iconName: string): string | null {
442
+ const iconMap: Record<string, string> = {
443
+ calculator:
444
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm0 2v4h10V4H7Zm0 6v2h2v-2H7Zm4 0v2h2v-2h-2Zm4 0v2h2v-2h-2Zm-8 4v2h2v-2H7Zm4 0v2h2v-2h-2Zm4 0v2h2v-2h-2Zm-8 4v2h2v-2H7Zm4 0v2h2v-2h-2Zm4 0v2h2v-2h-2Z" fill="currentColor"/></svg>',
445
+ 'volume-up':
446
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14 3.23v2.06A7.002 7.002 0 0 1 19 12a7 7 0 0 1-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77Zm-2 17.75V3L7 8H3v8h4l5 5Zm4.5-9a4.5 4.5 0 0 0-2.5-4.03v8.05A4.5 4.5 0 0 0 16.5 12Z" fill="currentColor"/></svg>',
447
+ swatch:
448
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 9 9c0-.55-.45-1-1-1h-2.5a1.5 1.5 0 0 1 0-3H20a1 1 0 0 0 1-1 8.99 8.99 0 0 0-9-4Zm-5.5 9A1.5 1.5 0 1 1 8 13.5 1.5 1.5 0 0 1 6.5 12Zm3-4A1.5 1.5 0 1 1 11 9.5 1.5 1.5 0 0 1 9.5 8Zm5 0A1.5 1.5 0 1 1 16 9.5 1.5 1.5 0 0 1 14.5 8Z" fill="currentColor"/></svg>',
449
+ 'chart-bar':
450
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4.75 5a.76.76 0 0 1 .75.75v11c0 .438.313.75.75.75h13a.76.76 0 0 1 .696 1.039.74.74 0 0 1-.696.461h-13C5 19 4 18 4 16.75v-11A.74.74 0 0 1 4.75 5ZM8 8.25a.74.74 0 0 1 .75-.75h6.5a.76.76 0 0 1 .696 1.039.74.74 0 0 1-.696.461h-6.5A.722.722 0 0 1 8 8.25Zm.75 2.25h4.5a.76.76 0 0 1 .696 1.039.74.74 0 0 1-.696.461h-4.5a.723.723 0 0 1-.75-.75.74.74 0 0 1 .75-.75Zm0 3h8.5a.76.76 0 0 1 .696 1.039.74.74 0 0 1-.696.461h-8.5a.723.723 0 0 1-.75-.75.74.74 0 0 1 .75-.75Z" fill="currentColor"/></svg>',
451
+ beaker:
452
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 21c-.85 0-1.454-.38-1.813-1.137-.358-.759-.27-1.463.263-2.113L9 11V5H8a.968.968 0 0 1-.713-.287A.968.968 0 0 1 7 4c0-.283.096-.52.287-.712A.968.968 0 0 1 8 3h8c.283 0 .52.096.712.288.192.191.288.429.288.712s-.096.52-.288.713A.968.968 0 0 1 16 5h-1v6l5.55 6.75c.533.65.62 1.354.262 2.113C20.454 20.62 19.85 21 19 21H5Zm2-3h10l-3.4-4h-3.2L7 18Zm-2 1h14l-6-7.3V5h-2v6.7L5 19Z" fill="currentColor"/></svg>',
453
+ protractor:
454
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m6.75 21-.25-2.2 2.85-7.85a3.95 3.95 0 0 0 1.75.95l-2.75 7.55L6.75 21Zm10.5 0-1.6-1.55-2.75-7.55a3.948 3.948 0 0 0 1.75-.95l2.85 7.85-.25 2.2ZM12 11a2.893 2.893 0 0 1-2.125-.875A2.893 2.893 0 0 1 9 8c0-.65.188-1.23.563-1.737A2.935 2.935 0 0 1 11 5.2V3h2v2.2c.583.2 1.063.554 1.438 1.063C14.812 6.77 15 7.35 15 8c0 .833-.292 1.542-.875 2.125A2.893 2.893 0 0 1 12 11Zm0-2c.283 0 .52-.096.713-.287A.967.967 0 0 0 13 8a.967.967 0 0 0-.287-.713A.968.968 0 0 0 12 7a.968.968 0 0 0-.713.287A.967.967 0 0 0 11 8c0 .283.096.52.287.713.192.191.43.287.713.287Z" fill="currentColor"/></svg>',
455
+ 'bars-3':
456
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M6.85 15c.517 0 .98-.15 1.388-.45.408-.3.695-.692.862-1.175l.375-1.15c.267-.8.2-1.537-.2-2.213C8.875 9.337 8.3 9 7.55 9H4.025l.475 3.925c.083.583.346 1.075.787 1.475.442.4.963.6 1.563.6Zm10.3 0c.6 0 1.12-.2 1.563-.6.441-.4.704-.892.787-1.475L19.975 9h-3.5c-.75 0-1.325.342-1.725 1.025-.4.683-.467 1.425-.2 2.225l.35 1.125c.167.483.454.875.862 1.175.409.3.871.45 1.388.45Zm-10.3 2c-1.1 0-2.063-.363-2.887-1.088a4.198 4.198 0 0 1-1.438-2.737L2 9H1V7h6.55c.733 0 1.404.18 2.013.537A3.906 3.906 0 0 1 11 9h2.025c.35-.617.83-1.104 1.438-1.463A3.892 3.892 0 0 1 16.474 7H23v2h-1l-.525 4.175a4.198 4.198 0 0 1-1.438 2.737A4.238 4.238 0 0 1 17.15 17c-.95 0-1.804-.27-2.562-.813A4.234 4.234 0 0 1 13 14.026l-.375-1.125a21.35 21.35 0 0 1-.1-.363 4.926 4.926 0 0 1-.1-.537h-.85c-.033.2-.067.363-.1.488a21.35 21.35 0 0 1-.1.362L11 14a4.3 4.3 0 0 1-1.588 2.175A4.258 4.258 0 0 1 6.85 17Z" fill="currentColor"/></svg>',
457
+ ruler:
458
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m8.8 10.95 2.15-2.175-1.4-1.425-1.1 1.1-1.4-1.4 1.075-1.1L7 4.825 4.825 7 8.8 10.95Zm8.2 8.225L19.175 17l-1.125-1.125-1.1 1.075-1.4-1.4 1.075-1.1-1.425-1.4-2.15 2.15L17 19.175ZM7.25 21H3v-4.25l4.375-4.375L2 7l5-5 5.4 5.4 3.775-3.8c.2-.2.425-.35.675-.45a2.068 2.068 0 0 1 1.55 0c.25.1.475.25.675.45L20.4 4.95c.2.2.35.425.45.675.1.25.15.508.15.775a1.975 1.975 0 0 1-.6 1.425l-3.775 3.8L22 17l-5 5-5.375-5.375L7.25 21ZM5 19h1.4l9.8-9.775L14.775 7.8 5 17.6V19Z" fill="currentColor"/></svg>'
459
+ };
460
+ return iconMap[iconName] || null;
461
+ }
303
462
 
304
463
  function syncRenderedToolsState() {
305
464
  const nextActiveState: Record<string, boolean> = {};
@@ -370,41 +529,81 @@
370
529
  {#if isBrowser}
371
530
  <div
372
531
  class="item-toolbar {className} item-toolbar--{size}"
532
+ class:item-toolbar--top={position === 'top'}
533
+ class:item-toolbar--right={position === 'right'}
534
+ class:item-toolbar--bottom={position === 'bottom'}
535
+ class:item-toolbar--left={position === 'left'}
373
536
  data-content-kind={effectiveContentKind}
537
+ data-level={effectiveLevel}
374
538
  bind:this={toolbarRootElement}
375
539
  >
376
- {#each renderedTools as renderedTool (renderedTool.toolId)}
377
- {#if renderedTool.inlineElement}
378
- <span class="item-toolbar__element-host" use:mountElement={renderedTool.inlineElement}></span>
379
- {/if}
540
+ {#each mountedElementsBeforeButtons as mountedElement (`before-${mountedElement.tagName}-${mountedElement.getAttribute('tool-id') || ''}`)}
541
+ <span class="item-toolbar__element-host" use:mountElement={mountedElement}></span>
542
+ {/each}
380
543
 
381
- {#if renderedTool.button}
544
+ {#each toolbarItems as item (item.id)}
545
+ {#if isToolbarLinkItem(item)}
546
+ <a
547
+ class="item-toolbar__button"
548
+ class:item-toolbar__button--active={isToolbarItemActive(item)}
549
+ href={item.disabled ? undefined : item.href}
550
+ target={item.target}
551
+ rel={item.rel}
552
+ aria-label={item.ariaLabel || item.label}
553
+ title={item.tooltip || item.label}
554
+ aria-disabled={item.disabled ? 'true' : undefined}
555
+ onclick={(event) => {
556
+ if (item.disabled) {
557
+ event.preventDefault();
558
+ }
559
+ }}
560
+ >
561
+ {#if item.icon}
562
+ {#if isInlineSvgIcon(item.icon)}
563
+ {@html item.icon}
564
+ {:else if isExternalIconUrl(item.icon)}
565
+ <img class="item-toolbar__icon-image" src={item.icon} alt="" />
566
+ {:else}
567
+ {@const fallbackIcon = getFallbackIconSvg(item.icon)}
568
+ {#if fallbackIcon}
569
+ {@html fallbackIcon}
570
+ {:else}
571
+ <i class={`icon icon-${item.icon}`} aria-hidden="true"></i>
572
+ {/if}
573
+ {/if}
574
+ {/if}
575
+ </a>
576
+ {:else}
382
577
  <button
383
578
  type="button"
384
- class="item-toolbar__button {renderedTool.button.className || ''}"
385
- class:item-toolbar__button--active={activeToolState[renderedTool.toolId] ?? renderedTool.button.active ?? false}
386
- onclick={() => {
387
- renderedTool.button?.onClick();
388
- syncRenderedToolsState();
389
- }}
390
- aria-label={renderedTool.button.ariaLabel || renderedTool.button.label}
391
- aria-pressed={activeToolState[renderedTool.toolId] ?? renderedTool.button.active ?? false}
392
- title={renderedTool.button.tooltip || renderedTool.button.label}
393
- disabled={renderedTool.button.disabled}
579
+ class="item-toolbar__button"
580
+ class:item-toolbar__button--active={isToolbarItemActive(item)}
581
+ onclick={item.onClick}
582
+ aria-label={item.ariaLabel || item.label}
583
+ aria-pressed={isToolbarItemActive(item)}
584
+ title={item.tooltip || item.label}
585
+ disabled={item.disabled}
394
586
  >
395
- {#if renderedTool.button.icon.startsWith('<svg')}
396
- {@html renderedTool.button.icon}
397
- {:else if renderedTool.button.icon.startsWith('http')}
398
- <img class="item-toolbar__icon-image" src={renderedTool.button.icon} alt="" />
399
- {:else}
400
- <i class={`icon icon-${renderedTool.button.icon}`} aria-hidden="true"></i>
587
+ {#if item.icon}
588
+ {#if isInlineSvgIcon(item.icon)}
589
+ {@html item.icon}
590
+ {:else if isExternalIconUrl(item.icon)}
591
+ <img class="item-toolbar__icon-image" src={item.icon} alt="" />
592
+ {:else}
593
+ {@const fallbackIcon = getFallbackIconSvg(item.icon)}
594
+ {#if fallbackIcon}
595
+ {@html fallbackIcon}
596
+ {:else}
597
+ <i class={`icon icon-${item.icon}`} aria-hidden="true"></i>
598
+ {/if}
599
+ {/if}
401
600
  {/if}
402
601
  </button>
403
602
  {/if}
603
+ {/each}
404
604
 
405
- {#if renderedTool.overlayElement}
406
- <span class="item-toolbar__element-host" use:mountElement={renderedTool.overlayElement}></span>
407
- {/if}
605
+ {#each mountedElementsAfterButtons as mountedElement (`after-${mountedElement.tagName}-${mountedElement.getAttribute('tool-id') || ''}`)}
606
+ <span class="item-toolbar__element-host" use:mountElement={mountedElement}></span>
408
607
  {/each}
409
608
  </div>
410
609
  {/if}
@@ -416,6 +615,18 @@
416
615
  gap: 0.5rem;
417
616
  }
418
617
 
618
+ .item-toolbar--top,
619
+ .item-toolbar--bottom {
620
+ flex-direction: row;
621
+ flex-wrap: wrap;
622
+ }
623
+
624
+ .item-toolbar--left,
625
+ .item-toolbar--right {
626
+ flex-direction: column;
627
+ align-items: stretch;
628
+ }
629
+
419
630
  .item-toolbar__button {
420
631
  display: flex;
421
632
  align-items: center;
@@ -429,6 +640,7 @@
429
640
  color: var(--pie-text, #333);
430
641
  cursor: pointer;
431
642
  transition: all 0.15s ease;
643
+ text-decoration: none;
432
644
  }
433
645
 
434
646
  .item-toolbar__element-host {