@cfasim-ui/docs 0.4.5 → 0.4.6
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/charts/BarChart/BarChart.md +126 -14
- package/charts/BarChart/BarChart.vue +113 -131
- package/charts/LineChart/LineChart.md +209 -14
- package/charts/LineChart/LineChart.vue +118 -146
- package/charts/_shared/ChartAnnotations.vue +346 -0
- package/charts/_shared/annotations.ts +101 -0
- package/charts/_shared/chartProps.ts +75 -0
- package/charts/_shared/index.ts +15 -0
- package/charts/_shared/useChartFoundation.ts +124 -0
- package/charts/_shared/useChartPadding.ts +74 -10
- package/charts/index.ts +1 -0
- package/components/ParamEditor/ParamEditor.md +78 -0
- package/components/ParamEditor/ParamEditor.vue +39 -0
- package/components/ParamEditor/ParamEditorImpl.vue +355 -0
- package/components/SidebarLayout/SidebarLayout.vue +10 -9
- package/components/index.ts +5 -0
- package/index.json +18 -1
- package/package.json +1 -1
- package/wasm/index.ts +1 -1
- package/wasm/wasmWorkerApi.ts +38 -6
|
@@ -3,6 +3,21 @@ import { computed } from "vue";
|
|
|
3
3
|
/** Vertical space reserved at the top of the chart for inline legend swatches. */
|
|
4
4
|
export const INLINE_LEGEND_HEIGHT = 20;
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Extra space added around the chart's standard layout. A number applies
|
|
8
|
+
* the same amount to all four sides; an object lets you pad sides
|
|
9
|
+
* independently (e.g. `{ top: 24 }` to make room for annotations above
|
|
10
|
+
* the plot). Named to match Altair's `padding` (outer view padding).
|
|
11
|
+
*/
|
|
12
|
+
export type ChartPadding =
|
|
13
|
+
| number
|
|
14
|
+
| {
|
|
15
|
+
top?: number;
|
|
16
|
+
right?: number;
|
|
17
|
+
bottom?: number;
|
|
18
|
+
left?: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
6
21
|
export interface ChartPaddingOptions {
|
|
7
22
|
title: () => string | undefined;
|
|
8
23
|
xLabel: () => string | undefined;
|
|
@@ -10,28 +25,77 @@ export interface ChartPaddingOptions {
|
|
|
10
25
|
hasInlineLegend: () => boolean;
|
|
11
26
|
width: () => number;
|
|
12
27
|
height: () => number;
|
|
28
|
+
/** Extra pixels added on top of the standard axis spacing. */
|
|
29
|
+
extraPadding?: () => ChartPadding | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolvePadding(p: ChartPadding | undefined) {
|
|
33
|
+
if (p == null) return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
34
|
+
if (typeof p === "number") return { top: p, right: p, bottom: p, left: p };
|
|
35
|
+
return {
|
|
36
|
+
top: p.top ?? 0,
|
|
37
|
+
right: p.right ?? 0,
|
|
38
|
+
bottom: p.bottom ?? 0,
|
|
39
|
+
left: p.left ?? 0,
|
|
40
|
+
};
|
|
13
41
|
}
|
|
14
42
|
|
|
15
43
|
/**
|
|
16
44
|
* Computes the standard chart padding (top/right/bottom/left) and the
|
|
17
45
|
* derived inner plotting region (innerW, innerH). Shared by LineChart
|
|
18
46
|
* and BarChart so the axis label spacing and inline legend strip stay
|
|
19
|
-
* consistent.
|
|
47
|
+
* consistent. `extraPadding` adds extra space outside the plot — useful
|
|
48
|
+
* for annotations that need to extend past the data area without
|
|
49
|
+
* clipping.
|
|
20
50
|
*/
|
|
21
51
|
export function useChartPadding(opts: ChartPaddingOptions) {
|
|
22
|
-
const padding = computed(() =>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
52
|
+
const padding = computed(() => {
|
|
53
|
+
const extra = resolvePadding(opts.extraPadding?.());
|
|
54
|
+
return {
|
|
55
|
+
top:
|
|
56
|
+
(opts.title() ? 26 : 10) +
|
|
57
|
+
(opts.hasInlineLegend() ? INLINE_LEGEND_HEIGHT : 0) +
|
|
58
|
+
extra.top,
|
|
59
|
+
right: 10 + extra.right,
|
|
60
|
+
bottom: (opts.xLabel() ? 38 : 30) + extra.bottom,
|
|
61
|
+
left: (opts.yLabel() ? 56 : 50) + extra.left,
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
// Y-center of the inline legend strip. Sits at the top of the padding
|
|
65
|
+
// band (just below any title), so any user-supplied `extraPadding.top`
|
|
66
|
+
// becomes empty room between the legend and the plot — useful for
|
|
67
|
+
// annotations that need to extend above the data area.
|
|
68
|
+
const legendY = computed(
|
|
69
|
+
() => (opts.title() ? 26 : 10) + INLINE_LEGEND_HEIGHT / 2,
|
|
70
|
+
);
|
|
30
71
|
const innerW = computed(
|
|
31
72
|
() => opts.width() - padding.value.left - padding.value.right,
|
|
32
73
|
);
|
|
33
74
|
const innerH = computed(
|
|
34
75
|
() => opts.height() - padding.value.top - padding.value.bottom,
|
|
35
76
|
);
|
|
36
|
-
|
|
77
|
+
// Pixel-space rect of the plot area. Single source of truth for any
|
|
78
|
+
// consumer that needs to span the full plot (e.g. rule annotations,
|
|
79
|
+
// background fills, clip paths).
|
|
80
|
+
const bounds = computed(() => {
|
|
81
|
+
const p = padding.value;
|
|
82
|
+
return {
|
|
83
|
+
left: p.left,
|
|
84
|
+
top: p.top,
|
|
85
|
+
right: p.left + innerW.value,
|
|
86
|
+
bottom: p.top + innerH.value,
|
|
87
|
+
width: innerW.value,
|
|
88
|
+
height: innerH.value,
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
return { padding, legendY, innerW, innerH, bounds };
|
|
37
92
|
}
|
|
93
|
+
|
|
94
|
+
export type ChartBounds = {
|
|
95
|
+
left: number;
|
|
96
|
+
top: number;
|
|
97
|
+
right: number;
|
|
98
|
+
bottom: number;
|
|
99
|
+
width: number;
|
|
100
|
+
height: number;
|
|
101
|
+
};
|
package/charts/index.ts
CHANGED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# ParamEditor
|
|
2
|
+
|
|
3
|
+
A code editor for editing simulation parameters as JSON, TOML, or YAML. Backed by CodeMirror 6 with syntax highlighting. Includes Import, Export, and Apply actions. Apply can also be triggered with `Cmd`/`Ctrl`+`S`, and the editor auto-applies on blur whenever the text parses cleanly and the parsed value differs from the current `value`.
|
|
4
|
+
|
|
5
|
+
Exported files default to `params-YYYYMMDD-HHMMSS.<ext>` (local-time timestamp captured when Export is clicked). Override the basename with the `filename` prop.
|
|
6
|
+
|
|
7
|
+
The editor is **lazy-loaded**: the underlying CodeMirror bundle and YAML/TOML parsers are only fetched the first time `<ParamEditor>` mounts, so consumers that never open the editor never pay for it.
|
|
8
|
+
|
|
9
|
+
Users can switch formats from the dropdown; the editor round-trips the current value through the new format. Apply emits an `apply` event with the parsed value — the parent decides what to do with it (typically merge back into its own parameter state).
|
|
10
|
+
|
|
11
|
+
## Examples
|
|
12
|
+
|
|
13
|
+
<script setup>
|
|
14
|
+
import { reactive } from 'vue'
|
|
15
|
+
const params = reactive({ beta: 0.5, gamma: 0.1, population: 10000 })
|
|
16
|
+
function onApply(v) { Object.assign(params, v) }
|
|
17
|
+
const yamlParams = reactive({ beta: 0.5, gamma: 0.1 })
|
|
18
|
+
function onYamlApply(v) { Object.assign(yamlParams, v) }
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
### Basic usage
|
|
22
|
+
|
|
23
|
+
<ComponentDemo>
|
|
24
|
+
<div style="width: 100%">
|
|
25
|
+
<ParamEditor :value="params" @apply="onApply" />
|
|
26
|
+
<p data-testid="basic-output">beta = {{ params.beta }}</p>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<template #code>
|
|
30
|
+
|
|
31
|
+
```vue
|
|
32
|
+
<script setup>
|
|
33
|
+
import { reactive } from "vue";
|
|
34
|
+
const params = reactive({ beta: 0.5, gamma: 0.1, population: 10000 });
|
|
35
|
+
function onApply(v) {
|
|
36
|
+
Object.assign(params, v);
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<ParamEditor :value="params" @apply="onApply" />
|
|
42
|
+
</template>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
</template>
|
|
46
|
+
</ComponentDemo>
|
|
47
|
+
|
|
48
|
+
### Starting in a different format
|
|
49
|
+
|
|
50
|
+
<ComponentDemo>
|
|
51
|
+
<div style="width: 100%">
|
|
52
|
+
<ParamEditor :value="yamlParams" format="yaml" @apply="onYamlApply" />
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<template #code>
|
|
56
|
+
|
|
57
|
+
```vue
|
|
58
|
+
<ParamEditor :value="params" format="yaml" @apply="onApply" />
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
</template>
|
|
62
|
+
</ComponentDemo>
|
|
63
|
+
|
|
64
|
+
## Props
|
|
65
|
+
|
|
66
|
+
| Prop | Type | Required | Default |
|
|
67
|
+
|------|------|----------|---------|
|
|
68
|
+
| `value` | `ParamEditorValue` | Yes | — |
|
|
69
|
+
| `format` | `ParamEditorFormat` | No | — |
|
|
70
|
+
| `height` | `string` | No | — |
|
|
71
|
+
| `filename` | `string` | No | — |
|
|
72
|
+
|
|
73
|
+
## Events
|
|
74
|
+
|
|
75
|
+
| Event | Payload |
|
|
76
|
+
|-------|---------|
|
|
77
|
+
| `apply` | `value: ParamEditorValue` |
|
|
78
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { defineAsyncComponent } from "vue";
|
|
3
|
+
import Spinner from "../Spinner/Spinner.vue";
|
|
4
|
+
|
|
5
|
+
export type ParamEditorFormat = "json" | "toml" | "yaml";
|
|
6
|
+
export type ParamEditorValue = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
defineProps<{
|
|
9
|
+
value: ParamEditorValue;
|
|
10
|
+
format?: ParamEditorFormat;
|
|
11
|
+
height?: string;
|
|
12
|
+
/** Basename for the Export download (no extension). Defaults to
|
|
13
|
+
* `params-YYYYMMDD-HHMMSS` computed at click time. */
|
|
14
|
+
filename?: string;
|
|
15
|
+
}>();
|
|
16
|
+
|
|
17
|
+
defineEmits<{
|
|
18
|
+
apply: [value: ParamEditorValue];
|
|
19
|
+
}>();
|
|
20
|
+
|
|
21
|
+
// The actual editor (CodeMirror + YAML/TOML parsers) is loaded on demand
|
|
22
|
+
// the first time this component mounts, so consumers that never open the
|
|
23
|
+
// editor never pay for the ~200KB gzipped of editor + language code.
|
|
24
|
+
const Impl = defineAsyncComponent({
|
|
25
|
+
loader: () => import("./ParamEditorImpl.vue"),
|
|
26
|
+
loadingComponent: Spinner,
|
|
27
|
+
delay: 100,
|
|
28
|
+
});
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<Impl
|
|
33
|
+
:value="value"
|
|
34
|
+
:format="format"
|
|
35
|
+
:height="height"
|
|
36
|
+
:filename="filename"
|
|
37
|
+
@apply="$emit('apply', $event)"
|
|
38
|
+
/>
|
|
39
|
+
</template>
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
ref,
|
|
4
|
+
computed,
|
|
5
|
+
watch,
|
|
6
|
+
useTemplateRef,
|
|
7
|
+
onMounted,
|
|
8
|
+
onUnmounted,
|
|
9
|
+
} from "vue";
|
|
10
|
+
import { Codemirror } from "vue-codemirror";
|
|
11
|
+
import { json as jsonLang } from "@codemirror/lang-json";
|
|
12
|
+
import { yaml as yamlLang } from "@codemirror/lang-yaml";
|
|
13
|
+
import { StreamLanguage } from "@codemirror/language";
|
|
14
|
+
import { toml as tomlMode } from "@codemirror/legacy-modes/mode/toml";
|
|
15
|
+
import { EditorView, keymap } from "@codemirror/view";
|
|
16
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
17
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
18
|
+
import Box from "../Box/Box.vue";
|
|
19
|
+
import Button from "../Button/Button.vue";
|
|
20
|
+
import SelectBox from "../SelectBox/SelectBox.vue";
|
|
21
|
+
|
|
22
|
+
type Format = "json" | "toml" | "yaml";
|
|
23
|
+
type Value = Record<string, unknown>;
|
|
24
|
+
|
|
25
|
+
const props = withDefaults(
|
|
26
|
+
defineProps<{
|
|
27
|
+
value: Value;
|
|
28
|
+
format?: Format;
|
|
29
|
+
height?: string;
|
|
30
|
+
filename?: string;
|
|
31
|
+
}>(),
|
|
32
|
+
{ format: "json", height: "320px" },
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const emit = defineEmits<{ apply: [value: Value] }>();
|
|
36
|
+
|
|
37
|
+
const formatOptions = [
|
|
38
|
+
{ value: "json", label: "JSON" },
|
|
39
|
+
{ value: "toml", label: "TOML" },
|
|
40
|
+
{ value: "yaml", label: "YAML" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function serialize(v: Value, f: Format): string {
|
|
44
|
+
if (f === "json") return JSON.stringify(v, null, 2);
|
|
45
|
+
if (f === "yaml") return stringifyYaml(v);
|
|
46
|
+
return stringifyToml(v);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parse(t: string, f: Format): Value {
|
|
50
|
+
if (f === "json") return JSON.parse(t) as Value;
|
|
51
|
+
if (f === "yaml") return parseYaml(t) as Value;
|
|
52
|
+
return parseToml(t) as Value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const format = ref<Format>(props.format);
|
|
56
|
+
const text = ref(serialize(props.value, format.value));
|
|
57
|
+
const error = ref("");
|
|
58
|
+
|
|
59
|
+
// Clear a previously-surfaced parse error as soon as the text parses
|
|
60
|
+
// cleanly again, so the user doesn't have to re-trigger Apply just to
|
|
61
|
+
// see the error go away after they fix it.
|
|
62
|
+
watch(text, () => {
|
|
63
|
+
if (!error.value) return;
|
|
64
|
+
try {
|
|
65
|
+
parse(text.value, format.value);
|
|
66
|
+
error.value = "";
|
|
67
|
+
} catch {
|
|
68
|
+
// Still broken — keep the error visible.
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const isMac =
|
|
73
|
+
typeof navigator !== "undefined" &&
|
|
74
|
+
/Mac|iPhone|iPad/.test(navigator.platform);
|
|
75
|
+
const applyShortcut = isMac ? "⌘S" : "Ctrl+S";
|
|
76
|
+
|
|
77
|
+
// External value updates (e.g., parent Reset) re-seed the editor. The
|
|
78
|
+
// inequality check keeps the post-apply re-render a no-op: when the
|
|
79
|
+
// parent absorbs the user's apply, `props.value` re-serializes back
|
|
80
|
+
// to the same string the user just typed, so we leave `text` untouched.
|
|
81
|
+
watch(
|
|
82
|
+
() => props.value,
|
|
83
|
+
(v) => {
|
|
84
|
+
const next = serialize(v, format.value);
|
|
85
|
+
if (next === text.value) return;
|
|
86
|
+
text.value = next;
|
|
87
|
+
error.value = "";
|
|
88
|
+
},
|
|
89
|
+
{ deep: true },
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Convert text between formats by round-tripping through `parse` →
|
|
93
|
+
// `serialize`. If the current text doesn't parse, we keep the old
|
|
94
|
+
// format and surface the error.
|
|
95
|
+
function onFormatChange(next: string) {
|
|
96
|
+
const target = next as Format;
|
|
97
|
+
if (target === format.value) return;
|
|
98
|
+
try {
|
|
99
|
+
const parsed = parse(text.value, format.value);
|
|
100
|
+
text.value = serialize(parsed, target);
|
|
101
|
+
format.value = target;
|
|
102
|
+
error.value = "";
|
|
103
|
+
} catch (e) {
|
|
104
|
+
error.value = `Cannot switch format: ${(e as Error).message}`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function handleApply() {
|
|
109
|
+
try {
|
|
110
|
+
const parsed = parse(text.value, format.value);
|
|
111
|
+
error.value = "";
|
|
112
|
+
emit("apply", parsed);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
error.value = (e as Error).message;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Auto-apply when the editor loses focus, but only when the text parses
|
|
119
|
+
// cleanly (we don't want to nag with errors as the user clicks away —
|
|
120
|
+
// they can hit Apply explicitly to see what's wrong) and only when the
|
|
121
|
+
// parsed value actually differs from `props.value`.
|
|
122
|
+
function handleBlur() {
|
|
123
|
+
let parsed: Value;
|
|
124
|
+
try {
|
|
125
|
+
parsed = parse(text.value, format.value);
|
|
126
|
+
} catch {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (serialize(parsed, format.value) === serialize(props.value, format.value))
|
|
130
|
+
return;
|
|
131
|
+
error.value = "";
|
|
132
|
+
emit("apply", parsed);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// CodeMirror's contenteditable doesn't fire `blur` when the user clicks
|
|
136
|
+
// on a non-focusable element (e.g., empty page area or a chart svg), so
|
|
137
|
+
// we watch document pointerdown for clicks outside our wrapper and
|
|
138
|
+
// trigger the blur-apply logic ourselves.
|
|
139
|
+
const rootRef = useTemplateRef<HTMLElement>("rootRef");
|
|
140
|
+
|
|
141
|
+
function onDocumentPointerDown(e: PointerEvent) {
|
|
142
|
+
const target = e.target as Node | null;
|
|
143
|
+
if (!rootRef.value || !target) return;
|
|
144
|
+
if (rootRef.value.contains(target)) return;
|
|
145
|
+
handleBlur();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
onMounted(() => {
|
|
149
|
+
document.addEventListener("pointerdown", onDocumentPointerDown);
|
|
150
|
+
});
|
|
151
|
+
onUnmounted(() => {
|
|
152
|
+
document.removeEventListener("pointerdown", onDocumentPointerDown);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const extensions = computed(() => [
|
|
156
|
+
format.value === "json"
|
|
157
|
+
? jsonLang()
|
|
158
|
+
: format.value === "yaml"
|
|
159
|
+
? yamlLang()
|
|
160
|
+
: StreamLanguage.define(tomlMode),
|
|
161
|
+
EditorView.lineWrapping,
|
|
162
|
+
// Bind Cmd/Ctrl+S to Apply and stop the browser from intercepting it
|
|
163
|
+
// for its own "Save page as…" dialog.
|
|
164
|
+
keymap.of([
|
|
165
|
+
{
|
|
166
|
+
key: "Mod-s",
|
|
167
|
+
preventDefault: true,
|
|
168
|
+
run: () => {
|
|
169
|
+
handleApply();
|
|
170
|
+
return true;
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
]),
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
// `YYYYMMDD-HHMMSS` in local time — readable, sortable, and filesystem-safe
|
|
177
|
+
// on Windows (no colons). Computed at export time so the name reflects
|
|
178
|
+
// when the user clicked, not when the editor mounted.
|
|
179
|
+
function defaultFilename(): string {
|
|
180
|
+
const d = new Date();
|
|
181
|
+
const pad = (n: number) => n.toString().padStart(2, "0");
|
|
182
|
+
const ts =
|
|
183
|
+
`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
|
|
184
|
+
`-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
185
|
+
return `params-${ts}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function handleExport() {
|
|
189
|
+
const ext = format.value === "yaml" ? "yml" : format.value;
|
|
190
|
+
const mime =
|
|
191
|
+
format.value === "json"
|
|
192
|
+
? "application/json"
|
|
193
|
+
: format.value === "yaml"
|
|
194
|
+
? "application/yaml"
|
|
195
|
+
: "application/toml";
|
|
196
|
+
const base = props.filename ?? defaultFilename();
|
|
197
|
+
const blob = new Blob([text.value], { type: mime });
|
|
198
|
+
const url = URL.createObjectURL(blob);
|
|
199
|
+
const a = document.createElement("a");
|
|
200
|
+
a.href = url;
|
|
201
|
+
a.download = `${base}.${ext}`;
|
|
202
|
+
a.click();
|
|
203
|
+
URL.revokeObjectURL(url);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const fileInput = useTemplateRef<HTMLInputElement>("fileInput");
|
|
207
|
+
|
|
208
|
+
function handleImport() {
|
|
209
|
+
fileInput.value?.click();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function inferFormat(filename: string): Format {
|
|
213
|
+
const n = filename.toLowerCase();
|
|
214
|
+
if (n.endsWith(".yaml") || n.endsWith(".yml")) return "yaml";
|
|
215
|
+
if (n.endsWith(".toml")) return "toml";
|
|
216
|
+
return "json";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function onFileChange(e: Event) {
|
|
220
|
+
const input = e.target as HTMLInputElement;
|
|
221
|
+
const file = input.files?.[0];
|
|
222
|
+
if (!file) return;
|
|
223
|
+
const raw = await file.text();
|
|
224
|
+
const inferred = inferFormat(file.name);
|
|
225
|
+
format.value = inferred;
|
|
226
|
+
text.value = raw;
|
|
227
|
+
try {
|
|
228
|
+
parse(raw, inferred);
|
|
229
|
+
error.value = "";
|
|
230
|
+
} catch (err) {
|
|
231
|
+
error.value = (err as Error).message;
|
|
232
|
+
}
|
|
233
|
+
input.value = "";
|
|
234
|
+
}
|
|
235
|
+
</script>
|
|
236
|
+
|
|
237
|
+
<template>
|
|
238
|
+
<div ref="rootRef" class="param-editor">
|
|
239
|
+
<div class="param-editor-toolbar">
|
|
240
|
+
<SelectBox
|
|
241
|
+
:model-value="format"
|
|
242
|
+
aria-label="Format"
|
|
243
|
+
:options="formatOptions"
|
|
244
|
+
@update:model-value="(v: string | undefined) => v && onFormatChange(v)"
|
|
245
|
+
/>
|
|
246
|
+
<div class="param-editor-actions">
|
|
247
|
+
<Button variant="secondary" @click="handleImport">Import</Button>
|
|
248
|
+
<Button variant="secondary" @click="handleExport">Export</Button>
|
|
249
|
+
<Button @click="handleApply">Apply</Button>
|
|
250
|
+
</div>
|
|
251
|
+
<input
|
|
252
|
+
ref="fileInput"
|
|
253
|
+
type="file"
|
|
254
|
+
accept=".json,.toml,.yaml,.yml,application/json,text/plain"
|
|
255
|
+
class="param-editor-file-input"
|
|
256
|
+
tabindex="-1"
|
|
257
|
+
aria-hidden="true"
|
|
258
|
+
@change="onFileChange"
|
|
259
|
+
/>
|
|
260
|
+
</div>
|
|
261
|
+
<Codemirror
|
|
262
|
+
v-model="text"
|
|
263
|
+
:extensions="extensions"
|
|
264
|
+
:style="{ height }"
|
|
265
|
+
:indent-with-tab="false"
|
|
266
|
+
:tab-size="2"
|
|
267
|
+
class="param-editor-cm"
|
|
268
|
+
@blur="handleBlur"
|
|
269
|
+
/>
|
|
270
|
+
<div class="param-editor-error-region" role="status">
|
|
271
|
+
<Box v-if="error" variant="error" class="param-editor-error">
|
|
272
|
+
{{ error }}
|
|
273
|
+
</Box>
|
|
274
|
+
</div>
|
|
275
|
+
<p class="param-editor-shortcut-hint">
|
|
276
|
+
Press <kbd>{{ applyShortcut }}</kbd> to apply, or click outside the
|
|
277
|
+
editor.
|
|
278
|
+
</p>
|
|
279
|
+
</div>
|
|
280
|
+
</template>
|
|
281
|
+
|
|
282
|
+
<style scoped>
|
|
283
|
+
.param-editor {
|
|
284
|
+
display: flex;
|
|
285
|
+
flex-direction: column;
|
|
286
|
+
gap: 0.5em;
|
|
287
|
+
min-width: 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.param-editor-toolbar {
|
|
291
|
+
display: flex;
|
|
292
|
+
flex-wrap: wrap;
|
|
293
|
+
gap: 0.5em;
|
|
294
|
+
align-items: center;
|
|
295
|
+
justify-content: space-between;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.param-editor-actions {
|
|
299
|
+
display: flex;
|
|
300
|
+
gap: 0.25em;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.param-editor-file-input {
|
|
304
|
+
position: absolute;
|
|
305
|
+
width: 1px;
|
|
306
|
+
height: 1px;
|
|
307
|
+
padding: 0;
|
|
308
|
+
margin: -1px;
|
|
309
|
+
overflow: hidden;
|
|
310
|
+
clip: rect(0, 0, 0, 0);
|
|
311
|
+
border: 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.param-editor-cm {
|
|
315
|
+
border: 1px solid var(--color-border);
|
|
316
|
+
border-radius: 0.375em;
|
|
317
|
+
font-size: var(--font-size-sm);
|
|
318
|
+
overflow: hidden;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.param-editor-cm :deep(.cm-editor) {
|
|
322
|
+
height: 100%;
|
|
323
|
+
outline: none;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.param-editor-cm :deep(.cm-editor.cm-focused) {
|
|
327
|
+
outline: none;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.param-editor-cm :deep(.cm-scroller) {
|
|
331
|
+
font-family:
|
|
332
|
+
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
|
333
|
+
"Courier New", monospace;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.param-editor-error {
|
|
337
|
+
white-space: pre-wrap;
|
|
338
|
+
word-break: break-word;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.param-editor-shortcut-hint {
|
|
342
|
+
margin: 0;
|
|
343
|
+
font-size: var(--font-size-xs, 0.75em);
|
|
344
|
+
color: var(--color-text-secondary, #666);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.param-editor-shortcut-hint kbd {
|
|
348
|
+
font-family: inherit;
|
|
349
|
+
padding: 0.1em 0.35em;
|
|
350
|
+
border: 1px solid var(--color-border);
|
|
351
|
+
border-radius: 0.25em;
|
|
352
|
+
background: var(--color-bg-1, transparent);
|
|
353
|
+
font-size: 0.95em;
|
|
354
|
+
}
|
|
355
|
+
</style>
|
|
@@ -13,9 +13,11 @@ import LightDarkToggle from "../LightDarkToggle/LightDarkToggle.vue";
|
|
|
13
13
|
|
|
14
14
|
// Optional vue-router integration (no hard dependency).
|
|
15
15
|
// $router/$route on globalProperties is vue-router's stable public API.
|
|
16
|
+
// $route is defined as a getter that returns the current route, so we
|
|
17
|
+
// must re-read it from globalProperties on each access to track changes.
|
|
16
18
|
const instance = getCurrentInstance();
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
+
const globals = instance?.appContext.config.globalProperties;
|
|
20
|
+
const router = globals?.$router;
|
|
19
21
|
|
|
20
22
|
export interface Tab {
|
|
21
23
|
value: string;
|
|
@@ -68,14 +70,13 @@ const activeTab = computed({
|
|
|
68
70
|
});
|
|
69
71
|
|
|
70
72
|
// Sync active tab from route changes in router mode
|
|
71
|
-
if (
|
|
73
|
+
if (globals) {
|
|
72
74
|
watch(
|
|
73
|
-
() => route
|
|
74
|
-
() => {
|
|
75
|
-
if (isRouterMode.value)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
75
|
+
() => globals.$route?.path,
|
|
76
|
+
(path) => {
|
|
77
|
+
if (!path || !isRouterMode.value) return;
|
|
78
|
+
const match = props.tabs?.find((t) => t.to === path);
|
|
79
|
+
if (match) tab.value = match.value;
|
|
79
80
|
},
|
|
80
81
|
{ immediate: true },
|
|
81
82
|
);
|
package/components/index.ts
CHANGED
|
@@ -11,6 +11,11 @@ export { default as Icon } from "./Icon/Icon.vue";
|
|
|
11
11
|
export { default as LightDarkToggle } from "./LightDarkToggle/LightDarkToggle.vue";
|
|
12
12
|
export { default as NumberInput } from "./NumberInput/NumberInput.vue";
|
|
13
13
|
export type { NumberRange } from "./NumberInput/NumberInput.vue";
|
|
14
|
+
export { default as ParamEditor } from "./ParamEditor/ParamEditor.vue";
|
|
15
|
+
export type {
|
|
16
|
+
ParamEditorFormat,
|
|
17
|
+
ParamEditorValue,
|
|
18
|
+
} from "./ParamEditor/ParamEditor.vue";
|
|
14
19
|
export { default as SelectBox } from "./SelectBox/SelectBox.vue";
|
|
15
20
|
export type { SelectOption } from "./SelectBox/SelectBox.vue";
|
|
16
21
|
export { default as SidebarLayout } from "./SidebarLayout/SidebarLayout.vue";
|
package/index.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.4.
|
|
2
|
+
"version": "0.4.6",
|
|
3
3
|
"package": "@cfasim-ui/docs",
|
|
4
4
|
"content": {
|
|
5
5
|
"components": [
|
|
@@ -83,6 +83,23 @@
|
|
|
83
83
|
"source": "components/NumberInput/NumberInput.vue",
|
|
84
84
|
"keywords": []
|
|
85
85
|
},
|
|
86
|
+
{
|
|
87
|
+
"name": "ParamEditor",
|
|
88
|
+
"slug": "param-editor",
|
|
89
|
+
"docs": "components/ParamEditor/ParamEditor.md",
|
|
90
|
+
"source": "components/ParamEditor/ParamEditor.vue",
|
|
91
|
+
"keywords": [
|
|
92
|
+
"parameters",
|
|
93
|
+
"editor",
|
|
94
|
+
"code",
|
|
95
|
+
"json",
|
|
96
|
+
"toml",
|
|
97
|
+
"yaml",
|
|
98
|
+
"codemirror",
|
|
99
|
+
"import",
|
|
100
|
+
"export"
|
|
101
|
+
]
|
|
102
|
+
},
|
|
86
103
|
{
|
|
87
104
|
"name": "SelectBox",
|
|
88
105
|
"slug": "select-box",
|
package/package.json
CHANGED
package/wasm/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { runWasm } from "./wasmWorkerApi.js";
|
|
1
|
+
export { runWasm, cancelWasm } from "./wasmWorkerApi.js";
|
|
2
2
|
export { useModel } from "./useModel.js";
|