@meistrari/tela-build 1.26.0 → 1.27.1

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.
@@ -22,7 +22,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
22
22
  <MenubarItem
23
23
  v-bind="forwarded"
24
24
  :class="cn(
25
- 'relative flex cursor-pointer select-none items-center rounded-xl px-3 py-1.5 text-body-14-medium font-460 outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-40',
25
+ 'relative flex cursor-pointer select-none items-center rounded-xl px-3 py-1.5 text-body-14-medium font-460 outline-none focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-40',
26
26
  inset && 'pl-8',
27
27
  props.class,
28
28
  )"
@@ -17,7 +17,7 @@ const forwardedProps = useForwardProps(delegatedProps)
17
17
  v-bind="forwardedProps"
18
18
  :class="
19
19
  cn(
20
- 'flex items-center gap-2 cursor-pointer select-none px-2 py-1.5 text-sm font-medium outline-none rounded-lg hover:bg-gray-200 data-[state=open]:bg-gray-200',
20
+ 'flex items-center gap-2 cursor-pointer select-none px-2 py-1.5 text-sm font-medium outline-none rounded-lg hover:bg-lowered data-[state=open]:bg-lowered',
21
21
  props.class,
22
22
  )
23
23
  "
@@ -330,3 +330,139 @@ This is useful when you want to prevent fullscreen functionality in certain moda
330
330
  - Labels and placeholders support screen readers
331
331
  - PDF controls (zoom, page, fullscreen) are keyboard-accessible
332
332
  - Error and loading states are communicated in the content area
