feat: revamp demo

This commit is contained in:
Justineo
2025-09-24 01:27:35 +08:00
committed by GU Yiling
parent 1da2bf7811
commit def0ad5bf5
47 changed files with 4180 additions and 1740 deletions

View File

@ -1,92 +1,33 @@
<script setup>
<script setup lang="ts">
import {
ref,
computed,
watch,
onBeforeUnmount,
onMounted,
onBeforeUnmount,
nextTick,
} from "vue";
import { useLocalStorage } from "@vueuse/core";
import "highlight.js/styles/github.css";
import hljs from "highlight.js/lib/core";
import javascript from "highlight.js/lib/languages/javascript";
import typescript from "highlight.js/lib/languages/typescript";
import hljsVuePlugin from "@highlightjs/vue-plugin";
import { initialize, transform } from "esbuild-wasm";
import wasmURL from "esbuild-wasm/esbuild.wasm?url";
import { useLocalStorage, useTimeoutFn } from "@vueuse/core";
import { track } from "@vercel/analytics";
import { getImportsFromOption } from "./utils/codegen";
import {
getImportsFromOption,
type Quote,
type PublicCodegenOptions,
} from "./utils/codegen";
import {
createOptionEditor,
createCodeViewer,
type OptionEditor,
type CodeViewer,
} from "./services/monaco";
import {
useOptionAnalysis,
type AnalyzerIssue,
} from "./composables/useOptionAnalysis";
import { useDemoDark } from "./composables/useDemoDark";
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("typescript", typescript);
const CodeHighlight = hljsVuePlugin.component;
const codegenOptions = useLocalStorage("ve.codegenOptions", {
indent: " ",
quote: "'",
multiline: false,
maxLen: 80,
semi: false,
includeType: false,
});
const props = defineProps({ open: Boolean, renderer: String });
const emit = defineEmits(["update:open"]);
const dialog = ref(null);
let clickFrom = null;
function onMousedown(e) {
clickFrom = e.target;
}
function closeFromOutside() {
if (dialog.value?.contains(clickFrom)) {
return;
}
close();
}
function close() {
emit("update:open", false);
}
const renderer = ref(props.renderer);
const source = ref(null);
watch(
() => props.open,
async (val) => {
if (val) {
renderer.value = props.renderer;
}
await nextTick();
if (initializing.value) {
return;
}
source.value?.focus();
},
);
const copied = ref(false);
const initializing = ref(true);
const optionCode = ref("");
const transformedCode = ref("");
const transformErrors = ref([]);
onMounted(async () => {
// prevent multiple initializations during HMR
if (!window.__esbuildInitialized) {
await initialize({ wasmURL });
window.__esbuildInitialized = true;
}
initializing.value = false;
optionCode.value = `{
const DEFAULT_OPTION = `{
title: {
text: 'Referer of a Website',
subtext: 'Fake Data',
@ -122,94 +63,281 @@ onMounted(async () => {
]
}`;
await nextTick();
source.value?.focus();
});
watch(optionCode, async (val) => {
try {
transformedCode.value = await transform(`(${val})`, { loader: "ts" });
transformErrors.value = [];
} catch (e) {
transformErrors.value = e.errors;
}
});
function formatError(errors) {
return errors
.map(({ text, location: { lineText, line, column, length } }) => {
const digit = Math.ceil(Math.log10(line)) || 1;
lineText = line === 1 ? lineText.slice(1) : lineText;
lineText =
line === optionCode.value.split("\n").length
? lineText.slice(0, -1)
: lineText;
column = line === 1 ? column - 1 : column;
return `/* ${text} */
// ${line} | ${lineText}
// ${" ".repeat(digit)} | ${" ".repeat(column)}${"~".repeat(length)}
`;
})
.join("\n\n");
interface CodegenPreferences {
indent: string;
quote: Quote;
multiline: boolean;
maxLen: number;
semi: boolean;
includeType: boolean;
renderer: Renderer;
}
const importCode = computed(() => {
if (optionCode.value.trim() === "") {
return "// Paste your option code first";
}
const codegenOptions = useLocalStorage<CodegenPreferences>(
"ve.codegenOptions",
{
indent: " ",
quote: "'",
multiline: false,
maxLen: 80,
semi: false,
includeType: false,
renderer: "canvas",
} satisfies CodegenPreferences,
);
if (transformErrors.value.length) {
return formatError(transformErrors.value);
}
const props = defineProps<{ open: boolean; renderer: string }>();
const emit = defineEmits<{ "update:open": [boolean] }>();
try {
return getImportsFromOption(eval(transformedCode.value.code), {
renderer: renderer.value,
...codegenOptions.value,
});
} catch (e) {
return `/* Invalid ECharts option */
const {
code: sourceCode,
state: analysisState,
updateSource,
dispose: disposeAnalysis,
} = useOptionAnalysis(DEFAULT_OPTION);
// ${e.message}
`;
}
const dialog = ref<HTMLElement | null>(null);
let clickFrom: Node | null = null;
const editorEl = ref<HTMLElement | null>(null);
const outputEl = ref<HTMLElement | null>(null);
let optionEditor: OptionEditor | null = null;
let importViewer: CodeViewer | null = null;
let suppressNextEditorEvent = false;
const initializing = ref<boolean>(true);
const showAnalyzingOverlay = ref(false);
const { start: scheduleAnalyzingOverlay, stop: cancelAnalyzingOverlay } =
useTimeoutFn(() => {
showAnalyzingOverlay.value = true;
}, 180);
type Renderer = "canvas" | "svg";
const renderer = ref<Renderer>(props.renderer === "svg" ? "svg" : "canvas");
codegenOptions.value.renderer = renderer.value;
const isDark = useDemoDark();
const monacoTheme = computed(() => (isDark.value ? "vs-dark" : "vs"));
watch(
() => props.renderer,
(value) => {
if (props.open) {
renderer.value = value === "svg" ? "svg" : "canvas";
}
},
);
watch(renderer, (value) => {
codegenOptions.value.renderer = value;
});
watch(importCode, () => {
copied.value = false;
});
function onMousedown(event: MouseEvent) {
clickFrom = event.target instanceof Node ? event.target : null;
}
// copy message
const messageOpen = ref(false);
let messageTimer;
function trackCopy(from) {
if (copied.value) {
// only track copy after modifications
function closeFromOutside() {
const target = clickFrom;
if (target && dialog.value?.contains(target)) {
return;
}
close();
}
function close() {
emit("update:open", false);
}
const copied = ref(false);
const messageOpen = ref(false);
const { start: scheduleMessageClose, stop: cancelMessageClose } = useTimeoutFn(
() => {
messageOpen.value = false;
},
2018,
);
function trackCopy(from: "button" | "system") {
if (copied.value) {
return;
}
copied.value = true;
track("copy-code", { from });
}
function copy() {
trackCopy("button");
clearTimeout(messageTimer);
navigator.clipboard.writeText(importCode.value);
messageOpen.value = true;
messageTimer = setTimeout(() => {
messageOpen.value = false;
}, 2018);
function formatIssues(issues: readonly AnalyzerIssue[]) {
if (!issues.length) {
return "";
}
return issues
.map((issue) => {
const lines = [`/* ${issue.message} */`];
if (issue.hint) {
lines.push(`// Hint: ${issue.hint}`);
}
if (issue.range) {
const pointer = `${issue.range.startLineNumber}:${issue.range.startColumn}`;
lines.push(`// ${pointer}`);
}
return lines.join("\n");
})
.join("\n\n");
}
const hasErrors = computed<boolean>(() => analysisState.hasBlockingIssue);
const isBusy = computed<boolean>(
() => initializing.value || showAnalyzingOverlay.value,
);
const importCode = computed(() => {
const raw = sourceCode.value.trim();
if (!raw) {
return "// Paste your option code first";
}
if (hasErrors.value) {
const blockingIssues = analysisState.issues.filter(
(issue) => issue.severity === "error",
);
return formatIssues(blockingIssues);
}
if (!analysisState.option) {
return "// Option analysis did not produce a result";
}
try {
const preferences = codegenOptions.value;
const config: PublicCodegenOptions = {
indent: preferences.indent,
quote: preferences.quote,
multiline: preferences.multiline,
maxLen: preferences.maxLen,
semi: preferences.semi,
includeType: preferences.includeType,
renderer: renderer.value,
};
return getImportsFromOption(analysisState.option, config);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error ?? "Unknown error");
return `/* Invalid ECharts option */\n\n// ${message}`;
}
});
watch(importCode, (value) => {
importViewer?.setValue(value);
copied.value = false;
});
function copy() {
if (!navigator.clipboard) {
return;
}
trackCopy("button");
navigator.clipboard.writeText(importCode.value);
messageOpen.value = true;
cancelMessageClose();
scheduleMessageClose();
}
onMounted(async () => {
await nextTick();
if (editorEl.value) {
optionEditor = createOptionEditor(editorEl.value, {
initialCode: sourceCode.value,
theme: monacoTheme.value,
onChange(value) {
if (suppressNextEditorEvent) {
return;
}
updateSource(value);
},
});
optionEditor.setMarkers(analysisState.diagnostics);
}
if (outputEl.value) {
importViewer = createCodeViewer(outputEl.value, {
initialCode: importCode.value,
language: codegenOptions.value.includeType ? "typescript" : "javascript",
theme: monacoTheme.value,
});
}
initializing.value = false;
});
watch(monacoTheme, (theme) => {
optionEditor?.setTheme(theme);
importViewer?.setTheme(theme);
});
watch(
() => codegenOptions.value.includeType,
(includeType) => {
importViewer?.setLanguage(includeType ? "typescript" : "javascript");
importViewer?.setValue(importCode.value);
},
);
watch(
() => analysisState.status,
(status) => {
if (status === "analyzing" && !initializing.value) {
scheduleAnalyzingOverlay();
return;
}
cancelAnalyzingOverlay();
showAnalyzingOverlay.value = false;
},
{ immediate: true },
);
watch(
() => props.open,
async (value) => {
if (value) {
renderer.value = props.renderer === "svg" ? "svg" : "canvas";
await nextTick();
optionEditor?.editor.focus();
optionEditor?.editor.layout();
importViewer?.editor.layout();
}
},
);
watch(
() => analysisState.diagnostics,
(diagnostics) => {
optionEditor?.setMarkers(diagnostics);
},
{ deep: true },
);
watch(sourceCode, (value) => {
if (!optionEditor) {
return;
}
const current = optionEditor.getValue();
if (current === value) {
return;
}
try {
suppressNextEditorEvent = true;
optionEditor.setValue(value);
} finally {
suppressNextEditorEvent = false;
}
});
onBeforeUnmount(() => {
clearTimeout(messageTimer);
cancelMessageClose();
cancelAnalyzingOverlay();
optionEditor?.dispose();
optionEditor = null;
importViewer?.dispose();
importViewer = null;
disposeAnalysis();
});
</script>
@ -221,8 +349,14 @@ onBeforeUnmount(() => {
@click="closeFromOutside"
@keydown.esc="close"
>
<section class="dialog" ref="dialog">
<h2> <code>import</code> code generator</h2>
<section
ref="dialog"
class="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="codegen-title"
>
<h2 id="codegen-title">Generate import code</h2>
<section class="options">
<label>
Renderer
@ -233,64 +367,59 @@ onBeforeUnmount(() => {
</label>
<label>
TypeScript
<input type="checkbox" v-model="codegenOptions.includeType" />
<input v-model="codegenOptions.includeType" type="checkbox" />
</label>
<label>
Multiline
<input type="checkbox" v-model="codegenOptions.multiline" />
<input v-model="codegenOptions.multiline" type="checkbox" />
</label>
<label>
Semi
<input type="checkbox" v-model="codegenOptions.semi" />
<input v-model="codegenOptions.semi" type="checkbox" />
</label>
<label>
Quote
<select v-model="codegenOptions.quote">
<option value="'">single</option>
<option value='"'>double</option>
<option value="'">Single</option>
<option value='"'>Double</option>
</select>
</label>
<label>
Indent
<select v-model="codegenOptions.indent">
<option value=" ">2</option>
<option value=" ">4</option>
<option value=" ">2 spaces</option>
<option value=" ">4 spaces</option>
<option value=" ">Tab</option>
</select>
</label>
<label>
Max length
<input
v-model.number="codegenOptions.maxLen"
type="number"
step="10"
v-model.number="codegenOptions.maxLen"
/>
</label>
</section>
<section class="code">
<textarea
ref="source"
<div
ref="editorEl"
class="option-code"
v-model="optionCode"
:placeholder="
initializing
? 'Initializing...'
: 'Paste your option code (TS/JS literal) here...'
"
:disabled="initializing"
autofocus
spellcheck="false"
></textarea>
<div class="import-code" @copy="trackCopy('system')">
<code-highlight
:language="codegenOptions.includeType ? 'typescript' : 'javascript'"
:code="importCode"
/>
</div>
aria-label="ECharts option (TS/JS literal)"
:aria-busy="isBusy"
></div>
<div
ref="outputEl"
class="import-code"
role="textbox"
aria-label="Generated import code"
aria-readonly="true"
@copy="trackCopy('system')"
></div>
<button
class="copy"
:disabled="analysisState.hasBlockingIssue"
@click="copy"
:disabled="importCode.startsWith('/*') || importCode.startsWith('//')"
>
Copy
</button>
@ -298,37 +427,31 @@ onBeforeUnmount(() => {
</section>
</aside>
<aside class="message" :class="{ open: messageOpen }">
<aside
class="message"
:class="{ open: messageOpen }"
role="status"
aria-live="polite"
>
Copied to clipboard
</aside>
</template>
<style>
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Works for Firefox */
input[type="number"] {
appearance: textfield;
}
input[type="text"],
input[type="number"] {
cursor: text;
}
.dialog {
display: flex;
flex-direction: column;
width: 80vw;
height: 90vh;
border-radius: 6px;
border-radius: var(--r-l);
overflow: hidden;
background-color: #fff;
box-shadow: 0 0 45px rgba(0, 0, 0, 0.2);
background-color: var(--surface);
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.dialog h2 {
margin-top: 2rem;
}
.options {
@ -337,22 +460,23 @@ input[type="number"] {
align-items: stretch;
gap: 16px;
padding: 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
border-bottom: 1px solid var(--border);
label {
display: flex;
align-items: center;
gap: 4px;
}
input,
button,
select {
height: 2.4em;
gap: 6px;
color: var(--muted);
}
input[type="number"] {
width: 60px;
width: 54px;
}
input[type="checkbox"] {
width: 1rem;
height: 1rem;
accent-color: var(--accent);
}
}
@ -360,6 +484,7 @@ input[type="number"] {
position: relative;
display: flex;
justify-content: center;
text-align: left;
align-items: stretch;
flex-grow: 1;
min-height: 0;
@ -370,33 +495,19 @@ input[type="number"] {
flex: 0 0 50%;
margin: 0;
border: none;
line-height: 1.2;
font-size: 13px;
overflow: auto;
}
.import-code {
border-left: 1px solid rgba(0, 0, 0, 0.1);
pre {
display: block;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
background: #fff;
box-shadow: none;
}
code {
height: 100%;
}
}
.option-code {
padding: 1em;
outline: none;
resize: none;
.option-code[aria-busy="true"]::after {
content: "Analyzing...";
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: var(--surface);
color: var(--muted);
font-size: 0.85rem;
pointer-events: none;
z-index: 1;
}
}
@ -404,6 +515,8 @@ input[type="number"] {
position: absolute;
right: 10px;
top: 10px;
border-radius: var(--r-m);
border: 1px solid var(--border);
}
.message {
@ -412,12 +525,12 @@ input[type="number"] {
bottom: 2rem;
left: 50%;
padding: 0.5rem 0.75rem;
background-color: rgba(45, 52, 64, 0.98);
box-shadow: 0 4px 16px rgba(45, 52, 64, 0.6);
color: #fff;
background-color: var(--text);
box-shadow: var(--shadow);
color: var(--surface);
font-size: 0.875rem;
transform: translate(-50%, 200%);
border-radius: 4px;
border-radius: var(--r-s);
opacity: 0;
transition:
transform 0.2s,