@leavepulse/ui 0.12.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.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/component-names.d.ts +1 -0
- package/dist/components/LayoutCanvas.vue.d.ts +28 -0
- package/dist/components/LayoutNode.vue.d.ts +40 -0
- package/dist/components/LpAlert.vue.d.ts +44 -0
- package/dist/components/LpAppShell.vue.d.ts +83 -0
- package/dist/components/LpAutocomplete.vue.d.ts +50 -0
- package/dist/components/LpAvatar.vue.d.ts +20 -0
- package/dist/components/LpBadge.vue.d.ts +47 -0
- package/dist/components/LpBreadcrumbs.vue.d.ts +14 -0
- package/dist/components/LpButton.vue.d.ts +113 -0
- package/dist/components/LpCalendar.vue.d.ts +20 -0
- package/dist/components/LpCard.vue.d.ts +29 -0
- package/dist/components/LpCheckbox.vue.d.ts +28 -0
- package/dist/components/LpCodeBlock.vue.d.ts +39 -0
- package/dist/components/LpCommandPalette.vue.d.ts +42 -0
- package/dist/components/LpConfirmDialog.vue.d.ts +32 -0
- package/dist/components/LpContextMenu.vue.d.ts +30 -0
- package/dist/components/LpDatePicker.vue.d.ts +25 -0
- package/dist/components/LpDisclosure.vue.d.ts +37 -0
- package/dist/components/LpDivider.vue.d.ts +17 -0
- package/dist/components/LpDrawer.vue.d.ts +88 -0
- package/dist/components/LpDropdownMenu.vue.d.ts +23 -0
- package/dist/components/LpEmptyState.vue.d.ts +18 -0
- package/dist/components/LpFormField.vue.d.ts +21 -0
- package/dist/components/LpIcon.vue.d.ts +8 -0
- package/dist/components/LpInfraNode.vue.d.ts +21 -0
- package/dist/components/LpInput.vue.d.ts +68 -0
- package/dist/components/LpLaneNode.vue.d.ts +24 -0
- package/dist/components/LpLink.vue.d.ts +27 -0
- package/dist/components/LpLogViewer.vue.d.ts +92 -0
- package/dist/components/LpModal.vue.d.ts +43 -0
- package/dist/components/LpNotificationBell.vue.d.ts +51 -0
- package/dist/components/LpNumberField.vue.d.ts +14 -0
- package/dist/components/LpOtpInput.vue.d.ts +27 -0
- package/dist/components/LpPagination.vue.d.ts +25 -0
- package/dist/components/LpPasswordInput.vue.d.ts +28 -0
- package/dist/components/LpPhoneInput.vue.d.ts +37 -0
- package/dist/components/LpPopover.vue.d.ts +34 -0
- package/dist/components/LpProgress.vue.d.ts +12 -0
- package/dist/components/LpRadio.vue.d.ts +18 -0
- package/dist/components/LpRadioGroup.vue.d.ts +26 -0
- package/dist/components/LpScrollArea.vue.d.ts +24 -0
- package/dist/components/LpSegmented.vue.d.ts +24 -0
- package/dist/components/LpSelect.vue.d.ts +45 -0
- package/dist/components/LpServiceNode.vue.d.ts +14 -0
- package/dist/components/LpSidebar.vue.d.ts +85 -0
- package/dist/components/LpSidebarNav.vue.d.ts +34 -0
- package/dist/components/LpSkeleton.vue.d.ts +6 -0
- package/dist/components/LpSlider.vue.d.ts +23 -0
- package/dist/components/LpStat.vue.d.ts +22 -0
- package/dist/components/LpStepper.vue.d.ts +10 -0
- package/dist/components/LpSwitch.vue.d.ts +11 -0
- package/dist/components/LpTable.vue.d.ts +63 -0
- package/dist/components/LpTableOfContents.vue.d.ts +19 -0
- package/dist/components/LpTabs.vue.d.ts +45 -0
- package/dist/components/LpTextarea.vue.d.ts +17 -0
- package/dist/components/LpThemeSwitcher.vue.d.ts +40 -0
- package/dist/components/LpToaster.vue.d.ts +3 -0
- package/dist/components/LpTooltip.vue.d.ts +19 -0
- package/dist/components/LpTopologyCanvas.vue.d.ts +154 -0
- package/dist/components/LpUptimeBar.vue.d.ts +30 -0
- package/dist/components/codeHighlight.d.ts +8 -0
- package/dist/components/countries-data.d.ts +2 -0
- package/dist/components/countries.d.ts +20 -0
- package/dist/components/dropdown.d.ts +29 -0
- package/dist/components/sidebar.d.ts +18 -0
- package/dist/composables/useInputFilter.d.ts +14 -0
- package/dist/composables/usePillTransition.d.ts +9 -0
- package/dist/composables/useToast.d.ts +39 -0
- package/dist/countries-data-9rgqJWHo.js +203 -0
- package/dist/icons.d.ts +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.js +17600 -0
- package/dist/layout/registry.d.ts +26 -0
- package/dist/layout/tree.d.ts +75 -0
- package/dist/layout/useLayout.d.ts +39 -0
- package/dist/theme/config.d.ts +16 -0
- package/dist/theme/define.d.ts +12 -0
- package/dist/theme/presets.d.ts +20 -0
- package/dist/theme/tokens.d.ts +72 -0
- package/dist/theme/useTheme.d.ts +36 -0
- package/package.json +93 -0
- package/src/canvas.css +67 -0
- package/src/component-names.ts +65 -0
- package/src/components/LayoutCanvas.vue +76 -0
- package/src/components/LayoutNode.vue +248 -0
- package/src/components/LpAlert.vue +59 -0
- package/src/components/LpAppShell.vue +210 -0
- package/src/components/LpAutocomplete.vue +244 -0
- package/src/components/LpAvatar.vue +67 -0
- package/src/components/LpBadge.vue +32 -0
- package/src/components/LpBreadcrumbs.vue +35 -0
- package/src/components/LpButton.vue +111 -0
- package/src/components/LpCalendar.vue +134 -0
- package/src/components/LpCard.vue +71 -0
- package/src/components/LpCheckbox.vue +44 -0
- package/src/components/LpCodeBlock.vue +298 -0
- package/src/components/LpCommandPalette.vue +323 -0
- package/src/components/LpConfirmDialog.vue +49 -0
- package/src/components/LpContextMenu.vue +111 -0
- package/src/components/LpDatePicker.vue +117 -0
- package/src/components/LpDisclosure.vue +92 -0
- package/src/components/LpDivider.vue +20 -0
- package/src/components/LpDrawer.vue +402 -0
- package/src/components/LpDropdownMenu.vue +54 -0
- package/src/components/LpEmptyState.vue +21 -0
- package/src/components/LpFormField.vue +31 -0
- package/src/components/LpIcon.vue +49 -0
- package/src/components/LpInfraNode.vue +108 -0
- package/src/components/LpInput.vue +147 -0
- package/src/components/LpLaneNode.vue +48 -0
- package/src/components/LpLink.vue +48 -0
- package/src/components/LpLogViewer.vue +569 -0
- package/src/components/LpModal.vue +123 -0
- package/src/components/LpNotificationBell.vue +238 -0
- package/src/components/LpNumberField.vue +56 -0
- package/src/components/LpOtpInput.vue +81 -0
- package/src/components/LpPagination.vue +165 -0
- package/src/components/LpPasswordInput.vue +50 -0
- package/src/components/LpPhoneInput.vue +241 -0
- package/src/components/LpPopover.vue +42 -0
- package/src/components/LpProgress.vue +30 -0
- package/src/components/LpRadio.vue +28 -0
- package/src/components/LpRadioGroup.vue +43 -0
- package/src/components/LpScrollArea.vue +82 -0
- package/src/components/LpSegmented.vue +83 -0
- package/src/components/LpSelect.vue +0 -0
- package/src/components/LpServiceNode.vue +57 -0
- package/src/components/LpSidebar.vue +200 -0
- package/src/components/LpSidebarNav.vue +118 -0
- package/src/components/LpSkeleton.vue +16 -0
- package/src/components/LpSlider.vue +53 -0
- package/src/components/LpStat.vue +90 -0
- package/src/components/LpStepper.vue +46 -0
- package/src/components/LpSwitch.vue +19 -0
- package/src/components/LpTable.vue +252 -0
- package/src/components/LpTableOfContents.vue +183 -0
- package/src/components/LpTabs.vue +120 -0
- package/src/components/LpTextarea.vue +39 -0
- package/src/components/LpThemeSwitcher.vue +168 -0
- package/src/components/LpToaster.vue +216 -0
- package/src/components/LpTooltip.vue +30 -0
- package/src/components/LpTopologyCanvas.vue +370 -0
- package/src/components/LpUptimeBar.vue +126 -0
- package/src/components/codeHighlight.ts +137 -0
- package/src/components/countries-data.ts +207 -0
- package/src/components/countries.ts +67 -0
- package/src/components/dropdown.ts +64 -0
- package/src/components/sidebar.ts +26 -0
- package/src/composables/useInputFilter.ts +89 -0
- package/src/composables/usePillTransition.ts +19 -0
- package/src/composables/useToast.ts +92 -0
- package/src/icons.ts +11 -0
- package/src/index.css +21 -0
- package/src/index.ts +146 -0
- package/src/layout/registry.ts +44 -0
- package/src/layout/tree.ts +288 -0
- package/src/layout/useLayout.ts +134 -0
- package/src/nuxt.ts +22 -0
- package/src/theme/config.ts +48 -0
- package/src/theme/define.ts +50 -0
- package/src/theme/presets.ts +279 -0
- package/src/theme/tokens.ts +151 -0
- package/src/theme/useTheme.ts +288 -0
- package/src/tokens/scrollbar.css +138 -0
- package/src/tokens/tokens.css +333 -0
- package/src/tokens/utilities.css +23 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Opt out of auto attr inheritance: the template has two root branches (menu /
|
|
3
|
+
// no-menu), so fallthrough class/attrs/events are bound explicitly onto the
|
|
4
|
+
// inner <div> via v-bind="$attrs" in both — keeping them on the card surface.
|
|
5
|
+
export default { inheritAttrs: false }
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<script setup lang="ts">
|
|
9
|
+
import { tv, type VariantProps } from "tailwind-variants"
|
|
10
|
+
import { computed } from "vue"
|
|
11
|
+
import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
|
|
12
|
+
|
|
13
|
+
const card = tv({
|
|
14
|
+
base: "rounded-card border bg-surface-raised",
|
|
15
|
+
variants: {
|
|
16
|
+
variant: {
|
|
17
|
+
// raised opts into the skin painter (.lp-skin-panel) so it follows the
|
|
18
|
+
// active surface tokens (flat → glass) instead of a fixed shadow.
|
|
19
|
+
raised: "lp-skin-panel border-line",
|
|
20
|
+
flat: "border-line",
|
|
21
|
+
ghost: "border-transparent bg-transparent",
|
|
22
|
+
},
|
|
23
|
+
padded: { true: "p-5", false: "" },
|
|
24
|
+
// Lift on hover with a BRAND-tinted glow + brand border (a black drop-shadow
|
|
25
|
+
// was invisible on dark themes — it only showed on light). We write the glow
|
|
26
|
+
// through `--surface-panel-shadow`, the very variable the skin-painter feeds
|
|
27
|
+
// into .lp-skin-panel's box-shadow, AND set box-shadow directly — so the glow
|
|
28
|
+
// lands on both the `raised` variant (skin panel, whose own box-shadow would
|
|
29
|
+
// otherwise win, being declared outside @layer) and `flat`/`ghost`.
|
|
30
|
+
// NOTE: in Tailwind v4 `translate-*` writes the native `translate` property
|
|
31
|
+
// (not `transform`), so the transition list MUST name `translate` explicitly
|
|
32
|
+
// or the lift snaps while the rest eases.
|
|
33
|
+
interactive: {
|
|
34
|
+
true: "cursor-pointer transition-[translate,box-shadow,border-color] duration-medium ease-[var(--ease-emphasized)] hover:-translate-y-0.5 hover:border-brand/40 hover:shadow-[var(--lp-card-glow)] hover:[--surface-panel-shadow:var(--lp-card-glow)] [--lp-card-glow:0_8px_24px_-6px_color-mix(in_srgb,var(--color-brand)_38%,transparent)] motion-reduce:transition-none motion-reduce:hover:translate-y-0",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
type CardVariants = VariantProps<typeof card>
|
|
40
|
+
|
|
41
|
+
// Defaults live on the props, not on tv() — passing `undefined` into tv
|
|
42
|
+
// overrides its defaultVariants and silently drops the padding.
|
|
43
|
+
const props = withDefaults(
|
|
44
|
+
defineProps<{
|
|
45
|
+
variant?: CardVariants["variant"]
|
|
46
|
+
padded?: boolean
|
|
47
|
+
interactive?: boolean
|
|
48
|
+
/** Right-click menu for the card. Omit (or pass []) to keep the native one. */
|
|
49
|
+
menuItems?: ContextMenuItemDef[]
|
|
50
|
+
}>(),
|
|
51
|
+
{ variant: "raised", padded: true, interactive: false },
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const classes = computed(() =>
|
|
55
|
+
card({ variant: props.variant, padded: props.padded, interactive: props.interactive }),
|
|
56
|
+
)
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<!-- Fallthrough class/attrs/events are bound onto the <div> in both branches
|
|
61
|
+
(inheritAttrs:false above), so the card surface looks identical whether or
|
|
62
|
+
not it's wrapped in a context menu. -->
|
|
63
|
+
<LpContextMenu v-if="menuItems?.length" :items="menuItems">
|
|
64
|
+
<div :class="classes" v-bind="$attrs">
|
|
65
|
+
<slot />
|
|
66
|
+
</div>
|
|
67
|
+
</LpContextMenu>
|
|
68
|
+
<div v-else :class="classes" v-bind="$attrs">
|
|
69
|
+
<slot />
|
|
70
|
+
</div>
|
|
71
|
+
</template>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { CheckboxIndicator, CheckboxRoot } from "reka-ui"
|
|
3
|
+
import LpIcon from "./LpIcon.vue"
|
|
4
|
+
|
|
5
|
+
defineProps<{
|
|
6
|
+
modelValue?: boolean
|
|
7
|
+
/** Mixed state (e.g. a "select all" with a partial selection). Shows a dash. */
|
|
8
|
+
indeterminate?: boolean
|
|
9
|
+
disabled?: boolean
|
|
10
|
+
label?: string
|
|
11
|
+
id?: string
|
|
12
|
+
name?: string
|
|
13
|
+
}>()
|
|
14
|
+
const emit = defineEmits<{
|
|
15
|
+
(e: "update:modelValue", value: boolean): void
|
|
16
|
+
(e: "change", value: boolean): void
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
function onUpdate(v: boolean | "indeterminate") {
|
|
20
|
+
const checked = v === true
|
|
21
|
+
emit("update:modelValue", checked)
|
|
22
|
+
emit("change", checked)
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<label class="group inline-flex cursor-pointer items-center gap-2 text-sm text-ink">
|
|
28
|
+
<CheckboxRoot
|
|
29
|
+
:id="id"
|
|
30
|
+
:name="name"
|
|
31
|
+
:model-value="indeterminate ? 'indeterminate' : modelValue"
|
|
32
|
+
:disabled="disabled"
|
|
33
|
+
class="flex size-[18px] items-center justify-center rounded-[5px] border border-line-strong bg-surface-soft outline-none transition-[color,background-color,border-color,box-shadow,scale] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] not-data-[state=checked]:not-data-[state=indeterminate]:group-hover:border-brand active:scale-90 motion-reduce:active:scale-100 focus-visible:ring-2 focus-visible:ring-ring data-[state=checked]:border-transparent data-[state=checked]:bg-brand data-[state=indeterminate]:border-transparent data-[state=indeterminate]:bg-brand disabled:cursor-not-allowed disabled:opacity-55"
|
|
34
|
+
@update:model-value="onUpdate"
|
|
35
|
+
>
|
|
36
|
+
<CheckboxIndicator
|
|
37
|
+
class="flex items-center justify-center text-ink-inverse data-[state=checked]:animate-[indicator-in_140ms_var(--ease-emphasized)] data-[state=indeterminate]:animate-[indicator-in_140ms_var(--ease-emphasized)]"
|
|
38
|
+
>
|
|
39
|
+
<LpIcon :name="indeterminate ? 'lucide:minus' : 'lucide:check'" :size="13" />
|
|
40
|
+
</CheckboxIndicator>
|
|
41
|
+
</CheckboxRoot>
|
|
42
|
+
<slot>{{ label }}</slot>
|
|
43
|
+
</label>
|
|
44
|
+
</template>
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Code block with lightweight, theme-aware syntax highlighting (codeHighlight.ts
|
|
4
|
+
* — zero deps, synchronous). Monospace, optional line numbers + wrapping, a copy
|
|
5
|
+
* button, and an optional language label.
|
|
6
|
+
*
|
|
7
|
+
* Lock: `locked` (default true) is read-only. Set `:locked="false"` to make it an
|
|
8
|
+
* editable code field — v-model writes back the text, highlighting stays in sync,
|
|
9
|
+
* and a header toggle flips lock/unlock. Useful for editable config snippets.
|
|
10
|
+
*
|
|
11
|
+
* Folding: wrap a range in `// #region label` … `// #endregion` markers (or `#`
|
|
12
|
+
* for shell/python/yaml/toml) to make it collapsible — handy for boilerplate that
|
|
13
|
+
* is useful but not essential to the point. The marker lines are removed from the
|
|
14
|
+
* output and from copied text. `foldImports` additionally collapses a leading
|
|
15
|
+
* import/package block automatically without markers. Regions collapse by default;
|
|
16
|
+
* set `region:open` (e.g. `// #region label open`) to start expanded.
|
|
17
|
+
*/
|
|
18
|
+
import { computed, reactive, ref, watch } from "vue"
|
|
19
|
+
import { type CodeLang, tokenizeLine } from "./codeHighlight"
|
|
20
|
+
import LpIcon from "./LpIcon.vue"
|
|
21
|
+
|
|
22
|
+
const props = withDefaults(
|
|
23
|
+
defineProps<{
|
|
24
|
+
/** The code (v-model when editable). */
|
|
25
|
+
modelValue?: string
|
|
26
|
+
lang?: CodeLang
|
|
27
|
+
/** Show 1-based gutter line numbers. */
|
|
28
|
+
lineNumbers?: boolean
|
|
29
|
+
/** Wrap long lines instead of scrolling horizontally. */
|
|
30
|
+
wrap?: boolean
|
|
31
|
+
/** Read-only. Set false for an editable field with a lock toggle. */
|
|
32
|
+
locked?: boolean
|
|
33
|
+
/** Show the lock/unlock toggle in the header (opt-in editability). */
|
|
34
|
+
lockToggle?: boolean
|
|
35
|
+
/** Show the copy button. */
|
|
36
|
+
copyable?: boolean
|
|
37
|
+
/** Caption shown in the header (e.g. a filename). */
|
|
38
|
+
title?: string
|
|
39
|
+
/** Fixed height; defaults to content height. */
|
|
40
|
+
height?: string
|
|
41
|
+
/** Auto-collapse a leading import/package block (no markers needed). */
|
|
42
|
+
foldImports?: boolean
|
|
43
|
+
}>(),
|
|
44
|
+
{
|
|
45
|
+
lang: "plain",
|
|
46
|
+
locked: true,
|
|
47
|
+
lockToggle: false,
|
|
48
|
+
copyable: true,
|
|
49
|
+
lineNumbers: false,
|
|
50
|
+
wrap: false,
|
|
51
|
+
foldImports: false,
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const emit = defineEmits<{
|
|
56
|
+
(e: "update:modelValue", value: string): void
|
|
57
|
+
(e: "update:locked", value: boolean): void
|
|
58
|
+
}>()
|
|
59
|
+
|
|
60
|
+
const code = computed(() => props.modelValue ?? "")
|
|
61
|
+
const rawLines = computed(() => code.value.replace(/\n$/, "").split("\n"))
|
|
62
|
+
|
|
63
|
+
// ── fold parsing ─────────────────────────────────────────────
|
|
64
|
+
// A segment is either a run of plain lines or a collapsible region. Line objects
|
|
65
|
+
// carry their original 1-based number so the gutter stays truthful after markers
|
|
66
|
+
// are stripped.
|
|
67
|
+
type NumberedLine = { n: number; text: string }
|
|
68
|
+
type Segment =
|
|
69
|
+
| { kind: "lines"; lines: NumberedLine[] }
|
|
70
|
+
| { kind: "fold"; id: number; label: string; defaultOpen: boolean; lines: NumberedLine[] }
|
|
71
|
+
|
|
72
|
+
const REGION_START = /^\s*(?:\/\/|#|--)\s*#region\b\s*(.*?)\s*$/
|
|
73
|
+
const REGION_END = /^\s*(?:\/\/|#|--)\s*#endregion\b/
|
|
74
|
+
const IMPORT_LINE = /^\s*(?:import|package|use|from|#include|using)\b/
|
|
75
|
+
|
|
76
|
+
/** Split raw lines into plain/fold segments, dropping the marker lines. */
|
|
77
|
+
const segments = computed<Segment[]>(() => {
|
|
78
|
+
const out: Segment[] = []
|
|
79
|
+
const src = rawLines.value
|
|
80
|
+
let plain: NumberedLine[] = []
|
|
81
|
+
let foldId = 0
|
|
82
|
+
|
|
83
|
+
const flushPlain = () => {
|
|
84
|
+
if (plain.length) {
|
|
85
|
+
out.push({ kind: "lines", lines: plain })
|
|
86
|
+
plain = []
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Optional automatic import fold: a leading block of import/package lines that
|
|
91
|
+
// isn't already inside an explicit region.
|
|
92
|
+
let i = 0
|
|
93
|
+
if (props.foldImports && !REGION_START.test(src[0] ?? "")) {
|
|
94
|
+
// Skip a leading blank line or two (rare), then collect the import run.
|
|
95
|
+
let start = 0
|
|
96
|
+
while (start < src.length && src[start].trim() === "") start++
|
|
97
|
+
let end = start
|
|
98
|
+
while (end < src.length && (IMPORT_LINE.test(src[end]) || src[end].trim() === "")) end++
|
|
99
|
+
// Trim trailing blank lines back out of the region.
|
|
100
|
+
let realEnd = end
|
|
101
|
+
while (realEnd > start && src[realEnd - 1].trim() === "") realEnd--
|
|
102
|
+
if (realEnd - start >= 1) {
|
|
103
|
+
// Keep any lines before `start` as plain (e.g. a leading blank).
|
|
104
|
+
for (let k = 0; k < start; k++) plain.push({ n: k + 1, text: src[k] })
|
|
105
|
+
flushPlain()
|
|
106
|
+
const lines: NumberedLine[] = []
|
|
107
|
+
for (let k = start; k < realEnd; k++) lines.push({ n: k + 1, text: src[k] })
|
|
108
|
+
out.push({ kind: "fold", id: foldId++, label: "imports", defaultOpen: false, lines })
|
|
109
|
+
i = realEnd
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (; i < src.length; i++) {
|
|
114
|
+
const line = src[i]
|
|
115
|
+
const startMatch = line.match(REGION_START)
|
|
116
|
+
if (startMatch) {
|
|
117
|
+
flushPlain()
|
|
118
|
+
const rest = startMatch[1] ?? ""
|
|
119
|
+
const defaultOpen = /\bopen\b/.test(rest)
|
|
120
|
+
const label = rest.replace(/\bopen\b/, "").trim() || "details"
|
|
121
|
+
const lines: NumberedLine[] = []
|
|
122
|
+
i++
|
|
123
|
+
for (; i < src.length; i++) {
|
|
124
|
+
if (REGION_END.test(src[i])) break
|
|
125
|
+
lines.push({ n: i + 1, text: src[i] })
|
|
126
|
+
}
|
|
127
|
+
out.push({ kind: "fold", id: foldId++, label, defaultOpen, lines })
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
plain.push({ n: i + 1, text: line })
|
|
131
|
+
}
|
|
132
|
+
flushPlain()
|
|
133
|
+
return out
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const hasFolds = computed(() => segments.value.some((s) => s.kind === "fold"))
|
|
137
|
+
|
|
138
|
+
// Per-region open state, keyed by fold id, defaulting to each region's declared
|
|
139
|
+
// open flag. Reset when the set of folds changes.
|
|
140
|
+
const openState = reactive<Record<number, boolean>>({})
|
|
141
|
+
watch(
|
|
142
|
+
segments,
|
|
143
|
+
(segs) => {
|
|
144
|
+
for (const key of Object.keys(openState)) delete openState[Number(key)]
|
|
145
|
+
for (const s of segs) if (s.kind === "fold") openState[s.id] = s.defaultOpen
|
|
146
|
+
},
|
|
147
|
+
{ immediate: true },
|
|
148
|
+
)
|
|
149
|
+
function toggleFold(id: number) {
|
|
150
|
+
openState[id] = !openState[id]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Highlight one line's text into tokens. */
|
|
154
|
+
function tokens(text: string) {
|
|
155
|
+
return tokenizeLine(text, props.lang)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Copied text is the real code with marker lines removed (so it pastes clean),
|
|
159
|
+
// regardless of what's currently collapsed.
|
|
160
|
+
const copyText = computed(() =>
|
|
161
|
+
segments.value
|
|
162
|
+
.flatMap((s) => s.lines.map((l) => l.text))
|
|
163
|
+
.join("\n"),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const showHeader = computed(
|
|
167
|
+
() => Boolean(props.title || props.lang !== "plain" || props.copyable || props.lockToggle),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
// ── copy ─────────────────────────────────────────────────────
|
|
171
|
+
const copied = ref(false)
|
|
172
|
+
async function copy() {
|
|
173
|
+
try {
|
|
174
|
+
await navigator.clipboard.writeText(copyText.value)
|
|
175
|
+
copied.value = true
|
|
176
|
+
window.setTimeout(() => (copied.value = false), 1400)
|
|
177
|
+
} catch {
|
|
178
|
+
/* clipboard blocked — no-op */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function toggleLock() {
|
|
183
|
+
emit("update:locked", !props.locked)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function onInput(e: Event) {
|
|
187
|
+
emit("update:modelValue", (e.target as HTMLTextAreaElement).value)
|
|
188
|
+
}
|
|
189
|
+
</script>
|
|
190
|
+
|
|
191
|
+
<template>
|
|
192
|
+
<div class="overflow-hidden rounded-card border border-line bg-surface font-mono text-xs">
|
|
193
|
+
<!-- Header -->
|
|
194
|
+
<div
|
|
195
|
+
v-if="showHeader"
|
|
196
|
+
class="flex items-center gap-2 border-b border-line bg-surface-soft px-3 py-1.5 text-muted"
|
|
197
|
+
>
|
|
198
|
+
<span v-if="title" class="truncate text-ink">{{ title }}</span>
|
|
199
|
+
<span v-if="lang !== 'plain'" class="rounded bg-surface px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
|
|
200
|
+
{{ lang }}
|
|
201
|
+
</span>
|
|
202
|
+
<div class="ml-auto flex items-center gap-1">
|
|
203
|
+
<button
|
|
204
|
+
v-if="lockToggle"
|
|
205
|
+
type="button"
|
|
206
|
+
class="group/lock flex items-center gap-1 rounded px-1.5 py-1 outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
|
|
207
|
+
:aria-label="locked ? 'Unlock to edit' : 'Lock (read-only)'"
|
|
208
|
+
@click="toggleLock"
|
|
209
|
+
>
|
|
210
|
+
<LpIcon
|
|
211
|
+
:name="locked ? 'lucide:lock' : 'lucide:lock-open'"
|
|
212
|
+
:size="13"
|
|
213
|
+
class="transition-transform duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] group-active/lock:scale-90"
|
|
214
|
+
/>
|
|
215
|
+
</button>
|
|
216
|
+
<button
|
|
217
|
+
v-if="copyable"
|
|
218
|
+
type="button"
|
|
219
|
+
class="group/copy flex items-center gap-1 rounded px-1.5 py-1 outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
|
|
220
|
+
:aria-label="copied ? 'Copied' : 'Copy code'"
|
|
221
|
+
@click="copy"
|
|
222
|
+
>
|
|
223
|
+
<LpIcon
|
|
224
|
+
:name="copied ? 'lucide:check' : 'lucide:copy'"
|
|
225
|
+
:size="13"
|
|
226
|
+
:class="copied ? 'text-action' : ''"
|
|
227
|
+
/>
|
|
228
|
+
</button>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<!-- Body. When there are no folds this stays a plain overlay editor; with
|
|
233
|
+
folds we render segments (the textarea editor is folding-agnostic and is
|
|
234
|
+
only used when unlocked, which authored docs never combine with folds). -->
|
|
235
|
+
<div
|
|
236
|
+
class="lp-scrollbar-none relative"
|
|
237
|
+
:style="height ? { height } : undefined"
|
|
238
|
+
:class="height ? 'overflow-auto' : ''"
|
|
239
|
+
>
|
|
240
|
+
<!-- Editable overlay: only when unlocked and there are no folds. -->
|
|
241
|
+
<textarea
|
|
242
|
+
v-if="!locked && !hasFolds"
|
|
243
|
+
:value="code"
|
|
244
|
+
spellcheck="false"
|
|
245
|
+
class="lp-scrollbar-none absolute inset-0 size-full resize-none overflow-auto whitespace-pre bg-transparent p-3 leading-relaxed text-transparent caret-brand outline-none"
|
|
246
|
+
:class="[wrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre', lineNumbers ? 'pl-12' : '']"
|
|
247
|
+
@input="onInput"
|
|
248
|
+
/>
|
|
249
|
+
<pre
|
|
250
|
+
class="lp-scrollbar-none m-0 overflow-auto p-3 leading-relaxed"
|
|
251
|
+
:class="[wrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre', !locked && !hasFolds ? 'pointer-events-none' : '']"
|
|
252
|
+
><code class="block"><template
|
|
253
|
+
v-for="(seg, s) in segments"
|
|
254
|
+
:key="s"
|
|
255
|
+
><!-- Plain lines --><template v-if="seg.kind === 'lines'"><span
|
|
256
|
+
v-for="ln in seg.lines"
|
|
257
|
+
:key="'l' + ln.n"
|
|
258
|
+
class="block min-h-[1.4em]"
|
|
259
|
+
><span
|
|
260
|
+
v-if="lineNumbers"
|
|
261
|
+
class="mr-3 inline-block w-7 select-none text-right text-muted/60"
|
|
262
|
+
aria-hidden="true"
|
|
263
|
+
>{{ ln.n }}</span><span
|
|
264
|
+
v-for="(tok, j) in tokens(ln.text)"
|
|
265
|
+
:key="j"
|
|
266
|
+
:class="tok.cls"
|
|
267
|
+
>{{ tok.text }}</span></span></template><!-- Fold region --><template
|
|
268
|
+
v-else
|
|
269
|
+
><button
|
|
270
|
+
type="button"
|
|
271
|
+
class="lp-fold-toggle flex w-full items-center gap-1.5 rounded px-1 py-0.5 text-left text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
|
|
272
|
+
:aria-expanded="openState[seg.id]"
|
|
273
|
+
@click="toggleFold(seg.id)"
|
|
274
|
+
><LpIcon
|
|
275
|
+
:name="openState[seg.id] ? 'lucide:chevron-down' : 'lucide:chevron-right'"
|
|
276
|
+
:size="12"
|
|
277
|
+
class="shrink-0"
|
|
278
|
+
/><span class="select-none">{{ openState[seg.id] ? seg.label : seg.label + ' …' }}</span><span
|
|
279
|
+
v-if="!openState[seg.id]"
|
|
280
|
+
class="select-none text-muted/50"
|
|
281
|
+
>({{ seg.lines.length }} lines)</span></button><template
|
|
282
|
+
v-if="openState[seg.id]"
|
|
283
|
+
><span
|
|
284
|
+
v-for="ln in seg.lines"
|
|
285
|
+
:key="'f' + ln.n"
|
|
286
|
+
class="block min-h-[1.4em]"
|
|
287
|
+
><span
|
|
288
|
+
v-if="lineNumbers"
|
|
289
|
+
class="mr-3 inline-block w-7 select-none text-right text-muted/60"
|
|
290
|
+
aria-hidden="true"
|
|
291
|
+
>{{ ln.n }}</span><span
|
|
292
|
+
v-for="(tok, j) in tokens(ln.text)"
|
|
293
|
+
:key="j"
|
|
294
|
+
:class="tok.cls"
|
|
295
|
+
>{{ tok.text }}</span></span></template></template></template></code></pre>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
</template>
|