@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.
- package/components/tela/menubar/menubar-item.vue +1 -1
- package/components/tela/menubar/menubar-trigger.vue +1 -1
- package/components/tela/preview/preview.mdx +136 -0
- package/components/tela/sidebar/sidebar-content.vue +10 -0
- package/components/tela/sidebar/sidebar-footer.vue +5 -0
- package/components/tela/sidebar/sidebar-header.vue +5 -0
- package/components/tela/sidebar/sidebar-item.vue +48 -0
- package/components/tela/sidebar/sidebar-logo.vue +21 -0
- package/components/tela/sidebar/sidebar-user.vue +47 -0
- package/components/tela/sidebar/sidebar.mdx +175 -0
- package/components/tela/sidebar/sidebar.stories.ts +190 -0
- package/components/tela/sidebar/sidebar.vue +5 -0
- package/composables/citation-navigation.ts +6 -6
- package/composables/use-pdf-loader.ts +56 -0
- package/composables/use-pdf.ts +385 -0
- package/package.json +2 -1
- package/unocss.config.ts +11 -0
- package/utils/citations.ts +30 -0
|
@@ -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-
|
|
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-
|
|
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,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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MaybeRefOrGetter
|
|
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
|
|
82
|
-
highlightPage
|
|
83
|
-
highlightExact
|
|
84
|
-
activeFile
|
|
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.
|
|
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
|
+
}
|