@scalar/components 0.21.4 → 0.22.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.
Files changed (45) hide show
  1. package/dist/components/ScalarHeader/ScalarHeader.vue.d.ts.map +1 -1
  2. package/dist/components/ScalarHeader/ScalarHeader.vue.js.map +1 -1
  3. package/dist/components/ScalarHeader/ScalarHeader.vue.script.js +7 -4
  4. package/dist/components/ScalarHeader/ScalarHeader.vue.script.js.map +1 -1
  5. package/dist/components/ScalarHeader/ScalarHeaderButton.vue.d.ts +2 -2
  6. package/dist/components/ScalarHeader/ScalarHeaderButton.vue.d.ts.map +1 -1
  7. package/dist/components/ScalarHeader/ScalarHeaderButton.vue.js.map +1 -1
  8. package/dist/components/ScalarHeader/ScalarHeaderButton.vue.script.js +6 -6
  9. package/dist/components/ScalarHeader/ScalarHeaderButton.vue.script.js.map +1 -1
  10. package/dist/components/ScalarIcon/variants.d.ts +2 -50
  11. package/dist/components/ScalarIcon/variants.d.ts.map +1 -1
  12. package/dist/components/ScalarMenu/ScalarMenuButton.vue.d.ts.map +1 -1
  13. package/dist/components/ScalarMenu/ScalarMenuButton.vue.js.map +1 -1
  14. package/dist/components/ScalarMenu/ScalarMenuButton.vue.script.js +2 -2
  15. package/dist/components/ScalarMenu/ScalarMenuButton.vue.script.js.map +1 -1
  16. package/dist/components/ScalarMenu/ScalarMenuLink.vue.d.ts +2 -0
  17. package/dist/components/ScalarMenu/ScalarMenuLink.vue.d.ts.map +1 -1
  18. package/dist/components/ScalarMenu/ScalarMenuLink.vue.js.map +1 -1
  19. package/dist/components/ScalarMenu/ScalarMenuLink.vue.script.js +7 -6
  20. package/dist/components/ScalarMenu/ScalarMenuLink.vue.script.js.map +1 -1
  21. package/dist/components/ScalarMenu/ScalarMenuTeamPicker.vue.js.map +1 -1
  22. package/dist/components/ScalarMenu/ScalarMenuTeamPicker.vue.script.js +4 -4
  23. package/dist/components/ScalarMenu/ScalarMenuTeamPicker.vue.script.js.map +1 -1
  24. package/dist/components/ScalarMenu/ScalarMenuWorkspacePicker.vue.js.map +1 -1
  25. package/dist/components/ScalarMenu/ScalarMenuWorkspacePicker.vue.script.js +4 -4
  26. package/dist/components/ScalarMenu/ScalarMenuWorkspacePicker.vue.script.js.map +1 -1
  27. package/dist/components/ScalarSidebar/ScalarSidebarFooter.vue.js.map +1 -1
  28. package/dist/components/ScalarSidebar/ScalarSidebarFooter.vue.script.js +1 -1
  29. package/dist/components/ScalarSidebar/ScalarSidebarFooter.vue.script.js.map +1 -1
  30. package/dist/components/ScalarVirtualText/ScalarVirtualText.vue.d.ts +10 -1
  31. package/dist/components/ScalarVirtualText/ScalarVirtualText.vue.d.ts.map +1 -1
  32. package/dist/components/ScalarVirtualText/ScalarVirtualText.vue.js +3 -1
  33. package/dist/components/ScalarVirtualText/ScalarVirtualText.vue.js.map +1 -1
  34. package/dist/components/ScalarVirtualText/ScalarVirtualText.vue.script.js +183 -37
  35. package/dist/components/ScalarVirtualText/ScalarVirtualText.vue.script.js.map +1 -1
  36. package/dist/components/ScalarVirtualText/ScalarVirtualTextSearch.vue.d.ts +45 -0
  37. package/dist/components/ScalarVirtualText/ScalarVirtualTextSearch.vue.d.ts.map +1 -0
  38. package/dist/components/ScalarVirtualText/ScalarVirtualTextSearch.vue.js +9 -0
  39. package/dist/components/ScalarVirtualText/ScalarVirtualTextSearch.vue.js.map +1 -0
  40. package/dist/components/ScalarVirtualText/ScalarVirtualTextSearch.vue.script.js +101 -0
  41. package/dist/components/ScalarVirtualText/ScalarVirtualTextSearch.vue.script.js.map +1 -0
  42. package/dist/docs/useBindCx/MockComponents.d.ts +4 -100
  43. package/dist/docs/useBindCx/MockComponents.d.ts.map +1 -1
  44. package/dist/style.css +144 -22
  45. package/package.json +12 -12