333
+
334
+ ## Citation Highlighting
335
+
336
+ When a user clicks on a field in a structured output, the PDF preview navigates to the correct page and highlights the exact text that the LLM cited as the source.
337
+
338
+ - `resolveCitationReferences(citations, files)` — resolves `attachment_index` to file URLs
339
+ - `useCitationNavigation({ citations })` — manages highlight state
340
+ - `usePdfLoader()` — creates PDF document handle for rendering
341
+ - `CitationTarget` interface — `{ file, page, literal?, rationale? }`
342
+
343
+ ### Citation Data Flow
344
+
345
+ ```
346
+ LLM returns raw citations (attachment_index + page + literal)
347
+
348
+ resolveCitationReferences(rawCitations, files)
349
+ → replaces attachment_index with file URL from files array
350
+
351
+ useCitationNavigation({ citations: resolvedCitations })
352
+ → provides navigateToCitation(path, value), clearCitation()
353
+ → exposes reactive refs: highlightText, highlightPage, highlightExact, activeFile
354
+
355
+ User clicks a field with citation
356
+ → navigateToCitation("fieldPath")
357
+ → sets activeFile, highlightPage (page + 1), highlightText (literal), highlightExact (true)
358
+
359
+ TelaPreview receives highlight props
360
+ → renders PDF page with yellow highlight on matching text
361
+ ```
362
+
363
+ ### Raw Citation Format (from LLM)
364
+
365
+ ```json
366
+ {
367
+ "companyName": {
368
+ "attachment_index": 0,
369
+ "page": 0,
370
+ "literal": "ACME Corporation Ltd."
371
+ },
372
+ "items": [
373
+ {
374
+ "attachment_index": 0,
375
+ "page": 2,
376
+ "literal": "Five year term agreement"
377
+ }
378
+ ]
379
+ }
380
+ ```
381
+
382
+ - `attachment_index`: 0-based index into the files array
383
+ - `page`: 0-based page index (first page = 0)
384
+ - `literal`: exact text span copied from the source document
385
+
386
+ ### Citation Usage Example
387
+
388
+ ```vue
389
+ <script setup lang="ts">
390
+ import { resolveCitationReferences } from '@meistrari/tela-build/utils/citations'
391
+
392
+ // 1. Resolve citations: map attachment_index → file URL
393
+ const files = ['vault://abc-123', 'vault://def-456']
394
+ const citations = resolveCitationReferences(rawCitations, files)
395
+
396
+ // 2. Setup composables
397
+ const { pdfLoader } = usePdfLoader()
398
+ const {
399
+ highlightText,
400
+ highlightPage,
401
+ highlightExact,
402
+ activeFile,
403
+ navigateToCitation,
404
+ clearCitation,
405
+ } = useCitationNavigation({
406
+ citations: computed(() => citations),
407
+ })
408
+
409
+ // 3. Compute active citation for the preview
410
+ const activeCitation = computed(() => {
411
+ if (!activeFile.value || highlightPage.value == null)
412
+ return null
413
+ return {
414
+ file: activeFile.value,
415
+ page: highlightPage.value,
416
+ text: highlightText.value ?? undefined,
417
+ }
418
+ })
419
+
420
+ // 4. Switch to the cited file when a citation is clicked
421
+ watch(activeCitation, (citation) => {
422
+ if (!citation) return
423
+ const match = fileOptions.find(opt => opt.value === citation.file)
424
+ if (match)
425
+ selectedVariable.value = match.variable
426
+ })
427
+
428
+ // 5. Handle citation clicks from your output viewer
429
+ function onCitationClick(path: string, value?: any) {
430
+ navigateToCitation(path, value)
431
+ }
432
+ </script>
433
+
434
+ <template>
435
+ <TelaPreview
436
+ :model-value="selectedVariable"
437
+ :file-options="fileOptions"
438
+ :current-file="currentFile"
439
+ :pdf-loader="pdfLoader"
440
+ :highlight-text="activeCitation?.text ?? null"
441
+ :highlight-page="activeCitation?.page ?? null"
442
+ :highlight-exact="!!activeCitation"
443
+ @update:model-value="onVariableChange"
444
+ />
445
+ </template>
446
+ ```
447
+
448
+ ### How the Highlight Works Internally
449
+
450
+ Two modes in the PDF text layer renderer:
451
+
452
+ **Exact mode** (citations, `highlightExact = true`):
453
+ 1. Concatenates all PDF text items on the page into a single string
454
+ 2. Normalizes both the literal and the concatenated text (lowercase, strip accents, collapse whitespace, strip invisible Unicode)
455
+ 3. Searches for the literal as a contiguous substring
456
+ 4. If exact match fails (PDF text extraction lost characters), falls back to a sliding window of 3 consecutive words
457
+ 5. Highlights matching text items with yellow background
458
+
459
+ **Fuzzy mode** (search, `highlightExact = false`):
460
+ 1. Splits highlight text into words (>= 3 chars)
461
+ 2. Each text item that contains or is contained by any word gets highlighted
462
+
463
+ ### Citation Key Details
464
+
465
+ - **Page numbering**: `page` in citations is 0-based. `useCitationNavigation` adds 1 internally for the 1-based PDF viewer.
466
+ - **`path` format**: dot-separated path matching the citations structure. Examples: `"companyName"`, `"items.0"`, `"nested.field.name"`.
467
+ - **Clearing**: call `clearCitation()` when switching context (e.g. changing tasks, closing modals).
468
+ - **File matching**: the `file` value after resolution must match what `TelaPreview` uses to identify files in `fileOptions`.
@@ -0,0 +1,10 @@
1
+ <template>
2
+ <div
3
+ flex flex-col h-full overflow-y-auto no-scrollbar
4
+ class="[mask-image:linear-gradient(to_bottom,transparent,black_20px,black_calc(100%_-_20px),transparent)]"
5
+ >
6
+ <nav flex flex-col items-center gap-14px h-full px-10px pt-12px mb-48px>
7
+ <slot />
8
+ </nav>
9
+ </div>
10
+ </template>
@@ -0,0 +1,5 @@
1
+ <template>
2
+ <footer flex flex-col items-center justify-center gap-22px pb-24px>
3
+ <slot />
4
+ </footer>
5
+ </template>
@@ -0,0 +1,5 @@
1
+ <template>
2
+ <header sticky top-0 z-10 flex flex-col items-center justify-center gap-24px pt-24px pb-12px>
3
+ <slot />
4
+ </header>
5
+ </template>
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ import { NuxtLink } from '#components'
3
+
4
+ const props = defineProps<{
5
+ icon: string
6
+ label: string
7
+ to?: string
8
+ onClick?: () => void
9
+ isActive: boolean
10
+ }>()
11
+
12
+ const iconName = computed(() => props.isActive ? `${props.icon}-fill` : props.icon)
13
+ </script>
14
+
15
+ <template>
16
+ <component
17
+ :is="to ? NuxtLink : 'button'"
18
+ :to="to"
19
+ :type="!to ? 'button' : undefined"
20
+ class="group"
21
+ flex="~ col" items-center justify-center gap-2px outline-none
22
+ :data-active="isActive"
23
+ v-bind="!to && onClick ? { onClick } : {}"
24
+ >
25
+ <div relative size-40px flex items-center justify-center rounded-10px>
26
+ <TelaIcon
27
+ :name="iconName"
28
+ size="20px"
29
+ relative z-1
30
+ :color="isActive ? 'icon' : 'icon-tertiary duration-150 ease-out group-hover:icon group-focus-within:icon'"
31
+ />
32
+ <div
33
+ :class="cn(
34
+ 'absolute inset-0 size-full rounded-[14px] z-0 border-[0.5px] border-transparent',
35
+ isActive ? 'bg-neutral-200 group-focus-within:border-strong' : 'bg scale-10 opacity-0 duration-150 ease-out origin-center group-hover:border-strong group-hover:scale-100 group-hover:opacity-100 group-focus-within:border-strong group-focus-within:scale-100 group-focus-within:opacity-100',
36
+ )"
37
+ />
38
+ </div>
39
+ <p
40
+ :class="cn(
41
+ 'text-[11px] leading-[12px] -tracking-0.2px',
42
+ isActive ? 'text-primary font-550' : 'font-460 text-tertiary duration-150 ease-out group-hover:text-primary group-focus-within:text-primary',
43
+ )"
44
+ >
45
+ {{ label }}
46
+ </p>
47
+ </component>
48
+ </template>
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ import { NuxtLink } from '#components'
3
+
4
+ defineProps<{
5
+ src?: string
6
+ alt: string
7
+ to?: string
8
+ }>()
9
+ </script>
10
+
11
+ <template>
12
+ <component
13
+ :is="to ? NuxtLink : 'div'"
14
+ :to="to"
15
+ class="rounded-12px overflow-hidden outline-none focus-within:ring-0.5px focus-within:ring-border-strong"
16
+ >
17
+ <img v-if="src" :src="src" :alt="alt" w-40px h-40px object-cover>
18
+ <TelaInitials v-else-if="alt" size="md" :word="alt" />
19
+ <div v-else w-40px h-40px bg-black />
20
+ </component>
21
+ </template>
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ image?: string
4
+ name: string
5
+ email: string
6
+ actions: {
7
+ label: string
8
+ icon: string
9
+ color: 'positive' | 'caution' | 'negative'
10
+ onClick: () => void
11
+ }[]
12
+ }>()
13
+ </script>
14
+
15
+ <template>
16
+ <TelaDropdownMenuRoot>
17
+ <TelaDropdownMenuTrigger as-child>
18
+ <button
19
+ :class="cn('group', image ? 'ring-transparent focus-within:ring-border-strong' : 'ring-border-strong focus-within:ring-border-accent')"
20
+ rounded-full overflow-hidden ring-0.5px
21
+ >
22
+ <img v-if="image" :src="image" :alt="name" w-32px h-32px object-cover>
23
+ <div v-else w-32px h-32px bg />
24
+ </button>
25
+ </TelaDropdownMenuTrigger>
26
+ <TelaDropdownMenuContent align="start" side="top" class="min-w-200px!">
27
+ <div px-12px py-8px>
28
+ <h5 heading-h5-semibold text-primary mb-2px>
29
+ {{ name }}
30
+ </h5>
31
+ <p body-12-regular text-secondary>
32
+ {{ email }}
33
+ </p>
34
+ </div>
35
+ <TelaDropdownMenuSeparator />
36
+ <TelaDropdownMenuItem
37
+ v-for="action in actions"
38
+ :key="action.label"
39
+ :icon="action.icon"
40
+ :color="action.color"
41
+ @click="action.onClick"
42
+ >
43
+ {{ action.label }}
44
+ </TelaDropdownMenuItem>
45
+ </TelaDropdownMenuContent>
46
+ </TelaDropdownMenuRoot>
47
+ </template>
@@ -0,0 +1,175 @@
1
+ import { Meta, Canvas, ArgTypes } from '@storybook/blocks';
2
+ import * as SidebarStories from './sidebar.stories.ts';
3
+
4
+ <Meta of={SidebarStories} />
5
+
6
+ # TelaSidebar
7
+
8
+ A composable sidebar navigation system built from focused sub-components. Fixed 80px wide and full-height, designed for icon-based navigation with labels.
9
+
10
+ ## Examples
11
+
12
+ ### Full Sidebar
13
+
14
+ ```vue
15
+ <TelaSidebar>
16
+ <TelaSidebarHeader>
17
+ <TelaSidebarLogo src="/tela-logo-black.svg" alt="Tela Logo" />
18
+ </TelaSidebarHeader>
19
+
20
+ <TelaSidebarContent>
21
+ <TelaSidebarItem
22
+ v-for="item in items"
23
+ :key="item.label"
24
+ :icon="item.icon"
25
+ :label="item.label"
26
+ :to="item.path"
27
+ :is-active="isItemActive(item)"
28
+ :on-click="item.onClick"
29
+ />
30
+ </TelaSidebarContent>
31
+
32
+ <TelaSidebarFooter>
33
+ <TelaSidebarItem
34
+ icon="i-ph-bell"
35
+ label="Activity"
36
+ :is-active="route.path === '/activity'"
37
+ />
38
+ <TelaSidebarUser
39
+ :image="user.image"
40
+ :name="user.name"
41
+ :email="user.email"
42
+ :actions="[
43
+ { label: 'Logout', icon: 'i-ph-sign-out', color: 'negative', onClick: handleLogout },
44
+ ]"
45
+ />
46
+ </TelaSidebarFooter>
47
+ </TelaSidebar>
48
+ ```
49
+
50
+ <Canvas of={SidebarStories.Default} />
51
+
52
+ ### Activity Active
53
+
54
+ <Canvas of={SidebarStories.ActivityActive} />
55
+
56
+ ### No Active Item
57
+
58
+ <Canvas of={SidebarStories.NoActiveItem} />
59
+
60
+ ### Individual Item States
61
+
62
+ <Canvas of={SidebarStories.SingleItem} />
63
+
64
+ ## Components
65
+
66
+ ### `<TelaSidebar>`
67
+
68
+ Root wrapper. Renders as an `<aside>` with fixed 80px width, full height, and a right border. Place `TelaSidebarHeader`, `TelaSidebarContent`, and `TelaSidebarFooter` as direct children.
69
+
70
+ ### `<TelaSidebarHeader>`
71
+
72
+ Top section of the sidebar. Stacks children vertically with 24px gap and vertical padding. Use for logos or workspace switchers.
73
+
74
+ ### `<TelaSidebarLogo>`
75
+
76
+ Displays a logo or fallback inside the header. When `src` is provided, renders an image. When only `alt` is provided, falls back to `TelaInitials`. If neither is set, renders a placeholder div.
77
+
78
+ ```typescript
79
+ interface TelaSidebarLogoProps {
80
+ src?: string // Image source URL or path — when provided, renders an <img>
81
+ alt: string // Accessible alt text — used as the word for TelaInitials fallback
82
+ to?: string // Optional link — wraps the content in a NuxtLink when provided
83
+ }
84
+ ```
85
+
86
+ ### `<TelaSidebarContent>`
87
+
88
+ Middle section that grows to fill available space. Scrollable with hidden scrollbar and a fade mask at the top and bottom edges for a smooth overflow effect. Place nav items directly as children — they stack vertically with 14px gap.
89
+
90
+ ### `<TelaSidebarItem>`
91
+
92
+ An individual navigation item with an icon and label. Renders as a `NuxtLink` when `to` is provided, otherwise as a `<button>`.
93
+
94
+ ```typescript
95
+ interface TelaSidebarItemProps {
96
+ icon: string // Iconify class (e.g. "i-ph-house")
97
+ label: string // Text label shown below the icon
98
+ isActive: boolean // Highlights the item as the current route
99
+ to?: string // Route path — renders as a link
100
+ onClick?: () => void // Click handler when not using `to`
101
+ }
102
+ ```
103
+
104
+ <ArgTypes of={SidebarStories} />
105
+
106
+ ### `<TelaSidebarFooter>`
107
+
108
+ Bottom section of the sidebar. Always contains `TelaSidebarUser` for account controls. Optionally add secondary `TelaSidebarItem` actions (e.g. activity, notifications) above it.
109
+
110
+ Default — user only:
111
+
112
+ ```vue
113
+ <TelaSidebarFooter>
114
+ <TelaSidebarUser
115
+ :avatar-url="user.avatarUrl"
116
+ :username="user.name"
117
+ :email="user.email"
118
+ :options="[
119
+ { label: 'Logout', icon: 'i-ph-sign-out', color: 'negative', onClick: handleLogout },
120
+ ]"
121
+ />
122
+ </TelaSidebarFooter>
123
+ ```
124
+
125
+ With secondary actions:
126
+
127
+ ```vue
128
+ <TelaSidebarFooter>
129
+ <TelaSidebarItem
130
+ icon="i-ph-bell"
131
+ label="Activity"
132
+ :is-active="route.path === '/activity'"
133
+ to="/activity"
134
+ />
135
+ <TelaSidebarUser
136
+ :avatar-url="user.avatarUrl"
137
+ :username="user.name"
138
+ :email="user.email"
139
+ :options="[
140
+ { label: 'Logout', icon: 'i-ph-sign-out', color: 'negative', onClick: handleLogout },
141
+ ]"
142
+ />
143
+ </TelaSidebarFooter>
144
+ ```
145
+
146
+ ### `<TelaSidebarUser>`
147
+
148
+ User account button in the footer. Clicking opens a dropdown menu with the user's name, email, and configurable action items.
149
+
150
+ ```typescript
151
+ interface TelaSidebarUserProps {
152
+ image?: string // Optional — falls back to a placeholder if not provided
153
+ name: string
154
+ email: string
155
+ actions: {
156
+ label: string
157
+ icon: string
158
+ color: 'positive' | 'caution' | 'negative'
159
+ onClick: () => void
160
+ }[]
161
+ }
162
+ ```
163
+
164
+ Example:
165
+
166
+ ```vue
167
+ <TelaSidebarUser
168
+ image="https://example.com/avatar.jpg"
169
+ name="Jane Doe"
170
+ email="jane@example.com"
171
+ :actions="[
172
+ { label: 'Logout', icon: 'i-ph-sign-out', color: 'negative', onClick: handleLogout },
173
+ ]"
174
+ />
175
+ ```
@@ -0,0 +1,190 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3'
2
+ import TelaSidebar from './sidebar.vue'
3
+ import TelaSidebarHeader from './sidebar-header.vue'
4
+ import TelaSidebarContent from './sidebar-content.vue'
5
+ import TelaSidebarLogo from './sidebar-logo.vue'
6
+ import TelaSidebarItem from './sidebar-item.vue'
7
+ import TelaSidebarFooter from './sidebar-footer.vue'
8
+ import TelaSidebarUser from './sidebar-user.vue'
9
+
10
+ const meta: Meta<typeof TelaSidebar> = {
11
+ title: 'Patterns/Sidebar',
12
+ component: TelaSidebar,
13
+ parameters: {
14
+ layout: 'fullscreen',
15
+ docs: {
16
+ description: {
17
+ component: 'A composable sidebar navigation system built from focused sub-components. Fixed 80px wide and full-height, designed for icon-based navigation with labels. Compose `TelaSidebarHeader`, `TelaSidebarContent`, and `TelaSidebarFooter` inside the root `TelaSidebar` to build the full layout.',
18
+ },
19
+ },
20
+ },
21
+ }
22
+
23
+ export default meta
24
+
25
+ type Story = StoryObj<typeof TelaSidebar>
26
+
27
+ const navItems = [
28
+ { icon: 'i-ph-house', label: 'Home', path: '/' },
29
+ { icon: 'i-ph-graph', label: 'Workflows', path: '/workflows' },
30
+ { icon: 'i-ph-database', label: 'Data', path: '/data' },
31
+ { icon: 'i-ph-gear', label: 'Settings', path: '/settings' },
32
+ ]
33
+
34
+ const userActions = [
35
+ { label: 'Logout', icon: 'i-ph-sign-out', color: 'negative' as const, onClick: () => {} },
36
+ ]
37
+
38
+ const components = {
39
+ TelaSidebar,
40
+ TelaSidebarHeader,
41
+ TelaSidebarLogo,
42
+ TelaSidebarContent,
43
+ TelaSidebarItem,
44
+ TelaSidebarFooter,
45
+ TelaSidebarUser,
46
+ }
47
+
48
+ export const Default: Story = {
49
+ render: () => ({
50
+ components,
51
+ setup() {
52
+ const items = navItems
53
+ const activePath = '/workflows'
54
+ const isItemActive = (item: { path: string }) => activePath === item.path
55
+ return { items, isItemActive, userActions }
56
+ },
57
+ template: `
58
+ <TelaSidebar>
59
+ <TelaSidebarHeader>
60
+ <TelaSidebarLogo src="/tela-logo-black.svg" alt="Tela Logo" />
61
+ </TelaSidebarHeader>
62
+
63
+ <TelaSidebarContent>
64
+ <TelaSidebarItem
65
+ v-for="item in items"
66
+ :key="item.label"
67
+ :icon="item.icon"
68
+ :label="item.label"
69
+ :to="item.path"
70
+ :is-active="isItemActive(item)"
71
+ />
72
+ <TelaSidebarItem
73
+ icon="i-ph-bell"
74
+ label="Activity"
75
+ :is-active="false"
76
+ />
77
+ </TelaSidebarContent>
78
+
79
+ <TelaSidebarFooter>
80
+ <TelaSidebarUser
81
+ name="Username"
82
+ email="user@example.com"
83
+ :actions="userActions"
84
+ />
85
+ </TelaSidebarFooter>
86
+ </TelaSidebar>
87
+ `,
88
+ }),
89
+ }
90
+
91
+ export const NoActiveItem: Story = {
92
+ render: () => ({
93
+ components,
94
+ setup() {
95
+ return { items: navItems, userActions }
96
+ },
97
+ template: `
98
+ <TelaSidebar>
99
+ <TelaSidebarHeader>
100
+ <TelaSidebarLogo src="/tela-logo-black.svg" alt="Tela Logo" />
101
+ </TelaSidebarHeader>
102
+
103
+ <TelaSidebarContent>
104
+ <TelaSidebarItem
105
+ v-for="item in items"
106
+ :key="item.label"
107
+ :icon="item.icon"
108
+ :label="item.label"
109
+ :to="item.path"
110
+ :is-active="false"
111
+ />
112
+ <TelaSidebarItem
113
+ icon="i-ph-bell"
114
+ label="Activity"
115
+ :is-active="false"
116
+ />
117
+ </TelaSidebarContent>
118
+
119
+ <TelaSidebarFooter>
120
+ <TelaSidebarUser
121
+ name="Username"
122
+ email="user@example.com"
123
+ :actions="userActions"
124
+ />
125
+ </TelaSidebarFooter>
126
+ </TelaSidebar>
127
+ `,
128
+ }),
129
+ }
130
+
131
+ export const ActivityActive: Story = {
132
+ render: () => ({
133
+ components,
134
+ setup() {
135
+ return { items: navItems, userActions }
136
+ },
137
+ template: `
138
+ <TelaSidebar>
139
+ <TelaSidebarHeader>
140
+ <TelaSidebarLogo src="/tela-logo-black.svg" alt="Tela Logo" />
141
+ </TelaSidebarHeader>
142
+
143
+ <TelaSidebarContent>
144
+ <TelaSidebarItem
145
+ v-for="item in items"
146
+ :key="item.label"
147
+ :icon="item.icon"
148
+ :label="item.label"
149
+ :to="item.path"
150
+ :is-active="false"
151
+ />
152
+ <TelaSidebarItem
153
+ icon="i-ph-bell"
154
+ label="Activity"
155
+ :is-active="true"
156
+ />
157
+ </TelaSidebarContent>
158
+
159
+ <TelaSidebarFooter>
160
+ <TelaSidebarUser
161
+ name="Username"
162
+ email="user@example.com"
163
+ :actions="userActions"
164
+ />
165
+ </TelaSidebarFooter>
166
+ </TelaSidebar>
167
+ `,
168
+ }),
169
+ }
170
+
171
+ export const SingleItem: Story = {
172
+ parameters: {
173
+ layout: 'centered',
174
+ docs: {
175
+ description: {
176
+ story: 'Individual `TelaSidebarItem` in active and inactive states.',
177
+ },
178
+ },
179
+ },
180
+ render: () => ({
181
+ components: { TelaSidebarItem },
182
+ template: `
183
+ <div style="display: flex; gap: 24px; align-items: center;">
184
+ <TelaSidebarItem icon="i-ph-house" label="Home" :is-active="false" />
185
+ <TelaSidebarItem icon="i-ph-graph" label="Workflows" :is-active="true" />
186
+ <TelaSidebarItem icon="i-ph-bell" label="Activity" :is-active="false" />
187
+ </div>
188
+ `,
189
+ }),
190
+ }
@@ -0,0 +1,5 @@
1
+ <template>
2
+ <aside flex flex-col items-center w-80px h-screen bg border-r-0.5px border>
3
+ <slot />
4
+ </aside>
5
+ </template>
@@ -1,4 +1,4 @@
1
- import type { MaybeRefOrGetter, Ref } from 'vue'
1
+ import type { MaybeRefOrGetter } from 'vue'
2
2
  import { nextTick, ref, toValue } from 'vue'
