mirror of
				https://github.com/ecomfe/vue-echarts.git
				synced 2025-11-01 01:18:17 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			551 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			551 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <script setup lang="ts">
 | |
| import {
 | |
|   ref,
 | |
|   computed,
 | |
|   watch,
 | |
|   onMounted,
 | |
|   onBeforeUnmount,
 | |
|   nextTick,
 | |
| } from "vue";
 | |
| import { useLocalStorage, useTimeoutFn } from "@vueuse/core";
 | |
| import { track } from "@vercel/analytics";
 | |
| 
 | |
| 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";
 | |
| 
 | |
| const DEFAULT_OPTION = `{
 | |
|   title: {
 | |
|     text: 'Referer of a Website',
 | |
|     subtext: 'Fake Data',
 | |
|     left: 'center'
 | |
|   },
 | |
|   tooltip: {
 | |
|     trigger: 'item'
 | |
|   },
 | |
|   legend: {
 | |
|     orient: 'vertical',
 | |
|     left: 'left'
 | |
|   },
 | |
|   series: [
 | |
|     {
 | |
|       name: 'Access From',
 | |
|       type: 'pie',
 | |
|       radius: '50%',
 | |
|       data: [
 | |
|         { value: 1048, name: 'Search Engine' },
 | |
|         { value: 735, name: 'Direct' },
 | |
|         { value: 580, name: 'Email' },
 | |
|         { value: 484, name: 'Union Ads' },
 | |
|         { value: 300, name: 'Video Ads' }
 | |
|       ],
 | |
|       emphasis: {
 | |
|         itemStyle: {
 | |
|           shadowBlur: 10,
 | |
|           shadowOffsetX: 0,
 | |
|           shadowColor: 'rgba(0, 0, 0, 0.5)'
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|   ]
 | |
| }`;
 | |
| 
 | |
| interface CodegenPreferences {
 | |
|   indent: string;
 | |
|   quote: Quote;
 | |
|   multiline: boolean;
 | |
|   maxLen: number;
 | |
|   semi: boolean;
 | |
|   includeType: boolean;
 | |
|   renderer: Renderer;
 | |
| }
 | |
| 
 | |
| const codegenOptions = useLocalStorage<CodegenPreferences>(
 | |
|   "ve.codegenOptions",
 | |
|   {
 | |
|     indent: "  ",
 | |
|     quote: "'",
 | |
|     multiline: false,
 | |
|     maxLen: 80,
 | |
|     semi: false,
 | |
|     includeType: false,
 | |
|     renderer: "canvas",
 | |
|   } satisfies CodegenPreferences,
 | |
| );
 | |
| 
 | |
| const props = defineProps<{ open: boolean; renderer: string }>();
 | |
| const emit = defineEmits<{ "update:open": [boolean] }>();
 | |
| 
 | |
| const {
 | |
|   code: sourceCode,
 | |
|   state: analysisState,
 | |
|   updateSource,
 | |
|   dispose: disposeAnalysis,
 | |
| } = useOptionAnalysis(DEFAULT_OPTION);
 | |
| 
 | |
| 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;
 | |
| });
 | |
| 
 | |
| function onMousedown(event: MouseEvent) {
 | |
|   clickFrom = event.target instanceof Node ? event.target : null;
 | |
| }
 | |
| 
 | |
| 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 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(() => {
 | |
|   cancelMessageClose();
 | |
|   cancelAnalyzingOverlay();
 | |
|   optionEditor?.dispose();
 | |
|   optionEditor = null;
 | |
|   importViewer?.dispose();
 | |
|   importViewer = null;
 | |
|   disposeAnalysis();
 | |
| });
 | |
| </script>
 | |
| 
 | |
| <template>
 | |
|   <aside
 | |
|     class="modal"
 | |
|     :class="{ open: props.open }"
 | |
|     @mousedown="onMousedown"
 | |
|     @click="closeFromOutside"
 | |
|     @keydown.esc="close"
 | |
|   >
 | |
|     <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
 | |
|           <select v-model="renderer">
 | |
|             <option value="canvas">Canvas</option>
 | |
|             <option value="svg">SVG</option>
 | |
|           </select>
 | |
|         </label>
 | |
|         <label>
 | |
|           TypeScript
 | |
|           <input v-model="codegenOptions.includeType" type="checkbox" />
 | |
|         </label>
 | |
|         <label>
 | |
|           Multiline
 | |
|           <input v-model="codegenOptions.multiline" type="checkbox" />
 | |
|         </label>
 | |
|         <label>
 | |
|           Semi
 | |