@@ -1 +1 @@
1
- {"version":3,"file":"ScalarVirtualText.vue.js","names":[],"sources":["../../../src/components/ScalarVirtualText/ScalarVirtualText.vue"],"sourcesContent":["<script lang=\"ts\">\n/**\n * Scalar Virtual Text component\n *\n * Renders large blocks of text efficiently using virtualized scrolling.\n * Only the visible lines are rendered to the DOM.\n *\n * @example\n * <ScalarVirtualText text=\"line1\\nline2\\nline3\" :lineHeight=\"20\" />\n */\nexport default {}\n</script>\n<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue'\n\nconst props = withDefaults(\n defineProps<{\n /** Text to display */\n text: string\n /**\n * Height of each line\n * @default 20\n */\n lineHeight?: number\n containerClass?: string\n contentClass?: string\n lineClass?: string\n }>(),\n {\n lineHeight: 20,\n containerClass: '',\n contentClass: '',\n lineClass: '',\n },\n)\n\nconst containerRef = ref<HTMLElement | null>(null)\nconst contentRef = ref<HTMLElement | null>(null)\nconst scrollPosition = ref(0)\nconst containerHeight = ref(0)\n\n/** Array of text broken into lines */\nconst lines = computed(() => props.text.split('\\n'))\n/**\n * Total height of all lines combined\n *\n * TODO: there is a problem with this calculation which screws up the scrollbar a bit\n */\nconst totalHeight = computed(() => lines.value.length * props.lineHeight)\n\n/** Index of the first visible line */\nconst visibleStartIndex = computed(() =>\n Math.floor(scrollPosition.value / props.lineHeight),\n)\n\n/** Index of the last visible line */\nconst visibleEndIndex = computed(() =>\n Math.min(\n Math.ceil(\n (scrollPosition.value + containerHeight.value) / props.lineHeight,\n ),\n lines.value.length,\n ),\n)\n\n/** Array of visible lines, including buffer for smooth scrolling */\nconst visibleLines = computed(() => {\n const buffer = 10\n const start = Math.max(0, visibleStartIndex.value - buffer)\n const end = Math.min(lines.value.length, visibleEndIndex.value + buffer)\n\n return lines.value.slice(start, end)\n})\n\n/** Style object for the content container, controls \"scrolling\" */\nconst contentStyle = computed(() => ({\n height: `${totalHeight.value}px`,\n transform: `translateY(${Math.max(0, visibleStartIndex.value - 10) * props.lineHeight}px)`,\n}))\n\n/** Updates the scroll position when the container is scrolled */\nconst handleScroll = () =>\n containerRef.value && (scrollPosition.value = containerRef.value.scrollTop)\n\n/** Updates the container height when the window is resized */\nconst updateContainerHeight = () =>\n containerRef.value &&\n (containerHeight.value = containerRef.value.clientHeight)\n\nonMounted(() => {\n updateContainerHeight()\n window.addEventListener('resize', updateContainerHeight)\n})\n\nonBeforeUnmount(() => {\n window.removeEventListener('resize', updateContainerHeight)\n})\n\nwatchEffect(() => {\n if (!contentRef.value) {\n return\n }\n\n contentRef.value.style.transform = `translateY(${Math.max(0, visibleStartIndex.value - 10) * props.lineHeight}px)`\n})\n</script>\n\n<template>\n <div\n ref=\"containerRef\"\n class=\"scalar-virtual-text overflow-auto\"\n :class=\"containerClass\"\n @scroll=\"handleScroll\">\n <code\n ref=\"contentRef\"\n class=\"scalar-virtual-text-content\"\n :class=\"contentClass\"\n :style=\"contentStyle\">\n <div\n v-for=\"(line, index) in visibleLines\"\n :key=\"visibleStartIndex + index\"\n class=\"scalar-virtual-text-line\"\n :class=\"lineClass\"\n :style=\"{\n height: `${props.lineHeight}px`,\n lineHeight: `${props.lineHeight}px`,\n }\">\n {{ line }}\n </div>\n </code>\n </div>\n</template>\n"],"mappings":""}
1
+ {"version":3,"file":"ScalarVirtualText.vue.js","names":[],"sources":["../../../src/components/ScalarVirtualText/ScalarVirtualText.vue"],"sourcesContent":["<script lang=\"ts\">\n/**\n * Scalar Virtual Text component\n *\n * Renders large blocks of text efficiently using virtualized scrolling.\n * Only the visible lines are rendered to the DOM. Supports in-content\n * search with match highlighting and navigation (Cmd/Ctrl+F).\n *\n * @example\n * <ScalarVirtualText text=\"line1\\nline2\\nline3\" :lineHeight=\"20\" />\n *\n * @example\n * <ScalarVirtualText text=\"lots of text\" searchable />\n */\nexport default {}\n</script>\n<script setup lang=\"ts\">\nimport { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'\n\nimport ScalarVirtualTextSearch from './ScalarVirtualTextSearch.vue'\n\nconst props = withDefaults(\n defineProps<{\n /** Text to display */\n text: string\n /**\n * Height of each line\n * @default 20\n */\n lineHeight?: number\n containerClass?: string\n contentClass?: string\n lineClass?: string\n /** Enable built-in search with Cmd/Ctrl+F */\n searchable?: boolean\n }>(),\n {\n lineHeight: 20,\n containerClass: '',\n contentClass: '',\n lineClass: '',\n searchable: false,\n },\n)\n\nconst containerRef = ref<HTMLElement | null>(null)\nconst searchRef = ref<InstanceType<typeof ScalarVirtualTextSearch> | null>(null)\nconst scrollPosition = ref(0)\nconst containerHeight = ref(0)\n\nconst searchQuery = ref('')\nconst searchOpen = ref(false)\nconst activeMatchIndex = ref(0)\n\n/** Array of text broken into lines */\nconst lines = computed(() => props.text.split('\\n'))\n/** Total height of all lines combined */\nconst totalHeight = computed(() => lines.value.length * props.lineHeight)\n\n/** Index of the first visible line */\nconst visibleStartIndex = computed(() =>\n Math.floor(scrollPosition.value / props.lineHeight),\n)\n\n/** Index of the last visible line */\nconst visibleEndIndex = computed(() =>\n Math.min(\n Math.ceil(\n (scrollPosition.value + containerHeight.value) / props.lineHeight,\n ),\n lines.value.length,\n ),\n)\n\nconst BUFFER = 10\n\n/** Index of the first rendered line (clamped, shared between slice and offset) */\nconst renderStartIndex = computed(() =>\n Math.max(0, visibleStartIndex.value - BUFFER),\n)\n\n/** Array of visible lines, including buffer for smooth scrolling */\nconst visibleLines = computed(() => {\n const end = Math.min(lines.value.length, visibleEndIndex.value + BUFFER)\n return lines.value.slice(renderStartIndex.value, end)\n})\n\n/** Pixel offset for the visible content slice */\nconst contentOffset = computed(() => renderStartIndex.value * props.lineHeight)\n\n// ── Search ────────────────────────────────────────────────────────────\n\ntype SearchMatch = { line: number; start: number; end: number }\n\n/** All matches across the full text, computed only when the query changes */\nconst searchMatches = computed<SearchMatch[]>(() => {\n const query = searchQuery.value\n if (!query) return []\n\n const lowerQuery = query.toLowerCase()\n const matches: SearchMatch[] = []\n\n for (let i = 0; i < lines.value.length; i++) {\n const line = lines.value[i]\n if (!line) continue\n const lowerLine = line.toLowerCase()\n let pos = 0\n while (pos < lowerLine.length) {\n const idx = lowerLine.indexOf(lowerQuery, pos)\n if (idx === -1) break\n matches.push({ line: i, start: idx, end: idx + query.length })\n pos = idx + lowerQuery.length\n }\n }\n return matches\n})\n\n/** Set of line indices that contain at least one match, for quick lookup */\nconst matchLineSet = computed(() => {\n const set = new Set<number>()\n for (const m of searchMatches.value) set.add(m.line)\n return set\n})\n\n/** Clamp active index when match count changes */\nwatch(\n () => searchMatches.value.length,\n (count) => {\n if (count === 0) {\n activeMatchIndex.value = 0\n } else if (activeMatchIndex.value >= count) {\n activeMatchIndex.value = count - 1\n }\n },\n)\n\n/** Scroll the container so the active match's line is visible */\nconst scrollToMatch = (match: SearchMatch) => {\n if (!containerRef.value) return\n\n const targetScroll = match.line * props.lineHeight\n const viewTop = containerRef.value.scrollTop\n const viewBottom = viewTop + containerRef.value.clientHeight\n\n if (targetScroll < viewTop || targetScroll + props.lineHeight > viewBottom) {\n containerRef.value.scrollTop =\n targetScroll - containerRef.value.clientHeight / 2\n }\n}\n\nconst goToMatch = (index: number) => {\n const match = searchMatches.value[index]\n if (!match) return\n activeMatchIndex.value = index\n scrollToMatch(match)\n}\n\nconst nextMatch = () => {\n if (searchMatches.value.length === 0) return\n goToMatch((activeMatchIndex.value + 1) % searchMatches.value.length)\n}\n\nconst prevMatch = () => {\n if (searchMatches.value.length === 0) return\n goToMatch(\n (activeMatchIndex.value - 1 + searchMatches.value.length) %\n searchMatches.value.length,\n )\n}\n\nconst openSearch = () => {\n searchOpen.value = true\n void nextTick(() => searchRef.value?.focus())\n}\n\nconst closeSearch = () => {\n searchOpen.value = false\n searchQuery.value = ''\n activeMatchIndex.value = 0\n}\n\n/** Intercepts Cmd/Ctrl+F when searchable and the container has focus */\nconst handleKeydown = (e: KeyboardEvent) => {\n if (!props.searchable) return\n\n const modifier = e.metaKey || e.ctrlKey\n if (modifier && e.key === 'f') {\n e.preventDefault()\n e.stopPropagation()\n openSearch()\n }\n if (e.key === 'Escape' && searchOpen.value) {\n e.preventDefault()\n closeSearch()\n }\n}\n\n/** Jump to the first match whenever the query changes */\nwatch(searchQuery, () => {\n activeMatchIndex.value = 0\n const first = searchMatches.value[0]\n if (first) {\n scrollToMatch(first)\n }\n})\n\n// ── Scroll / resize ───────────────────────────────────────────────────\n\n/** Updates the scroll position when the container is scrolled */\nconst handleScroll = () =>\n containerRef.value && (scrollPosition.value = containerRef.value.scrollTop)\n\n/** Updates the container height when the window is resized */\nconst updateContainerHeight = () =>\n containerRef.value &&\n (containerHeight.value = containerRef.value.clientHeight)\n\nonMounted(() => {\n updateContainerHeight()\n window.addEventListener('resize', updateContainerHeight)\n})\n\nonBeforeUnmount(() => {\n window.removeEventListener('resize', updateContainerHeight)\n})\n\n// ── Line rendering with highlights ────────────────────────────────────\n\ntype LineSegment = { text: string; highlight: boolean; active: boolean }\n\n/**\n * For a given absolute line index, splits the line text into segments\n * with match highlighting metadata.\n */\nconst getLineSegments = (absoluteIndex: number): LineSegment[] => {\n const lineText = lines.value[absoluteIndex] ?? ''\n if (!searchQuery.value || !matchLineSet.value.has(absoluteIndex)) {\n return [{ text: lineText, highlight: false, active: false }]\n }\n\n const lineMatches = searchMatches.value.filter(\n (m) => m.line === absoluteIndex,\n )\n const segments: LineSegment[] = []\n let cursor = 0\n\n for (const match of lineMatches) {\n if (match.start > cursor) {\n segments.push({\n text: lineText.slice(cursor, match.start),\n highlight: false,\n active: false,\n })\n }\n\n const isActive =\n searchMatches.value.indexOf(match) === activeMatchIndex.value\n\n segments.push({\n text: lineText.slice(match.start, match.end),\n highlight: true,\n active: isActive,\n })\n cursor = match.end\n }\n\n if (cursor < lineText.length) {\n segments.push({\n text: lineText.slice(cursor),\n highlight: false,\n active: false,\n })\n }\n\n return segments\n}\n\n/**\n * Converts a relative visible-line index to the absolute line index\n * in the full text.\n */\nconst toAbsoluteIndex = (relativeIndex: number): number =>\n renderStartIndex.value + relativeIndex\n</script>\n\n<template>\n <div\n ref=\"containerRef\"\n class=\"scalar-virtual-text relative overflow-auto\"\n :class=\"containerClass\"\n tabindex=\"0\"\n @keydown=\"handleKeydown\"\n @scroll=\"handleScroll\">\n <!-- Zero-height wrapper so the search bar does not contribute to scrollHeight -->\n <div\n v-if=\"searchable && searchOpen\"\n class=\"sticky top-0 z-10 flex h-0 w-full justify-end overflow-visible\">\n <ScalarVirtualTextSearch\n ref=\"searchRef\"\n v-model:query=\"searchQuery\"\n :activeMatchIndex=\"activeMatchIndex\"\n :matchCount=\"searchMatches.length\"\n @close=\"closeSearch\"\n @next=\"nextMatch\"\n @prev=\"prevMatch\" />\n </div>\n\n <!-- Invisible spacer that defines the full scrollable height -->\n <div :style=\"{ height: `${totalHeight}px` }\" />\n <code\n class=\"scalar-virtual-text-content absolute left-0 right-0 top-0\"\n :class=\"contentClass\"\n :style=\"{ transform: `translateY(${contentOffset}px)` }\">\n <div\n v-for=\"(line, index) in visibleLines\"\n :key=\"renderStartIndex + index\"\n class=\"scalar-virtual-text-line\"\n :class=\"lineClass\"\n :style=\"{\n height: `${props.lineHeight}px`,\n lineHeight: `${props.lineHeight}px`,\n }\">\n <template\n v-if=\"searchQuery && matchLineSet.has(toAbsoluteIndex(index))\">\n <template\n v-for=\"(segment, sIdx) in getLineSegments(toAbsoluteIndex(index))\"\n :key=\"sIdx\">\n <mark\n v-if=\"segment.highlight\"\n class=\"scalar-virtual-text-highlight\"\n :class=\"\n segment.active ? 'scalar-virtual-text-highlight-active' : ''\n \">\n {{ segment.text }}\n </mark>\n <template v-else>{{ segment.text }}</template>\n </template>\n </template>\n <template v-else>{{ line }}</template>\n </div>\n </code>\n </div>\n</template>\n\n<style scoped>\n.scalar-virtual-text:focus {\n outline: none;\n}\n\n.scalar-virtual-text-highlight {\n background: var(--scalar-background-accent);\n color: inherit;\n border-radius: 2px;\n padding: 1px 0;\n}\n\n.scalar-virtual-text-highlight-active {\n background: var(--scalar-color-accent);\n color: var(--scalar-background-1);\n border-radius: 2px;\n}\n</style>\n"],"mappings":""}
@@ -1,4 +1,10 @@
1
- import { Fragment, computed, createElementBlock, createElementVNode, defineComponent, normalizeClass, normalizeStyle, onBeforeUnmount, onMounted, openBlock, ref, renderList, toDisplayString, watchEffect } from "vue";
1
+ import ScalarVirtualTextSearch_default from "./ScalarVirtualTextSearch.vue.js";
2
+ import { Fragment, computed, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, nextTick, normalizeClass, normalizeStyle, onBeforeUnmount, onMounted, openBlock, ref, renderList, toDisplayString, watch } from "vue";
3
+ //#region src/components/ScalarVirtualText/ScalarVirtualText.vue?vue&type=script&setup=true&lang.ts
4
+ var _hoisted_1 = {
5
+ key: 0,
6
+ class: "sticky top-0 z-10 flex h-0 w-full justify-end overflow-visible"
7
+ };
2
8
  var ScalarVirtualText_vue_vue_type_script_setup_true_lang_default = /* @__PURE__ */ defineComponent({
3
9
  __name: "ScalarVirtualText",
4
10
  props: {
@@ -6,38 +12,123 @@ var ScalarVirtualText_vue_vue_type_script_setup_true_lang_default = /* @__PURE__
6
12
  lineHeight: { default: 20 },
7
13
  containerClass: { default: "" },
8
14
  contentClass: { default: "" },
9
- lineClass: { default: "" }
15
+ lineClass: { default: "" },
16
+ searchable: {
17
+ type: Boolean,
18
+ default: false
19
+ }
10
20
  },
11
21
  setup(__props) {
12
22
  const props = __props;
13
23
  const containerRef = ref(null);
14
- const contentRef = ref(null);
24
+ const searchRef = ref(null);
15
25
  const scrollPosition = ref(0);
16
26
  const containerHeight = ref(0);
27
+ const searchQuery = ref("");
28
+ const searchOpen = ref(false);
29
+ const activeMatchIndex = ref(0);
17
30
  /** Array of text broken into lines */
18
31
  const lines = computed(() => props.text.split("\n"));
19
- /**
20
- * Total height of all lines combined
21
- *
22
- * TODO: there is a problem with this calculation which screws up the scrollbar a bit
23
- */
32
+ /** Total height of all lines combined */
24
33
  const totalHeight = computed(() => lines.value.length * props.lineHeight);
25
34
  /** Index of the first visible line */
26
35
  const visibleStartIndex = computed(() => Math.floor(scrollPosition.value / props.lineHeight));
27
36
  /** Index of the last visible line */
28
37
  const visibleEndIndex = computed(() => Math.min(Math.ceil((scrollPosition.value + containerHeight.value) / props.lineHeight), lines.value.length));
38
+ const BUFFER = 10;
39
+ /** Index of the first rendered line (clamped, shared between slice and offset) */
40
+ const renderStartIndex = computed(() => Math.max(0, visibleStartIndex.value - BUFFER));
29
41
  /** Array of visible lines, including buffer for smooth scrolling */
30
42
  const visibleLines = computed(() => {
31
- const buffer = 10;
32
- const start = Math.max(0, visibleStartIndex.value - buffer);
33
- const end = Math.min(lines.value.length, visibleEndIndex.value + buffer);
34
- return lines.value.slice(start, end);
43
+ const end = Math.min(lines.value.length, visibleEndIndex.value + BUFFER);
44
+ return lines.value.slice(renderStartIndex.value, end);
45
+ });
46
+ /** Pixel offset for the visible content slice */
47
+ const contentOffset = computed(() => renderStartIndex.value * props.lineHeight);
48
+ const searchMatches = computed(() => {
49
+ const query = searchQuery.value;
50
+ if (!query) return [];
51
+ const lowerQuery = query.toLowerCase();
52
+ const matches = [];
53
+ for (let i = 0; i < lines.value.length; i++) {
54
+ const line = lines.value[i];
55
+ if (!line) continue;
56
+ const lowerLine = line.toLowerCase();
57
+ let pos = 0;
58
+ while (pos < lowerLine.length) {
59
+ const idx = lowerLine.indexOf(lowerQuery, pos);
60
+ if (idx === -1) break;
61
+ matches.push({
62
+ line: i,
63
+ start: idx,
64
+ end: idx + query.length
65
+ });
66
+ pos = idx + lowerQuery.length;
67
+ }
68
+ }
69
+ return matches;
70
+ });
71
+ /** Set of line indices that contain at least one match, for quick lookup */
72
+ const matchLineSet = computed(() => {
73
+ const set = /* @__PURE__ */ new Set();
74
+ for (const m of searchMatches.value) set.add(m.line);
75
+ return set;
76
+ });
77
+ /** Clamp active index when match count changes */
78
+ watch(() => searchMatches.value.length, (count) => {
79
+ if (count === 0) activeMatchIndex.value = 0;
80
+ else if (activeMatchIndex.value >= count) activeMatchIndex.value = count - 1;
81
+ });
82
+ /** Scroll the container so the active match's line is visible */
83
+ const scrollToMatch = (match) => {
84
+ if (!containerRef.value) return;
85
+ const targetScroll = match.line * props.lineHeight;
86
+ const viewTop = containerRef.value.scrollTop;
87
+ const viewBottom = viewTop + containerRef.value.clientHeight;
88
+ if (targetScroll < viewTop || targetScroll + props.lineHeight > viewBottom) containerRef.value.scrollTop = targetScroll - containerRef.value.clientHeight / 2;
89
+ };
90
+ const goToMatch = (index) => {
91
+ const match = searchMatches.value[index];
92
+ if (!match) return;
93
+ activeMatchIndex.value = index;
94
+ scrollToMatch(match);
95
+ };
96
+ const nextMatch = () => {
97
+ if (searchMatches.value.length === 0) return;
98
+ goToMatch((activeMatchIndex.value + 1) % searchMatches.value.length);
99
+ };
100
+ const prevMatch = () => {
101
+ if (searchMatches.value.length === 0) return;
102
+ goToMatch((activeMatchIndex.value - 1 + searchMatches.value.length) % searchMatches.value.length);
103
+ };
104
+ const openSearch = () => {
105
+ searchOpen.value = true;
106
+ nextTick(() => searchRef.value?.focus());
107
+ };
108
+ const closeSearch = () => {
109
+ searchOpen.value = false;
110
+ searchQuery.value = "";
111
+ activeMatchIndex.value = 0;
112
+ };
113
+ /** Intercepts Cmd/Ctrl+F when searchable and the container has focus */
114
+ const handleKeydown = (e) => {
115
+ if (!props.searchable) return;
116
+ if ((e.metaKey || e.ctrlKey) && e.key === "f") {
117
+ e.preventDefault();
118
+ e.stopPropagation();
119
+ openSearch();
120
+ }
121
+ if (e.key === "Escape" && searchOpen.value) {
122
+ e.preventDefault();
123
+ closeSearch();
124
+ }
125
+ };
126
+ /** Jump to the first match whenever the query changes */
127
+ watch(searchQuery, () => {
128
+ activeMatchIndex.value = 0;
129
+ const first = searchMatches.value[0];
130
+ if (first) scrollToMatch(first);
35
131
  });
36
- /** Style object for the content container, controls "scrolling" */
37
- const contentStyle = computed(() => ({
38
- height: `${totalHeight.value}px`,
39
- transform: `translateY(${Math.max(0, visibleStartIndex.value - 10) * props.lineHeight}px)`
40
- }));
41
132
  /** Updates the scroll position when the container is scrolled */
42
133
  const handleScroll = () => containerRef.value && (scrollPosition.value = containerRef.value.scrollTop);
43
134
  /** Updates the container height when the window is resized */
@@ -49,31 +140,86 @@ var ScalarVirtualText_vue_vue_type_script_setup_true_lang_default = /* @__PURE__
49
140
  onBeforeUnmount(() => {
50
141
  window.removeEventListener("resize", updateContainerHeight);
51
142
  });
52
- watchEffect(() => {
53
- if (!contentRef.value) return;
54
- contentRef.value.style.transform = `translateY(${Math.max(0, visibleStartIndex.value - 10) * props.lineHeight}px)`;
55
- });
143
+ const getLineSegments = (absoluteIndex) => {
144
+ const lineText = lines.value[absoluteIndex] ?? "";
145
+ if (!searchQuery.value || !matchLineSet.value.has(absoluteIndex)) return [{
146
+ text: lineText,
147
+ highlight: false,
148
+ active: false
149
+ }];
150
+ const lineMatches = searchMatches.value.filter((m) => m.line === absoluteIndex);
151
+ const segments = [];
152
+ let cursor = 0;
153
+ for (const match of lineMatches) {
154
+ if (match.start > cursor) segments.push({
155
+ text: lineText.slice(cursor, match.start),
156
+ highlight: false,
157
+ active: false
158
+ });
159
+ const isActive = searchMatches.value.indexOf(match) === activeMatchIndex.value;
160
+ segments.push({
161
+ text: lineText.slice(match.start, match.end),
162
+ highlight: true,
163
+ active: isActive
164
+ });
165
+ cursor = match.end;
166
+ }
167
+ if (cursor < lineText.length) segments.push({
168
+ text: lineText.slice(cursor),
169
+ highlight: false,
170
+ active: false
171
+ });
172
+ return segments;
173
+ };
174
+ /**
175
+ * Converts a relative visible-line index to the absolute line index
176
+ * in the full text.
177
+ */
178
+ const toAbsoluteIndex = (relativeIndex) => renderStartIndex.value + relativeIndex;
56
179
  return (_ctx, _cache) => {
57
180
  return openBlock(), createElementBlock("div", {
58
181
  ref_key: "containerRef",
59
182
  ref: containerRef,
60
- class: normalizeClass(["scalar-virtual-text overflow-auto", __props.containerClass]),
183
+ class: normalizeClass(["scalar-virtual-text relative overflow-auto", __props.containerClass]),
184
+ tabindex: "0",
185
+ onKeydown: handleKeydown,
61
186
  onScroll: handleScroll
62
- }, [createElementVNode("code", {
63
- ref_key: "contentRef",
64
- ref: contentRef,
65
- class: normalizeClass(["scalar-virtual-text-content", __props.contentClass]),
66
- style: normalizeStyle(contentStyle.value)
67
- }, [(openBlock(true), createElementBlock(Fragment, null, renderList(visibleLines.value, (line, index) => {
68
- return openBlock(), createElementBlock("div", {
69
- key: visibleStartIndex.value + index,
70
- class: normalizeClass(["scalar-virtual-text-line", __props.lineClass]),
71
- style: normalizeStyle({
72
- height: `${props.lineHeight}px`,
73
- lineHeight: `${props.lineHeight}px`
74
- })
75
- }, toDisplayString(line), 7);
76
- }), 128))], 6)], 34);
187
+ }, [
188
+ __props.searchable && searchOpen.value ? (openBlock(), createElementBlock("div", _hoisted_1, [createVNode(ScalarVirtualTextSearch_default, {
189
+ ref_key: "searchRef",
190
+ ref: searchRef,
191
+ query: searchQuery.value,
192
+ "onUpdate:query": _cache[0] || (_cache[0] = ($event) => searchQuery.value = $event),
193
+ activeMatchIndex: activeMatchIndex.value,
194
+ matchCount: searchMatches.value.length,
195
+ onClose: closeSearch,
196
+ onNext: nextMatch,
197
+ onPrev: prevMatch
198
+ }, null, 8, [
199
+ "query",
200
+ "activeMatchIndex",
201
+ "matchCount"
202
+ ])])) : createCommentVNode("", true),
203
+ createElementVNode("div", { style: normalizeStyle({ height: `${totalHeight.value}px` }) }, null, 4),
204
+ createElementVNode("code", {
205
+ class: normalizeClass(["scalar-virtual-text-content absolute left-0 right-0 top-0", __props.contentClass]),
206
+ style: normalizeStyle({ transform: `translateY(${contentOffset.value}px)` })
207
+ }, [(openBlock(true), createElementBlock(Fragment, null, renderList(visibleLines.value, (line, index) => {
208
+ return openBlock(), createElementBlock("div", {
209
+ key: renderStartIndex.value + index,
210
+ class: normalizeClass(["scalar-virtual-text-line", __props.lineClass]),
211
+ style: normalizeStyle({
212
+ height: `${props.lineHeight}px`,
213
+ lineHeight: `${props.lineHeight}px`
214
+ })
215
+ }, [searchQuery.value && matchLineSet.value.has(toAbsoluteIndex(index)) ? (openBlock(true), createElementBlock(Fragment, { key: 0 }, renderList(getLineSegments(toAbsoluteIndex(index)), (segment, sIdx) => {
216
+ return openBlock(), createElementBlock(Fragment, { key: sIdx }, [segment.highlight ? (openBlock(), createElementBlock("mark", {
217
+ key: 0,
218
+ class: normalizeClass(["scalar-virtual-text-highlight", segment.active ? "scalar-virtual-text-highlight-active" : ""])
219
+ }, toDisplayString(segment.text), 3)) : (openBlock(), createElementBlock(Fragment, { key: 1 }, [createTextVNode(toDisplayString(segment.text), 1)], 64))], 64);
220
+ }), 128)) : (openBlock(), createElementBlock(Fragment, { key: 1 }, [createTextVNode(toDisplayString(line), 1)], 64))], 6);
221
+ }), 128))], 6)
222
+ ], 34);
77
223
  };
78
224
  }
