feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,
}
}