|           <input v-model="codegenOptions.semi" type="checkbox" />
 | |
|         </label>
 | |
|         <label>
 | |
|           Quote
 | |
|           <select v-model="codegenOptions.quote">
 | |
|             <option value="'">Single</option>
 | |
|             <option value='"'>Double</option>
 | |
|           </select>
 | |
|         </label>
 | |
|         <label>
 | |
|           Indent
 | |
|           <select v-model="codegenOptions.indent">
 | |
|             <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"
 | |
|           />
 | |
|         </label>
 | |
|       </section>
 | |
|       <section class="code">
 | |
|         <div
 | |
|           ref="editorEl"
 | |
|           class="option-code"
 | |
|           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"
 | |
|         >
 | |
|           Copy
 | |
|         </button>
 | |
|       </section>
 | |
|     </section>
 | |
|   </aside>
 | |
| 
 | |
|   <aside
 | |
|     class="message"
 | |
|     :class="{ open: messageOpen }"
 | |
|     role="status"
 | |
|     aria-live="polite"
 | |
|   >
 | |
|     Copied to clipboard
 | |
|   </aside>
 | |
| </template>
 | |
| 
 | |
| <style>
 | |
| .dialog {
 | |
|   display: flex;
 | |
|   flex-direction: column;
 | |
|   width: 80vw;
 | |
|   height: 90vh;
 | |
|   border-radius: var(--r-l);
 | |
|   overflow: hidden;
 | |
|   background-color: var(--surface);
 | |
|   border: 1px solid var(--border);
 | |
|   box-shadow: var(--shadow);
 | |
| }
 | |
| 
 | |
| .dialog h2 {
 | |
|   margin-top: 2rem;
 | |
| }
 | |
| 
 | |
| .options {
 | |
|   display: flex;
 | |
|   justify-content: center;
 | |
|   align-items: stretch;
 | |
|   gap: var(--space-4);
 | |
|   padding: var(--space-4);
 | |
|   border-bottom: 1px solid var(--border);
 | |
| 
 | |
|   label {
 | |
|     display: flex;
 | |
|     align-items: center;
 | |
|     gap: var(--space-2);
 | |
|     color: var(--muted);
 | |
|   }
 | |
| 
 | |
|   input[type="number"] {
 | |
|     width: 54px;
 | |
|   }
 | |
| 
 | |
|   input[type="checkbox"] {
 | |
|     width: 1rem;
 | |
|     height: 1rem;
 | |
|     accent-color: var(--accent);
 | |
|   }
 | |
| }
 | |
| 
 | |
| .code {
 | |
|   position: relative;
 | |
|   display: flex;
 | |
|   justify-content: center;
 | |
|   text-align: left;
 | |
|   align-items: stretch;
 | |
|   flex-grow: 1;
 | |
|   min-height: 0;
 | |
|   tab-size: 4;
 | |
| 
 | |
|   .option-code,
 | |
|   .import-code {
 | |
|     flex: 0 0 50%;
 | |
|     margin: 0;
 | |
|     border: 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;
 | |
|   }
 | |
| }
 | |
| 
 | |
| .copy {
 | |
|   position: absolute;
 | |
|   right: var(--space-2);
 | |
|   top: var(--space-2);
 | |
|   border-radius: var(--r-m);
 | |
|   border: 1px solid var(--border);
 | |
| }
 | |
| 
 | |
| .message {
 | |
|   position: fixed;
 | |
|   z-index: 2147483647;
 | |
|   bottom: 2rem;
 | |
|   left: 50%;
 | |
|   padding: var(--space-2) var(--space-3);
 | |
|   background-color: color-mix(in srgb, var(--text) 88%, var(--surface) 12%);
 | |
|   box-shadow: var(--shadow);
 | |
|   color: var(--surface);
 | |
|   font-size: 0.875rem;
 | |
|   transform: translate(-50%, 200%);
 | |
|   border-radius: var(--r-s);
 | |
|   opacity: 0;
 | |
|   transition:
 | |
|     transform 0.2s,
 | |
|     opacity 0.2s;
 | |
| }
 | |
| 
 | |
| html.dark .message {
 | |
|   background-color: color-mix(in srgb, var(--surface) 72%, var(--border) 28%);
 | |
|   color: var(--heading);
 | |
|   border: 1px solid color-mix(in srgb, var(--border) 45%, transparent 55%);
 | |
| }
 | |
| 
 | |
| .message.open {
 | |
|   transform: translate(-50%, 0);
 | |
|   opacity: 1;
 | |
| }
 | |
| </style>
 | 