79
225
  });
@@ -1 +1 @@
1
- {"version":3,"file":"ScalarVirtualText.vue.script.js","names":[],"sources":["../../../src/components/ScalarVirtualText/ScalarVirtualText.vue"],"sourcesContent":["<script lang=\"ts\">\n/**\n * Scalar Virtual Text component\n *\n * Renders large blocks of text efficiently using virtualized scrolling.\n * Only the visible lines are rendered to the DOM.\n *\n * @example\n * <ScalarVirtualText text=\"line1\\nline2\\nline3\" :lineHeight=\"20\" />\n */\nexport default {}\n</script>\n<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue'\n\nconst props = withDefaults(\n defineProps<{\n /** Text to display */\n text: string\n /**\n * Height of each line\n * @default 20\n */\n lineHeight?: number\n containerClass?: string\n contentClass?: string\n lineClass?: string\n }>(),\n {\n lineHeight: 20,\n containerClass: '',\n contentClass: '',\n lineClass: '',\n },\n)\n\nconst containerRef = ref<HTMLElement | null>(null)\nconst contentRef = ref<HTMLElement | null>(null)\nconst scrollPosition = ref(0)\nconst containerHeight = ref(0)\n\n/** Array of text broken into lines */\nconst lines = computed(() => props.text.split('\\n'))\n/**\n * Total height of all lines combined\n *\n * TODO: there is a problem with this calculation which screws up the scrollbar a bit\n */\nconst totalHeight = computed(() => lines.value.length * props.lineHeight)\n\n/** Index of the first visible line */\nconst visibleStartIndex = computed(() =>\n Math.floor(scrollPosition.value / props.lineHeight),\n)\n\n/** Index of the last visible line */\nconst visibleEndIndex = computed(() =>\n Math.min(\n Math.ceil(\n (scrollPosition.value + containerHeight.value) / props.lineHeight,\n ),\n lines.value.length,\n ),\n)\n\n/** Array of visible lines, including buffer for smooth scrolling */\nconst visibleLines = computed(() => {\n const buffer = 10\n const start = Math.max(0, visibleStartIndex.value - buffer)\n const end = Math.min(lines.value.length, visibleEndIndex.value + buffer)\n\n return lines.value.slice(start, end)\n})\n\n/** Style object for the content container, controls \"scrolling\" */\nconst contentStyle = computed(() => ({\n height: `${totalHeight.value}px`,\n transform: `translateY(${Math.max(0, visibleStartIndex.value - 10) * props.lineHeight}px)`,\n}))\n\n/** Updates the scroll position when the container is scrolled */\nconst handleScroll = () =>\n containerRef.value && (scrollPosition.value = containerRef.value.scrollTop)\n\n/** Updates the container height when the window is resized */\nconst updateContainerHeight = () =>\n containerRef.value &&\n (containerHeight.value = containerRef.value.clientHeight)\n\nonMounted(() => {\n updateContainerHeight()\n window.addEventListener('resize', updateContainerHeight)\n})\n\nonBeforeUnmount(() => {\n window.removeEventListener('resize', updateContainerHeight)\n})\n\nwatchEffect(() => {\n if (!contentRef.value) {\n return\n }\n\n contentRef.value.style.transform = `translateY(${Math.max(0, visibleStartIndex.value - 10) * props.lineHeight}px)`\n})\n</script>\n\n<template>\n <div\n ref=\"containerRef\"\n class=\"scalar-virtual-text overflow-auto\"\n :class=\"containerClass\"\n @scroll=\"handleScroll\">\n <code\n ref=\"contentRef\"\n class=\"scalar-virtual-text-content\"\n :class=\"contentClass\"\n :style=\"contentStyle\">\n <div\n v-for=\"(line, index) in visibleLines\"\n :key=\"visibleStartIndex + index\"\n class=\"scalar-virtual-text-line\"\n :class=\"lineClass\"\n :style=\"{\n height: `${props.lineHeight}px`,\n lineHeight: `${props.lineHeight}px`,\n }\">\n {{ line }}\n </div>\n </code>\n </div>\n</template>\n"],"mappings":";;;;;;;;;;;EAeA,MAAM,QAAQ;EAqBd,MAAM,eAAe,IAAwB,KAAI;EACjD,MAAM,aAAa,IAAwB,KAAI;EAC/C,MAAM,iBAAiB,IAAI,EAAC;EAC5B,MAAM,kBAAkB,IAAI,EAAC;;EAG7B,MAAM,QAAQ,eAAe,MAAM,KAAK,MAAM,KAAK,CAAA;;;;;;EAMnD,MAAM,cAAc,eAAe,MAAM,MAAM,SAAS,MAAM,WAAU;;EAGxE,MAAM,oBAAoB,eACxB,KAAK,MAAM,eAAe,QAAQ,MAAM,WAAW,CACrD;;EAGA,MAAM,kBAAkB,eACtB,KAAK,IACH,KAAK,MACF,eAAe,QAAQ,gBAAgB,SAAS,MAAM,WACxD,EACD,MAAM,MAAM,OACb,CACH;;EAGA,MAAM,eAAe,eAAe;GAClC,MAAM,SAAS;GACf,MAAM,QAAQ,KAAK,IAAI,GAAG,kBAAkB,QAAQ,OAAM;GAC1D,MAAM,MAAM,KAAK,IAAI,MAAM,MAAM,QAAQ,gBAAgB,QAAQ,OAAM;AAEvE,UAAO,MAAM,MAAM,MAAM,OAAO,IAAG;IACpC;;EAGD,MAAM,eAAe,gBAAgB;GACnC,QAAQ,GAAG,YAAY,MAAM;GAC7B,WAAW,cAAc,KAAK,IAAI,GAAG,kBAAkB,QAAQ,GAAG,GAAG,MAAM,WAAW;GACvF,EAAC;;EAGF,MAAM,qBACJ,aAAa,UAAU,eAAe,QAAQ,aAAa,MAAM;;EAGnE,MAAM,8BACJ,aAAa,UACZ,gBAAgB,QAAQ,aAAa,MAAM;AAE9C,kBAAgB;AACd,0BAAsB;AACtB,UAAO,iBAAiB,UAAU,sBAAqB;IACxD;AAED,wBAAsB;AACpB,UAAO,oBAAoB,UAAU,sBAAqB;IAC3D;AAED,oBAAkB;AAChB,OAAI,CAAC,WAAW,MACd;AAGF,cAAW,MAAM,MAAM,YAAY,cAAc,KAAK,IAAI,GAAG,kBAAkB,QAAQ,GAAG,GAAG,MAAM,WAAW;IAC/G;;uBAIC,mBAsBM,OAAA;aArBA;IAAJ,KAAI;IACJ,OAAK,eAAA,CAAC,qCACE,QAAA,eAAc,CAAA;IACrB,UAAQ;OACT,mBAgBO,QAAA;aAfD;IAAJ,KAAI;IACJ,OAAK,eAAA,CAAC,+BACE,QAAA,aAAY,CAAA;IACnB,OAAK,eAAE,aAAA,MAAY;yBACpB,mBAUM,UAAA,MAAA,WAToB,aAAA,QAAhB,MAAM,UAAK;wBADrB,mBAUM,OAAA;KARH,KAAK,kBAAA,QAAoB;KAC1B,OAAK,eAAA,CAAC,4BACE,QAAA,UAAS,CAAA;KAChB,OAAK,eAAA;iBAAyB,MAAM,WAAU;qBAA+B,MAAM,WAAU;;uBAI3F,KAAI,EAAA,EAAA"}
1
+ {"version":3,"file":"ScalarVirtualText.vue.script.js","names":[],"sources":["../../../src/components/ScalarVirtualText/ScalarVirtualText.vue"],"sourcesContent":["<script lang=\"ts\">\n/**\n * Scalar Virtual Text component\n *\n * Renders large blocks of text efficiently using virtualized scrolling.\n * Only the visible lines are rendered to the DOM. Supports in-content\n * search with match highlighting and navigation (Cmd/Ctrl+F).\n *\n * @example\n * <ScalarVirtualText text=\"line1\\nline2\\nline3\" :lineHeight=\"20\" />\n *\n * @example\n * <ScalarVirtualText text=\"lots of text\" searchable />\n */\nexport default {}\n</script>\n<script setup lang=\"ts\">\nimport { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'\n\nimport ScalarVirtualTextSearch from './ScalarVirtualTextSearch.vue'\n\nconst props = withDefaults(\n defineProps<{\n /** Text to display */\n text: string\n /**\n * Height of each line\n * @default 20\n */\n lineHeight?: number\n containerClass?: string\n contentClass?: string\n lineClass?: string\n /** Enable built-in search with Cmd/Ctrl+F */\n searchable?: boolean\n }>(),\n {\n lineHeight: 20,\n containerClass: '',\n contentClass: '',\n lineClass: '',\n searchable: false,\n },\n)\n\nconst containerRef = ref<HTMLElement | null>(null)\nconst searchRef = ref<InstanceType<typeof ScalarVirtualTextSearch> | null>(null)\nconst scrollPosition = ref(0)\nconst containerHeight = ref(0)\n\nconst searchQuery = ref('')\nconst searchOpen = ref(false)\nconst activeMatchIndex = ref(0)\n\n/** Array of text broken into lines */\nconst lines = computed(() => props.text.split('\\n'))\n/** Total height of all lines combined */\nconst totalHeight = computed(() => lines.value.length * props.lineHeight)\n\n/** Index of the first visible line */\nconst visibleStartIndex = computed(() =>\n Math.floor(scrollPosition.value / props.lineHeight),\n)\n\n/** Index of the last visible line */\nconst visibleEndIndex = computed(() =>\n Math.min(\n Math.ceil(\n (scrollPosition.value + containerHeight.value) / props.lineHeight,\n ),\n lines.value.length,\n ),\n)\n\nconst BUFFER = 10\n\n/** Index of the first rendered line (clamped, shared between slice and offset) */\nconst renderStartIndex = computed(() =>\n Math.max(0, visibleStartIndex.value - BUFFER),\n)\n\n/** Array of visible lines, including buffer for smooth scrolling */\nconst visibleLines = computed(() => {\n const end = Math.min(lines.value.length, visibleEndIndex.value + BUFFER)\n return lines.value.slice(renderStartIndex.value, end)\n})\n\n/** Pixel offset for the visible content slice */\nconst contentOffset = computed(() => renderStartIndex.value * props.lineHeight)\n\n// ── Search ────────────────────────────────────────────────────────────\n\ntype SearchMatch = { line: number; start: number; end: number }\n\n/** All matches across the full text, computed only when the query changes */\nconst searchMatches = computed<SearchMatch[]>(() => {\n const query = searchQuery.value\n if (!query) return []\n\n const lowerQuery = query.toLowerCase()\n const matches: SearchMatch[] = []\n\n for (let i = 0; i < lines.value.length; i++) {\n const line = lines.value[i]\n if (!line) continue\n const lowerLine = line.toLowerCase()\n let pos = 0\n while (pos < lowerLine.length) {\n const idx = lowerLine.indexOf(lowerQuery, pos)\n if (idx === -1) break\n matches.push({ line: i, start: idx, end: idx + query.length })\n pos = idx + lowerQuery.length\n }\n }\n return matches\n})\n\n/** Set of line indices that contain at least one match, for quick lookup */\nconst matchLineSet = computed(() => {\n const set = new Set<number>()\n for (const m of searchMatches.value) set.add(m.line)\n return set\n})\n\n/** Clamp active index when match count changes */\nwatch(\n () => searchMatches.value.length,\n (count) => {\n if (count === 0) {\n activeMatchIndex.value = 0\n } else if (activeMatchIndex.value >= count) {\n activeMatchIndex.value = count - 1\n }\n },\n)\n\n/** Scroll the container so the active match's line is visible */\nconst scrollToMatch = (match: SearchMatch) => {\n if (!containerRef.value) return\n\n const targetScroll = match.line * props.lineHeight\n const viewTop = containerRef.value.scrollTop\n const viewBottom = viewTop + containerRef.value.clientHeight\n\n if (targetScroll < viewTop || targetScroll + props.lineHeight > viewBottom) {\n containerRef.value.scrollTop =\n targetScroll - containerRef.value.clientHeight / 2\n }\n}\n\nconst goToMatch = (index: number) => {\n const match = searchMatches.value[index]\n if (!match) return\n activeMatchIndex.value = index\n scrollToMatch(match)\n}\n\nconst nextMatch = () => {\n if (searchMatches.value.length === 0) return\n goToMatch((activeMatchIndex.value + 1) % searchMatches.value.length)\n}\n\nconst prevMatch = () => {\n if (searchMatches.value.length === 0) return\n goToMatch(\n (activeMatchIndex.value - 1 + searchMatches.value.length) %\n searchMatches.value.length,\n )\n}\n\nconst openSearch = () => {\n searchOpen.value = true\n void nextTick(() => searchRef.value?.focus())\n}\n\nconst closeSearch = () => {\n searchOpen.value = false\n searchQuery.value = ''\n activeMatchIndex.value = 0\n}\n\n/** Intercepts Cmd/Ctrl+F when searchable and the container has focus */\nconst handleKeydown = (e: KeyboardEvent) => {\n if (!props.searchable) return\n\n const modifier = e.metaKey || e.ctrlKey\n if (modifier && e.key === 'f') {\n e.preventDefault()\n e.stopPropagation()\n openSearch()\n }\n if (e.key === 'Escape' && searchOpen.value) {\n e.preventDefault()\n closeSearch()\n }\n}\n\n/** Jump to the first match whenever the query changes */\nwatch(searchQuery, () => {\n activeMatchIndex.value = 0\n const first = searchMatches.value[0]\n if (first) {\n scrollToMatch(first)\n }\n})\n\n// ── Scroll / resize ───────────────────────────────────────────────────\n\n/** Updates the scroll position when the container is scrolled */\nconst handleScroll = () =>\n containerRef.value && (scrollPosition.value = containerRef.value.scrollTop)\n\n/** Updates the container height when the window is resized */\nconst updateContainerHeight = () =>\n containerRef.value &&\n (containerHeight.value = containerRef.value.clientHeight)\n\nonMounted(() => {\n updateContainerHeight()\n window.addEventListener('resize', updateContainerHeight)\n})\n\nonBeforeUnmount(() => {\n window.removeEventListener('resize', updateContainerHeight)\n})\n\n// ── Line rendering with highlights ────────────────────────────────────\n\ntype LineSegment = { text: string; highlight: boolean; active: boolean }\n\n/**\n * For a given absolute line index, splits the line text into segments\n * with match highlighting metadata.\n */\nconst getLineSegments = (absoluteIndex: number): LineSegment[] => {\n const lineText = lines.value[absoluteIndex] ?? ''\n if (!searchQuery.value || !matchLineSet.value.has(absoluteIndex)) {\n return [{ text: lineText, highlight: false, active: false }]\n }\n\n const lineMatches = searchMatches.value.filter(\n (m) => m.line === absoluteIndex,\n )\n const segments: LineSegment[] = []\n let cursor = 0\n\n for (const match of lineMatches) {\n if (match.start > cursor) {\n segments.push({\n text: lineText.slice(cursor, match.start),\n highlight: false,\n active: false,\n })\n }\n\n const isActive =\n searchMatches.value.indexOf(match) === activeMatchIndex.value\n\n segments.push({\n text: lineText.slice(match.start, match.end),\n highlight: true,\n active: isActive,\n })\n cursor = match.end\n }\n\n if (cursor < lineText.length) {\n segments.push({\n text: lineText.slice(cursor),\n highlight: false,\n active: false,\n })\n }\n\n return segments\n}\n\n/**\n * Converts a relative visible-line index to the absolute line index\n * in the full text.\n */\nconst toAbsoluteIndex = (relativeIndex: number): number =>\n renderStartIndex.value + relativeIndex\n</script>\n\n<template>\n <div\n ref=\"containerRef\"\n class=\"scalar-virtual-text relative overflow-auto\"\n :class=\"containerClass\"\n tabindex=\"0\"\n @keydown=\"handleKeydown\"\n @scroll=\"handleScroll\">\n <!-- Zero-height wrapper so the search bar does not contribute to scrollHeight -->\n <div\n v-if=\"searchable && searchOpen\"\n class=\"sticky top-0 z-10 flex h-0 w-full justify-end overflow-visible\">\n <ScalarVirtualTextSearch\n ref=\"searchRef\"\n v-model:query=\"searchQuery\"\n :activeMatchIndex=\"activeMatchIndex\"\n :matchCount=\"searchMatches.length\"\n @close=\"closeSearch\"\n @next=\"nextMatch\"\n @prev=\"prevMatch\" />\n </div>\n\n <!-- Invisible spacer that defines the full scrollable height -->\n <div :style=\"{ height: `${totalHeight}px` }\" />\n <code\n class=\"scalar-virtual-text-content absolute left-0 right-0 top-0\"\n :class=\"contentClass\"\n :style=\"{ transform: `translateY(${contentOffset}px)` }\">\n <div\n v-for=\"(line, index) in visibleLines\"\n :key=\"renderStartIndex + index\"\n class=\"scalar-virtual-text-line\"\n :class=\"lineClass\"\n :style=\"{\n height: `${props.lineHeight}px`,\n lineHeight: `${props.lineHeight}px`,\n }\">\n <template\n v-if=\"searchQuery && matchLineSet.has(toAbsoluteIndex(index))\">\n <template\n v-for=\"(segment, sIdx) in getLineSegments(toAbsoluteIndex(index))\"\n :key=\"sIdx\">\n <mark\n v-if=\"segment.highlight\"\n class=\"scalar-virtual-text-highlight\"\n :class=\"\n segment.active ? 'scalar-virtual-text-highlight-active' : ''\n \">\n {{ segment.text }}\n </mark>\n <template v-else>{{ segment.text }}</template>\n </template>\n </template>\n <template v-else>{{ line }}</template>\n </div>\n </code>\n </div>\n</template>\n\n<style scoped>\n.scalar-virtual-text:focus {\n outline: none;\n}\n\n.scalar-virtual-text-highlight {\n background: var(--scalar-background-accent);\n color: inherit;\n border-radius: 2px;\n padding: 1px 0;\n}\n\n.scalar-virtual-text-highlight-active {\n background: var(--scalar-color-accent);\n color: var(--scalar-background-1);\n border-radius: 2px;\n}\n</style>\n"],"mappings":"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"}
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Floating search bar for ScalarVirtualText
3
+ *
4
+ * Provides find-in-text functionality with match navigation (next/prev)
5
+ * and result count display.
6
+ *
7
+ * @example
8
+ * <ScalarVirtualTextSearch
9
+ * v-model:query="searchQuery"
10
+ * :matchCount="searchMatches.length"
11
+ * :activeMatchIndex="activeMatchIndex"
12
+ * @next="nextMatch"
13
+ * @prev="prevMatch"
14
+ * @close="closeSearch" />
15
+ */
16
+ declare const _default: typeof __VLS_export;
17
+ export default _default;
18
+ declare const __VLS_export: import("vue").DefineComponent<{
19
+ /** Current search query (v-model) */
20
+ query: string;
21
+ /** Total number of matches */
22
+ matchCount: number;
23
+ /** Zero-based index of the currently active match */
24
+ activeMatchIndex: number;
25
+ }, {
26
+ focus: () => void;
27
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
28
+ close: () => any;
29
+ next: () => any;
30
+ prev: () => any;
31
+ "update:query": (value: string) => any;
32
+ }, string, import("vue").PublicProps, Readonly<{
33
+ /** Current search query (v-model) */
34
+ query: string;
35
+ /** Total number of matches */
36
+ matchCount: number;
37
+ /** Zero-based index of the currently active match */
38
+ activeMatchIndex: number;
39
+ }> & Readonly<{
40
+ onClose?: (() => any) | undefined;
41
+ onNext?: (() => any) | undefined;
42
+ onPrev?: (() => any) | undefined;
43
+ "onUpdate:query"?: ((value: string) => any) | undefined;
44
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
45
+ //# sourceMappingURL=ScalarVirtualTextSearch.vue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ScalarVirtualTextSearch.vue.d.ts","sourceRoot":"","sources":["../../../src/components/ScalarVirtualText/ScalarVirtualTextSearch.vue"],"names":[],"mappings":"AAuJA;;;;;;;;;;;;;;GAcG;wBACkB,OAAO,YAAY;AAAxC,wBAAyC;AAGzC,QAAA,MAAM,YAAY;IAEhB,qCAAqC;WAC9B,MAAM;IACb,8BAA8B;gBAClB,MAAM;IAClB,qDAAqD;sBACnC,MAAM;;;;;;;;;IALxB,qCAAqC;WAC9B,MAAM;IACb,8BAA8B;gBAClB,MAAM;IAClB,qDAAqD;sBACnC,MAAM;;;;;;kFAmQtB,CAAC"}
@@ -0,0 +1,9 @@
1
+ import _plugin_vue_export_helper_default from "../../_virtual/_plugin-vue_export-helper.js";
2
+ import ScalarVirtualTextSearch_vue_vue_type_script_setup_true_lang_default from "./ScalarVirtualTextSearch.vue.script.js";
3
+ /* empty css */
4
+ //#region src/components/ScalarVirtualText/ScalarVirtualTextSearch.vue
5
+ var ScalarVirtualTextSearch_default = /* @__PURE__ */ _plugin_vue_export_helper_default(ScalarVirtualTextSearch_vue_vue_type_script_setup_true_lang_default, [["__scopeId", "data-v-95edadbb"]]);
6
+ //#endregion
7
+ export { ScalarVirtualTextSearch_default as default };
8
+
9
+ //# sourceMappingURL=ScalarVirtualTextSearch.vue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ScalarVirtualTextSearch.vue.js","names":[],"sources":["../../../src/components/ScalarVirtualText/ScalarVirtualTextSearch.vue"],"sourcesContent":["<script lang=\"ts\">\n/**\n * Floating search bar for ScalarVirtualText\n *\n * Provides find-in-text functionality with match navigation (next/prev)\n * and result count display.\n *\n * @example\n * <ScalarVirtualTextSearch\n * v-model:query=\"searchQuery\"\n * :matchCount=\"searchMatches.length\"\n * :activeMatchIndex=\"activeMatchIndex\"\n * @next=\"nextMatch\"\n * @prev=\"prevMatch\"\n * @close=\"closeSearch\" />\n */\nexport default {}\n</script>\n<script setup lang=\"ts\">\nimport {\n ScalarIconCaretDown,\n ScalarIconCaretUp,\n ScalarIconMagnifyingGlass,\n ScalarIconX,\n} from '@scalar/icons'\nimport { nextTick, ref, watch } from 'vue'\n\nconst props = defineProps<{\n /** Current search query (v-model) */\n query: string\n /** Total number of matches */\n matchCount: number\n /** Zero-based index of the currently active match */\n activeMatchIndex: number\n}>()\n\nconst emit = defineEmits<{\n (e: 'update:query', value: string): void\n (e: 'next'): void\n (e: 'prev'): void\n (e: 'close'): void\n}>()\n\nconst searchInputRef = ref<HTMLInputElement | null>(null)\n\nconst localQuery = ref(props.query)\n\nwatch(\n () => props.query,\n (val) => {\n localQuery.value = val\n },\n)\n\nwatch(localQuery, (val) => {\n emit('update:query', val)\n})\n\n/** Focus the input when mounted */\nconst focus = () => {\n void nextTick(() => searchInputRef.value?.focus())\n}\n\n/** Navigate on Enter / Shift+Enter inside the search input */\nconst handleSearchKeydown = (e: KeyboardEvent) => {\n if (e.key === 'Enter') {\n e.preventDefault()\n if (e.shiftKey) emit('prev')\n else emit('next')\n }\n if (e.key === 'Escape') {\n e.preventDefault()\n emit('close')\n }\n}\n\ndefineExpose({ focus })\n</script>\n\n<template>\n <div\n class=\"scalar-virtual-text-search ml-auto mr-2 mt-2 flex h-fit w-fit items-center gap-1 overflow-visible rounded-lg bg-b-2 px-2 py-1 shadow-md\"\n @click.stop>\n <ScalarIconMagnifyingGlass\n class=\"pointer-events-none size-3.5 shrink-0 text-c-3\" />\n <input\n ref=\"searchInputRef\"\n v-model=\"localQuery\"\n aria-label=\"Search in text\"\n autocomplete=\"off\"\n class=\"scalar-virtual-text-search-input min-w-0 w-36 appearance-none border-none bg-transparent px-1 py-0.5 text-xs text-c-1 outline-none\"\n placeholder=\"Find...\"\n spellcheck=\"false\"\n type=\"search\"\n @keydown=\"handleSearchKeydown\" />\n <span\n v-if=\"localQuery\"\n class=\"shrink-0 whitespace-nowrap text-xs tabular-nums text-c-3\">\n {{\n matchCount > 0\n ? `${activeMatchIndex + 1} of ${matchCount}`\n : 'No results'\n }}\n </span>\n <span class=\"mx-0.5 h-3.5 w-px bg-b-3\" />\n <button\n aria-label=\"Previous match\"\n class=\"flex size-5 items-center justify-center rounded text-c-3 hover:bg-b-3 hover:text-c-1 disabled:pointer-events-none disabled:opacity-30\"\n :disabled=\"matchCount === 0\"\n title=\"Previous match (Shift+Enter)\"\n type=\"button\"\n @click=\"emit('prev')\">\n <ScalarIconCaretUp class=\"size-3\" />\n </button>\n <button\n aria-label=\"Next match\"\n class=\"flex size-5 items-center justify-center rounded text-c-3 hover:bg-b-3 hover:text-c-1 disabled:pointer-events-none disabled:opacity-30\"\n :disabled=\"matchCount === 0\"\n title=\"Next match (Enter)\"\n type=\"button\"\n @click=\"emit('next')\">\n <ScalarIconCaretDown class=\"size-3\" />\n </button>\n <button\n aria-label=\"Close search\"\n class=\"flex size-5 items-center justify-center rounded text-c-3 hover:bg-b-3 hover:text-c-1\"\n title=\"Close (Escape)\"\n type=\"button\"\n @click=\"emit('close')\">\n <ScalarIconX class=\"size-3\" />\n </button>\n </div>\n</template>\n\n<style scoped>\n.scalar-virtual-text-search-input::-webkit-search-cancel-button {\n display: none;\n}\n</style>\n"],"mappings":""}
@@ -0,0 +1,101 @@
1
+ import { createCommentVNode, createElementBlock, createElementVNode, createVNode, defineComponent, nextTick, openBlock, ref, toDisplayString, unref, vModelText, watch, withDirectives, withModifiers } from "vue";
2
+ import { ScalarIconCaretDown, ScalarIconCaretUp, ScalarIconMagnifyingGlass, ScalarIconX } from "@scalar/icons";
3
+ //#region src/components/ScalarVirtualText/ScalarVirtualTextSearch.vue?vue&type=script&setup=true&lang.ts
4
+ var _hoisted_1 = {
5
+ key: 0,
6
+ class: "shrink-0 whitespace-nowrap text-xs tabular-nums text-c-3"
7
+ };
8
+ var _hoisted_2 = ["disabled"];
9
+ var _hoisted_3 = ["disabled"];
10
+ var ScalarVirtualTextSearch_vue_vue_type_script_setup_true_lang_default = /* @__PURE__ */ defineComponent({
11
+ __name: "ScalarVirtualTextSearch",
12
+ props: {
13
+ query: {},
14
+ matchCount: {},
15
+ activeMatchIndex: {}
16
+ },
17
+ emits: [
18
+ "update:query",
19
+ "next",
20
+ "prev",
21
+ "close"
22
+ ],
23
+ setup(__props, { expose: __expose, emit: __emit }) {
24
+ const props = __props;
25
+ const emit = __emit;
26
+ const searchInputRef = ref(null);
27
+ const localQuery = ref(props.query);
28
+ watch(() => props.query, (val) => {
29
+ localQuery.value = val;
30
+ });
31
+ watch(localQuery, (val) => {
32
+ emit("update:query", val);
33
+ });
34
+ /** Focus the input when mounted */
35
+ const focus = () => {
36
+ nextTick(() => searchInputRef.value?.focus());
37
+ };
38
+ /** Navigate on Enter / Shift+Enter inside the search input */
39
+ const handleSearchKeydown = (e) => {
40
+ if (e.key === "Enter") {
41
+ e.preventDefault();
42
+ if (e.shiftKey) emit("prev");
43
+ else emit("next");
44
+ }
45
+ if (e.key === "Escape") {
46
+ e.preventDefault();
47
+ emit("close");
48
+ }
49
+ };
50
+ __expose({ focus });
51
+ return (_ctx, _cache) => {
52
+ return openBlock(), createElementBlock("div", {
53
+ class: "scalar-virtual-text-search ml-auto mr-2 mt-2 flex h-fit w-fit items-center gap-1 overflow-visible rounded-lg bg-b-2 px-2 py-1 shadow-md",
54
+ onClick: _cache[4] || (_cache[4] = withModifiers(() => {}, ["stop"]))
55
+ }, [
56
+ createVNode(unref(ScalarIconMagnifyingGlass), { class: "pointer-events-none size-3.5 shrink-0 text-c-3" }),
57
+ withDirectives(createElementVNode("input", {
58
+ ref_key: "searchInputRef",
59
+ ref: searchInputRef,
60
+ "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => localQuery.value = $event),
61
+ "aria-label": "Search in text",
62
+ autocomplete: "off",
63
+ class: "scalar-virtual-text-search-input min-w-0 w-36 appearance-none border-none bg-transparent px-1 py-0.5 text-xs text-c-1 outline-none",
64
+ placeholder: "Find...",
65
+ spellcheck: "false",
66
+ type: "search",
67
+ onKeydown: handleSearchKeydown
68
+ }, null, 544), [[vModelText, localQuery.value]]),
69
+ localQuery.value ? (openBlock(), createElementBlock("span", _hoisted_1, toDisplayString(__props.matchCount > 0 ? `${__props.activeMatchIndex + 1} of ${__props.matchCount}` : "No results"), 1)) : createCommentVNode("", true),
70
+ _cache[5] || (_cache[5] = createElementVNode("span", { class: "mx-0.5 h-3.5 w-px bg-b-3" }, null, -1)),
71
+ createElementVNode("button", {
72
+ "aria-label": "Previous match",
73
+ class: "flex size-5 items-center justify-center rounded text-c-3 hover:bg-b-3 hover:text-c-1 disabled:pointer-events-none disabled:opacity-30",
74
+ disabled: __props.matchCount === 0,
75
+ title: "Previous match (Shift+Enter)",
76
+ type: "button",
77
+ onClick: _cache[1] || (_cache[1] = ($event) => emit("prev"))
78
+ }, [createVNode(unref(ScalarIconCaretUp), { class: "size-3" })], 8, _hoisted_2),
79
+ createElementVNode("button", {
80
+ "aria-label": "Next match",
81
+ class: "flex size-5 items-center justify-center rounded text-c-3 hover:bg-b-3 hover:text-c-1 disabled:pointer-events-none disabled:opacity-30",
82
+ disabled: __props.matchCount === 0,
83
+ title: "Next match (Enter)",
84
+ type: "button",
85
+ onClick: _cache[2] || (_cache[2] = ($event) => emit("next"))
86
+ }, [createVNode(unref(ScalarIconCaretDown), { class: "size-3" })], 8, _hoisted_3),
87
+ createElementVNode("button", {
88
+ "aria-label": "Close search",
89
+ class: "flex size-5 items-center justify-center rounded text-c-3 hover:bg-b-3 hover:text-c-1",
90
+ title: "Close (Escape)",
91
+ type: "button",
92
+ onClick: _cache[3] || (_cache[3] = ($event) => emit("close"))
93
+ }, [createVNode(unref(ScalarIconX), { class: "size-3" })])
94
+ ]);
95
+ };
96
+ }
97
+ });
98
+ //#endregion
99
+ export { ScalarVirtualTextSearch_vue_vue_type_script_setup_true_lang_default as default };
100
+
101
+ //# sourceMappingURL=ScalarVirtualTextSearch.vue.script.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ScalarVirtualTextSearch.vue.script.js","names":[],"sources":["../../../src/components/ScalarVirtualText/ScalarVirtualTextSearch.vue"],"sourcesContent":["<script lang=\"ts\">\n/**\n * Floating search bar for ScalarVirtualText\n *\n * Provides find-in-text functionality with match navigation (next/prev)\n * and result count display.\n *\n * @example\n * <ScalarVirtualTextSearch\n * v-model:query=\"searchQuery\"\n * :matchCount=\"searchMatches.length\"\n * :activeMatchIndex=\"activeMatchIndex\"\n * @next=\"nextMatch\"\n * @prev=\"prevMatch\"\n * @close=\"closeSearch\" />\n */\nexport default {}\n</script>\n<script setup lang=\"ts\">\nimport {\n ScalarIconCaretDown,\n ScalarIconCaretUp,\n ScalarIconMagnifyingGlass,\n ScalarIconX,\n} from '@scalar/icons'\nimport { nextTick, ref, watch } from 'vue'\n\nconst props = defineProps<{\n /** Current search query (v-model) */\n query: string\n /** Total number of matches */\n matchCount: number\n /** Zero-based index of the currently active match */\n activeMatchIndex: number\n}>()\n\nconst emit = defineEmits<{\n (e: 'update:query', value: string): void\n (e: 'next'): void\n (e: 'prev'): void\n (e: 'close'): void\n}>()\n\nconst searchInputRef = ref<HTMLInputElement | null>(null)\n\nconst localQuery = ref(props.query)\n\nwatch(\n () => props.query,\n (val) => {\n localQuery.value = val\n },\n)\n\nwatch(localQuery, (val) => {\n emit('update:query', val)\n})\n\n/** Focus the input when mounted */\nconst focus = () => {\n void nextTick(() => searchInputRef.value?.focus())\n}\n\n/** Navigate on Enter / Shift+Enter inside the search input */\nconst handleSearchKeydown = (e: KeyboardEvent) => {\n if (e.key === 'Enter') {\n e.preventDefault()\n if (e.shiftKey) emit('prev')\n else emit('next')\n }\n if (e.key === 'Escape') {\n e.preventDefault()\n emit('close')\n }\n}\n\ndefineExpose({ focus })\n</script>\n\n<template>\n <div\n class=\"scalar-virtual-text-search ml-auto mr-2 mt-2 flex h-fit w-fit items-center gap-1 overflow-visible rounded-lg bg-b-2 px-2 py-1 shadow-md\"\n @click.stop>\n <ScalarIconMagnifyingGlass\n class=\"pointer-events-none size-3.5 shrink-0 text-c-3\" />\n <input\n ref=\"searchInputRef\"\n v-model=\"localQuery\"\n aria-label=\"Search in text\"\n autocomplete=\"off\"\n class=\"scalar-virtual-text-search-input min-w-0 w-36 appearance-none border-none bg-transparent px-1 py-0.5 text-xs text-c-1 outline-none\"\n placeholder=\"Find...\"\n spellcheck=\"false\"\n type=\"search\"\n @keydown=\"handleSearchKeydown\" />\n <span\n v-if=\"localQuery\"\n class=\"shrink-0 whitespace-nowrap text-xs tabular-nums text-c-3\">\n {{\n matchCount > 0\n ? `${activeMatchIndex + 1} of ${matchCount}`\n : 'No results'\n }}\n </span>\n <span class=\"mx-0.5 h-3.5 w-px bg-b-3\" />\n <button\n aria-label=\"Previous match\"\n class=\"flex size-5 items-center justify-center rounded text-c-3 hover:bg-b-3 hover:text-c-1 disabled:pointer-events-none disabled:opacity-30\"\n :disabled=\"matchCount === 0\"\n title=\"Previous match (Shift+Enter)\"\n type=\"button\"\n @click=\"emit('prev')\">\n <ScalarIconCaretUp class=\"size-3\" />\n </button>\n <button\n aria-label=\"Next match\"\n class=\"flex size-5 items-center justify-center rounded text-c-3 hover:bg-b-3 hover:text-c-1 disabled:pointer-events-none disabled:opacity-30\"\n :disabled=\"matchCount === 0\"\n title=\"Next match (Enter)\"\n type=\"button\"\n @click=\"emit('next')\">\n <ScalarIconCaretDown class=\"size-3\" />\n </button>\n <button\n aria-label=\"Close search\"\n class=\"flex size-5 items-center justify-center rounded text-c-3 hover:bg-b-3 hover:text-c-1\"\n title=\"Close (Escape)\"\n type=\"button\"\n @click=\"emit('close')\">\n <ScalarIconX class=\"size-3\" />\n </button>\n </div>\n</template>\n\n<style scoped>\n.scalar-virtual-text-search-input::-webkit-search-cancel-button {\n display: none;\n}\n</style>\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;EA2BA,MAAM,QAAQ;EASd,MAAM,OAAO;EAOb,MAAM,iBAAiB,IAA6B,KAAI;EAExD,MAAM,aAAa,IAAI,MAAM,MAAK;AAElC,cACQ,MAAM,QACX,QAAQ;AACP,cAAW,QAAQ;IAEvB;AAEA,QAAM,aAAa,QAAQ;AACzB,QAAK,gBAAgB,IAAG;IACzB;;EAGD,MAAM,cAAc;AACb,kBAAe,eAAe,OAAO,OAAO,CAAA;;;EAInD,MAAM,uBAAuB,MAAqB;AAChD,OAAI,EAAE,QAAQ,SAAS;AACrB,MAAE,gBAAe;AACjB,QAAI,EAAE,SAAU,MAAK,OAAM;QACtB,MAAK,OAAM;;AAElB,OAAI,EAAE,QAAQ,UAAU;AACtB,MAAE,gBAAe;AACjB,SAAK,QAAO;;;AAIhB,WAAa,EAAE,OAAO,CAAA;;uBAIpB,mBAmDM,OAAA;IAlDJ,OAAM;IACL,SAAK,OAAA,OAAA,OAAA,KAAA,oBAAN,IAAW,CAAA,OAAA,CAAA;;IACX,YAC2D,MAAA,0BAAA,EAAA,EAAzD,OAAM,kDAAgD,CAAA;mBACxD,mBASmC,SAAA;cAR7B;KAAJ,KAAI;6EACe,QAAA;KACnB,cAAW;KACX,cAAa;KACb,OAAM;KACN,aAAY;KACZ,YAAW;KACX,MAAK;KACJ,WAAS;iCAPD,WAAA,MAAU,CAAA,CAAA;IASb,WAAA,SAAA,WAAA,EADR,mBAQO,QARP,YAQO,gBAJH,QAAA,aAAU,IAAA,GAAoB,QAAA,mBAAgB,EAAA,MAAW,QAAA,eAAA,aAAA,EAAA,EAAA,IAAA,mBAAA,IAAA,KAAA;8BAK7D,mBAAyC,QAAA,EAAnC,OAAM,4BAA0B,EAAA,MAAA,GAAA;IACtC,mBAQS,UAAA;KAPP,cAAW;KACX,OAAM;KACL,UAAU,QAAA,eAAU;KACrB,OAAM;KACN,MAAK;KACJ,SAAK,OAAA,OAAA,OAAA,MAAA,WAAE,KAAI,OAAA;QACZ,YAAoC,MAAA,kBAAA,EAAA,EAAjB,OAAM,UAAQ,CAAA,CAAA,EAAA,GAAA,WAAA;IAEnC,mBAQS,UAAA;KAPP,cAAW;KACX,OAAM;KACL,UAAU,QAAA,eAAU;KACrB,OAAM;KACN,MAAK;KACJ,SAAK,OAAA,OAAA,OAAA,MAAA,WAAE,KAAI,OAAA;QACZ,YAAsC,MAAA,oBAAA,EAAA,EAAjB,OAAM,UAAQ,CAAA,CAAA,EAAA,GAAA,WAAA;IAErC,mBAOS,UAAA;KANP,cAAW;KACX,OAAM;KACN,OAAM;KACN,MAAK;KACJ,SAAK,OAAA,OAAA,OAAA,MAAA,WAAE,KAAI,QAAA;QACZ,YAA8B,MAAA,YAAA,EAAA,EAAjB,OAAM,UAAQ,CAAA,CAAA,CAAA"}