@linto-ai/transcript-ui-ui 0.9.0

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 (39) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +17 -0
  3. package/package.json +55 -0
  4. package/src/atoms/Badge.vue +26 -0
  5. package/src/atoms/Button.vue +264 -0
  6. package/src/atoms/CodeBlock.vue +98 -0
  7. package/src/atoms/CopyButton.vue +98 -0
  8. package/src/atoms/EditableText.vue +115 -0
  9. package/src/atoms/EditorCheckbox.vue +66 -0
  10. package/src/atoms/EditorIcon.vue +59 -0
  11. package/src/atoms/MarkdownEditor.vue +542 -0
  12. package/src/atoms/MarkdownView.vue +159 -0
  13. package/src/atoms/PopoverList.vue +92 -0
  14. package/src/atoms/SelectableListItem.vue +183 -0
  15. package/src/atoms/SpeakerIndicator.vue +19 -0
  16. package/src/atoms/SwitchToggle.vue +90 -0
  17. package/src/atoms/UserAvatar.vue +38 -0
  18. package/src/atoms/icons.ts +108 -0
  19. package/src/index.ts +36 -0
  20. package/src/molecules/DocumentArticle.vue +169 -0
  21. package/src/molecules/DownloadMenu.vue +48 -0
  22. package/src/molecules/FormInput.vue +510 -0
  23. package/src/molecules/SpeakerMenu.vue +43 -0
  24. package/src/molecules/Tabs.vue +119 -0
  25. package/src/molecules/TurnTextEditor.vue +93 -0
  26. package/src/styles/base.css +110 -0
  27. package/src/styles/fonts.css +45 -0
  28. package/src/styles/popover-list.css +67 -0
  29. package/src/styles/variables.css +131 -0
  30. package/src/turndown-plugin-gfm.d.ts +10 -0
  31. package/src/utils/computeInitials.ts +10 -0
  32. package/src/utils/computeSelectionOffset.ts +10 -0
  33. package/src/utils/computeTextOffsetInContainer.ts +54 -0
  34. package/src/utils/highlight.ts +46 -0
  35. package/src/utils/markdown.ts +56 -0
  36. package/src/utils/placeCaretAt.ts +23 -0
  37. package/src/utils/shadowAwareSelection.ts +52 -0
  38. package/src/utils/test/computeInitials.test.ts +15 -0
  39. package/src/utils/test/computeTextOffsetInContainer.test.ts +53 -0
