- Added quick development features including toggles for line numbers, word wrap, and active line highlighting to enhance the coding experience. - Implemented search functionality with keyboard shortcuts for improved code navigation and editing. - Enhanced JSON formatting capabilities to handle JSON code without relying on Prettier, ensuring better performance for JSON files. - Updated the component's template and styles to accommodate new features, including a responsive layout for quick dev tools. - Improved cursor position tracking and display for better user feedback during code editing.
691 lines
17 KiB
Vue
691 lines
17 KiB
Vue
<script setup>
|
|
import { useThemeStore } from "~/stores/theme";
|
|
|
|
import { vue } from "@codemirror/lang-vue";
|
|
import { javascript } from "@codemirror/lang-javascript";
|
|
import { css } from "@codemirror/lang-css";
|
|
|
|
import { oneDark } from "@codemirror/theme-one-dark";
|
|
import { amy, ayuLight, barf, clouds, cobalt, dracula } from "thememirror";
|
|
|
|
import { autocompletion } from "@codemirror/autocomplete";
|
|
import { indentUnit } from "@codemirror/language";
|
|
import { indentOnInput } from "@codemirror/language";
|
|
import { EditorView } from "@codemirror/view";
|
|
import { searchKeymap, search } from "@codemirror/search";
|
|
import { keymap } from "@codemirror/view";
|
|
import { useDebounceFn } from "@vueuse/core";
|
|
|
|
// Dynamically import Prettier and its plugins
|
|
const prettier = ref(null);
|
|
const parserHTML = ref(null);
|
|
const parserBabel = ref(null);
|
|
const parserPostCSS = ref(null);
|
|
const pluginVue = ref(null);
|
|
|
|
const props = defineProps({
|
|
options: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
language: {
|
|
type: String,
|
|
default: "javascript",
|
|
},
|
|
height: {
|
|
type: String,
|
|
default: "70vh",
|
|
},
|
|
modelValue: {
|
|
type: String,
|
|
default: "",
|
|
},
|
|
theme: {
|
|
type: String,
|
|
default: "oneDark",
|
|
},
|
|
disabled: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
class: {
|
|
type: [String, Object, Array],
|
|
default: "",
|
|
},
|
|
});
|
|
|
|
const emits = defineEmits(["update:modelValue", "format-code"]);
|
|
|
|
const themeStore = useThemeStore();
|
|
const editorTheme = ref(themeStore.codeTheme);
|
|
|
|
const dropdownThemes = ref([
|
|
{
|
|
label: "default",
|
|
value: "clouds",
|
|
},
|
|
{
|
|
label: "oneDark",
|
|
value: "oneDark",
|
|
},
|
|
{
|
|
label: "amy",
|
|
value: "amy",
|
|
},
|
|
{
|
|
label: "ayu",
|
|
value: "ayuLight",
|
|
},
|
|
{
|
|
label: "barf",
|
|
value: "barf",
|
|
},
|
|
{
|
|
label: "cobalt",
|
|
value: "cobalt",
|
|
},
|
|
{
|
|
label: "dracula",
|
|
value: "dracula",
|
|
},
|
|
]);
|
|
|
|
const value = ref(props.modelValue);
|
|
const extensions = ref([]);
|
|
|
|
// Quick dev features
|
|
const showLineNumbers = ref(true);
|
|
const enableWordWrap = ref(false);
|
|
const highlightActiveLine = ref(true);
|
|
const enableSearch = ref(true);
|
|
|
|
// Helper function to get theme extension
|
|
const getThemeExtension = (themeVal) => {
|
|
switch (themeVal) {
|
|
case "oneDark":
|
|
return oneDark;
|
|
case "amy":
|
|
return amy;
|
|
case "ayuLight":
|
|
return ayuLight;
|
|
case "barf":
|
|
return barf;
|
|
case "cobalt":
|
|
return cobalt;
|
|
case "dracula":
|
|
return dracula;
|
|
case "clouds":
|
|
default:
|
|
return clouds;
|
|
}
|
|
};
|
|
|
|
// Helper function to get language extension
|
|
const getLanguageExtension = () => {
|
|
switch (props.language) {
|
|
case "vue":
|
|
return vue();
|
|
case "css":
|
|
case "scss":
|
|
return css();
|
|
case "html":
|
|
return css(); // Use CSS for HTML highlighting
|
|
case "json":
|
|
return javascript();
|
|
case "javascript":
|
|
case "js":
|
|
default:
|
|
return javascript();
|
|
}
|
|
};
|
|
|
|
// Initialize extensions with proper theme
|
|
const initializeExtensions = () => {
|
|
const currentTheme = props.theme || editorTheme.value || "oneDark";
|
|
const themeExtension = getThemeExtension(currentTheme);
|
|
const languageExtension = getLanguageExtension();
|
|
|
|
const baseExtensions = [
|
|
languageExtension,
|
|
themeExtension,
|
|
autocompletion(),
|
|
indentUnit.of(" "),
|
|
indentOnInput(),
|
|
];
|
|
|
|
// Don't add lineNumbers() extension - vue-codemirror already has them
|
|
// We'll control visibility via CSS instead
|
|
|
|
if (enableWordWrap.value) {
|
|
baseExtensions.push(EditorView.lineWrapping);
|
|
}
|
|
|
|
if (highlightActiveLine.value) {
|
|
baseExtensions.push(EditorView.theme({
|
|
'.cm-activeLine': { backgroundColor: 'rgba(255, 255, 255, 0.1)' },
|
|
'.cm-focused .cm-activeLine': { backgroundColor: 'rgba(255, 255, 255, 0.1)' }
|
|
}));
|
|
}
|
|
|
|
if (enableSearch.value) {
|
|
baseExtensions.push(search());
|
|
baseExtensions.push(keymap.of(searchKeymap));
|
|
}
|
|
|
|
extensions.value = baseExtensions;
|
|
};
|
|
|
|
// Toggle functions
|
|
const toggleLineNumbers = () => {
|
|
showLineNumbers.value = !showLineNumbers.value;
|
|
};
|
|
|
|
const toggleWordWrap = () => {
|
|
enableWordWrap.value = !enableWordWrap.value;
|
|
initializeExtensions();
|
|
};
|
|
|
|
const toggleActiveLine = () => {
|
|
highlightActiveLine.value = !highlightActiveLine.value;
|
|
initializeExtensions();
|
|
};
|
|
|
|
// Initialize extensions on component creation
|
|
initializeExtensions();
|
|
|
|
const totalLines = ref(0);
|
|
const totalLength = ref(0);
|
|
const cursorPosition = ref({ line: 1, column: 1 });
|
|
|
|
// Codemirror EditorView instance ref
|
|
const view = shallowRef();
|
|
const handleReady = (payload) => {
|
|
view.value = payload.view;
|
|
totalLines.value = view.value.state.doc.lines;
|
|
totalLength.value = view.value.state.doc.length;
|
|
|
|
// Track cursor position
|
|
const updateCursor = () => {
|
|
if (view.value) {
|
|
const state = view.value.state;
|
|
const pos = state.selection.main.head;
|
|
const line = state.doc.lineAt(pos);
|
|
cursorPosition.value = {
|
|
line: line.number,
|
|
column: pos - line.from + 1
|
|
};
|
|
}
|
|
};
|
|
|
|
view.value.dom.addEventListener('click', updateCursor);
|
|
view.value.dom.addEventListener('keyup', updateCursor);
|
|
updateCursor();
|
|
};
|
|
|
|
// Watch for theme changes
|
|
watch(
|
|
() => props.theme || editorTheme.value,
|
|
(newTheme) => {
|
|
if (newTheme) {
|
|
initializeExtensions();
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
// Watch for language changes
|
|
watch(
|
|
() => props.language,
|
|
() => {
|
|
initializeExtensions();
|
|
}
|
|
);
|
|
|
|
// Watch for editorTheme store changes
|
|
watch(
|
|
() => editorTheme.value,
|
|
(newTheme) => {
|
|
if (!props.theme && newTheme) {
|
|
initializeExtensions();
|
|
}
|
|
}
|
|
);
|
|
|
|
// Status is available at all times via Codemirror EditorView
|
|
const getCodemirrorStates = () => {
|
|
const state = view.value.state;
|
|
const ranges = state.selection.ranges;
|
|
const selected = ranges.reduce((r, range) => r + range.to - range.from, 0);
|
|
const cursor = ranges[0].anchor;
|
|
const length = state.doc.length;
|
|
const lines = state.doc.lines;
|
|
|
|
console.log("state", view.value.state);
|
|
};
|
|
|
|
const onChange = (value) => {
|
|
// console.log("onChange", value);
|
|
emits("update:modelValue", value);
|
|
totalLines.value = view.value.state.doc.lines;
|
|
totalLength.value = view.value.state.doc.length;
|
|
};
|
|
|
|
const onFocus = (value) => {
|
|
// console.log("onFocus", value);
|
|
};
|
|
|
|
const onBlur = (value) => {
|
|
// console.log("onBlur", value);
|
|
};
|
|
|
|
const onUpdate = (value) => {
|
|
// console.log("onUpdate", value);
|
|
};
|
|
|
|
function numberComma(x) {
|
|
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
}
|
|
|
|
// Function to load Prettier and its plugins
|
|
const loadPrettier = async () => {
|
|
if (!prettier.value) {
|
|
prettier.value = await import("prettier/standalone");
|
|
parserHTML.value = await import("prettier/parser-html");
|
|
parserBabel.value = await import("prettier/parser-babel");
|
|
parserPostCSS.value = await import("prettier/parser-postcss");
|
|
pluginVue.value = await import("prettier-plugin-vue");
|
|
}
|
|
};
|
|
|
|
// Function Format Code
|
|
const formatCode = async (code) => {
|
|
// Handle JSON formatting without Prettier
|
|
if (props.language === 'json') {
|
|
try {
|
|
const parsed = JSON.parse(code);
|
|
return JSON.stringify(parsed, null, 2);
|
|
} catch (error) {
|
|
console.error('JSON formatting error:', error);
|
|
return code;
|
|
}
|
|
}
|
|
|
|
// Use Prettier for other languages
|
|
await loadPrettier();
|
|
try {
|
|
let parser = "babel";
|
|
let plugins = [parserBabel.value];
|
|
|
|
switch (props.language) {
|
|
case "vue":
|
|
parser = "vue";
|
|
plugins = [parserHTML.value, parserBabel.value, parserPostCSS.value, pluginVue.value];
|
|
break;
|
|
case "css":
|
|
case "scss":
|
|
parser = "css";
|
|
plugins = [parserPostCSS.value];
|
|
break;
|
|
case "html":
|
|
parser = "html";
|
|
plugins = [parserHTML.value];
|
|
break;
|
|
case "javascript":
|
|
case "js":
|
|
default:
|
|
parser = "babel";
|
|
plugins = [parserBabel.value];
|
|
break;
|
|
}
|
|
|
|
const formattedCode = await prettier.value.format(code, {
|
|
parser: parser,
|
|
plugins: plugins,
|
|
semi: false,
|
|
singleQuote: true,
|
|
trailingComma: "es5",
|
|
printWidth: 80,
|
|
tabWidth: 2,
|
|
});
|
|
return formattedCode;
|
|
} catch (error) {
|
|
console.error("Formatting error:", error);
|
|
return code; // Return original code if formatting fails
|
|
}
|
|
};
|
|
|
|
const formatCurrentCode = async () => {
|
|
try {
|
|
const formattedCode = await formatCode(value.value);
|
|
value.value = formattedCode;
|
|
emits("update:modelValue", formattedCode);
|
|
emits("format-code");
|
|
|
|
// Show success message based on language
|
|
const langName = props.language === 'json' ? 'JSON' : props.language.toUpperCase();
|
|
console.log(`${langName} formatted successfully`);
|
|
} catch (error) {
|
|
console.error("Error formatting code:", error);
|
|
}
|
|
};
|
|
|
|
const debouncedFormatCode = useDebounceFn(formatCurrentCode, 300);
|
|
|
|
// Fullscreen functionality
|
|
const isFullscreen = ref(false);
|
|
const fullscreenContainer = ref(null);
|
|
|
|
const toggleFullscreen = () => {
|
|
isFullscreen.value = !isFullscreen.value;
|
|
|
|
if (isFullscreen.value) {
|
|
document.body.style.overflow = 'hidden';
|
|
// Focus the editor after entering fullscreen
|
|
nextTick(() => {
|
|
if (view.value) {
|
|
view.value.focus();
|
|
}
|
|
});
|
|
} else {
|
|
document.body.style.overflow = '';
|
|
}
|
|
};
|
|
|
|
const exitFullscreen = () => {
|
|
if (isFullscreen.value) {
|
|
isFullscreen.value = false;
|
|
document.body.style.overflow = '';
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e) => {
|
|
// Press Shift + Alt + F to format code
|
|
if (e.shiftKey && e.altKey && e.key === "F") {
|
|
e.preventDefault();
|
|
debouncedFormatCode();
|
|
}
|
|
// Press F11 or Ctrl+Shift+F to toggle fullscreen
|
|
if (e.key === "F11" || (e.ctrlKey && e.shiftKey && e.key === "F")) {
|
|
e.preventDefault();
|
|
toggleFullscreen();
|
|
}
|
|
// Press Escape to exit fullscreen
|
|
if (e.key === "Escape" && isFullscreen.value) {
|
|
e.preventDefault();
|
|
exitFullscreen();
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
loadPrettier(); // Preload Prettier when the component mounts
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
// Ensure body overflow is reset
|
|
if (isFullscreen.value) {
|
|
document.body.style.overflow = '';
|
|
}
|
|
});
|
|
|
|
// Add this watch effect after the value ref declaration
|
|
watch(
|
|
() => props.modelValue,
|
|
(newValue) => {
|
|
if (newValue !== value.value) {
|
|
value.value = newValue;
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="fullscreenContainer"
|
|
:class="[
|
|
props.class,
|
|
{
|
|
'fullscreen-editor': isFullscreen,
|
|
'fixed inset-0 z-[9999] bg-[#282C34]': isFullscreen,
|
|
'hide-line-numbers': !showLineNumbers
|
|
}
|
|
]"
|
|
>
|
|
<div
|
|
class="flex justify-between items-center gap-2 p-2 bg-[#282C34] text-[#abb2bf] border-b border-gray-600"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<div class="flex items-center gap-2">
|
|
Theme:
|
|
<FormKit
|
|
v-model="editorTheme"
|
|
type="select"
|
|
placeholder="Select Themes"
|
|
:options="dropdownThemes"
|
|
:classes="{
|
|
input:
|
|
'!bg-[#282C34] !text-[#abb2bf] !border-[#abb2bf] hover:cursor-pointer h-6 w-[100px]',
|
|
inner: ' !rounded-none !mb-0',
|
|
outer: '!mb-0',
|
|
}"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Quick dev tools -->
|
|
<div class="flex items-center gap-1 ml-3 border-l border-gray-600 pl-3">
|
|
<button
|
|
@click="toggleLineNumbers"
|
|
:title="showLineNumbers ? 'Hide line numbers' : 'Show line numbers'"
|
|
class="dev-toggle"
|
|
:class="showLineNumbers ? 'active' : 'inactive'"
|
|
>
|
|
#
|
|
</button>
|
|
<button
|
|
@click="toggleWordWrap"
|
|
:title="enableWordWrap ? 'Disable word wrap' : 'Enable word wrap'"
|
|
class="dev-toggle"
|
|
:class="enableWordWrap ? 'active' : 'inactive'"
|
|
>
|
|
↩
|
|
</button>
|
|
<button
|
|
@click="toggleActiveLine"
|
|
:title="highlightActiveLine ? 'Disable line highlight' : 'Enable line highlight'"
|
|
class="dev-toggle"
|
|
:class="highlightActiveLine ? 'active' : 'inactive'"
|
|
>
|
|
▬
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="isFullscreen" class="text-sm text-gray-300 ml-4">
|
|
<Icon name="material-symbols:info-outline" class="w-4 h-4 inline mr-1" />
|
|
Press <kbd class="px-1 py-0.5 text-xs bg-gray-700 rounded">Esc</kbd> or
|
|
<kbd class="px-1 py-0.5 text-xs bg-gray-700 rounded">F11</kbd> to exit fullscreen
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
@click="toggleFullscreen"
|
|
class="px-3 py-1 bg-gray-600 hover:bg-gray-500 text-sm rounded transition-colors flex items-center"
|
|
:title="isFullscreen ? 'Exit Fullscreen (F11)' : 'Enter Fullscreen (F11)'"
|
|
>
|
|
<Icon
|
|
:name="isFullscreen ? 'material-symbols:fullscreen-exit' : 'material-symbols:fullscreen'"
|
|
class="!w-4 !h-4 mr-2"
|
|
/>
|
|
{{ isFullscreen ? 'Exit Fullscreen' : 'Fullscreen' }}
|
|
</button>
|
|
<rs-button
|
|
@click="formatCurrentCode"
|
|
class="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-sm rounded transition-colors"
|
|
:title="getFormatButtonTitle()"
|
|
>
|
|
<Icon :name="getFormatButtonIcon()" class="!w-5 !h-5 mr-2" />
|
|
{{ getFormatButtonText() }}
|
|
</rs-button>
|
|
</div>
|
|
</div>
|
|
<client-only>
|
|
<CodeMirror
|
|
v-model="value"
|
|
placeholder="Code goes here..."
|
|
:style="{
|
|
height: isFullscreen ? 'calc(100vh - 120px)' : height,
|
|
minHeight: isFullscreen ? 'calc(100vh - 120px)' : 'auto'
|
|
}"
|
|
:autofocus="true"
|
|
:indent-with-tab="true"
|
|
:tab-size="2"
|
|
:extensions="extensions"
|
|
:disabled="disabled"
|
|
@ready="handleReady"
|
|
@change="onChange($event)"
|
|
@focus="onFocus($event)"
|
|
@blur="onBlur($event)"
|
|
@update="onUpdate($event)"
|
|
/>
|
|
</client-only>
|
|
<div
|
|
class="footer flex justify-between items-center gap-2 p-2 bg-[#282C34] text-[#abb2bf] border-t border-gray-600"
|
|
>
|
|
<div class="flex items-center gap-4 text-sm">
|
|
<div class="text-gray-300 text-xs">
|
|
<Icon name="material-symbols:keyboard" class="w-4 h-4 inline mr-1" />
|
|
Shortcuts:
|
|
<kbd class="px-1 py-0.5 text-xs bg-gray-700 rounded mx-0.5">Ctrl+F</kbd> Search,
|
|
<kbd class="px-1 py-0.5 text-xs bg-gray-700 rounded mx-0.5">Ctrl+H</kbd> Replace,
|
|
<kbd class="px-1 py-0.5 text-xs bg-gray-700 rounded mx-0.5">Shift+Alt+F</kbd> Format
|
|
<span v-if="isFullscreen">, <kbd class="px-1 py-0.5 text-xs bg-gray-700 rounded mx-0.5">F11</kbd> Exit Fullscreen</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-4 text-sm">
|
|
<span>Lines: {{ numberComma(totalLines) }}</span>
|
|
<span>Length: {{ numberComma(totalLength) }}</span>
|
|
<span class="text-gray-400">Ln {{ cursorPosition.line }}, Col {{ cursorPosition.column }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* Fullscreen editor styles */
|
|
.fullscreen-editor {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
width: 100vw;
|
|
left: 0;
|
|
top: 0;
|
|
background: #282C34;
|
|
z-index: 9999;
|
|
}
|
|
|
|
.fullscreen-editor .footer,
|
|
.fullscreen-editor .header {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.fullscreen-editor :deep(.cm-editor) {
|
|
flex: 1;
|
|
height: auto !important;
|
|
}
|
|
|
|
.fullscreen-editor :deep(.cm-scroller) {
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
/* Smooth transitions */
|
|
.fullscreen-editor {
|
|
transition: all 0.2s ease-in-out;
|
|
}
|
|
|
|
/* Keyboard shortcut styling */
|
|
kbd {
|
|
@apply inline-flex items-center px-2 py-1 bg-gray-700 text-gray-200 text-xs font-mono rounded shadow;
|
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
}
|
|
|
|
/* Better button styling */
|
|
button:focus {
|
|
@apply outline-none ring-2 ring-blue-500 ring-offset-2 ring-offset-gray-800;
|
|
}
|
|
|
|
/* Responsive adjustments for fullscreen */
|
|
@media (max-width: 768px) {
|
|
.fullscreen-editor .footer,
|
|
.fullscreen-editor .header {
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.fullscreen-editor .footer span,
|
|
.fullscreen-editor .header span {
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.fullscreen-editor button {
|
|
padding: 0.25rem 0.75rem;
|
|
font-size: 0.75rem;
|
|
}
|
|
}
|
|
|
|
/* Animation for entering fullscreen */
|
|
@keyframes fadeInScale {
|
|
from {
|
|
opacity: 0;
|
|
transform: scale(0.95);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
}
|
|
|
|
.fullscreen-editor {
|
|
animation: fadeInScale 0.2s ease-out;
|
|
}
|
|
|
|
/* Focus styles for better accessibility */
|
|
.fullscreen-editor :deep(.cm-focused) {
|
|
outline: 2px solid #3b82f6;
|
|
outline-offset: -2px;
|
|
}
|
|
|
|
/* Hide line numbers when toggle is off */
|
|
.hide-line-numbers :deep(.cm-lineNumbers) {
|
|
display: none;
|
|
}
|
|
|
|
.hide-line-numbers :deep(.cm-gutters) {
|
|
display: none;
|
|
}
|
|
|
|
/* Adjust content when line numbers are hidden */
|
|
.hide-line-numbers :deep(.cm-content) {
|
|
padding-left: 8px;
|
|
}
|
|
|
|
/* Quick dev tools styling */
|
|
.dev-toggle {
|
|
@apply px-2 py-1 text-xs rounded transition-colors;
|
|
}
|
|
|
|
.dev-toggle.active {
|
|
@apply bg-blue-600 text-white;
|
|
}
|
|
|
|
.dev-toggle.inactive {
|
|
@apply bg-gray-600 text-gray-300;
|
|
}
|
|
|
|
.dev-toggle.inactive:hover {
|
|
@apply bg-gray-500;
|
|
}
|
|
</style>
|
|
|
|
<style lang="scss" scoped></style>
|