3
3
 
4
4
  export interface CitationTarget {
@@ -61,7 +61,7 @@ export function useCitationNavigation(options: {
61
61
  highlightExact.value = false
62
62
  activeFile.value = null
63
63
 
64
- nextTick(() => {
64
+ void nextTick(() => {
65
65
  activeFile.value = citation.file
66
66
  highlightPage.value = citation.page + 1
67
67
  highlightText.value = citation.literal
@@ -78,10 +78,10 @@ export function useCitationNavigation(options: {
78
78
  }
79
79
 
80
80
  return {
81
- highlightText: highlightText as Ref<string | null>,
82
- highlightPage: highlightPage as Ref<number | null>,
83
- highlightExact: highlightExact as Ref<boolean>,
84
- activeFile: activeFile as Ref<string | null>,
81
+ highlightText,
82
+ highlightPage,
83
+ highlightExact,
84
+ activeFile,
85
85
  lookupCitation,
86
86
  navigateToCitation,
87
87
  clearCitation,
@@ -0,0 +1,56 @@
1
+ import { onUnmounted, ref } from 'vue'
2
+ import type { PdfDocumentHandle } from '../components/tela/preview/types'
3
+ import { usePdf } from './use-pdf'
4
+
5
+ export function usePdfLoader() {
6
+ const pdfUrlRef = ref('')
7
+ const { loadPdf, renderPage, getPageDimensions } = usePdf(pdfUrlRef)
8
+ let currentPdfDoc: Awaited<ReturnType<typeof loadPdf>> = null
9
+
10
+ async function pdfLoader(url: string): Promise<PdfDocumentHandle | null> {
11
+ if (currentPdfDoc) {
12
+ void currentPdfDoc.destroy()
13
+ currentPdfDoc = null
14
+ }
15
+
16
+ pdfUrlRef.value = url
17
+
18
+ const doc = await loadPdf()
19
+
20
+ if (!doc)
21
+ return null
22
+
23
+ currentPdfDoc = doc
24
+
25
+ const capturedDoc = doc
26
+ return {
27
+ numPages: doc.numPages,
28
+ async renderPage(opts: {
29
+ pageNum: number
30
+ canvas: HTMLCanvasElement
31
+ scale: number
32
+ textLayer?: HTMLDivElement | null
33
+ highlight?: string | null
34
+ highlightPage?: number | null
35
+ highlightExact?: boolean
36
+ }) {
37
+ return await renderPage(opts)
38
+ },
39
+ async getPageDimensions(opts: { pageNum: number, scale: number }) {
40
+ return await getPageDimensions(opts)
41
+ },
42
+ destroy() {
43
+ if (capturedDoc === currentPdfDoc) {
44
+ void capturedDoc.destroy()
45
+ currentPdfDoc = null
46
+ }
47
+ },
48
+ }
49
+ }
50
+
51
+ onUnmounted(() => {
52
+ currentPdfDoc = null
53
+ })
54
+
55
+ return { pdfLoader }
56
+ }
@@ -0,0 +1,385 @@
1
+ import * as pdfjsLib from 'pdfjs-dist'
2
+ import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist'
3
+ import type { Ref } from 'vue'
4
+ import { markRaw, onUnmounted, reactive, watch } from 'vue'
5
+
6
+ if (!pdfjsLib.GlobalWorkerOptions.workerSrc) {
7
+ pdfjsLib.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`
8
+ }
9
+
10
+ function normalizeForMatch(text: string): string {
11
+ return text
12
+ .toLowerCase()
13
+ .normalize('NFD')
14
+ .replace(/[\u0300-\u036F]/gu, '')
15
+ .replace(/[\0\u00AD\u200B\uFEFF]/g, '')
16
+ .replace(/[\u200C\u200D]/gu, '')
17
+ .replace(/\s+/g, ' ')
18
+ .trim()
19
+ }
20
+
21
+ export interface PdfState {
22
+ pdfDoc: PDFDocumentProxy | null
23
+ totalPages: number
24
+ isLoading: boolean
25
+ loadError: string | null
26
+ }
27
+
28
+ export interface RenderPageOptions {
29
+ pageNum: number
30
+ canvas: HTMLCanvasElement
31
+ textLayer?: HTMLDivElement | null
32
+ scale: number
33
+ highlight?: string | null
34
+ highlightPage?: number | null
35
+ /** When true, uses exact matching instead of fuzzy word-based matching */
36
+ highlightExact?: boolean
37
+ }
38
+
39
+ export function usePdf(url: Ref<string>) {
40
+ const state = reactive<PdfState>({
41
+ pdfDoc: null,
42
+ totalPages: 0,
43
+ isLoading: true,
44
+ loadError: null,
45
+ })
46
+
47
+ let isUnmounted = false
48
+ const activeRenderTasks = new Map<number, RenderTask>()
49
+ let isExplicitlyLoading = false
50
+ const activeCanvasRenders = new WeakMap<
51
+ HTMLCanvasElement,
52
+ {
53
+ promise: Promise<void>
54
+ cancel: () => void
55
+ pageNum: number
56
+ scale: number
57
+ hasTextLayer: boolean
58
+ highlight: string | null
59
+ highlightPage: number | null
60
+ highlightExact: boolean
61
+ }
62
+ >()
63
+
64
+ async function loadPdf(): Promise<PDFDocumentProxy | null> {
65
+ isExplicitlyLoading = true
66
+ if (isUnmounted)
67
+ return null
68
+
69
+ state.isLoading = true
70
+ state.loadError = null
71
+
72
+ try {
73
+ const loadingTask = pdfjsLib.getDocument(url.value)
74
+ const doc = await loadingTask.promise
75
+
76
+ if (isUnmounted) {
77
+ void doc.destroy()
78
+ return null
79
+ }
80
+
81
+ state.pdfDoc = markRaw(doc)
82
+ state.totalPages = doc.numPages
83
+ state.isLoading = false
84
+ isExplicitlyLoading = false
85
+
86
+ return doc
87
+ }
88
+ catch (err: any) {
89
+ isExplicitlyLoading = false
90
+ if (isUnmounted)
91
+ return null
92
+
93
+ console.error('PDF load error:', err)
94
+ state.loadError = err?.message || 'Failed to load PDF'
95
+ state.isLoading = false
96
+
97
+ return null
98
+ }
99
+ }
100
+
101
+ async function renderPage(options: RenderPageOptions): Promise<void> {
102
+ if (isUnmounted || !state.pdfDoc)
103
+ return
104
+
105
+ const { pageNum, canvas, textLayer, scale, highlight, highlightPage, highlightExact } = options
106
+ let currentRenderPromise: Promise<void> | null = null
107
+
108
+ try {
109
+ const previousTask = activeRenderTasks.get(pageNum)
110
+ if (previousTask) {
111
+ previousTask.cancel()
112
+ activeRenderTasks.delete(pageNum)
113
+ }
114
+
115
+ const page = await state.pdfDoc.getPage(pageNum)
116
+
117
+ if (isUnmounted)
118
+ return
119
+
120
+ const viewport = page.getViewport({ scale })
121
+ const context = canvas.getContext('2d')
122
+ if (!context)
123
+ return
124
+
125
+ const existingRender = activeCanvasRenders.get(canvas)
126
+ if (existingRender) {
127
+ if (
128
+ existingRender.pageNum === pageNum
129
+ && existingRender.scale === scale
130
+ && existingRender.hasTextLayer === Boolean(textLayer)
131
+ && existingRender.highlight === (highlight ?? null)
132
+ && existingRender.highlightPage === (highlightPage ?? null)
133
+ && existingRender.highlightExact === Boolean(highlightExact)
134
+ ) {
135
+ await existingRender.promise
136
+ return
137
+ }
138
+
139
+ existingRender.cancel()
140
+ try {
141
+ await existingRender.promise
142
+ }
143
+ catch {
144
+ // Expected when we cancel an obsolete render for the same canvas.
145
+ }
146
+ }
147
+
148
+ canvas.height = viewport.height
149
+ canvas.width = viewport.width
150
+
151
+ const renderTask = page.render({
152
+ canvasContext: context,
153
+ viewport,
154
+ })
155
+ activeRenderTasks.set(pageNum, renderTask)
156
+
157
+ try {
158
+ await renderTask.promise
159
+ }
160
+ finally {
161
+ if (activeRenderTasks.get(pageNum) === renderTask) {
162
+ activeRenderTasks.delete(pageNum)
163
+ }
164
+ }
165
+
166
+ currentRenderPromise = (async () => {
167
+ await renderTask.promise
168
+
169
+ if (isUnmounted)
170
+ return
171
+
172
+ if (textLayer) {
173
+ if (highlight) {
174
+ await renderTextLayer(page, viewport, textLayer, highlight, highlightPage, pageNum, highlightExact)
175
+ }
176
+ else {
177
+ textLayer.innerHTML = ''
178
+ }
179
+ }
180
+ })()
181
+
182
+ activeCanvasRenders.set(canvas, {
183
+ promise: currentRenderPromise,
184
+ cancel: () => renderTask.cancel(),
185
+ pageNum,
186
+ scale,
187
+ hasTextLayer: Boolean(textLayer),
188
+ highlight: highlight ?? null,
189
+ highlightPage: highlightPage ?? null,
190
+ highlightExact: Boolean(highlightExact),
191
+ })
192
+
193
+ await currentRenderPromise
194
+ }
195
+ catch (err) {
196
+ if ((err as Error)?.name === 'RenderingCancelledException') {
197
+ return
198
+ }
199
+
200
+ if (!isUnmounted) {
201
+ console.error('Error rendering page:', err)
202
+ }
203
+ }
204
+ finally {
205
+ const activeRender = activeCanvasRenders.get(canvas)
206
+ if (currentRenderPromise && activeRender?.promise === currentRenderPromise) {
207
+ activeCanvasRenders.delete(canvas)
208
+ }
209
+ }
210
+ }
211
+
212
+ async function renderTextLayer(
213
+ page: PDFPageProxy,
214
+ viewport: any,
215
+ textLayer: HTMLDivElement,
216
+ highlight?: string | null,
217
+ highlightPage?: number | null,
218
+ pageNum?: number,
219
+ exact?: boolean,
220
+ ): Promise<void> {
221
+ if (isUnmounted)
222
+ return
223
+
224
+ textLayer.innerHTML = ''
225
+ textLayer.style.width = `${viewport.width}px`
226
+ textLayer.style.height = `${viewport.height}px`
227
+
228
+ const textContent = await page.getTextContent()
229
+
230
+ if (isUnmounted)
231
+ return
232
+
233
+ const textItems = textContent.items as Array<{ str: string, transform: number[], width: number, height: number }>
234
+
235
+ const searchWords: string[] = []
236
+ const shouldHighlight = highlight && (!highlightPage || highlightPage === pageNum)
237
+
238
+ let exactMatchedIndices: Set<number> | null = null
239
+
240
+ if (shouldHighlight && highlight) {
241
+ if (exact) {
242
+ const normalizedSearch = normalizeForMatch(highlight)
243
+
244
+ const ranges: { start: number, end: number }[] = []
245
+ let concat = ''
246
+ for (const item of textItems) {
247
+ const normalized = normalizeForMatch(item.str)
248
+ if (concat.length > 0 && normalized.length > 0)
249
+ concat += ' '
250
+ const start = concat.length
251
+ concat += normalized
252
+ ranges.push({ start, end: concat.length })
253
+ }
254
+
255
+ exactMatchedIndices = new Set<number>()
256
+ let searchFrom = 0
257
+ while (normalizedSearch.length > 0 && searchFrom <= concat.length - normalizedSearch.length) {
258
+ const idx = concat.indexOf(normalizedSearch, searchFrom)
259
+ if (idx === -1)
260
+ break
261
+ const matchEnd = idx + normalizedSearch.length
262
+ for (let i = 0; i < ranges.length; i++) {
263
+ if (ranges[i]!.end > idx && ranges[i]!.start < matchEnd)
264
+ exactMatchedIndices.add(i)
265
+ }
266
+ searchFrom = idx + 1
267
+ }
268
+
269
+ // Fallback: PDF text extraction can lose characters (e.g. \u0000 replacing
270
+ // letters), so the full literal won't match as a substring. Use a sliding
271
+ // window of consecutive words — windows without corrupted words still match.
272
+ if (exactMatchedIndices.size === 0) {
273
+ const words = normalizedSearch.split(/\s+/)
274
+ const windowSize = Math.min(3, words.length)
275
+ for (let w = 0; w <= words.length - windowSize; w++) {
276
+ const segment = words.slice(w, w + windowSize).join(' ')
277
+ if (segment.length < 4)
278
+ continue
279
+ let segFrom = 0
280
+ while (segFrom <= concat.length - segment.length) {
281
+ const idx = concat.indexOf(segment, segFrom)
282
+ if (idx === -1)
283
+ break
284
+ const matchEnd = idx + segment.length
285
+ for (let i = 0; i < ranges.length; i++) {
286
+ if (ranges[i]!.end > idx && ranges[i]!.start < matchEnd)
287
+ exactMatchedIndices.add(i)
288
+ }
289
+ segFrom = idx + 1
290
+ }
291
+ }
292
+ }
293
+ }
294
+ else {
295
+ const normalized = normalizeForMatch(highlight).replace(/[^\w\s]/g, ' ')
296
+
297
+ normalized.split(/\s+/).forEach((word) => {
298
+ if (word.length >= 3)
299
+ searchWords.push(word)
300
+ })
301
+
302
+ if (normalized.length >= 3)
303
+ searchWords.push(normalized)
304
+ }
305
+ }
306
+
307
+ for (let itemIndex = 0; itemIndex < textItems.length; itemIndex++) {
308
+ if (isUnmounted)
309
+ return
310
+
311
+ const item = textItems[itemIndex]!
312
+ const div = document.createElement('span')
313
+ const tx = pdfjsLib.Util.transform(viewport.transform, item.transform)
314
+ const fontHeight = Math.hypot(tx[2], tx[3])
315
+
316
+ div.textContent = item.str
317
+ div.style.position = 'absolute'
318
+ div.style.left = `${tx[4]}px`
319
+ div.style.top = `${tx[5] - fontHeight}px`
320
+ div.style.fontSize = `${fontHeight}px`
321
+ div.style.fontFamily = 'sans-serif'
322
+ div.style.color = 'transparent'
323
+ div.style.whiteSpace = 'nowrap'
324
+
325
+ if (item.str.trim()) {
326
+ let isMatch = false
327
+
328
+ if (exact && exactMatchedIndices !== null) {
329
+ isMatch = exactMatchedIndices.has(itemIndex)
330
+ }
331
+ else if (searchWords.length > 0) {
332
+ const itemText = normalizeForMatch(item.str)
333
+
334
+ if (itemText.length >= 2) {
335
+ isMatch = searchWords.some(word => word.includes(itemText) || itemText.includes(word))
336
+ }
337
+ }
338
+
339
+ if (isMatch) {
340
+ div.style.backgroundColor = 'rgba(255, 235, 59, 0.7)'
341
+ div.style.borderRadius = '2px'
342
+ }
343
+ }
344
+
345
+ textLayer.appendChild(div)
346
+ }
347
+ }
348
+
349
+ async function getPageDimensions(options: { pageNum: number, scale: number }): Promise<{ width: number, height: number } | null> {
350
+ if (isUnmounted || !state.pdfDoc)
351
+ return null
352
+ const page = await state.pdfDoc.getPage(options.pageNum)
353
+ const viewport = page.getViewport({ scale: options.scale })
354
+ return { width: viewport.width, height: viewport.height }
355
+ }
356
+
357
+ function cleanup() {
358
+ isUnmounted = true
359
+
360
+ activeRenderTasks.forEach(task => task.cancel())
361
+ activeRenderTasks.clear()
362
+
363
+ if (state.pdfDoc) {
364
+ void state.pdfDoc.destroy()
365
+ state.pdfDoc = null
366
+ }
367
+ }
368
+
369
+ watch(url, () => {
370
+ if (!isExplicitlyLoading)
371
+ void loadPdf()
372
+ })
373
+
374
+ onUnmounted(() => {
375
+ cleanup()
376
+ })
377
+
378
+ return {
379
+ state,
380
+ loadPdf,
381
+ renderPage,
382
+ getPageDimensions,
383
+ cleanup,
384
+ }
385
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/tela-build",
3
- "version": "1.26.0",
3
+ "version": "1.27.1",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "app.config.ts",
@@ -60,6 +60,7 @@
60
60
  "ts-morph": "22.0.0",
61
61
  "typescript": "5.8.2",
62
62
  "unocss": "66.5.12",
63
+ "pdfjs-dist": "4.10.38",
63
64
  "vue": "3.5.13",
64
65
  "vue-component-meta": "3.0.8",
65
66
  "vue-docgen-api": "4.78.0",
package/unocss.config.ts CHANGED
@@ -1,8 +1,19 @@
1
1
  import transformerDirectives from '@unocss/transformer-directives'
2
2
  import { defineConfig, toEscapedSelector } from 'unocss'
3
+ import type { Extractor } from 'unocss'
3
4
  import { DT } from './utils/design-tokens'
4
5
 
6
+ /* Enables fill variants for Phosphor icons used as a dynamic suffix (e.g. `${icon}-fill`)
7
+ * in components, which UnoCSS can't detect through static scanning */
8
+ const phosphorFillExtractor: Extractor = {
9
+ name: 'phosphor-fill-icons',
10
+ extract: ({ code }) => new Set(
11
+ [...code.matchAll(/['"`](i-ph-[\w-]+)['"`]/g)].map(([, icon]) => `${icon}-fill`),
12
+ ),
13
+ }
14
+
5
15
  export default defineConfig({
16
+ extractors: [phosphorFillExtractor],
6
17
  theme: {
7
18
  ...DT,
8
19
  animation: {
@@ -0,0 +1,30 @@
1
+ function resolveAttachment(value: Record<string, any>, files: (string | null)[]): Record<string, any> {
2
+ const file = files[value.attachment_index] ?? undefined
3
+ const { attachment_index: _, ...rest } = value
4
+ return { ...rest, file }
5
+ }
6
+
7
+ export function resolveCitationReferences(citations: Record<string, any>, files: (string | null)[]): Record<string, any> {
8
+ const resolved: Record<string, any> = {}
9
+ for (const [key, value] of Object.entries(citations)) {
10
+ if (Array.isArray(value)) {
11
+ resolved[key] = value.map((item) => {
12
+ if (typeof item !== 'object' || item === null)
13
+ return item
14
+ if ('attachment_index' in item)
15
+ return resolveAttachment(item, files)
16
+ return resolveCitationReferences(item, files)
17
+ })
18
+ }
19
+ else if (value && typeof value === 'object' && 'attachment_index' in value) {
20
+ resolved[key] = resolveAttachment(value, files)
21
+ }
22
+ else if (value && typeof value === 'object') {
23
+ resolved[key] = resolveCitationReferences(value, files)
24
+ }
25
+ else {
26
+ resolved[key] = value
27
+ }
28
+ }
29
+ return resolved
30
+ }