@@ -0,0 +1,93 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, useTemplateRef } from "vue"
3
+ import { placeCaretAt } from "../utils/placeCaretAt"
4
+ import { computeSelectionOffset } from "../utils/computeSelectionOffset"
5
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
6
+
7
+ // Plain-text in-place editor for one turn. Strictly a single text node inside
8
+ // a plaintext-only contenteditable — never any markup in editable content
9
+ // (hard-learned rule from the ProseMirror era).
10
+ const props = defineProps<{
11
+ text: string
12
+ caretOffset?: number
13
+ }>()
14
+
15
+ const emit = defineEmits<{
16
+ save: [text: string]
17
+ cancel: []
18
+ split: [text: string, offset: number]
19
+ }>()
20
+
21
+ const { t } = useI18n()
22
+ const editableRef = useTemplateRef<HTMLElement>("editable")
23
+
24
+ // Set once an explicit outcome (save/cancel/split) was emitted, so the blur
25
+ // that follows it — or the one fired during unmount — can't emit a second one.
26
+ let settled = false
27
+
28
+ onMounted(() => {
29
+ const el = editableRef.value
30
+ if (!el) return
31
+ placeCaretAt(el, props.caretOffset ?? props.text.length)
32
+ })
33
+
34
+ function getText(): string {
35
+ return editableRef.value?.innerText ?? ""
36
+ }
37
+
38
+ defineExpose({ getText })
39
+
40
+ function settle(emitOutcome: () => void): void {
41
+ if (settled) return
42
+ settled = true
43
+ emitOutcome()
44
+ }
45
+
46
+ function onKeydown(event: KeyboardEvent) {
47
+ if (event.key === "Enter") {
48
+ // A turn is a single paragraph: Enter splits it at the caret instead of
49
+ // inserting a line break.
50
+ event.preventDefault()
51
+ const el = editableRef.value
52
+ const text = getText()
53
+ const offset = (el && computeSelectionOffset(el)) ?? text.length
54
+ settle(() => emit("split", text, offset))
55
+ } else if (event.key === "Escape") {
56
+ event.preventDefault()
57
+ settle(() => emit("cancel"))
58
+ }
59
+ }
60
+
61
+ // Leaving the field commits (Notion-like inline editing). Escape is the only
62
+ // way out without saving.
63
+ function onBlur() {
64
+ settle(() => emit("save", getText()))
65
+ }
66
+ </script>
67
+
68
+ <template>
69
+ <p
70
+ ref="editable"
71
+ class="turn-text-editor"
72
+ contenteditable="plaintext-only"
73
+ role="textbox"
74
+ aria-multiline="false"
75
+ :aria-label="t('transcription.turnEditor')"
76
+ spellcheck="true"
77
+ @keydown="onKeydown"
78
+ @blur="onBlur"
79
+ v-text="text" />
80
+ </template>
81
+
82
+ <style scoped>
83
+ .turn-text-editor {
84
+ margin: 0;
85
+ white-space: pre-wrap;
86
+ overflow-wrap: anywhere;
87
+ cursor: text;
88
+ outline: 2px solid var(--color-primary);
89
+ border-radius: var(--radius-sm);
90
+ background-color: var(--color-surface);
91
+ padding: 0;
92
+ }
93
+ </style>
@@ -0,0 +1,110 @@
1
+ /* Utility */
2
+ .sr-only {
3
+ position: absolute;
4
+ width: 1px;
5
+ height: 1px;
6
+ padding: 0;
7
+ margin: -1px;
8
+ overflow: hidden;
9
+ clip: rect(0, 0, 0, 0);
10
+ white-space: nowrap;
11
+ border-width: 0;
12
+ }
13
+
14
+ /* Overlay (shared by sidebar drawer + sheet select) */
15
+ .editor-overlay {
16
+ position: fixed;
17
+ inset: 0;
18
+ background-color: rgba(0, 0, 0, 0.4);
19
+ z-index: var(--z-overlay);
20
+ animation: overlay-fade-in 200ms ease;
21
+ }
22
+
23
+ /* Drawer mobile (sidebar) */
24
+ .sidebar-drawer {
25
+ position: fixed;
26
+ top: 0;
27
+ right: 0;
28
+ bottom: 0;
29
+ width: min(320px, 85vw);
30
+ z-index: var(--z-drawer);
31
+ background-color: var(--color-surface);
32
+ box-shadow: var(--shadow-md);
33
+ animation: drawer-slide-in 250ms ease;
34
+ overflow-y: auto;
35
+ display: flex;
36
+ flex-direction: column;
37
+ }
38
+
39
+ .sidebar-close {
40
+ position: absolute;
41
+ top: var(--spacing-sm);
42
+ right: var(--spacing-sm);
43
+ display: inline-flex;
44
+ align-items: center;
45
+ justify-content: center;
46
+ width: 32px;
47
+ height: 32px;
48
+ border: none;
49
+ background: none;
50
+ color: var(--color-text-muted);
51
+ border-radius: var(--radius-md);
52
+ cursor: pointer;
53
+ z-index: 1;
54
+ }
55
+
56
+ .sidebar-close:hover {
57
+ background-color: var(--color-surface-hover);
58
+ color: var(--color-text-primary);
59
+ }
60
+
61
+ /* Keyframes */
62
+ @keyframes overlay-fade-in {
63
+ from {
64
+ opacity: 0;
65
+ }
66
+ to {
67
+ opacity: 1;
68
+ }
69
+ }
70
+
71
+ @keyframes drawer-slide-in {
72
+ from {
73
+ translate: 100% 0;
74
+ }
75
+ to {
76
+ translate: 0 0;
77
+ }
78
+ }
79
+
80
+ @media (prefers-reduced-motion: reduce) {
81
+ .editor-overlay,
82
+ .sidebar-drawer {
83
+ animation: none;
84
+ }
85
+ }
86
+
87
+ /* Wavesurfer ::part (cannot work in scoped styles) */
88
+ /* No backdrop-filter: there is one region per turn (hundreds on a long
89
+ transcript), and each backdrop-filter forces a separate WebRender backdrop
90
+ render target — on a multi-hour document this balloons GPU/GTT memory to
91
+ several GB and freezes weaker machines. The border/shadow alone read fine. */
92
+ .waveform-container ::part(region) {
93
+ border-top: 2px solid var(--region-color, rgba(255, 255, 255, 0.4));
94
+ border-bottom: 1px solid var(--region-color, rgba(255, 255, 255, 0.4));
95
+ box-shadow:
96
+ inset 0 1px 0 rgba(255, 255, 255, 0.2),
97
+ 0 1px 4px rgba(0, 0, 0, 0.1);
98
+ }
99
+
100
+ /* Turn nodes use `content-visibility: auto` for long-document perf, which
101
+ implies paint containment and clips any overflow to the turn's box. The
102
+ speaker popover floats out of the turn, so it gets cut off at the turn's
103
+ bottom edge. While its trigger is open (Reka sets data-state="open"), drop
104
+ the containment on that turn so the popover can overflow freely. The turn is
105
+ on-screen when open, so lifting content-visibility causes no layout shift.
106
+ Global (not scoped) because the open trigger is rendered by another
107
+ component, so a scoped :has() selector would not match it. */
108
+ section.turn:has([data-state="open"]) {
109
+ content-visibility: visible;
110
+ }
@@ -0,0 +1,45 @@
1
+ /* Atkinson Hyperlegible Next — main font */
2
+ @font-face {
3
+ font-family: "Atkinson Hyperlegible Next";
4
+ font-style: normal;
5
+ font-weight: 400;
6
+ font-display: swap;
7
+ src: url("/fonts/AtkinsonHyperlegibleNext-Regular.woff2") format("woff2");
8
+ }
9
+ @font-face {
10
+ font-family: "Atkinson Hyperlegible Next";
11
+ font-style: normal;
12
+ font-weight: 500;
13
+ font-display: swap;
14
+ src: url("/fonts/AtkinsonHyperlegibleNext-Medium.woff2") format("woff2");
15
+ }
16
+ @font-face {
17
+ font-family: "Atkinson Hyperlegible Next";
18
+ font-style: normal;
19
+ font-weight: 600;
20
+ font-display: swap;
21
+ src: url("/fonts/AtkinsonHyperlegibleNext-SemiBold.woff2") format("woff2");
22
+ }
23
+ @font-face {
24
+ font-family: "Atkinson Hyperlegible Next";
25
+ font-style: normal;
26
+ font-weight: 700;
27
+ font-display: swap;
28
+ src: url("/fonts/AtkinsonHyperlegibleNext-Bold.woff2") format("woff2");
29
+ }
30
+
31
+ /* Atkinson Hyperlegible Mono — monospace font */
32
+ @font-face {
33
+ font-family: "Atkinson Hyperlegible Mono";
34
+ font-style: normal;
35
+ font-weight: 400;
36
+ font-display: swap;
37
+ src: url("/fonts/AtkinsonHyperlegibleMono-Regular.woff2") format("woff2");
38
+ }
39
+ @font-face {
40
+ font-family: "Atkinson Hyperlegible Mono";
41
+ font-style: normal;
42
+ font-weight: 500;
43
+ font-display: swap;
44
+ src: url("/fonts/AtkinsonHyperlegibleMono-Medium.woff2") format("woff2");
45
+ }
@@ -0,0 +1,67 @@
1
+ /* Shared surface and row styles for PopoverList and similar anchored panels.
2
+ Kept global because Reka portals render outside the component's scoped
3
+ CSS boundary. */
4
+
5
+ .popover-list {
6
+ min-width: 180px;
7
+ padding: var(--spacing-xs);
8
+ background-color: var(--color-surface);
9
+ border: 1px solid var(--color-border);
10
+ border-radius: var(--radius-md);
11
+ box-shadow: 0 8px 24px
12
+ color-mix(in srgb, var(--color-text-primary) 15%, transparent);
13
+ z-index: 50;
14
+ }
15
+
16
+ .popover-list__items {
17
+ list-style: none;
18
+ display: flex;
19
+ flex-direction: column;
20
+ gap: 2px;
21
+ margin: 0;
22
+ padding: 0;
23
+ max-height: 280px;
24
+ overflow-y: auto;
25
+ }
26
+
27
+ .popover-list__item {
28
+ all: unset;
29
+ box-sizing: border-box;
30
+ display: flex;
31
+ align-items: center;
32
+ gap: var(--spacing-sm);
33
+ width: 100%;
34
+ padding: var(--spacing-xs) var(--spacing-sm);
35
+ border-radius: var(--radius-sm);
36
+ font-size: var(--font-size-sm);
37
+ color: var(--color-text-primary);
38
+ cursor: pointer;
39
+ }
40
+
41
+ .popover-list__item:hover,
42
+ .popover-list__item[data-highlighted] {
43
+ background-color: var(--color-surface-hover);
44
+ }
45
+
46
+ .popover-list__item:focus-visible {
47
+ outline: 2px solid var(--color-primary);
48
+ outline-offset: -2px;
49
+ }
50
+
51
+ .popover-list__item--current {
52
+ background-color: color-mix(
53
+ in srgb,
54
+ var(--color-primary) 10%,
55
+ transparent
56
+ );
57
+ }
58
+
59
+ .popover-list__divider {
60
+ height: 1px;
61
+ background-color: var(--color-border);
62
+ margin: var(--spacing-xs) 0;
63
+ }
64
+
65
+ .popover-list__footer {
66
+ padding: var(--spacing-xs);
67
+ }
@@ -0,0 +1,131 @@
1
+ /*
2
+ * Design tokens
3
+ *
4
+ * Public theming API — these CSS custom properties can be overridden from
5
+ * outside the web component by setting them on the <linto-editor> element:
6
+ *
7
+ * linto-editor {
8
+ * --color-primary: #e63946;
9
+ * --color-background: #fafafa;
10
+ * }
11
+ *
12
+ * Colors:
13
+ * --color-primary Accent / brand color
14
+ * --color-primary-hover Primary hover state
15
+ * --color-background Page background
16
+ * --color-surface Cards, panels, player
17
+ * --color-surface-hover Hover on surfaces
18
+ * --color-text-primary Main text
19
+ * --color-text-secondary Secondary text (timestamps…)
20
+ * --color-text-muted Muted text (labels)
21
+ * --color-border Borders
22
+ * --color-border-light Light borders
23
+ *
24
+ * Typography, spacing, radius, and shadows are also overridable.
25
+ */
26
+
27
+ :root,
28
+ :host {
29
+ /* Colors — light theme */
30
+ --color-background: #f8f9fa;
31
+ --color-surface: #ffffff;
32
+ --color-surface-hover: #f1f3f5;
33
+ --color-text-primary: #1a1d21;
34
+ --color-text-secondary: #495057;
35
+ --color-text-muted: #6c757d;
36
+ --color-primary: #4263eb;
37
+ --color-primary-hover: #3b5bdb;
38
+ --color-border: #dee2e6;
39
+ --color-border-light: #e9ecef;
40
+ --color-white: #ffffff;
41
+ --color-black: #000000;
42
+ --color-danger: #e53935;
43
+ --color-danger-hover: #c62828;
44
+ --color-danger-soft: #fdecea;
45
+
46
+ /* Typography */
47
+ --font-family:
48
+ "Atkinson Hyperlegible Next", system-ui, -apple-system, sans-serif;
49
+ --font-family-mono: "Atkinson Hyperlegible Mono", ui-monospace, monospace;
50
+ --font-size-xs: 0.875rem;
51
+ --font-size-sm: 1rem;
52
+ --font-size-base: 1.125rem;
53
+ --font-size-lg: 1.25rem;
54
+ --font-size-xl: 1.75rem;
55
+ --line-height: 1.6;
56
+
57
+ /* Spacing */
58
+ --spacing-xxs: 0.125rem;
59
+ --spacing-xs: 0.25rem;
60
+ --spacing-sm: 0.5rem;
61
+ --spacing-md: 1rem;
62
+ --spacing-lg: 1.5rem;
63
+ --spacing-xl: 2rem;
64
+
65
+ /* Radius */
66
+ --radius-sm: 4px;
67
+ --radius-md: 8px;
68
+ --radius-lg: 12px;
69
+
70
+ /* Layout */
71
+ --sidebar-width: 300px;
72
+ --header-height: 56px;
73
+
74
+ /* Shadows */
75
+ --shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.1);
76
+ --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.15);
77
+
78
+ /* Transitions */
79
+ --transition-duration: 150ms;
80
+
81
+ /* Z-index scale */
82
+ --z-sticky: 10;
83
+ --z-overlay: 50;
84
+ --z-drawer: 1000;
85
+ --z-dropdown: 1100;
86
+
87
+ /* Glass effect — backdrop blur intentionally removed: each backdrop-filter
88
+ forces a WebRender render target, which on long transcripts (one waveform
89
+ region per turn, tall scroll container) balloons GPU memory to several GB
90
+ and freezes weaker machines. Keep the semi-opaque background only. */
91
+ --glass-background: rgba(255, 255, 255, 0.8);
92
+ --glass-border: rgba(255, 255, 255, 0.3);
93
+ }
94
+
95
+ :host,
96
+ body {
97
+ font-family: var(--font-family);
98
+ font-size: var(--font-size-base);
99
+ line-height: var(--line-height);
100
+ color: var(--color-text-primary);
101
+ background-color: var(--color-background);
102
+ -webkit-font-smoothing: antialiased;
103
+ -moz-osx-font-smoothing: grayscale;
104
+ }
105
+
106
+ :host {
107
+ display: block;
108
+ height: 100%;
109
+ overflow: hidden;
110
+ }
111
+
112
+ /* Reset */
113
+ *,
114
+ *::before,
115
+ *::after {
116
+ box-sizing: border-box;
117
+ margin: 0;
118
+ padding: 0;
119
+ }
120
+
121
+ /* Dev mode SPA (ignored in Shadow DOM — these elements don't exist) */
122
+ html,
123
+ body {
124
+ height: 100%;
125
+ overflow: hidden;
126
+ }
127
+
128
+ #app {
129
+ height: 100%;
130
+ overflow: hidden;
131
+ }
@@ -0,0 +1,10 @@
1
+ declare module "turndown-plugin-gfm" {
2
+ import type TurndownService from "turndown"
3
+
4
+ type Plugin = (service: TurndownService) => void
5
+
6
+ export const gfm: Plugin
7
+ export const strikethrough: Plugin
8
+ export const tables: Plugin
9
+ export const taskListItems: Plugin
10
+ }
@@ -0,0 +1,10 @@
1
+ /** Initials shown in a user avatar: first letter of the first two words,
2
+ * uppercased — "Marie Dupont" → "MD", "marie" → "M", "" → "?". */
3
+ export function computeInitials(name: string): string {
4
+ const words = name.trim().split(/\s+/).filter(Boolean)
5
+ if (words.length === 0) return "?"
6
+ return words
7
+ .slice(0, 2)
8
+ .map((w) => w[0]!.toUpperCase())
9
+ .join("")
10
+ }
@@ -0,0 +1,10 @@
1
+ import { computeTextOffsetInContainer } from "./computeTextOffsetInContainer"
2
+ import { getSelectionFocus } from "./shadowAwareSelection"
3
+
4
+ /** Character offset of the current selection focus inside `container`'s plain
5
+ * text, or null when the selection lives elsewhere. */
6
+ export function computeSelectionOffset(container: HTMLElement): number | null {
7
+ const focus = getSelectionFocus(container)
8
+ if (!focus || !container.contains(focus.node)) return null
9
+ return computeTextOffsetInContainer(container, focus.node, focus.offset)
10
+ }
@@ -0,0 +1,54 @@
1
+ const TEXT_NODE = 3
2
+
3
+ /**
4
+ * Absolute character offset of a caret position inside `container`'s plain
5
+ * text — the sum of every text node's length before the position, in document
6
+ * order, plus the local offset. Works whether the caret API resolved a text
7
+ * node (offset in characters) or an element (offset in child index).
8
+ */
9
+ export function computeTextOffsetInContainer(
10
+ container: Node,
11
+ target: Node,
12
+ offsetInTarget: number,
13
+ ): number {
14
+ if (target.nodeType === TEXT_NODE) {
15
+ return textLengthBefore(container, target) + offsetInTarget
16
+ }
17
+ // Element position: the caret sits before target's Nth child.
18
+ const child = target.childNodes[offsetInTarget]
19
+ if (child) return textLengthBefore(container, child)
20
+ return textLengthUnder(target) + textLengthBefore(container, target)
21
+ }
22
+
23
+ /** Total length of the text nodes preceding `stop` in document order. */
24
+ function textLengthBefore(container: Node, stop: Node): number {
25
+ let length = 0
26
+ let reached = false
27
+
28
+ function walk(node: Node): void {
29
+ if (reached || node === stop) {
30
+ reached = true
31
+ return
32
+ }
33
+ if (node.nodeType === TEXT_NODE) {
34
+ length += node.nodeValue?.length ?? 0
35
+ return
36
+ }
37
+ for (const child of Array.from(node.childNodes)) {
38
+ walk(child)
39
+ if (reached) return
40
+ }
41
+ }
42
+
43
+ walk(container)
44
+ return length
45
+ }
46
+
47
+ function textLengthUnder(node: Node): number {
48
+ if (node.nodeType === TEXT_NODE) return node.nodeValue?.length ?? 0
49
+ let length = 0
50
+ for (const child of Array.from(node.childNodes)) {
51
+ length += textLengthUnder(child)
52
+ }
53
+ return length
54
+ }
@@ -0,0 +1,46 @@
1
+ // Lazy-loaded syntax highlighter. Imported dynamically by CodeBlock so Prism
2
+ // and its grammars land in a separate chunk, never the initial bundle.
3
+ // The classic Prism theme CSS is injected separately (see webcomponent.ts for
4
+ // the Shadow DOM, main.ts for dev) — this module only produces token markup.
5
+ import Prism from "prismjs"
6
+ import DOMPurify from "dompurify"
7
+
8
+ // Grammars, in dependency order (javascript needs clike, typescript needs
9
+ // javascript). Extend this list to support more languages.
10
+ import "prismjs/components/prism-markup"
11
+ import "prismjs/components/prism-css"
12
+ import "prismjs/components/prism-clike"
13
+ import "prismjs/components/prism-c"
14
+ import "prismjs/components/prism-java"
15
+ import "prismjs/components/prism-javascript"
16
+ import "prismjs/components/prism-jsx"
17
+ import "prismjs/components/prism-typescript"
18
+ import "prismjs/components/prism-json"
19
+ import "prismjs/components/prism-bash"
20
+ import "prismjs/components/prism-python"
21
+ import "prismjs/components/prism-yaml"
22
+ import "prismjs/components/prism-sql"
23
+ import "prismjs/components/prism-markdown"
24
+ import "prismjs/components/prism-diff"
25
+
26
+ // Markdown fence languages → Prism canonical names.
27
+ const ALIASES: Record<string, string> = {
28
+ js: "javascript",
29
+ ts: "typescript",
30
+ sh: "bash",
31
+ shell: "bash",
32
+ py: "python",
33
+ yml: "yaml",
34
+ html: "markup",
35
+ xml: "markup",
36
+ md: "markdown",
37
+ }
38
+
39
+ // Returns sanitized token HTML, or null when the language is unknown (caller
40
+ // falls back to plain text).
41
+ export function highlightCode(code: string, lang: string): string | null {
42
+ const name = ALIASES[lang] ?? lang
43
+ const grammar = Prism.languages[name]
44
+ if (!grammar) return null
45
+ return DOMPurify.sanitize(Prism.highlight(code, grammar, name))
46
+ }
@@ -0,0 +1,56 @@
1
+ import { marked } from "marked"
2
+ import type { Token, TokensList } from "marked"
3
+ import DOMPurify from "dompurify"
4
+
5
+ // Shared markdown renderer for MarkdownView (v-html) and MarkdownEditor
6
+ // (innerHTML / execCommand "insertHTML"). The source — LLM output,
7
+ // collaboratively-edited content, pasted text — is untrusted, so marked's raw
8
+ // HTML output MUST be sanitized before it reaches the DOM. marked removed its
9
+ // built-in `sanitize` option in v8 and explicitly defers to a dedicated
10
+ // sanitizer (DOMPurify), so without this every `<img onerror>` / `javascript:`
11
+ // payload would execute in the host page (the editor ships as a Web Component).
12
+ marked.setOptions({ gfm: true, breaks: false })
13
+
14
+ export function renderMarkdown(md: string): string {
15
+ if (!md) return ""
16
+ const rawHtml = marked.parse(md, { async: false }) as string
17
+ return DOMPurify.sanitize(rawHtml)
18
+ }
19
+
20
+ // Code blocks are extracted as their own segments so the view can render them
21
+ // with a real Vue component (copy button, etc.) instead of inert v-html. Every
22
+ // other run of block tokens is parsed back to sanitized HTML as before.
23
+ export type MarkdownSegment =
24
+ | { type: "html"; html: string }
25
+ | { type: "code"; code: string; lang: string }
26
+
27
+ export function renderMarkdownSegments(md: string): MarkdownSegment[] {
28
+ if (!md) return []
29
+
30
+ const tokens = marked.lexer(md)
31
+ const segments: MarkdownSegment[] = []
32
+ let buffer: Token[] = []
33
+
34
+ // Reference-link definitions ([x][ref] … [ref]: url) live on tokens.links,
35
+ // not on individual tokens. Each sliced group must carry them over or
36
+ // reference links break when re-parsed in isolation.
37
+ const flush = () => {
38
+ if (buffer.length === 0) return
39
+ const group = buffer as TokensList
40
+ group.links = tokens.links
41
+ segments.push({ type: "html", html: DOMPurify.sanitize(marked.parser(group)) })
42
+ buffer = []
43
+ }
44
+
45
+ for (const token of tokens) {
46
+ if (token.type === "code") {
47
+ flush()
48
+ segments.push({ type: "code", code: token.text, lang: token.lang ?? "" })
49
+ } else {
50
+ buffer.push(token)
51
+ }
52
+ }
53
+ flush()
54
+
55
+ return segments
56
+ }
@@ -0,0 +1,23 @@
1
+ import { getShadowAwareSelection } from "./shadowAwareSelection"
2
+
3
+ const TEXT_NODE = 3
4
+
5
+ /**
6
+ * Focus an editable element holding a single text node and place the caret at
7
+ * `offset` (clamped). Falls back to a plain focus when the element is empty.
8
+ */
9
+ export function placeCaretAt(element: HTMLElement, offset: number): void {
10
+ element.focus({ preventScroll: true })
11
+ const textNode = element.firstChild
12
+ if (!textNode || textNode.nodeType !== TEXT_NODE) return
13
+
14
+ const clamped = Math.max(0, Math.min(offset, textNode.nodeValue?.length ?? 0))
15
+ const range = element.ownerDocument.createRange()
16
+ range.setStart(textNode, clamped)
17
+ range.collapse(true)
18
+
19
+ const selection = getShadowAwareSelection(element)
20
+ if (!selection) return
21
+ selection.removeAllRanges()
22
+ selection.addRange(range)
23
+ }