feat: restore workflow editing experience
This commit is contained in:
@@ -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';
|
||||
Reference in New Issue
Block a user