feat: restore workflow editing experience

This commit is contained in:
2026-06-12 10:48:46 +08:00
parent 16490392b2
commit 7e3a2587a7
11 changed files with 338 additions and 66 deletions
@@ -0,0 +1,195 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
const props = withDefaults(
defineProps<{
autocompletion?: boolean;
bracketMatching?: boolean;
foldGutter?: boolean;
height?: number | string;
language?: string;
lineNumbers?: boolean;
modelValue?: string;
placeholder?: string;
readonly?: boolean;
}>(),
{
autocompletion: false,
bracketMatching: false,
foldGutter: false,
height: '240px',
language: 'text',
lineNumbers: false,
modelValue: '',
placeholder: '',
readonly: false,
},
);
const emit = defineEmits<{
change: [value: string];
'update:modelValue': [value: string];
}>();
const lineNumbersRef = ref<HTMLPreElement | null>(null);
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const editorStyle = computed(() => ({
height: typeof props.height === 'number' ? `${props.height}px` : props.height,
}));
const lineNumbersText = computed(() => {
const count = Math.max(1, props.modelValue.split(/\r\n|\r|\n/).length);
return Array.from({ length: count }, (_, index) => index + 1).join('\n');
});
const languageLabel = computed(() => {
const language = props.language === 'python3' ? 'python' : props.language;
return language.toUpperCase();
});
function handleInput(event: Event) {
const value = (event.target as HTMLTextAreaElement).value;
emit('update:modelValue', value);
}
function handleChange(event: Event) {
const value = (event.target as HTMLTextAreaElement).value;
emit('change', value);
}
function handleKeydown(event: KeyboardEvent) {
if (props.readonly || event.key !== 'Tab') {
return;
}
event.preventDefault();
const target = event.target as HTMLTextAreaElement;
const start = target.selectionStart;
const end = target.selectionEnd;
const value = props.modelValue || '';
const nextValue = `${value.slice(0, start)} ${value.slice(end)}`;
emit('update:modelValue', nextValue);
requestAnimationFrame(() => {
target.selectionStart = start + 2;
target.selectionEnd = start + 2;
});
}
function handleScroll(event: Event) {
if (!lineNumbersRef.value) {
return;
}
lineNumbersRef.value.scrollTop = (event.target as HTMLTextAreaElement).scrollTop;
}
defineExpose({
focus: () => textareaRef.value?.focus(),
});
</script>
<template>
<div
class="code-editor"
:class="{ 'code-editor--readonly': readonly }"
:style="editorStyle"
>
<div class="code-editor__body">
<pre
v-if="lineNumbers"
ref="lineNumbersRef"
class="code-editor__lines"
aria-hidden="true"
>{{ lineNumbersText }}</pre
>
<textarea
ref="textareaRef"
class="code-editor__textarea"
:placeholder="placeholder"
:readonly="readonly"
:spellcheck="false"
:value="modelValue"
@change="handleChange"
@input="handleInput"
@keydown="handleKeydown"
@scroll="handleScroll"
></textarea>
<span v-if="language" class="code-editor__language">
{{ languageLabel }}
</span>
</div>
</div>
</template>
<style scoped>
.code-editor {
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: 6px;
background: hsl(var(--muted) / 45%);
}
.code-editor__body {
position: relative;
display: flex;
height: 100%;
min-height: 0;
}
.code-editor__lines {
min-width: 42px;
height: 100%;
margin: 0;
padding: 10px 8px;
overflow: hidden;
border-right: 1px solid hsl(var(--border));
color: hsl(var(--muted-foreground) / 70%);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 12px;
line-height: 1.6;
text-align: right;
user-select: none;
}
.code-editor__textarea {
width: 100%;
height: 100%;
min-width: 0;
padding: 10px 12px;
overflow: auto;
border: 0;
outline: none;
resize: none;
background: transparent;
color: hsl(var(--foreground));
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 12px;
line-height: 1.6;
tab-size: 2;
}
.code-editor__textarea::placeholder {
color: hsl(var(--muted-foreground) / 55%);
}
.code-editor__language {
position: absolute;
right: 8px;
bottom: 6px;
pointer-events: none;
color: hsl(var(--muted-foreground) / 55%);
font-size: 10px;
font-weight: 600;
letter-spacing: 0;
}
.code-editor--readonly .code-editor__textarea {
cursor: default;
}
</style>
@@ -0,0 +1 @@
export { default as CodeEditor } from './code-editor.vue';