feat:移除了弹窗,服务器添加sls
This commit is contained in:
275
apps/app-frontend/src/components/instance/studio/NbtEditor.vue
Normal file
275
apps/app-frontend/src/components/instance/studio/NbtEditor.vue
Normal file
@ -0,0 +1,275 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { NbtString, NbtTag } from 'deepslate/nbt'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import NbtTreeNode from './NbtTreeNode.vue'
|
||||
import StudioEditor from './StudioEditor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
filePath: string
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [content: string]
|
||||
'update:mode': [mode: 'tree' | 'snbt']
|
||||
format: []
|
||||
save: []
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
tree: { id: 'instance.files.studio.nbt.tree', defaultMessage: 'Tree' },
|
||||
snbt: { id: 'instance.files.studio.nbt.snbt', defaultMessage: 'SNBT' },
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const mode = ref<'tree' | 'snbt'>('tree')
|
||||
const root = ref<NbtTag | null>(null)
|
||||
const parseError = ref('')
|
||||
const draft = ref(props.content)
|
||||
const snbtEditor = ref<InstanceType<typeof StudioEditor> | null>(null)
|
||||
const history = ref([props.content])
|
||||
let historyIndex = 0
|
||||
let lastEmittedContent = props.content
|
||||
|
||||
function parseContent(content: string) {
|
||||
draft.value = content
|
||||
try {
|
||||
const parsed = NbtTag.fromString(content)
|
||||
if (!parsed.isCompound()) throw new Error('NBT root must be a compound')
|
||||
root.value = parsed
|
||||
parseError.value = ''
|
||||
} catch (error) {
|
||||
parseError.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
function updateSnbt(content: string) {
|
||||
draft.value = content
|
||||
parseContent(content)
|
||||
lastEmittedContent = content
|
||||
emit('update:content', content)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.content,
|
||||
(content) => {
|
||||
parseContent(content)
|
||||
if (content !== lastEmittedContent) {
|
||||
history.value = [content]
|
||||
historyIndex = 0
|
||||
lastEmittedContent = content
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const rootCompound = computed(() => (root.value?.isCompound() ? root.value : null))
|
||||
|
||||
function setMode(nextMode: 'tree' | 'snbt') {
|
||||
if (nextMode === 'tree' && parseError.value) return
|
||||
if (nextMode === 'tree') {
|
||||
history.value = [draft.value]
|
||||
historyIndex = 0
|
||||
}
|
||||
mode.value = nextMode
|
||||
emit('update:mode', nextMode)
|
||||
emit('save')
|
||||
}
|
||||
|
||||
function handleFocusout(event: FocusEvent) {
|
||||
const currentTarget = event.currentTarget
|
||||
const nextTarget = event.relatedTarget
|
||||
if (
|
||||
currentTarget instanceof HTMLElement &&
|
||||
nextTarget instanceof Node &&
|
||||
currentTarget.contains(nextTarget)
|
||||
)
|
||||
return
|
||||
emit('save')
|
||||
}
|
||||
|
||||
function resolve(path: (string | number)[]) {
|
||||
let current: NbtTag | undefined = root.value ?? undefined
|
||||
for (const segment of path) {
|
||||
if (!current) return undefined
|
||||
if (typeof segment === 'string' && current.isCompound()) current = current.get(segment)
|
||||
else if (typeof segment === 'number' && (current.isList() || current.isArray())) {
|
||||
current = current.get(segment)
|
||||
} else return undefined
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function parentOf(path: (string | number)[]) {
|
||||
return resolve(path.slice(0, -1))
|
||||
}
|
||||
|
||||
function updateContent() {
|
||||
if (!root.value) return
|
||||
const content = root.value.toPrettyString()
|
||||
draft.value = content
|
||||
if (history.value[historyIndex] !== content) {
|
||||
history.value = history.value.slice(0, historyIndex + 1)
|
||||
history.value.push(content)
|
||||
historyIndex += 1
|
||||
}
|
||||
lastEmittedContent = content
|
||||
emit('update:content', content)
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (mode.value !== 'tree' || historyIndex === 0) return
|
||||
historyIndex -= 1
|
||||
const content = history.value[historyIndex]
|
||||
parseContent(content)
|
||||
lastEmittedContent = content
|
||||
emit('update:content', content)
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (mode.value !== 'tree' || historyIndex >= history.value.length - 1) return
|
||||
historyIndex += 1
|
||||
const content = history.value[historyIndex]
|
||||
parseContent(content)
|
||||
lastEmittedContent = content
|
||||
emit('update:content', content)
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!(event.ctrlKey || event.metaKey)) return
|
||||
if (event.key.toLowerCase() === 'z') {
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) redo()
|
||||
else undo()
|
||||
} else if (event.key.toLowerCase() === 'y') {
|
||||
event.preventDefault()
|
||||
redo()
|
||||
}
|
||||
}
|
||||
|
||||
function editValue(path: (string | number)[], value: string) {
|
||||
const target = resolve(path)
|
||||
if (!target) return
|
||||
try {
|
||||
const parsed = target.isString() ? new NbtString(value) : NbtTag.fromString(value)
|
||||
if (parsed.getId() !== target.getId()) throw new Error('Value type cannot be changed')
|
||||
const parent = parentOf(path)
|
||||
const last = path.at(-1)
|
||||
if (parent?.isCompound() && typeof last === 'string') parent.set(last, parsed)
|
||||
else if (parent?.isList() && typeof last === 'number') parent.set(last, parsed)
|
||||
else if (parent?.isByteArray() && typeof last === 'number' && parsed.isByte())
|
||||
parent.set(last, parsed)
|
||||
else if (parent?.isIntArray() && typeof last === 'number' && parsed.isInt())
|
||||
parent.set(last, parsed)
|
||||
else if (parent?.isLongArray() && typeof last === 'number' && parsed.isLong())
|
||||
parent.set(last, parsed)
|
||||
else throw new Error('Value cannot be changed')
|
||||
parseError.value = ''
|
||||
updateContent()
|
||||
} catch (error) {
|
||||
parseError.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
function removeValue(path: (string | number)[]) {
|
||||
const parent = parentOf(path)
|
||||
const last = path.at(-1)
|
||||
if (parent?.isCompound() && typeof last === 'string') parent.delete(last)
|
||||
else if (parent?.isListOrArray() && typeof last === 'number') parent.delete(last)
|
||||
parseError.value = ''
|
||||
updateContent()
|
||||
}
|
||||
|
||||
function renameValue(path: (string | number)[], name: string) {
|
||||
const parent = parentOf(path)
|
||||
const oldName = path.at(-1)
|
||||
if (!parent?.isCompound() || typeof oldName !== 'string' || parent.has(name)) return
|
||||
const value = parent.get(oldName)
|
||||
if (!value) return
|
||||
parent.delete(oldName)
|
||||
parent.set(name, value)
|
||||
parseError.value = ''
|
||||
updateContent()
|
||||
}
|
||||
|
||||
function addValue(path: (string | number)[], input: string) {
|
||||
const parent = resolve(path)
|
||||
if (!parent) return
|
||||
try {
|
||||
const separator = parent.isCompound() ? input.indexOf(':') : -1
|
||||
const name = separator === -1 ? undefined : input.slice(0, separator).trim()
|
||||
const valueText = separator === -1 ? input.trim() : input.slice(separator + 1).trim()
|
||||
const value = NbtTag.fromString(valueText)
|
||||
if (parent.isCompound() && name && !parent.has(name)) parent.set(name, value)
|
||||
else if (parent.isList() && value.getId() === parent.getType()) parent.add(value)
|
||||
else if (parent.isByteArray() && value.isByte()) parent.add(value)
|
||||
else if (parent.isIntArray() && value.isInt()) parent.add(value)
|
||||
else if (parent.isLongArray() && value.isLong()) parent.add(value)
|
||||
else throw new Error('Value type does not match the container')
|
||||
parseError.value = ''
|
||||
updateContent()
|
||||
} catch (error) {
|
||||
parseError.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function formatDocument() {
|
||||
await snbtEditor.value?.formatDocument()
|
||||
}
|
||||
|
||||
defineExpose({ formatDocument })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex size-full min-h-0 min-w-0 flex-col bg-surface-2" @focusout="handleFocusout">
|
||||
<div
|
||||
class="flex h-10 shrink-0 items-center gap-1 border-0 border-b border-solid border-surface-4 px-3"
|
||||
>
|
||||
<button
|
||||
v-for="candidate in ['tree', 'snbt'] as const"
|
||||
:key="candidate"
|
||||
type="button"
|
||||
class="rounded border-0 px-3 py-1 text-xs font-semibold capitalize"
|
||||
:class="
|
||||
mode === candidate
|
||||
? 'bg-brand text-contrast'
|
||||
: 'bg-transparent text-secondary hover:bg-surface-3'
|
||||
"
|
||||
@click="setMode(candidate)"
|
||||
>
|
||||
{{ formatMessage(messages[candidate]) }}
|
||||
</button>
|
||||
<span v-if="parseError" class="ml-2 truncate text-xs text-red">{{ parseError }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="mode === 'tree'"
|
||||
class="min-h-0 flex-1 overflow-auto p-2"
|
||||
tabindex="0"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<NbtTreeNode
|
||||
v-if="rootCompound"
|
||||
:tag="rootCompound"
|
||||
:path="[]"
|
||||
:depth="0"
|
||||
:read-only="readOnly"
|
||||
@edit="editValue"
|
||||
@remove="removeValue"
|
||||
@rename="renameValue"
|
||||
@add="addValue"
|
||||
/>
|
||||
</div>
|
||||
<StudioEditor
|
||||
v-else
|
||||
ref="snbtEditor"
|
||||
:file-path="filePath"
|
||||
:content="draft"
|
||||
:read-only="readOnly"
|
||||
:language="'snbt'"
|
||||
@update:content="updateSnbt"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
222
apps/app-frontend/src/components/instance/studio/NbtTreeNode.vue
Normal file
222
apps/app-frontend/src/components/instance/studio/NbtTreeNode.vue
Normal file
@ -0,0 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { type NbtTag, NbtType } from 'deepslate/nbt'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import NbtTypeIcon from './NbtTypeIcon.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name?: string
|
||||
tag: NbtTag
|
||||
path: (string | number)[]
|
||||
depth: number
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [path: (string | number)[], value: string]
|
||||
remove: [path: (string | number)[]]
|
||||
rename: [path: (string | number)[], value: string]
|
||||
add: [path: (string | number)[], value: string]
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
collapse: { id: 'instance.files.studio.nbt.collapse', defaultMessage: 'Collapse node' },
|
||||
expand: { id: 'instance.files.studio.nbt.expand', defaultMessage: 'Expand node' },
|
||||
add: { id: 'instance.files.studio.nbt.add', defaultMessage: 'Add child' },
|
||||
remove: { id: 'instance.files.studio.nbt.remove', defaultMessage: 'Remove node' },
|
||||
addPlaceholder: {
|
||||
id: 'instance.files.studio.nbt.add-placeholder',
|
||||
defaultMessage: 'name:value',
|
||||
},
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const expanded = ref(props.depth < 1)
|
||||
const editing = ref(false)
|
||||
const renaming = ref(false)
|
||||
const draft = ref('')
|
||||
const renameDraft = ref(props.name ?? '')
|
||||
const adding = ref(false)
|
||||
const addDraft = ref('')
|
||||
|
||||
const expandable = computed(
|
||||
() =>
|
||||
props.tag.isCompound() ||
|
||||
props.tag.isList() ||
|
||||
props.tag.isByteArray() ||
|
||||
props.tag.isIntArray() ||
|
||||
props.tag.isLongArray(),
|
||||
)
|
||||
const typeName = computed(() => NbtType[props.tag.getId()])
|
||||
const displayValue = computed(() => {
|
||||
if (props.tag.isCompound()) return `${props.tag.size} entries`
|
||||
if (props.tag.isList()) return `${props.tag.length} ${NbtType[props.tag.getType()]} values`
|
||||
if (props.tag.isArray()) return `${props.tag.length} values`
|
||||
return props.tag.toString()
|
||||
})
|
||||
|
||||
function children(): Array<{ name?: string; tag: NbtTag; path: (string | number)[] }> {
|
||||
if (props.tag.isCompound()) {
|
||||
return [...props.tag.keys()].map((name) => ({
|
||||
name,
|
||||
tag: props.tag.get(name)!,
|
||||
path: [...props.path, name],
|
||||
}))
|
||||
}
|
||||
if (props.tag.isList()) {
|
||||
return Array.from({ length: props.tag.length }, (_, index) => ({
|
||||
tag: props.tag.get(index),
|
||||
path: [...props.path, index],
|
||||
}))
|
||||
}
|
||||
if (props.tag.isArray()) {
|
||||
return Array.from({ length: props.tag.length }, (_, index) => ({
|
||||
tag: props.tag.get(index),
|
||||
path: [...props.path, index],
|
||||
}))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function beginEdit() {
|
||||
if (props.readOnly || expandable.value) return
|
||||
draft.value = props.tag.isString() ? props.tag.getAsString() : props.tag.toString()
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function commitEdit() {
|
||||
if (draft.value.trim()) emit('edit', props.path, draft.value)
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
function commitRename() {
|
||||
if (renameDraft.value.trim() && renameDraft.value !== props.name) {
|
||||
emit('rename', props.path, renameDraft.value.trim())
|
||||
}
|
||||
renaming.value = false
|
||||
}
|
||||
|
||||
function commitAdd() {
|
||||
if (addDraft.value.trim()) emit('add', props.path, addDraft.value)
|
||||
addDraft.value = ''
|
||||
adding.value = false
|
||||
}
|
||||
|
||||
function forwardEdit(path: (string | number)[], value: string) {
|
||||
emit('edit', path, value)
|
||||
}
|
||||
|
||||
function forwardRename(path: (string | number)[], value: string) {
|
||||
emit('rename', path, value)
|
||||
}
|
||||
|
||||
function forwardAdd(path: (string | number)[], value: string) {
|
||||
emit('add', path, value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="group flex min-h-8 items-center gap-2 rounded px-2 text-sm hover:bg-surface-3"
|
||||
:style="{ paddingLeft: `${depth * 1.25 + 0.5}rem` }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-secondary"
|
||||
:class="expandable ? 'cursor-pointer' : 'cursor-default'"
|
||||
:aria-label="formatMessage(expanded ? messages.collapse : messages.expand)"
|
||||
@click="expandable && (expanded = !expanded)"
|
||||
>
|
||||
<span v-if="expandable">{{ expanded ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<NbtTypeIcon :type="tag.getId()" />
|
||||
<template v-if="name !== undefined">
|
||||
<input
|
||||
v-if="renaming"
|
||||
v-model="renameDraft"
|
||||
class="min-w-0 flex-1 rounded border border-surface-5 bg-surface-1 px-1 text-sm text-contrast"
|
||||
@blur="commitRename"
|
||||
@keydown.enter.prevent="commitRename"
|
||||
/>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="shrink-0 border-0 bg-transparent p-0 text-secondary"
|
||||
:class="{ 'cursor-text': !readOnly }"
|
||||
@dblclick="!readOnly && (renaming = true)"
|
||||
>
|
||||
{{ name }}:
|
||||
</button>
|
||||
</template>
|
||||
<span class="text-xs text-secondary">{{ typeName }}</span>
|
||||
<input
|
||||
v-if="editing"
|
||||
v-model="draft"
|
||||
autofocus
|
||||
class="min-w-0 flex-1 rounded border border-brand bg-surface-1 px-2 py-0.5 font-mono text-xs text-contrast"
|
||||
@blur="commitEdit"
|
||||
@keydown.enter.prevent="commitEdit"
|
||||
@keydown.escape="editing = false"
|
||||
/>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="min-w-0 truncate border-0 bg-transparent p-0 text-left font-mono text-xs text-primary"
|
||||
:class="{ 'cursor-text': !expandable && !readOnly }"
|
||||
@dblclick="beginEdit"
|
||||
>
|
||||
{{ displayValue }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!readOnly && expandable"
|
||||
type="button"
|
||||
class="ml-auto hidden rounded border-0 bg-transparent px-1 text-xs text-secondary group-hover:inline-flex hover:text-contrast"
|
||||
:aria-label="formatMessage(messages.add)"
|
||||
@click="adding = !adding"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
v-if="!readOnly && path.length > 0"
|
||||
type="button"
|
||||
class="hidden rounded border-0 bg-transparent px-1 text-xs text-secondary group-hover:inline-flex hover:text-red"
|
||||
:aria-label="formatMessage(messages.remove)"
|
||||
@click="emit('remove', path)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="adding"
|
||||
class="flex items-center gap-2 px-3 py-1"
|
||||
:style="{ paddingLeft: `${(depth + 1) * 1.25 + 2.25}rem` }"
|
||||
>
|
||||
<input
|
||||
v-model="addDraft"
|
||||
autofocus
|
||||
class="min-w-0 flex-1 rounded border border-surface-5 bg-surface-1 px-2 py-1 font-mono text-xs text-contrast"
|
||||
:placeholder="formatMessage(messages.addPlaceholder)"
|
||||
@keydown.enter="commitAdd"
|
||||
@blur="commitAdd"
|
||||
@keydown.escape="adding = false"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="expanded && expandable">
|
||||
<NbtTreeNode
|
||||
v-for="child in children()"
|
||||
:key="child.path.join('.')"
|
||||
:name="child.name"
|
||||
:tag="child.tag"
|
||||
:path="child.path"
|
||||
:depth="depth + 1"
|
||||
:read-only="readOnly"
|
||||
@edit="forwardEdit"
|
||||
@remove="emit('remove', $event)"
|
||||
@rename="forwardRename"
|
||||
@add="forwardAdd"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { NbtType } from 'deepslate/nbt'
|
||||
|
||||
defineProps<{
|
||||
type: NbtType
|
||||
}>()
|
||||
|
||||
const colors: Record<NbtType, string> = {
|
||||
[NbtType.End]: 'text-secondary',
|
||||
[NbtType.Byte]: 'text-orange',
|
||||
[NbtType.Short]: 'text-yellow',
|
||||
[NbtType.Int]: 'text-green',
|
||||
[NbtType.Long]: 'text-blue',
|
||||
[NbtType.Float]: 'text-purple',
|
||||
[NbtType.Double]: 'text-pink',
|
||||
[NbtType.ByteArray]: 'text-orange',
|
||||
[NbtType.String]: 'text-brand',
|
||||
[NbtType.List]: 'text-cyan',
|
||||
[NbtType.Compound]: 'text-contrast',
|
||||
[NbtType.IntArray]: 'text-green',
|
||||
[NbtType.LongArray]: 'text-blue',
|
||||
}
|
||||
|
||||
const labels: Partial<Record<NbtType, string>> = {
|
||||
[NbtType.Byte]: 'B',
|
||||
[NbtType.Short]: 'S',
|
||||
[NbtType.Int]: 'I',
|
||||
[NbtType.Long]: 'L',
|
||||
[NbtType.Float]: 'F',
|
||||
[NbtType.Double]: 'D',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="size-4 shrink-0"
|
||||
:class="colors[type]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path v-if="type === NbtType.Compound" d="M3 2.5h7l3 3v8H3zM10 2.5v3h3" />
|
||||
<path v-else-if="type === NbtType.List" d="M3 3h10M3 8h10M3 13h10M5 3v10" />
|
||||
<path
|
||||
v-else-if="type === NbtType.String"
|
||||
d="M4 3h8M4 13h8M5 3c-3 2-3 8 0 10M11 3c3 2 3 8 0 10"
|
||||
/>
|
||||
<path
|
||||
v-else-if="
|
||||
type === NbtType.ByteArray || type === NbtType.IntArray || type === NbtType.LongArray
|
||||
"
|
||||
d="M4 2.5h8v11H4zM6.5 5h3M6.5 8h3M6.5 11h3"
|
||||
/>
|
||||
<path v-else d="M3 3h10v10H3z" />
|
||||
<text
|
||||
v-if="labels[type]"
|
||||
x="8"
|
||||
y="11"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
text-anchor="middle"
|
||||
font-size="7"
|
||||
font-weight="700"
|
||||
>
|
||||
{{ labels[type] }}
|
||||
</text>
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,279 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import type * as Monaco from 'monaco-editor'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__axolotlMonacoRuntime?: Promise<typeof Monaco>
|
||||
require?: {
|
||||
config(config: Record<string, unknown>): void
|
||||
(dependencies: string[], callback: (monaco: typeof Monaco) => void): void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
filePath: string
|
||||
language: string
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [content: string]
|
||||
save: []
|
||||
blur: []
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
loading: {
|
||||
id: 'instance.files.studio.editor-loading',
|
||||
defaultMessage: 'Loading editor...',
|
||||
},
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const editorElement = ref<HTMLElement | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
let monaco: typeof Monaco | null = null
|
||||
let editor: Monaco.editor.IStandaloneCodeEditor | null = null
|
||||
let model: Monaco.editor.ITextModel | null = null
|
||||
let contentSubscription: Monaco.IDisposable | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let themeObserver: MutationObserver | null = null
|
||||
let applyingExternalContent = false
|
||||
let disposed = false
|
||||
|
||||
function loadCodiconStyles() {
|
||||
if (document.querySelector('link[data-monaco-codicons]')) return
|
||||
const stylesheet = document.createElement('link')
|
||||
stylesheet.rel = 'stylesheet'
|
||||
stylesheet.href = '/monaco/codicon/codicon.css'
|
||||
stylesheet.dataset.monacoCodicons = 'true'
|
||||
document.head.append(stylesheet)
|
||||
}
|
||||
|
||||
function loadMonaco(): Promise<typeof Monaco> {
|
||||
if (window.__axolotlMonacoRuntime) return window.__axolotlMonacoRuntime
|
||||
|
||||
window.__axolotlMonacoRuntime = new Promise((resolve, reject) => {
|
||||
loadCodiconStyles()
|
||||
const initialize = () => {
|
||||
const require = window.require
|
||||
if (!require) {
|
||||
reject(new Error('Monaco loader did not initialize'))
|
||||
return
|
||||
}
|
||||
require.config({ paths: { vs: '/monaco/vs' } })
|
||||
require(['vs/editor/editor.main'], (loadedMonaco: typeof Monaco) => resolve(loadedMonaco))
|
||||
}
|
||||
|
||||
if (window.require) {
|
||||
initialize()
|
||||
return
|
||||
}
|
||||
|
||||
const existingLoader = document.querySelector<HTMLScriptElement>('script[data-monaco-loader]')
|
||||
if (existingLoader) {
|
||||
existingLoader.addEventListener('load', initialize, { once: true })
|
||||
existingLoader.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error('Failed to load Monaco editor')),
|
||||
{ once: true },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const loader = document.createElement('script')
|
||||
loader.src = '/monaco/vs/loader.js'
|
||||
loader.dataset.monacoLoader = 'true'
|
||||
loader.onload = initialize
|
||||
loader.onerror = () => reject(new Error('Failed to load Monaco editor'))
|
||||
document.head.append(loader)
|
||||
})
|
||||
|
||||
return window.__axolotlMonacoRuntime
|
||||
}
|
||||
|
||||
function cssVariable(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
if (!monaco) return
|
||||
const isLight = document.documentElement.classList.contains('light-mode')
|
||||
monaco.editor.defineTheme('axolotl-studio', {
|
||||
base: isLight ? 'vs' : 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': cssVariable('--surface-2'),
|
||||
'editor.foreground': cssVariable('--color-base'),
|
||||
'editorGutter.background': cssVariable('--surface-2'),
|
||||
'editorLineNumber.foreground': cssVariable('--color-secondary'),
|
||||
'editor.lineHighlightBackground': cssVariable('--surface-3'),
|
||||
'editorCursor.foreground': cssVariable('--color-brand'),
|
||||
},
|
||||
})
|
||||
monaco.editor.setTheme('axolotl-studio')
|
||||
}
|
||||
|
||||
function registerStudioLanguages() {
|
||||
if (!monaco) return
|
||||
|
||||
if (!monaco.languages.getLanguages().some(({ id }) => id === 'toml')) {
|
||||
monaco.languages.register({ id: 'toml', extensions: ['.toml'] })
|
||||
monaco.languages.setMonarchTokensProvider('toml', {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/#.*/, 'comment'],
|
||||
[/\[\[?.*?\]\]?/, 'type.identifier'],
|
||||
[/^[\w.-]+(?=\s*=)/, 'key'],
|
||||
[/"([^"\\]|\\.)*"/, 'string'],
|
||||
[/'[^']*'/, 'string'],
|
||||
[/\b(true|false)\b/, 'keyword'],
|
||||
[/[-+]?\b\d+(\.\d+)?\b/, 'number'],
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!monaco.languages.getLanguages().some(({ id }) => id === 'properties')) {
|
||||
monaco.languages.register({ id: 'properties', extensions: ['.properties'] })
|
||||
monaco.languages.setMonarchTokensProvider('properties', {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/^[#!].*$/, 'comment'],
|
||||
[/^[^\s:=]+(?=\s*[:=])/, 'key'],
|
||||
[/[:=]/, 'delimiter'],
|
||||
[/\\./, 'string.escape'],
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!monaco.languages.getLanguages().some(({ id }) => id === 'snbt')) {
|
||||
monaco.languages.register({ id: 'snbt' })
|
||||
monaco.languages.setMonarchTokensProvider('snbt', {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/\/\/.*$/, 'comment'],
|
||||
[/[{}[\],:]/, 'delimiter'],
|
||||
[/(?:true|false)\b/, 'keyword'],
|
||||
[/-?(?:\d+\.?\d*|\.\d+)(?:[bBsSlLfFdD])?\b/, 'number'],
|
||||
[/'(?:[^'\\]|\\.)*'/, 'string'],
|
||||
[/"(?:[^"\\]|\\.)*"/, 'string'],
|
||||
[/[A-Za-z0-9_.+-]+(?=\s*:)/, 'key'],
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function createModel() {
|
||||
if (!monaco || !editor) return
|
||||
contentSubscription?.dispose()
|
||||
model?.dispose()
|
||||
model = monaco.editor.createModel(
|
||||
props.content,
|
||||
props.language,
|
||||
monaco.Uri.parse(
|
||||
`axolotl-instance://studio/${props.filePath.split('/').map(encodeURIComponent).join('/')}`,
|
||||
),
|
||||
)
|
||||
editor.setModel(model)
|
||||
contentSubscription = model.onDidChangeContent(() => {
|
||||
if (!applyingExternalContent) emit('update:content', model?.getValue() ?? '')
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
monaco = await loadMonaco()
|
||||
} catch (error) {
|
||||
loading.value = false
|
||||
console.error('Failed to load Monaco editor', error)
|
||||
return
|
||||
}
|
||||
if (disposed) return
|
||||
registerStudioLanguages()
|
||||
applyTheme()
|
||||
|
||||
if (!editorElement.value) return
|
||||
editor = monaco.editor.create(editorElement.value, {
|
||||
automaticLayout: false,
|
||||
fontSize: 14,
|
||||
fontLigatures: false,
|
||||
minimap: { enabled: true },
|
||||
padding: { top: 12 },
|
||||
readOnly: props.readOnly,
|
||||
renderWhitespace: 'selection',
|
||||
scrollBeyondLastLine: false,
|
||||
theme: 'axolotl-studio',
|
||||
})
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => emit('save'))
|
||||
editor.onDidBlurEditorWidget(() => emit('blur'))
|
||||
createModel()
|
||||
|
||||
resizeObserver = new ResizeObserver(() => editor?.layout())
|
||||
resizeObserver.observe(editorElement.value)
|
||||
themeObserver = new MutationObserver(applyTheme)
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
loading.value = false
|
||||
})
|
||||
watch(
|
||||
() => props.filePath,
|
||||
() => createModel(),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.content,
|
||||
(content) => {
|
||||
if (!model || model.getValue() === content) return
|
||||
applyingExternalContent = true
|
||||
model.setValue(content)
|
||||
applyingExternalContent = false
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.language,
|
||||
(language) => {
|
||||
if (monaco && model) monaco.editor.setModelLanguage(model, language)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.readOnly,
|
||||
(readOnly) => editor?.updateOptions({ readOnly }),
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
contentSubscription?.dispose()
|
||||
resizeObserver?.disconnect()
|
||||
themeObserver?.disconnect()
|
||||
editor?.dispose()
|
||||
model?.dispose()
|
||||
})
|
||||
|
||||
async function formatDocument() {
|
||||
await editor?.getAction('editor.action.formatDocument')?.run()
|
||||
}
|
||||
|
||||
defineExpose({ formatDocument })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative size-full min-h-0 min-w-0 bg-surface-2">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="absolute inset-0 z-[1] flex items-center justify-center text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</div>
|
||||
<div ref="editorElement" class="size-full min-h-0 min-w-0" />
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { FileCodeIcon, XIcon } from '@modrinth/assets'
|
||||
import { commonMessages, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import type { StudioDocument } from './useStudioDocuments'
|
||||
|
||||
defineProps<{
|
||||
documents: StudioDocument[]
|
||||
activePath: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
activate: [path: string]
|
||||
close: [path: string]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
let middleClickPath: string | null = null
|
||||
|
||||
function handleWheel(event: WheelEvent) {
|
||||
const container = event.currentTarget as HTMLElement
|
||||
if (container.scrollWidth <= container.clientWidth) return
|
||||
event.preventDefault()
|
||||
container.scrollLeft += event.deltaY || event.deltaX
|
||||
}
|
||||
|
||||
function handleAuxClick(event: MouseEvent, path: string) {
|
||||
if (event.button !== 1) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (middleClickPath === path) {
|
||||
middleClickPath = null
|
||||
return
|
||||
}
|
||||
emit('close', path)
|
||||
}
|
||||
|
||||
function handleMouseDown(event: MouseEvent, path: string) {
|
||||
if (event.button !== 1) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
middleClickPath = path
|
||||
emit('close', path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-w-0 flex-1 overflow-x-auto" @wheel="handleWheel">
|
||||
<div
|
||||
v-for="document in documents"
|
||||
:key="document.path"
|
||||
role="tab"
|
||||
tabindex="0"
|
||||
:aria-selected="document.path === activePath"
|
||||
class="flex h-full max-w-[14rem] min-w-[8rem] shrink-0 select-none items-center gap-2 border-0 border-r border-solid border-surface-4 px-3 text-left text-sm text-secondary hover:bg-surface-2"
|
||||
:class="{ 'bg-surface-2 !text-contrast': document.path === activePath }"
|
||||
@click="emit('activate', document.path)"
|
||||
@mousedown="handleMouseDown($event, document.path)"
|
||||
@auxclick="handleAuxClick($event, document.path)"
|
||||
@keydown.enter="emit('activate', document.path)"
|
||||
@keydown.space.prevent="emit('activate', document.path)"
|
||||
>
|
||||
<XIcon v-if="document.kind === 'unsupported'" class="size-4 shrink-0 text-red" />
|
||||
<FileCodeIcon v-else class="size-4 shrink-0 text-secondary" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ document.name }}</span>
|
||||
<span
|
||||
v-if="document.content !== document.savedContent"
|
||||
class="size-2 shrink-0 rounded-full bg-brand"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="formatMessage(commonMessages.closeButton)"
|
||||
class="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-secondary hover:bg-surface-4 hover:text-contrast"
|
||||
@pointerdown.stop
|
||||
@click.stop.prevent="emit('close', document.path)"
|
||||
>
|
||||
<XIcon class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,137 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export interface StudioDocument {
|
||||
kind: 'text' | 'nbt' | 'image' | 'video' | 'unsupported'
|
||||
path: string
|
||||
name: string
|
||||
content: string
|
||||
savedContent: string
|
||||
saving: boolean
|
||||
}
|
||||
|
||||
export function useStudioDocuments(
|
||||
writeDocument: (document: StudioDocument, content: string) => Promise<void>,
|
||||
onSaveError: (error: unknown) => void,
|
||||
) {
|
||||
const documents = ref<StudioDocument[]>([])
|
||||
const activeIndex = ref(-1)
|
||||
const savePromises = new Map<string, Promise<boolean>>()
|
||||
|
||||
const activeDocument = computed(() => documents.value[activeIndex.value] ?? null)
|
||||
const activePath = computed(() => activeDocument.value?.path ?? '')
|
||||
const hasUnsavedChanges = computed(
|
||||
() =>
|
||||
activeDocument.value !== null &&
|
||||
activeDocument.value.content !== activeDocument.value.savedContent,
|
||||
)
|
||||
const hasAnyUnsavedChanges = computed(() =>
|
||||
documents.value.some((document) => document.content !== document.savedContent),
|
||||
)
|
||||
|
||||
function saveDocument(document: StudioDocument | null): Promise<boolean> {
|
||||
if (
|
||||
!document ||
|
||||
(document.kind !== 'text' && document.kind !== 'nbt') ||
|
||||
document.content === document.savedContent
|
||||
) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
|
||||
const existingPromise = savePromises.get(document.path)
|
||||
if (existingPromise) return existingPromise
|
||||
|
||||
document.saving = true
|
||||
const contentToSave = document.content
|
||||
const savePromise = writeDocument(document, contentToSave)
|
||||
.then(() => {
|
||||
document.savedContent = contentToSave
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
onSaveError(error)
|
||||
return false
|
||||
})
|
||||
.finally(() => {
|
||||
document.saving = false
|
||||
savePromises.delete(document.path)
|
||||
})
|
||||
|
||||
savePromises.set(document.path, savePromise)
|
||||
return savePromise
|
||||
}
|
||||
|
||||
async function activate(path: string) {
|
||||
if (path === activePath.value) return true
|
||||
if (!(await saveDocument(activeDocument.value))) return false
|
||||
const nextIndex = documents.value.findIndex((document) => document.path === path)
|
||||
if (nextIndex === -1) return false
|
||||
activeIndex.value = nextIndex
|
||||
return true
|
||||
}
|
||||
|
||||
async function open(document: StudioDocument) {
|
||||
const existing = documents.value.find((candidate) => candidate.path === document.path)
|
||||
if (existing) return activate(existing.path)
|
||||
if (!(await saveDocument(activeDocument.value))) return false
|
||||
documents.value.push(document)
|
||||
activeIndex.value = documents.value.length - 1
|
||||
return true
|
||||
}
|
||||
|
||||
async function close(path: string) {
|
||||
const index = documents.value.findIndex((document) => document.path === path)
|
||||
if (index === -1) return false
|
||||
if (!(await saveDocument(documents.value[index]))) return false
|
||||
|
||||
const wasActive = activeIndex.value === index
|
||||
documents.value.splice(index, 1)
|
||||
if (documents.value.length === 0) {
|
||||
activeIndex.value = -1
|
||||
} else if (wasActive) {
|
||||
activeIndex.value = Math.min(index, documents.value.length - 1)
|
||||
} else if (index < activeIndex.value) {
|
||||
activeIndex.value -= 1
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function updateActiveContent(content: string) {
|
||||
if (activeDocument.value) activeDocument.value.content = content
|
||||
}
|
||||
|
||||
function discardActiveChanges() {
|
||||
if (activeDocument.value) activeDocument.value.content = activeDocument.value.savedContent
|
||||
}
|
||||
|
||||
async function saveActive() {
|
||||
return saveDocument(activeDocument.value)
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
const results = await Promise.all(documents.value.map((document) => saveDocument(document)))
|
||||
return results.every(Boolean)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
documents.value = []
|
||||
activeIndex.value = -1
|
||||
savePromises.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
documents,
|
||||
activeDocument,
|
||||
activePath,
|
||||
hasUnsavedChanges,
|
||||
hasAnyUnsavedChanges,
|
||||
activate,
|
||||
open,
|
||||
close,
|
||||
saveDocument,
|
||||
saveActive,
|
||||
saveAll,
|
||||
updateActiveContent,
|
||||
discardActiveChanges,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user