Files
2026-10-17 23:56:32 +02:00

316 lines
9.2 KiB
Vue

<template>
<div
class="mrmth-editor mrmth-editor-v2"
data-mrmth-script-widget="vue"
>
<!-- Gutter / Line Numbers -->
<div class="mrmth-gutter">
<div class="mrmth-gutter-content" :style="gutterStyle">{{ gutterText }}</div>
</div>
<!-- Editor Container -->
<div class="mrmth-editor-container" ref="editorContainerRef">
<!-- Measure Layer for wrapped line calculations -->
<pre
ref="measureElRef"
class="mrmth-measure"
:style="sharedTextStyle"
v-html="measureHtml"
></pre>
<!-- Syntax Highlighting Layer -->
<pre
ref="syntaxLayerRef"
class="mrmth-syntax-layer"
:style="sharedTextStyle"
v-html="highlightedHtml"
></pre>
<!-- Textarea -->
<textarea
ref="textareaRef"
v-model="modelValue"
class="mrmth-script-textarea"
spellcheck="false"
autocapitalize="off"
autocomplete="off"
autocorrect="off"
placeholder="Enter your script here..."
@scroll="onScroll"
@blur="cursor = -2"
></textarea>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import { app } from '/scripts/app.js';
import { attachAutocomplete, escapeHtml, handleEditingKey, renderHighlight } from './autocomplete.js';
const modelValue = defineModel<string>({ default: '' });
const editorContainerRef = ref<HTMLDivElement | null>(null);
const measureElRef = ref<HTMLPreElement | null>(null);
const syntaxLayerRef = ref<HTMLPreElement | null>(null);
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const gutterText = ref('1');
const scrollTop = ref(0);
// Collapsed caret position while focused, used to outline the bracket pair at the cursor.
const cursor = ref(-2);
// Layout-relevant textarea styles, copied so overlay, mirror and textarea wrap identically.
const textStyle = ref<Record<string, string>>({});
const textWidth = ref(0);
const gutterPaddingTop = ref('0px');
const highlightedHtml = computed(() => renderHighlight(modelValue.value, cursor.value));
// Empty lines need content to produce a line box in the mirror.
const measureHtml = computed(() =>
modelValue.value
.split('\n')
.map((line) => `<span class="mrmth-ln-marker"></span>${escapeHtml(line || ' ')}`)
.join('\n')
);
const sharedTextStyle = computed(() => ({
...textStyle.value,
width: textWidth.value ? `${textWidth.value}px` : '100%',
}));
const gutterStyle = computed(() => ({
fontFamily: textStyle.value.fontFamily,
fontSize: textStyle.value.fontSize,
lineHeight: textStyle.value.lineHeight,
paddingTop: gutterPaddingTop.value,
transform: `translateY(${-scrollTop.value}px)`,
}));
const updateLineNumbers = () => {
const measureEl = measureElRef.value;
if (!measureEl) return;
const lineHeightPx = parseFloat(textStyle.value.lineHeight);
const markers = measureEl.querySelectorAll<HTMLElement>('.mrmth-ln-marker');
const contentBottom = measureEl.clientHeight - parseFloat(getComputedStyle(measureEl).paddingBottom);
const lines: string[] = [];
for (let i = 0; i < markers.length; i++) {
const y0 = markers[i].offsetTop;
const y1 = i + 1 < markers.length ? markers[i + 1].offsetTop : contentBottom;
lines.push(String(i + 1));
const visualLines = Math.max(1, Math.round((y1 - y0) / lineHeightPx));
for (let j = 1; j < visualLines; j++) lines.push('');
}
gutterText.value = lines.join('\n');
};
const refresh = () => {
const textarea = textareaRef.value;
if (!textarea) return;
const cs = window.getComputedStyle(textarea);
textStyle.value = {
fontFamily: cs.fontFamily,
fontSize: cs.fontSize,
fontWeight: cs.fontWeight,
fontStyle: cs.fontStyle,
letterSpacing: cs.letterSpacing,
lineHeight: cs.lineHeight,
tabSize: cs.tabSize,
padding: `${cs.paddingTop} ${cs.paddingRight} ${cs.paddingBottom} ${cs.paddingLeft}`,
borderWidth: `${cs.borderTopWidth} ${cs.borderRightWidth} ${cs.borderBottomWidth} ${cs.borderLeftWidth}`,
};
// Same border-box width as the textarea minus its scrollbar.
textWidth.value = textarea.clientWidth + parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth);
gutterPaddingTop.value = `${parseFloat(cs.paddingTop) + parseFloat(cs.borderTopWidth)}px`;
// Measure once the mirror has re-rendered with the new text and styles.
nextTick(updateLineNumbers);
};
const syncScroll = () => {
const textarea = textareaRef.value;
const syntaxLayer = syntaxLayerRef.value;
if (!textarea || !syntaxLayer) return;
syntaxLayer.scrollTop = textarea.scrollTop;
syntaxLayer.scrollLeft = textarea.scrollLeft;
};
const onScroll = () => {
scrollTop.value = textareaRef.value?.scrollTop ?? 0;
syncScroll();
};
const onKeyDown = (e: KeyboardEvent) => {
if (textareaRef.value && handleEditingKey(textareaRef.value, e)) e.preventDefault();
};
const onSelectionChange = () => {
const textarea = textareaRef.value;
if (!textarea || document.activeElement !== textarea) return;
cursor.value = textarea.selectionStart === textarea.selectionEnd ? textarea.selectionStart : -2;
};
let resizeObserver: ResizeObserver | null = null;
let autocompleteController: { destroy: () => void } | null = null;
onMounted(() => {
const textarea = textareaRef.value!;
// Autocomplete first: its keydown handler must run before the editing keys.
autocompleteController = attachAutocomplete(textarea, (id) => app.extensionManager?.setting?.get(id));
textarea.addEventListener('keydown', onKeyDown);
document.addEventListener('selectionchange', onSelectionChange);
resizeObserver = new ResizeObserver(refresh);
resizeObserver.observe(editorContainerRef.value!);
refresh();
});
onBeforeUnmount(() => {
autocompleteController?.destroy();
resizeObserver?.disconnect();
textareaRef.value?.removeEventListener('keydown', onKeyDown);
document.removeEventListener('selectionchange', onSelectionChange);
});
watch(modelValue, refresh);
// Replacing the overlay's content can reset its scroll position.
watch(highlightedHtml, syncScroll, { flush: 'post' });
</script>
<style scoped>
.mrmth-editor {
position: relative;
display: block;
width: 100%;
height: 100%;
min-height: 0;
box-sizing: border-box;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
overflow: hidden;
background: rgba(0, 0, 0, 0.18);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15);
transition: border-color 0.25s ease, box-shadow 0.25s ease;
font-family: monospace;
}
.mrmth-editor:focus-within {
border-color: var(--p-primary-color, #4ea658);
box-shadow: 0 0 0 2px rgba(78, 166, 88, 0.2), inset 0 2px 4px rgba(0, 0, 0, 0.15);
}
.mrmth-gutter {
position: absolute;
inset: 0 auto 0 0;
padding: 0;
width: 44px;
min-height: 0;
text-align: right;
user-select: none;
color: #9aa0a6;
background: rgba(0, 0, 0, 0.35);
border-right: 1px solid rgba(255, 255, 255, 0.12);
box-sizing: border-box;
overflow: hidden;
}
.mrmth-gutter-content {
padding: 0 8px;
white-space: pre;
will-change: transform;
}
.mrmth-editor-container {
position: absolute;
inset: 0 0 0 44px;
min-width: 0;
min-height: 0;
overflow: hidden;
background: transparent;
}
.mrmth-measure,
.mrmth-syntax-layer {
margin: 0 !important;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
box-sizing: border-box;
border-style: solid;
border-color: transparent;
}
.mrmth-measure {
position: absolute;
visibility: hidden;
pointer-events: none;
inset: 0 auto auto 0;
height: auto;
}
.mrmth-syntax-layer {
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
overflow: hidden;
color: #ecf0f1;
}
/* Extra scroll room so the last line can scroll as far as in the textarea */
.mrmth-syntax-layer::after {
content: "";
display: block;
height: 1em;
}
.mrmth-script-textarea {
position: absolute;
inset: 0;
z-index: 2;
line-height: 1.45;
border: 0 !important;
outline: none !important;
resize: none !important;
color: transparent !important;
caret-color: #ffffff !important;
background: transparent !important;
-webkit-text-fill-color: transparent;
overflow-y: auto;
scrollbar-gutter: stable;
margin: 0 !important;
}
/* Syntax Token Highlighting */
:deep(.mrmth-token-comment) { color: #7f8c8d; font-style: italic; }
:deep(.mrmth-token-number) { color: #f39c12; }
:deep(.mrmth-token-constant) { color: #f1c40f; font-weight: 500; }
:deep(.mrmth-token-keyword) { color: #e74c3c; font-weight: 500; }
:deep(.mrmth-token-function) { color: #9b59b6; }
:deep(.mrmth-token-variable) { color: #ecf0f1; }
:deep(.mrmth-token-operator) { color: #3498db; }
:deep(.mrmth-token-string) { color: #2ecc71; }
:deep(.mrmth-token-bracket) { font-weight: 700; }
:deep(.mrmth-bracket-0) { color: #ffd700; }
:deep(.mrmth-bracket-1) { color: #da70d6; }
:deep(.mrmth-bracket-2) { color: #87cefa; }
:deep(.mrmth-bracket-3) { color: #98fb98; }
:deep(.mrmth-bracket-4) { color: #ff6347; }
:deep(.mrmth-bracket-5) { color: #ffa500; }
:deep(.mrmth-bracket-unmatched) {
color: #ff4d4d;
text-decoration: underline wavy #ff4d4d;
}
:deep(.mrmth-bracket-active) {
outline: 1px solid rgba(255, 255, 255, 0.6);
border-radius: 2px;
background: rgba(255, 255, 255, 0.12);
}
</style>