Merge pull request #5 from ApTx2012/feat/screenshot-center
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled

feat: 移植截图中心(浏览/分组/编辑/导出)
This commit is contained in:
Ap_Tx authored and GitHub committed 2026-10-02 08:21:07 +08:00
commit 4c13f849eb
83 files changed
+10687 -1146

No files matched your search

+3
View File
@@ -14,6 +14,9 @@
"test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\"" "test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\""
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "6.3.1",
"@dnd-kit/utilities": "3.2.2",
"@dnd-kit/vue": "^0.5.0",
"@lobehub/icons-static-svg": "1.94.0", "@lobehub/icons-static-svg": "1.94.0",
"@modrinth/api-client": "workspace:^", "@modrinth/api-client": "workspace:^",
"@modrinth/assets": "workspace:*", "@modrinth/assets": "workspace:*",
+13
View File
@@ -8,6 +8,7 @@ import {
FlaskConicalIcon, FlaskConicalIcon,
FolderOpenIcon, FolderOpenIcon,
HomeIcon, HomeIcon,
ImagesIcon,
LeftArrowIcon, LeftArrowIcon,
LibraryIcon, LibraryIcon,
LogInIcon, LogInIcon,
@@ -763,6 +764,10 @@ const messages = defineMessages({
defaultMessage: 'Library', defaultMessage: 'Library',
}, },
multiplayer: { multiplayer: {
screenshots: {
id: 'app.navigation.screenshots',
defaultMessage: 'Screenshots',
},
id: 'app.navigation.multiplayer', id: 'app.navigation.multiplayer',
defaultMessage: 'Multiplayer', defaultMessage: 'Multiplayer',
}, },
@@ -2324,6 +2329,14 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
> >
<UsersIcon /> <UsersIcon />
</NavButton> </NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.screenshots)"
data-onboarding-id="nav-screenshots"
to="/screenshots"
:is-primary="(r) => r.path.startsWith('/screenshots')"
>
<ImagesIcon />
</NavButton>
<NavButton <NavButton
v-tooltip.right="formatMessage(messages.lab)" v-tooltip.right="formatMessage(messages.lab)"
data-onboarding-id="nav-lab" data-onboarding-id="nav-lab"
@@ -0,0 +1,289 @@
<script lang="ts"></script>
<script setup lang="ts">
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
import { CheckIcon, ClipboardCopyIcon, EditIcon, MoreHorizontalIcon } from '@modrinth/assets'
import {
defineMessages,
IconButton,
useDebugLogger,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useImageThumbnail } from '@/composables/use-image-thumbnail'
import type { InstanceScreenshot } from '@/helpers/instance'
const props = defineProps<{
screenshot: InstanceScreenshot
selectionKey: string
selected: boolean
selectionActive: boolean
activeDragged: boolean
canDrag: boolean
showInstanceName: boolean
highlighted: boolean
copied: boolean
}>()
const emit = defineEmits<{
(e: 'activate', event: MouseEvent | KeyboardEvent): void
(e: 'copy' | 'edit' | 'toggle-selection'): void
(e: 'more', event: MouseEvent): void
}>()
const card = ref<HTMLElement>()
const thumbnail = useImageThumbnail(
() => ({
instanceId: props.screenshot.instance_id,
filePath: `screenshots/${props.screenshot.file_name}`,
path: props.screenshot.path,
}),
512,
() => props.screenshot.modified_at,
)
const image = ref<HTMLImageElement>()
const imageReady = ref(false)
const { formatMessage } = useVIntl()
const debugImage = useDebugLogger('Screenshots:Card')
const formatTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
let loadStartedAt = performance.now()
let loadGeneration = 0
const messages = defineMessages({
select: { id: 'app.screenshots.select', defaultMessage: 'Select {name}' },
deselect: { id: 'app.screenshots.deselect', defaultMessage: 'Deselect {name}' },
copy: { id: 'app.screenshots.copy', defaultMessage: 'Copy image' },
copied: { id: 'app.screenshots.copied', defaultMessage: 'Copied' },
edit: { id: 'app.screenshots.edit', defaultMessage: 'Edit screenshot' },
moreActions: { id: 'app.screenshots.more-actions', defaultMessage: 'More actions' },
})
const sensors = [
PointerSensor.configure({
preventActivation: () => false,
}),
KeyboardSensor,
]
useDraggable({
id: computed(() => `screenshot:${props.selectionKey}`),
element: card,
disabled: computed(() => !props.canDrag),
sensors,
data: computed(() => ({
selectionKey: props.selectionKey,
instanceId: props.screenshot.instance_id,
})),
})
function activate(event: MouseEvent | KeyboardEvent) {
if (event instanceof KeyboardEvent) {
if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) {
return
}
event.preventDefault()
}
emit('activate', event)
}
async function markImageLoaded() {
const loadedImage = image.value
const loadedUrl = thumbnail.value
const generation = loadGeneration
if (!loadedImage) return
try {
await loadedImage.decode()
} catch {
if (!loadedImage.complete || loadedImage.naturalWidth === 0) return
}
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => window.requestAnimationFrame(() => resolve()))
})
if (
generation !== loadGeneration ||
image.value !== loadedImage ||
thumbnail.value !== loadedUrl
) {
return
}
const wasReady = imageReady.value
imageReady.value = true
if (!wasReady) {
debugImage('image loaded', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loadDurationMs: performance.now() - loadStartedAt,
naturalWidth: image.value?.naturalWidth,
naturalHeight: image.value?.naturalHeight,
})
}
}
function markImageFailed(event: Event) {
debugImage('image failed', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loadDurationMs: performance.now() - loadStartedAt,
event,
})
}
onMounted(() => {
debugImage('mounted', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
complete: image.value?.complete,
})
if (image.value?.complete && image.value.naturalWidth > 0) markImageLoaded()
})
onBeforeUnmount(() => {
loadGeneration += 1
debugImage('unmounted', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loaded: imageReady.value,
})
})
watch(thumbnail, (url, previousUrl) => {
loadGeneration += 1
loadStartedAt = performance.now()
imageReady.value = false
debugImage('source changed', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
previousUrl,
url,
})
})
</script>
<template>
<article
ref="card"
role="button"
tabindex="0"
class="group relative isolate aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
:class="{
'!border-brand brightness-110': selected,
'!border-brand ring-2 ring-brand animate-pulse': highlighted,
'opacity-50': activeDragged,
'cursor-grab active:cursor-grabbing': canDrag,
}"
data-screenshot-card
:data-screenshot-id="screenshot.id"
:data-selection-key="selectionKey"
:aria-label="
selectionActive
? formatMessage(selected ? messages.deselect : messages.select, {
name: screenshot.file_name,
})
: screenshot.file_name
"
:aria-pressed="selectionActive ? selected : undefined"
@click="activate"
@contextmenu.prevent.stop="emit('more', $event)"
@keydown="activate"
>
<button
type="button"
class="selection-button group/selection absolute right-0.5 top-0 z-[3] flex size-[50px] cursor-pointer items-start justify-center border-0 bg-transparent p-0 pt-4"
:aria-label="
formatMessage(selected ? messages.deselect : messages.select, {
name: screenshot.file_name,
})
"
:aria-pressed="selected"
@click.stop="emit('toggle-selection')"
>
<span
class="relative flex size-6 items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover:opacity-100 group-focus-within:opacity-100 group-hover/selection:brightness-125"
:class="
selected ? 'border-0 !opacity-100' : 'border-2 border-solid border-primary bg-transparent'
"
>
<span v-if="selected" class="absolute inset-0 rounded-full bg-brand" />
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
</span>
</button>
<img
v-if="thumbnail"
ref="image"
:src="thumbnail"
:alt="screenshot.file_name"
loading="eager"
decoding="async"
draggable="false"
class="screenshot-card-fade absolute inset-0 z-[1] h-full w-full object-cover"
:class="imageReady ? 'opacity-100' : 'opacity-0'"
@load="markImageLoaded"
@error="markImageFailed"
/>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-0 bg-surface-4"
:class="{ 'animate-pulse': !imageReady }"
/>
<div
class="absolute inset-x-0 bottom-0 z-[2] flex items-end justify-between gap-2 bg-gradient-to-t from-surface-1 to-transparent p-3 pt-[120px] text-[var(--color-text-primary)] opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
>
<div class="min-w-0">
<div v-tooltip="screenshot.file_name" class="truncate text-sm font-semibold">
{{ screenshot.file_name }}
</div>
<div class="truncate text-xs text-[var(--color-text-tertiary)]">
{{ showInstanceName ? screenshot.instance_name : formatTime(screenshot.created_at) }}
</div>
</div>
<div
v-if="!selectionActive"
class="flex shrink-0 translate-y-1 gap-1 opacity-0 transition group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100"
@click.stop
>
<IconButton
v-tooltip="formatMessage(messages.edit)"
:label="formatMessage(messages.edit)"
type="quiet"
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
@click="emit('edit')"
>
<EditIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(copied ? messages.copied : messages.copy)"
:label="formatMessage(copied ? messages.copied : messages.copy)"
type="quiet"
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
@click="emit('copy')"
>
<CheckIcon v-if="copied" class="text-green" />
<ClipboardCopyIcon v-else />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.moreActions)"
:label="formatMessage(messages.moreActions)"
type="quiet"
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
@click="emit('more', $event)"
>
<MoreHorizontalIcon />
</IconButton>
</div>
</div>
</article>
</template>
<style scoped>
.screenshot-card-fade {
transition: opacity 350ms ease-in-out;
}
</style>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import ScreenshotDragPreview from './drag-preview.vue'
import { gatherDuration, type ScreenshotDragGatherItem } from './use-screenshot-drag-gather'
const props = defineProps<{
items: ScreenshotDragGatherItem[]
target: {
x: number
y: number
}
}>()
const emit = defineEmits<{
complete: []
}>()
const gathered = ref(false)
const finishing = ref(false)
let animationFrame: number | undefined
let completionTimer: number | undefined
const itemContainerStyles = computed(() =>
props.items.map((_, index) => ({
left: `${props.target.x}px`,
top: `${props.target.y}px`,
zIndex: `${index}`,
})),
)
const itemStyles = computed(() =>
props.items.map((item, index) => {
const originX = item.rect.left - props.target.x
const originY = item.rect.top - props.target.y
return {
width: `${item.rect.width}px`,
height: `${item.rect.height}px`,
transform: gathered.value
? `translate3d(${-item.rect.width / 2}px, ${-item.rect.height / 2}px, 0)`
: `translate3d(${originX}px, ${originY}px, 0)`,
transitionDuration: `${gatherDuration}ms`,
transitionDelay: `${Math.min(index, 8) * 10}ms`,
}
}),
)
onMounted(() => {
animationFrame = requestAnimationFrame(() => {
animationFrame = requestAnimationFrame(() => {
gathered.value = true
completionTimer = window.setTimeout(
() => {
finishing.value = true
emit('complete')
},
Math.max(gatherDuration - 250, 0),
)
})
})
})
onBeforeUnmount(() => {
if (animationFrame !== undefined) {
cancelAnimationFrame(animationFrame)
}
if (completionTimer !== undefined) {
clearTimeout(completionTimer)
}
})
</script>
<template>
<Teleport to="body">
<div
class="pointer-events-none fixed inset-0 z-[9998] overflow-hidden transition-opacity duration-150 ease-out"
:class="finishing ? 'opacity-0' : 'opacity-100'"
aria-hidden="true"
>
<div
v-for="(item, index) in items"
:key="item.selectionKey"
class="fixed"
:style="itemContainerStyles[index]"
>
<div
class="origin-center transition-transform ease-[cubic-bezier(0.2,0.8,0.2,1)]"
:style="itemStyles[index]"
>
<ScreenshotDragPreview :screenshot="item.screenshot" />
</div>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { TagItem } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useImageThumbnail } from '@/composables/use-image-thumbnail'
import type { InstanceScreenshot } from '@/helpers/instance'
import { gatherDuration } from './use-screenshot-drag-gather'
const props = withDefaults(
defineProps<{
screenshot: InstanceScreenshot
count?: number
}>(),
{
count: 1,
},
)
const thumbnail = useImageThumbnail(
() => ({
instanceId: props.screenshot.instance_id,
filePath: `screenshots/${props.screenshot.file_name}`,
path: props.screenshot.path,
}),
512,
() => props.screenshot.modified_at,
)
const showGatheredCount = ref(false)
let countTimer: number | undefined
onMounted(() => {
if (props.count <= 1 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
showGatheredCount.value = true
return
}
countTimer = window.setTimeout(() => {
showGatheredCount.value = true
countTimer = undefined
}, gatherDuration - 150)
})
onBeforeUnmount(() => {
if (countTimer !== undefined) {
clearTimeout(countTimer)
}
})
</script>
<template>
<div aria-hidden="true" class="relative w-full select-none">
<div
v-if="count > 1"
class="absolute inset-x-3 -bottom-2 top-2 rounded-xl border border-solid border-surface-5 bg-surface-2 shadow-md motion-safe:transition-opacity motion-safe:duration-200 motion-safe:delay-100 motion-safe:ease-out"
:class="showGatheredCount ? 'opacity-60' : 'opacity-0'"
/>
<div
v-if="count > 1"
class="absolute inset-x-1.5 -bottom-1 top-1 rounded-xl border border-solid border-surface-4 bg-surface-3 shadow-md motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
:class="showGatheredCount ? 'opacity-80' : 'opacity-0'"
/>
<div
class="relative aspect-video w-full overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 opacity-90 shadow-lg"
>
<img v-if="thumbnail" :src="thumbnail" alt="" class="h-full w-full object-cover" />
<TagItem
v-if="count > 1"
class="!absolute right-3 top-3 z-[2] border-surface-5 bg-surface-4 tabular-nums text-[var(--color-text-tertiary)] motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
>
{{ count }}
</TagItem>
</div>
</div>
</template>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { useDroppable } from '@dnd-kit/vue'
import { defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
import ScreenshotCard from './card.vue'
import ScreenshotSection from './section.vue'
const props = defineProps<{
id: string
title: string
screenshots: InstanceScreenshot[]
renderedScreenshots?: InstanceScreenshot[]
virtualGridHeight?: number
virtualGridTop?: number
selectedKeys: ReadonlySet<string>
selectionActive: boolean
activeDraggedKeys: ReadonlySet<string>
showDropOutline: boolean
canDrag: boolean
dropInstanceId?: string
dropCustomGroup?: boolean
dropCustomGroupId?: string
showInstanceName: boolean
highlightedScreenshotId?: string
copiedScreenshotIds: ReadonlySet<string>
forceOpen: boolean
hideHeader?: boolean
editableTitle?: boolean
startEditingTitle?: boolean
maxTitleLength?: number
validateTitle?: (value: string) => boolean
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
}>()
const collapsed = defineModel<boolean>('collapsed', { required: true })
const dropTarget = ref<HTMLElement>()
const { formatMessage } = useVIntl()
const debugLayout = useDebugLogger('Screenshots:Group')
const messages = defineMessages({
emptyGroup: {
id: 'app.screenshots.group.empty',
defaultMessage: 'Drag and drop to add screenshots.',
},
})
const shouldShowGrid = computed(() =>
Boolean(props.hideHeader || props.forceOpen || !collapsed.value),
)
const visibleScreenshots = ref<InstanceScreenshot[]>(props.renderedScreenshots ?? props.screenshots)
const renderGrid = ref(shouldShowGrid.value)
const virtualGridStyle = computed(() =>
props.virtualGridHeight === undefined ? undefined : { height: `${props.virtualGridHeight}px` },
)
const visibleGridStyle = computed(() =>
props.virtualGridTop === undefined
? undefined
: { transform: `translateY(${props.virtualGridTop}px)` },
)
let unmountGridTimeout: ReturnType<typeof setTimeout> | undefined
let resizeObserver: ResizeObserver | undefined
async function logGeometry(reason: string) {
await nextTick()
const rect = dropTarget.value?.getBoundingClientRect()
debugLayout(reason, {
id: props.id,
title: props.title,
collapsed: collapsed.value,
shouldShowGrid: shouldShowGrid.value,
renderGrid: renderGrid.value,
screenshotCount: props.screenshots.length,
renderedCount: visibleScreenshots.value.length,
firstScreenshotId: visibleScreenshots.value.at(0)?.id,
lastScreenshotId: visibleScreenshots.value.at(-1)?.id,
virtualGridHeight: props.virtualGridHeight,
virtualGridTop: props.virtualGridTop,
actual: rect
? { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
: undefined,
})
}
watch(
() => props.renderedScreenshots ?? props.screenshots,
(screenshots) => {
if (shouldShowGrid.value) visibleScreenshots.value = screenshots
void logGeometry('rendered screenshots changed')
},
)
watch(
() => [props.virtualGridHeight, props.virtualGridTop] as const,
() => void logGeometry('virtual grid geometry changed'),
)
watch(
shouldShowGrid,
(showGrid, previouslyShown) => {
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
if (showGrid) {
visibleScreenshots.value = props.renderedScreenshots ?? props.screenshots
renderGrid.value = true
void logGeometry('grid shown')
return
}
if (!previouslyShown) {
renderGrid.value = false
return
}
unmountGridTimeout = setTimeout(() => {
renderGrid.value = false
void logGeometry('grid unmounted after collapse')
unmountGridTimeout = undefined
}, 300)
},
{ flush: 'post' },
)
onMounted(() => {
debugLayout('mounted', { id: props.id, title: props.title })
void logGeometry('mounted geometry')
if (dropTarget.value) {
resizeObserver = new ResizeObserver(([entry]) => {
const rect = entry?.target.getBoundingClientRect()
debugLayout('actual size changed', {
id: props.id,
title: props.title,
width: rect?.width,
height: rect?.height,
expectedHeight:
(props.hideHeader ? 0 : 40) +
(shouldShowGrid.value ? 10 + (props.virtualGridHeight ?? 0) : 0) +
12,
})
})
resizeObserver.observe(dropTarget.value)
}
})
onBeforeUnmount(() => {
debugLayout('unmounted', {
id: props.id,
title: props.title,
renderedCount: visibleScreenshots.value.length,
})
resizeObserver?.disconnect()
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
})
const emit = defineEmits<{
(e: 'activate', screenshot: InstanceScreenshot, event: MouseEvent | KeyboardEvent): void
(e: 'toggle-selection' | 'copy' | 'edit', screenshot: InstanceScreenshot): void
(e: 'more', screenshot: InstanceScreenshot, event: MouseEvent): void
}>()
useDroppable({
id: computed(() => `screenshot-group:${props.id}`),
element: dropTarget,
disabled: computed(() => !props.dropInstanceId && !props.dropCustomGroup),
data: computed(() =>
props.dropCustomGroup
? { groupId: props.id, customGroupId: props.dropCustomGroupId ?? null }
: { groupId: props.id, instanceId: props.dropInstanceId },
),
})
function getSelectionKey(screenshot: InstanceScreenshot) {
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
}
</script>
<template>
<div
ref="dropTarget"
class="group/instance-container relative select-none pb-3 transition-colors"
>
<Transition
enter-active-class="transition-opacity duration-150 ease-out"
enter-from-class="!opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="!opacity-0"
>
<div
v-if="showDropOutline"
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 border-dashed border-brand bg-brand-highlight opacity-40"
/>
</Transition>
<ScreenshotSection
v-model:collapsed="collapsed"
:title="title"
:count="screenshots.length"
:force-open="forceOpen"
:hide-header="hideHeader"
:editable="editableTitle"
:start-editing="startEditingTitle"
:max-title-length="maxTitleLength"
:validate-title="validateTitle"
:on-title-change="onTitleChange"
>
<template #actions="{ startEditing }">
<slot name="actions" :start-editing="startEditing" />
</template>
<div v-if="renderGrid" class="relative min-h-[45px] w-full" :style="virtualGridStyle">
<div
class="grid min-h-[45px] w-full grid-cols-1 gap-3 sm:grid-cols-2 2xl:grid-cols-4"
:class="{ 'absolute inset-x-0 top-0': virtualGridHeight !== undefined }"
:style="visibleGridStyle"
>
<ScreenshotCard
v-for="screenshot in visibleScreenshots"
:key="getSelectionKey(screenshot)"
:screenshot="screenshot"
:selection-key="getSelectionKey(screenshot)"
:selected="selectedKeys.has(getSelectionKey(screenshot))"
:selection-active="selectionActive"
:active-dragged="activeDraggedKeys.has(getSelectionKey(screenshot))"
:can-drag="canDrag"
:show-instance-name="showInstanceName"
:highlighted="highlightedScreenshotId === screenshot.id"
:copied="copiedScreenshotIds.has(screenshot.id)"
@activate="(event) => emit('activate', screenshot, event)"
@toggle-selection="emit('toggle-selection', screenshot)"
@copy="emit('copy', screenshot)"
@edit="emit('edit', screenshot)"
@more="(event) => emit('more', screenshot, event)"
/>
<p
v-if="screenshots.length === 0"
key="empty-group"
class="col-span-full m-0 pl-0.5 pt-1 text-base font-base text-[var(--color-text-tertiary)] opacity-80"
>
{{ formatMessage(messages.emptyGroup) }}
</p>
</div>
</div>
</ScreenshotSection>
</div>
</template>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets'
import {
Accordion,
commonMessages,
defineMessages,
InlineEditableText,
TagItem,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
title: string
count: number
collapsed?: boolean
forceOpen?: boolean
hideHeader?: boolean
editable?: boolean
startEditing?: boolean
maxTitleLength?: number
validateTitle?: (value: string) => boolean
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
}>(),
{
collapsed: false,
forceOpen: false,
hideHeader: false,
editable: false,
startEditing: false,
},
)
const emit = defineEmits<{
(e: 'update:collapsed', collapsed: boolean): void
}>()
const { formatMessage } = useVIntl()
const debugLayout = useDebugLogger('Screenshots:Section')
const accordion = ref<InstanceType<typeof Accordion>>()
const titleInput = ref<InstanceType<typeof InlineEditableText>>()
const titleModel = ref(props.title)
const messages = defineMessages({
collapse: { id: 'app.screenshots.group.collapse', defaultMessage: 'Collapse group' },
expand: { id: 'app.screenshots.group.expand', defaultMessage: 'Expand group' },
})
function toggle() {
debugLayout('toggle requested', {
title: props.title,
count: props.count,
isOpen: accordion.value?.isOpen,
})
if (accordion.value?.isOpen) {
accordion.value.close()
} else {
accordion.value?.open()
}
}
function handleOpen() {
debugLayout('opened', { title: props.title, count: props.count })
emit('update:collapsed', false)
}
function handleClose() {
debugLayout('closed', { title: props.title, count: props.count })
emit('update:collapsed', true)
}
onMounted(() => {
debugLayout('mounted', {
title: props.title,
count: props.count,
collapsed: props.collapsed,
hideHeader: props.hideHeader,
forceOpen: props.forceOpen,
})
})
onBeforeUnmount(() => {
debugLayout('unmounted', { title: props.title, count: props.count })
})
async function startTitleEditing() {
if (!props.editable) return
await titleInput.value?.startEditing()
}
watch(
() => props.title,
(title) => {
titleModel.value = title
},
)
watch(
() => props.startEditing,
async (shouldStartEditing) => {
if (!shouldStartEditing) return
await nextTick()
await startTitleEditing()
},
{ immediate: true, flush: 'post' },
)
</script>
<template>
<section class="flex w-full flex-col">
<div
v-if="!hideHeader"
class="group/header flex h-10 w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
>
<div class="group/open-target flex min-w-0 cursor-pointer items-center gap-2" @click="toggle">
<button
type="button"
class="flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0"
:aria-expanded="accordion?.isOpen"
:aria-label="formatMessage(accordion?.isOpen ? messages.collapse : messages.expand)"
@click.stop="toggle"
>
<DropdownIcon
class="size-5 shrink-0 text-[var(--color-text-tertiary)] transition-all duration-300 group-hover/open-target:text-[var(--color-text-default)]"
:class="{ 'rotate-180': accordion?.isOpen }"
/>
</button>
<InlineEditableText
v-if="editable"
ref="titleInput"
v-model="titleModel"
activation-mode="manual"
class="!h-10 select-none text-base font-semibold text-[var(--color-text-default)] group-hover/open-target:text-[var(--color-text-primary)]"
:edit-label="formatMessage(commonMessages.renameButton)"
max-width="24rem"
icon-text-class="select-none"
:max-length="maxTitleLength"
:validate="validateTitle"
:on-change="onTitleChange"
/>
<span
v-else
class="select-none truncate text-base font-semibold text-[var(--color-text-default)] group-hover/open-target:text-[var(--color-text-primary)]"
>
{{ title }}
</span>
<TagItem v-if="count" class="shrink-0 border-surface-3 bg-surface-2">
{{ count }}
</TagItem>
</div>
<div class="min-w-0 flex-1" />
<slot name="actions" :start-editing="startTitleEditing" />
</div>
<Accordion
ref="accordion"
:open-by-default="hideHeader || !props.collapsed"
:force-open="forceOpen"
overflow-visible
class="w-full"
@on-open="handleOpen"
@on-close="handleClose"
>
<div class="mt-2.5">
<slot />
</div>
</Accordion>
</section>
</template>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { ArrowUpDownIcon, GridIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import {
Button,
Combobox,
type ComboboxOption,
defineMessages,
StyledInput,
useVIntl,
} from '@modrinth/ui'
const search = defineModel<string>('search', { required: true })
const sort = defineModel<string>('sort', { required: true })
const group = defineModel<string>('group', { required: true })
defineProps<{
sortOptions: ComboboxOption<string>[]
groupOptions: ComboboxOption<string>[]
}>()
const emit = defineEmits<{
(e: 'new-group'): void
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
search: { id: 'app.screenshots.search', defaultMessage: 'Search' },
newGroup: { id: 'app.screenshots.group.new', defaultMessage: 'New group' },
sortBy: { id: 'app.screenshots.sort-by', defaultMessage: 'Sort by' },
groupBy: { id: 'app.screenshots.group-by', defaultMessage: 'Group by' },
})
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2">
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.search)"
clearable
wrapper-class="min-w-[16rem] flex-1"
/>
<Button @click="emit('new-group')">
<PlusIcon />
{{ formatMessage(messages.newGroup) }}
</Button>
</div>
<div class="flex flex-wrap items-center gap-2">
<Combobox
v-model="sort"
class="w-max"
:options="sortOptions"
:show-icon-in-selected="false"
dropdown-min-width="160px"
>
<template #prefix>
<ArrowUpDownIcon
class="size-5 text-[var(--color-text-default)]"
:aria-label="formatMessage(messages.sortBy)"
/>
</template>
<template #selected="{ label }">
<span>{{ label }}</span>
</template>
</Combobox>
<Combobox
v-model="group"
class="w-max"
:options="groupOptions"
:show-icon-in-selected="false"
dropdown-min-width="160px"
>
<template #prefix>
<GridIcon
class="size-5 text-[var(--color-text-default)]"
:aria-label="formatMessage(messages.groupBy)"
/>
</template>
<template #selected="{ label }">
<span>{{ label }}</span>
</template>
</Combobox>
</div>
</div>
</template>
@@ -0,0 +1,130 @@
import { onBeforeUnmount, type Ref, ref } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
type Point = {
x: number
y: number
}
export type ActiveScreenshotDrag = {
primarySelectionKey: string
selectionKeys: string[]
}
export const gatherDuration = 500
export type ScreenshotDragGatherItem = {
screenshot: InstanceScreenshot
selectionKey: string
rect: {
left: number
top: number
width: number
height: number
}
}
function getSelectionKey(screenshot: InstanceScreenshot) {
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
}
export function useScreenshotDragGather(screenshots: Ref<InstanceScreenshot[]>) {
const items = ref<ScreenshotDragGatherItem[]>([])
const target = ref<Point>({ x: 0, y: 0 })
const targetOffset = ref<Point>({ x: 0, y: 0 })
const isGathering = ref(false)
let cleanupTimer: number | undefined
const clearCleanupTimer = () => {
if (cleanupTimer !== undefined) {
clearTimeout(cleanupTimer)
cleanupTimer = undefined
}
}
const clear = () => {
clearCleanupTimer()
items.value = []
targetOffset.value = { x: 0, y: 0 }
isGathering.value = false
}
const updateTarget = (pointer: Point) => {
target.value = {
x: pointer.x + targetOffset.value.x,
y: pointer.y + targetOffset.value.y,
}
}
const start = (drag: ActiveScreenshotDrag | null, pointer: Point) => {
clear()
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!drag || drag.selectionKeys.length < 2 || reduceMotion) return
const screenshotCards = Array.from(
document.querySelectorAll<HTMLElement>('[data-screenshot-card]'),
)
const sourceCard = screenshotCards.find(
(card) => card.dataset.selectionKey === drag.primarySelectionKey,
)
if (sourceCard) {
const sourceRect = sourceCard.getBoundingClientRect()
targetOffset.value = {
x: sourceRect.left + sourceRect.width / 2 - pointer.x,
y: sourceRect.top + sourceRect.height / 2 - pointer.y,
}
}
const screenshotsByKey = new Map(
screenshots.value.map((screenshot) => [getSelectionKey(screenshot), screenshot]),
)
items.value = drag.selectionKeys.flatMap((selectionKey) => {
const screenshot = screenshotsByKey.get(selectionKey)
const card = screenshotCards.find(
(candidate) =>
candidate.dataset.selectionKey === selectionKey && candidate.getClientRects().length > 0,
)
if (!screenshot || !card) return []
const rect = card.getBoundingClientRect()
return [
{
screenshot,
selectionKey,
rect: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
},
]
})
updateTarget(pointer)
isGathering.value = items.value.length > 0
}
const finish = () => {
isGathering.value = false
clearCleanupTimer()
cleanupTimer = window.setTimeout(() => {
items.value = []
cleanupTimer = undefined
}, 400)
}
onBeforeUnmount(clearCleanupTimer)
return {
items,
target,
isGathering,
start,
updateTarget,
clear,
finish,
}
}
@@ -0,0 +1,17 @@
import { onUnmounted } from 'vue'
import { instance_listener } from '@/helpers/events.js'
export interface InstanceAppEvent {
event: string
instance_id?: string
}
export function useAppEvent(type: 'instance', callback: (event: InstanceAppEvent) => void) {
if (type !== 'instance') return
let unlisten: (() => void) | undefined
void instance_listener(callback).then((dispose) => {
unlisten = dispose
})
onUnmounted(() => unlisten?.())
}
@@ -0,0 +1,154 @@
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { type MaybeRefOrGetter, ref, toValue, watch } from 'vue'
export type ImageThumbnailSource = {
instanceId: string
filePath: string
path: string
}
type ThumbnailCacheEntry = {
promise: Promise<string>
url?: string
users: number
lastUsed: number
}
const MAX_CONCURRENT_THUMBNAILS = 4
const MAX_CACHED_THUMBNAILS = 256
const thumbnailCache = new Map<string, ThumbnailCacheEntry>()
const thumbnailQueue: Array<() => void> = []
let activeThumbnailRequests = 0
function runThumbnailQueue() {
while (activeThumbnailRequests < MAX_CONCURRENT_THUMBNAILS && thumbnailQueue.length > 0) {
activeThumbnailRequests += 1
thumbnailQueue.shift()?.()
}
}
function enqueueThumbnail<T>(task: () => Promise<T>) {
return new Promise<T>((resolve, reject) => {
thumbnailQueue.push(() => {
void task()
.then(resolve, reject)
.finally(() => {
activeThumbnailRequests -= 1
runThumbnailQueue()
})
})
runThumbnailQueue()
})
}
function removeThumbnail(cacheKey: string, entry: ThumbnailCacheEntry) {
if (thumbnailCache.get(cacheKey) !== entry) return
thumbnailCache.delete(cacheKey)
if (entry.url) {
URL.revokeObjectURL(entry.url)
} else {
void entry.promise.then(
(url) => URL.revokeObjectURL(url),
() => undefined,
)
}
}
function trimThumbnailCache() {
if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) return
const unused = [...thumbnailCache.entries()]
.filter(([, entry]) => entry.users === 0)
.sort(([, left], [, right]) => left.lastUsed - right.lastUsed)
for (const [cacheKey, entry] of unused) {
if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) break
removeThumbnail(cacheKey, entry)
}
}
function acquireThumbnail(source: ImageThumbnailSource, maxDimension: number, revision: unknown) {
const cacheKey = JSON.stringify([
source.instanceId,
source.filePath,
maxDimension,
revision ?? null,
])
let entry = thumbnailCache.get(cacheKey)
if (!entry) {
entry = {
users: 0,
lastUsed: performance.now(),
promise: enqueueThumbnail(async () => {
const bytes = await invoke<ArrayBuffer>('plugin:files|screenshot_thumbnail', {
instanceId: source.instanceId,
filePath: source.filePath,
maxDimension,
})
return URL.createObjectURL(new Blob([bytes]))
}),
}
thumbnailCache.set(cacheKey, entry)
void entry.promise.then(
(url) => {
entry!.url = url
trimThumbnailCache()
},
() => {
if (thumbnailCache.get(cacheKey) === entry) thumbnailCache.delete(cacheKey)
},
)
}
entry.users += 1
entry.lastUsed = performance.now()
return {
promise: entry.promise,
release: () => {
entry!.users = Math.max(0, entry!.users - 1)
entry!.lastUsed = performance.now()
trimThumbnailCache()
},
}
}
export function useImageThumbnail(
source: MaybeRefOrGetter<ImageThumbnailSource | null | undefined>,
maxDimension: MaybeRefOrGetter<number> = 512,
revision: MaybeRefOrGetter<string | number | undefined> = undefined,
) {
const thumbnail = ref('')
watch(
[() => toValue(source), () => toValue(maxDimension), () => toValue(revision)],
([currentSource, currentMaxDimension, currentRevision], _, onCleanup) => {
thumbnail.value = ''
if (!currentSource) return
let active = true
const fallbackUrl = convertFileSrc(currentSource.path)
const acquired = acquireThumbnail(
currentSource,
Math.max(1, Math.round(currentMaxDimension)),
currentRevision,
)
onCleanup(() => {
active = false
acquired.release()
})
void acquired.promise.then(
(url) => {
if (active) thumbnail.value = url
},
(error) => {
console.warn('Could not create image thumbnail', error)
if (active) thumbnail.value = fallbackUrl
},
)
},
{ immediate: true },
)
return thumbnail
}
@@ -0,0 +1 @@
export const MAX_INSTANCE_GROUP_NAME_LENGTH = 256
+88 -1
View File
@@ -9,7 +9,7 @@ import {
type ContentItem, type ContentItem,
type ContentOwner, type ContentOwner,
} from '@modrinth/ui' } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core' import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { isOfflineMode } from '@/composables/useNetworkStatus' import { isOfflineMode } from '@/composables/useNetworkStatus'
import { buildGcCandidateChain, resolveAutoGcStrategy } from '@/helpers/gc/auto-selector' import { buildGcCandidateChain, resolveAutoGcStrategy } from '@/helpers/gc/auto-selector'
@@ -1124,3 +1124,90 @@ export async function check_symlink_capability(): Promise<SymlinkCapability> {
export async function allow_symlink_target(path: string): Promise<void> { export async function allow_symlink_target(path: string): Promise<void> {
return await invoke('allow_symlink_target', { path }) return await invoke('allow_symlink_target', { path })
} }
export type ScreenshotKey = { instance_id: string; file_name: string }
export type InstanceScreenshot = {
id: string
instance_id: string
instance_name: string
file_name: string
created_at: string
modified_at: number
group_id: string | null
path: string
url: string
}
export type ScreenshotGroup = { id: string; name: string }
export type ScreenshotGroupImport = ScreenshotGroup & { screenshot_ids: string[] }
export type ScreenshotGroupMembershipUpdate = { screenshot_id: string; group_id: string | null }
export async function list_screenshots(instanceId: string): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_screenshots', { instanceId })
}
export async function list_all_screenshots(): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_all_screenshots')
}
export async function list_synced_screenshots(): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_synced_screenshots')
}
export async function list_screenshot_groups(): Promise<ScreenshotGroup[]> {
return await invoke('plugin:instance|instance_list_screenshot_groups')
}
export async function create_screenshot_group(
name: string,
screenshotIds: string[],
): Promise<ScreenshotGroup> {
return await invoke('plugin:instance|instance_create_screenshot_group', {
name,
ids: screenshotIds,
})
}
export async function rename_screenshot_group(
id: string,
newName: string,
): Promise<ScreenshotGroup> {
return await invoke('plugin:instance|instance_rename_screenshot_group', { id, newName })
}
export async function delete_screenshot_group(id: string): Promise<void> {
return await invoke('plugin:instance|instance_delete_screenshot_group', { id })
}
export async function set_screenshot_group_memberships(
updates: ScreenshotGroupMembershipUpdate[],
): Promise<void> {
return await invoke('plugin:instance|instance_set_screenshot_group_memberships', { updates })
}
export async function import_screenshot_groups(groups: ScreenshotGroupImport[]): Promise<void> {
return await invoke('plugin:instance|instance_import_screenshot_groups', { groups })
}
export async function save_edited_screenshot(
key: ScreenshotKey,
pngBytes: Uint8Array,
mode: 'create_copy' | 'replace_edit',
): Promise<InstanceScreenshot> {
return await invoke('plugin:instance|instance_save_edited_screenshot', {
key,
bytes: Array.from(pngBytes),
mode,
})
}
export async function delete_screenshots(keys: ScreenshotKey[]): Promise<void> {
return await invoke('plugin:instance|instance_delete_screenshots', { keys })
}
export async function export_screenshots(keys: ScreenshotKey[], path: string): Promise<void> {
return await invoke('plugin:instance|instance_export_screenshots', { keys, path })
}
export async function open_screenshot(key: ScreenshotKey): Promise<void> {
return await invoke('plugin:instance|instance_open_screenshot', { key })
}
export async function move_screenshots(
keys: ScreenshotKey[],
targetInstanceId: string,
): Promise<ScreenshotKey[]> {
return await invoke('plugin:instance|instance_move_screenshots', {
keys,
target: targetInstanceId,
})
}
export function getInstanceIconUrl(iconPath?: string | null): string | undefined {
return iconPath ? convertFileSrc(iconPath) : undefined
}
File diff suppressed because it is too large. Load diff
@@ -10458,5 +10458,227 @@
}, },
"app.settings.about.easteregg.game-title": { "app.settings.about.easteregg.game-title": {
"message": "彩蛋小游戏" "message": "彩蛋小游戏"
},
"app.home.launch-progress.empty": {
"message": "启动游戏后,这里会显示进度。"
},
"app.home.launch-progress.title": {
"message": "启动进度"
},
"app.screenshots.copied": {
"message": "已复制"
},
"app.screenshots.copy": {
"message": "复制图像"
},
"app.screenshots.delete-description": {
"message": "永久删除 {name}?此操作无法撤销。"
},
"app.screenshots.delete-success": {
"message": "截图已删除"
},
"app.screenshots.delete-title": {
"message": "删除截图"
},
"app.screenshots.deselect": {
"message": "取消选择 {name}"
},
"app.screenshots.edit": {
"message": "编辑截图"
},
"app.screenshots.empty-description": {
"message": "你在游戏中拍摄的截图会显示在这里。"
},
"app.screenshots.empty-heading": {
"message": "还没有截图"
},
"app.screenshots.error-heading": {
"message": "加载截图失败"
},
"app.screenshots.go-to-instance": {
"message": "前往实例"
},
"app.screenshots.group-by": {
"message": "分组方式"
},
"app.screenshots.group.all-screenshots": {
"message": "全部截图"
},
"app.screenshots.group.collapse": {
"message": "折叠分组"
},
"app.screenshots.group.custom": {
"message": "自定义分组"
},
"app.screenshots.group.date": {
"message": "日期"
},
"app.screenshots.group.delete": {
"message": "删除分组"
},
"app.screenshots.group.delete-description": {
"message": "此分组中的截图将变为未分组。"
},
"app.screenshots.group.edit": {
"message": "编辑分组名称"
},
"app.screenshots.group.empty": {
"message": "拖放以添加截图。"
},
"app.screenshots.group.expand": {
"message": "展开分组"
},
"app.screenshots.group.instance": {
"message": "实例"
},
"app.screenshots.group.new": {
"message": "新建分组"
},
"app.screenshots.group.none": {
"message": "不分组"
},
"app.screenshots.group.this-month": {
"message": "本月"
},
"app.screenshots.group.this-week": {
"message": "本周"
},
"app.screenshots.group.today": {
"message": "今天"
},
"app.screenshots.group.ungrouped": {
"message": "未分组"
},
"app.screenshots.group.yesterday": {
"message": "昨天"
},
"app.screenshots.heading": {
"message": "截图"
},
"app.screenshots.more-actions": {
"message": "更多操作"
},
"app.screenshots.no-results-description": {
"message": "请尝试其他搜索或筛选条件。"
},
"app.screenshots.no-results-heading": {
"message": "没有匹配的截图"
},
"app.screenshots.preview.instance-and-date": {
"message": "{instance} · {date}"
},
"app.screenshots.search": {
"message": "搜索"
},
"app.screenshots.select": {
"message": "选择 {name}"
},
"app.screenshots.selection.aria-label": {
"message": "已选中的截图"
},
"app.screenshots.selection.delete-description": {
"message": "删除 {count, plural, one {# 张截图} other {# 张截图}}?此操作无法撤销。"
},
"app.screenshots.selection.delete-success": {
"message": "{count, plural, one {已删除 # 张截图} other {已删除 # 张截图}}"
},
"app.screenshots.selection.delete-title": {
"message": "删除选中的截图"
},
"app.screenshots.selection.export-zip": {
"message": "导出 ZIP"
},
"app.screenshots.selection.global-export-filename": {
"message": "Modrinth screenshots.zip"
},
"app.screenshots.selection.instance-export-filename": {
"message": "{instance} screenshots.zip"
},
"app.screenshots.selection.remove-from-group": {
"message": "从分组中移除"
},
"app.screenshots.selection.selected-count": {
"message": "已选中 {count} 张"
},
"app.screenshots.selection.zip-archive": {
"message": "ZIP 压缩包"
},
"app.screenshots.show-in-folder": {
"message": "在文件夹中显示"
},
"app.screenshots.sort-by": {
"message": "排序方式"
},
"app.screenshots.sort.name": {
"message": "名称"
},
"app.screenshots.sort.newest": {
"message": "最新"
},
"app.screenshots.sort.oldest": {
"message": "最旧"
},
"app.settings.about.copyright": {
"message": "版权所有 © StarLight 保留所有权利。"
},
"app.settings.about.game.best": {
"message": "最佳:{score}"
},
"app.settings.about.game.best-label": {
"message": "最佳"
},
"app.settings.about.game.completed": {
"message": "下界之星!"
},
"app.settings.about.game.exit": {
"message": "退出游戏"
},
"app.settings.about.game.game-over": {
"message": "群星坠落!"
},
"app.settings.about.game.highest-level": {
"message": "最高:等级 {level}"
},
"app.settings.about.game.level-label": {
"message": "最高等级"
},
"app.settings.about.game.new-record": {
"message": "新纪录!"
},
"app.settings.about.game.overtime": {
"message": "涨潮!"
},
"app.settings.about.game.overtime-detail": {
"message": "潮水涌回——继续合成!"
},
"app.settings.about.game.overtimes-label": {
"message": "加时次数"
},
"app.settings.about.game.restart": {
"message": "重新开始"
},
"app.settings.about.game.score": {
"message": "得分:{score}"
},
"app.settings.about.game.score-label": {
"message": "得分"
},
"app.settings.about.game.settle": {
"message": "稳定"
},
"app.settings.about.game.settle-title": {
"message": "本局已稳定!"
},
"app.settings.about.game.tap-to-drop": {
"message": "点击投放一颗星"
},
"app.settings.about.game.tide-hint": {
"message": "天空正在坠落——继续合成!"
},
"app.settings.about.game.title": {
"message": "Starlight 合成"
},
"app.starlight-skin.frame-title": {
"message": "StarLight 皮肤站"
} }
} }
@@ -0,0 +1,11 @@
<script setup lang="ts">
import ScreenshotsPage from '@/components/ui/screenshots-page/index.vue'
defineOptions({ name: 'ScreenshotsPage' })
</script>
<template>
<div class="box-border h-full p-6">
<ScreenshotsPage show-heading />
</div>
</template>
@@ -0,0 +1,57 @@
import { queryOptions } from '@tanstack/vue-query'
import {
list,
list_all_screenshots,
list_screenshot_groups,
list_screenshots,
list_synced_screenshots,
} from '@/helpers/instance'
export const instanceKeys = {
all: ['instances'] as const,
list: () => ['instances', 'list'] as const,
}
export function instanceListQueryOptions() {
return queryOptions({
queryKey: instanceKeys.list(),
queryFn: list,
})
}
export const screenshotKeys = {
all: ['screenshots'] as const,
global: () => ['screenshots', 'global'] as const,
synced: () => ['screenshots', 'synced'] as const,
instance: (instanceId: string) => ['screenshots', 'instance', instanceId] as const,
groups: () => ['screenshots', 'groups'] as const,
}
export function instanceScreenshotsQueryOptions(instanceId: string) {
return queryOptions({
queryKey: screenshotKeys.instance(instanceId),
queryFn: () => list_screenshots(instanceId),
})
}
export function allScreenshotsQueryOptions() {
return queryOptions({
queryKey: screenshotKeys.global(),
queryFn: list_all_screenshots,
})
}
export function syncedScreenshotsQueryOptions() {
return queryOptions({
queryKey: screenshotKeys.synced(),
queryFn: list_synced_screenshots,
})
}
export function screenshotGroupsQueryOptions() {
return queryOptions({
queryKey: screenshotKeys.groups(),
queryFn: list_screenshot_groups,
})
}
+8
View File
@@ -101,6 +101,14 @@ export default new createRouter({
breadcrumb: [{ name: 'Starlight skin' }], breadcrumb: [{ name: 'Starlight skin' }],
}, },
}, },
{
path: '/screenshots',
name: 'Screenshots',
component: () => import('@/pages/Screenshots.vue'),
meta: {
breadcrumb: [{ name: 'Screenshots' }],
},
},
{ {
path: '/multiplayer', path: '/multiplayer',
name: 'Multiplayer', name: 'Multiplayer',
+1 -1
View File
@@ -9,7 +9,7 @@ use std::path::PathBuf;
use std::sync::Mutex; use std::sync::Mutex;
use tauri::{Emitter, Runtime}; use tauri::{Emitter, Runtime};
use tauri_plugin_dialog::DialogExt; use tauri_plugin_dialog::DialogExt;
use theseus::instance::get_full_path; use theseus::instance::{get_full_path, list};
const STUDIO_FILES_CHANGED_EVENT: &str = "studio-files-changed"; const STUDIO_FILES_CHANGED_EVENT: &str = "studio-files-changed";
@@ -6,6 +6,9 @@ use path_util::SafeRelativeUtf8UnixPathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tauri::Manager;
use tauri_plugin_fs::FsExt;
use tauri_plugin_opener::OpenerExt;
use theseus::data::{ use theseus::data::{
AppliedContentSetPatch, ContentItem, Dependency, AppliedContentSetPatch, ContentItem, Dependency,
EditInstance as CoreEditInstance, InstanceInstallCandidate, EditInstance as CoreEditInstance, InstanceInstallCandidate,
@@ -103,6 +106,20 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
instance_edit_icon, instance_edit_icon,
instance_export_mrpack, instance_export_mrpack,
instance_get_pack_export_candidates, instance_get_pack_export_candidates,
instance_list_screenshots,
instance_list_all_screenshots,
instance_list_synced_screenshots,
instance_save_edited_screenshot,
instance_list_screenshot_groups,
instance_create_screenshot_group,
instance_rename_screenshot_group,
instance_delete_screenshot_group,
instance_set_screenshot_group_memberships,
instance_import_screenshot_groups,
instance_delete_screenshots,
instance_export_screenshots,
instance_move_screenshots,
instance_open_screenshot,
]) ])
.build() .build()
} }
@@ -1490,3 +1507,171 @@ pub async fn instance_edit_icon(
theseus::instance::edit_icon(instance_id, icon_path).await?; theseus::instance::edit_icon(instance_id, icon_path).await?;
Ok(()) Ok(())
} }
#[derive(Serialize, Debug, Clone)]
pub struct InstanceScreenshot {
pub id: String,
pub instance_id: String,
pub instance_name: String,
pub file_name: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub modified_at: i64,
pub group_id: Option<String>,
pub path: PathBuf,
pub url: String,
}
fn serialize_screenshot<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
s: theseus::instance::InstanceScreenshot,
) -> Result<InstanceScreenshot> {
if let Some(parent) = s.path.parent() {
crate::api::files::ensure_browsable(app, parent);
}
let mut url = super::utils::tauri_convert_file_src(&s.path)?;
app.asset_protocol_scope().allow_file(&s.path)?;
app.fs_scope().allow_file(&s.path)?;
url.query_pairs_mut()
.append_pair("revision", &s.modified_at.to_string());
Ok(InstanceScreenshot {
id: s.id,
instance_id: s.instance_id,
instance_name: s.instance_name,
file_name: s.file_name,
created_at: s.created_at,
modified_at: s.modified_at,
group_id: s.group_id,
path: s.path,
url: url.to_string(),
})
}
fn serialize_screenshots<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
items: Vec<theseus::instance::InstanceScreenshot>,
) -> Result<Vec<InstanceScreenshot>> {
items
.into_iter()
.map(|s| serialize_screenshot(app, s))
.collect()
}
#[tauri::command]
pub async fn instance_list_screenshots<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
instance_id: &str,
) -> Result<Vec<InstanceScreenshot>> {
serialize_screenshots(
&app,
theseus::instance::list_screenshots(instance_id).await?,
)
}
#[tauri::command]
pub async fn instance_list_all_screenshots<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Vec<InstanceScreenshot>> {
serialize_screenshots(
&app,
theseus::instance::list_all_screenshots().await?,
)
}
#[tauri::command]
pub async fn instance_list_synced_screenshots<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Vec<InstanceScreenshot>> {
serialize_screenshots(
&app,
theseus::instance::list_synced_screenshots().await?,
)
}
#[tauri::command]
pub async fn instance_save_edited_screenshot<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
key: theseus::instance::ScreenshotKey,
bytes: Vec<u8>,
mode: theseus::instance::ScreenshotEditSaveMode,
) -> Result<InstanceScreenshot> {
serialize_screenshot(
&app,
theseus::instance::save_edited_screenshot(key, bytes, mode).await?,
)
}
#[tauri::command]
pub async fn instance_list_screenshot_groups()
-> Result<Vec<theseus::instance::ScreenshotGroup>> {
Ok(theseus::instance::list_screenshot_groups().await?)
}
#[tauri::command]
pub async fn instance_create_screenshot_group(
name: String,
ids: Vec<String>,
) -> Result<theseus::instance::ScreenshotGroup> {
Ok(theseus::instance::create_screenshot_group(name, ids).await?)
}
#[tauri::command]
pub async fn instance_rename_screenshot_group(
id: String,
new_name: String,
) -> Result<theseus::instance::ScreenshotGroup> {
Ok(theseus::instance::rename_screenshot_group(id, new_name).await?)
}
#[tauri::command]
pub async fn instance_delete_screenshot_group(id: String) -> Result<()> {
Ok(theseus::instance::delete_screenshot_group(id).await?)
}
#[tauri::command]
pub async fn instance_set_screenshot_group_memberships(
updates: Vec<theseus::instance::ScreenshotGroupMembershipUpdate>,
) -> Result<()> {
Ok(theseus::instance::set_screenshot_group_memberships(updates).await?)
}
#[tauri::command]
pub async fn instance_import_screenshot_groups(
groups: Vec<theseus::instance::ScreenshotGroupImport>,
) -> Result<()> {
Ok(theseus::instance::import_screenshot_groups(groups).await?)
}
#[tauri::command]
pub async fn instance_delete_screenshots(
keys: Vec<theseus::instance::ScreenshotKey>,
) -> Result<()> {
Ok(theseus::instance::delete_screenshots(&keys).await?)
}
#[tauri::command]
pub async fn instance_export_screenshots(
keys: Vec<theseus::instance::ScreenshotKey>,
path: PathBuf,
) -> Result<()> {
Ok(theseus::instance::export_screenshots(&keys, path).await?)
}
#[tauri::command]
pub async fn instance_move_screenshots(
keys: Vec<theseus::instance::ScreenshotKey>,
target: &str,
) -> Result<Vec<theseus::instance::ScreenshotKey>> {
Ok(theseus::instance::move_screenshots(&keys, target).await?)
}
#[tauri::command]
pub async fn instance_open_screenshot<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
key: theseus::instance::ScreenshotKey,
) -> Result<()> {
let path = theseus::instance::get_screenshot_path(&key).await?;
app.opener()
.reveal_item_in_dir(path)
.map_err(|e| std::io::Error::other(e.to_string()))?;
Ok(())
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM screenshot_groups WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "0a9977b44045c6d0e974770deabc5cb51b41bf5fa94b8bd4b9676f360bf2de7e"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM screenshot_groups WHERE id = ?) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "247aa979f6f6fd116b832cc5a513bd43f0aaa4b554c2a3cac6fb1079e539bf27"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM screenshots WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "2ede94b5eab73255500fbd4b6f7eb85abf6c98d4bdfcd93a9a6054e9d5235099"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE screenshots\n\t\tSET instance_id = ?, file_name = ?\n\t\tWHERE instance_id = ? AND file_name = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "48c53bc771b0c2af2ccef50cd121ab4de4ed309b54239bacb54b0d173c4e5acb"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "\n SELECT id, name\n\t\tFROM screenshot_groups\n\t\tORDER BY display_order, name, id\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false
]
},
"hash": "4e75c5ee789d5a781967fc0845f12e9c53683afbe05ab5f441c5f5fb1bce0472"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO screenshot_group_memberships (screenshot_id, group_id)\n\t\tSELECT ?, group_id\n\t\tFROM screenshot_group_memberships\n\t\tWHERE screenshot_id = ?\n\t\tON CONFLICT (screenshot_id) DO UPDATE SET group_id = excluded.group_id\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "5616069c5dd7233ef7cf867ac8367fdc4ff254e929cafce9fd3ff38449e88b89"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE screenshot_groups SET name = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "607eac5be3e2a29bb81cce3d0ff14690b61e08f5cb3316c4349c78dc0e4d81f7"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM screenshot_group_memberships WHERE screenshot_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "6c58c5daf14ed5198c14799f4a2dd02ebf24d7d61bb74b22abdf46e53b4efdfa"
}
@@ -0,0 +1,62 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT\n\t\t\tscreenshots.id,\n\t\t\tscreenshots.instance_id,\n\t\t\tscreenshots.file_name,\n\t\t\tscreenshots.content_hash,\n\t\t\tscreenshots.file_size,\n\t\t\tscreenshots.modified_at,\n\t\t\tscreenshots.created_at,\n\t\t\tmemberships.group_id AS \"group_id?\"\n\t\tFROM screenshots\n\t\tLEFT JOIN screenshot_group_memberships memberships\n\t\t\tON memberships.screenshot_id = screenshots.id\n\t\tWHERE screenshots.instance_id = ? AND screenshots.file_name = ?\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "instance_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "file_name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "content_hash",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "file_size",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "modified_at",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "created_at",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "group_id?",
"ordinal": 7,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "6f17b1880668e6efb235eaa3808bd64a1c6bb5fc5f3c0082500afdcfa7340efa"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT DISTINCT screenshots.instance_id\n\t\tFROM screenshot_group_memberships memberships\n\t\tINNER JOIN screenshots ON screenshots.id = memberships.screenshot_id\n\t\tWHERE memberships.group_id = ?\n\t\t",
"describe": {
"columns": [
{
"name": "instance_id",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "743f351ba5f612262b98b0a12dc2a75e1c92cece3b2715f9a3392c058e9347a8"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\t\tINSERT INTO screenshot_group_memberships (screenshot_id, group_id)\n\t\t\tVALUES (?, ?)\n\t\t\tON CONFLICT(screenshot_id) DO UPDATE SET group_id = excluded.group_id\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "7567783dcac147378086ed2bd7eab5a059f3d1b868a5c45dbb9de86dcd177085"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE screenshot_groups SET display_order = display_order + 1",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "778ef883e1e68bb549dd1b82761f674df6125b03954683a70431257cc8c923e9"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO screenshots (\n\t\t\tid,\n\t\t\tinstance_id,\n\t\t\tfile_name,\n\t\t\tcontent_hash,\n\t\t\tfile_size,\n\t\t\tmodified_at,\n\t\t\tcreated_at\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?)\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "7ea54f8960b92b275b5f9d4190c44bedb489b84d81eba304af25523c8d38dcd2"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE screenshots\n\t\tSET\n\t\t\tinstance_id = ?,\n\t\t\tfile_name = ?,\n\t\t\tcontent_hash = ?,\n\t\t\tfile_size = ?,\n\t\t\tmodified_at = ?,\n\t\t\tcreated_at = ?\n\t\tWHERE id = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "8aae262e7a2e6375164af3b296465d017d21a6c6476d0f2ae09c076d75f0dda3"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT instance_id FROM screenshots WHERE id = ?",
"describe": {
"columns": [
{
"name": "instance_id",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "98fbd284e94d1c509ded25c1c17004b3283447e78920e762bcac2765fca6f3eb"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM screenshots WHERE instance_id = ? AND file_name = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "a0705b02be354c37f7a1294eca7eba891c23cd79886a97d91bddb7019baab3ce"
}
@@ -0,0 +1,62 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT\n\t\t\tscreenshots.id,\n\t\t\tscreenshots.instance_id,\n\t\t\tscreenshots.file_name,\n\t\t\tscreenshots.content_hash,\n\t\t\tscreenshots.file_size,\n\t\t\tscreenshots.modified_at,\n\t\t\tscreenshots.created_at,\n\t\t\tmemberships.group_id AS \"group_id?\"\n\t\tFROM screenshots\n\t\tLEFT JOIN screenshot_group_memberships memberships\n\t\t\tON memberships.screenshot_id = screenshots.id\n\t\tWHERE screenshots.instance_id = ?\n\t\tORDER BY screenshots.file_name, screenshots.id\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "instance_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "file_name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "content_hash",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "file_size",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "modified_at",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "created_at",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "group_id?",
"ordinal": 7,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "a83e168791306f30ee6f03a274398455c6db66d485824f27ab198c3f00de1770"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\t\tINSERT INTO screenshot_groups (id, name, display_order)\n\t\t\tVALUES (?, ?, ?)\n\t\t\tON CONFLICT(id) DO UPDATE SET name = excluded.name\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "c3448ac48772d99b4cfd296996a93230488492804bb4f1d5ccec4a90454438f7"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO screenshot_groups (id, name, display_order) VALUES (?, ?, 0)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "c695093aadc64edd159fce723d1e053f5fb7e204e01e54892168c6c36873d60f"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO screenshot_group_memberships (screenshot_id, group_id) VALUES (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "cd9918f43f0f30b863ee48a7626782521100fac266da99102379ba01a89e9076"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' FROM screenshot_groups",
"describe": {
"columns": [
{
"name": "display_order!: i64",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "fd2eb3ed33e2a0eaca507bbe156640acd542e91c99fa491b282542ec4c58032c"
}
@@ -0,0 +1,38 @@
-- Screenshot center: unified index of per-instance screenshots plus
-- user-defined groups. Ported from the Axolotl implementation, trimmed of
-- the instance-sync / game-options tables that Starlight does not use.
CREATE TABLE screenshots (
id TEXT NOT NULL PRIMARY KEY,
instance_id TEXT NOT NULL,
file_name TEXT NOT NULL,
content_hash TEXT NOT NULL,
file_size INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
editor_state TEXT,
UNIQUE (instance_id, file_name),
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE
);
CREATE INDEX screenshots_instance_id ON screenshots(instance_id);
CREATE INDEX screenshots_instance_hash ON screenshots(instance_id, content_hash, file_size);
CREATE TABLE screenshot_groups (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
display_order INTEGER NOT NULL DEFAULT 0,
CHECK (length(trim(name)) > 0)
);
CREATE INDEX screenshot_groups_display_order ON screenshot_groups(display_order);
CREATE TABLE screenshot_group_memberships (
screenshot_id TEXT NOT NULL PRIMARY KEY,
group_id TEXT NOT NULL,
FOREIGN KEY (screenshot_id) REFERENCES screenshots(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES screenshot_groups(id) ON DELETE CASCADE
);
CREATE INDEX screenshot_group_memberships_group_id ON screenshot_group_memberships(group_id);
ALTER TABLE settings ADD COLUMN show_screenshots_tab_in_instances INTEGER NOT NULL DEFAULT FALSE CHECK (show_screenshots_tab_in_instances IN (0, 1));
@@ -12,6 +12,8 @@ mod paths;
mod planet_minecraft; mod planet_minecraft;
mod projects; mod projects;
mod run; mod run;
mod screenshot_groups;
mod screenshots;
mod upgrade; mod upgrade;
pub use self::content::{ pub use self::content::{
@@ -70,6 +72,20 @@ pub use self::run::{
GcLaunchIntent, GcLaunchReport, QuickPlayType, kill, run, GcLaunchIntent, GcLaunchReport, QuickPlayType, kill, run,
run_with_extra_launch_args, run_with_extra_launch_args_with_gc, run_with_extra_launch_args, run_with_extra_launch_args_with_gc,
}; };
pub use self::screenshot_groups::{
ScreenshotGroup, ScreenshotGroupImport, ScreenshotGroupMembershipUpdate,
create_screenshot_group, delete_screenshot_group, import_screenshot_groups,
list_screenshot_groups, rename_screenshot_group,
set_screenshot_group_memberships,
};
#[allow(unused_imports)]
pub(crate) use self::screenshots::reconcile_screenshots;
pub use self::screenshots::{
InstanceScreenshot, ScreenshotEditSaveMode, ScreenshotKey,
delete_screenshots, export_screenshots, get_screenshot_path,
list_all_screenshots, list_screenshots, list_synced_screenshots,
move_screenshots, save_edited_screenshot,
};
pub use self::upgrade::{ pub use self::upgrade::{
dismiss_instance_post_upgrade_notice, execute_instance_upgrade, dismiss_instance_post_upgrade_notice, execute_instance_upgrade,
get_instance_post_upgrade_notice, get_instance_upgrade_plan, get_instance_post_upgrade_notice, get_instance_upgrade_plan,
@@ -0,0 +1,349 @@
use crate::State;
use crate::event::InstancePayloadType;
use crate::event::emit::emit_instance;
use crate::state::instances::adapters::sqlite::screenshot_rows;
use serde::{Deserialize, Serialize};
use sqlx::{Sqlite, Transaction};
use std::collections::HashSet;
use uuid::Uuid;
const MAX_GROUP_NAME_LENGTH: usize = 256;
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ScreenshotGroup {
pub id: String,
pub name: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ScreenshotGroupMembershipUpdate {
pub screenshot_id: String,
pub group_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ScreenshotGroupImport {
pub id: String,
pub name: String,
pub screenshot_ids: Vec<String>,
}
fn validate_group_name(name: &str) -> crate::Result<&str> {
let name = name.trim();
if name.is_empty() {
return Err(crate::ErrorKind::InputError(
"Group name cannot be empty".to_string(),
)
.into());
}
if name.chars().count() > MAX_GROUP_NAME_LENGTH {
return Err(crate::ErrorKind::InputError(format!(
"Group name cannot exceed {MAX_GROUP_NAME_LENGTH} characters"
))
.into());
}
Ok(name)
}
pub async fn list_screenshot_groups() -> crate::Result<Vec<ScreenshotGroup>> {
let state = State::get().await?;
Ok(screenshot_rows::list_groups(&state.pool)
.await?
.into_iter()
.map(|group| ScreenshotGroup {
id: group.id,
name: group.name,
})
.collect())
}
pub async fn create_screenshot_group(
name: String,
screenshot_ids: Vec<String>,
) -> crate::Result<ScreenshotGroup> {
let name = validate_group_name(&name)?;
let state = State::get().await?;
let id = Uuid::new_v4().to_string();
let mut tx = state.pool.begin().await?;
sqlx::query!(
"UPDATE screenshot_groups SET display_order = display_order + 1",
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO screenshot_groups (id, name, display_order) VALUES (?, ?, 0)",
id,
name,
)
.execute(&mut *tx)
.await?;
let instance_ids = set_group_members(
&id,
normalize_screenshot_ids(screenshot_ids)?,
&mut tx,
)
.await?;
tx.commit().await?;
emit_screenshot_updates(instance_ids).await;
Ok(ScreenshotGroup {
id,
name: name.to_string(),
})
}
pub async fn rename_screenshot_group(
id: String,
new_name: String,
) -> crate::Result<ScreenshotGroup> {
let new_name = validate_group_name(&new_name)?;
let state = State::get().await?;
let result = sqlx::query!(
"UPDATE screenshot_groups SET name = ? WHERE id = ?",
new_name,
id,
)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::ErrorKind::InputError(format!(
"Unknown screenshot group {id}"
))
.into());
}
Ok(ScreenshotGroup {
id,
name: new_name.to_string(),
})
}
pub async fn delete_screenshot_group(id: String) -> crate::Result<()> {
let state = State::get().await?;
let instance_ids = sqlx::query_scalar!(
"
SELECT DISTINCT screenshots.instance_id
FROM screenshot_group_memberships memberships
INNER JOIN screenshots ON screenshots.id = memberships.screenshot_id
WHERE memberships.group_id = ?
",
id,
)
.fetch_all(&state.pool)
.await?;
let result =
sqlx::query!("DELETE FROM screenshot_groups WHERE id = ?", id,)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::ErrorKind::InputError(format!(
"Unknown screenshot group {id}"
))
.into());
}
emit_screenshot_updates(instance_ids).await;
Ok(())
}
pub async fn set_screenshot_group_memberships(
updates: Vec<ScreenshotGroupMembershipUpdate>,
) -> crate::Result<()> {
let updates = normalize_membership_updates(updates)?;
if updates.is_empty() {
return Ok(());
}
let state = State::get().await?;
let mut tx = state.pool.begin().await?;
let group_ids = updates
.iter()
.filter_map(|update| update.group_id.as_deref())
.collect::<HashSet<_>>();
for group_id in group_ids {
ensure_group_exists(group_id, &mut tx).await?;
}
let mut instance_ids = HashSet::new();
for update in updates {
let instance_id =
screenshot_instance_id(&update.screenshot_id, &mut tx).await?;
instance_ids.insert(instance_id);
sqlx::query!(
"DELETE FROM screenshot_group_memberships WHERE screenshot_id = ?",
update.screenshot_id,
)
.execute(&mut *tx)
.await?;
if let Some(group_id) = update.group_id {
sqlx::query!(
"INSERT INTO screenshot_group_memberships (screenshot_id, group_id) VALUES (?, ?)",
update.screenshot_id,
group_id,
)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
emit_screenshot_updates(instance_ids).await;
Ok(())
}
pub async fn import_screenshot_groups(
groups: Vec<ScreenshotGroupImport>,
) -> crate::Result<()> {
if groups.is_empty() {
return Ok(());
}
let state = State::get().await?;
let mut tx = state.pool.begin().await?;
let base_order = sqlx::query_scalar!(
"SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' FROM screenshot_groups",
)
.fetch_one(&mut *tx)
.await?;
let mut group_ids = HashSet::new();
let mut affected_instance_ids = HashSet::new();
for (index, group) in groups.into_iter().enumerate() {
if group.id.trim().is_empty() || !group_ids.insert(group.id.clone()) {
return Err(crate::ErrorKind::InputError(
"Screenshot group import contains an invalid group ID"
.to_string(),
)
.into());
}
let name = validate_group_name(&group.name)?;
let display_order = base_order + index as i64;
sqlx::query!(
"
INSERT INTO screenshot_groups (id, name, display_order)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET name = excluded.name
",
group.id,
name,
display_order,
)
.execute(&mut *tx)
.await?;
affected_instance_ids.extend(
set_group_members(
&group.id,
normalize_screenshot_ids(group.screenshot_ids)?,
&mut tx,
)
.await?,
);
}
tx.commit().await?;
emit_screenshot_updates(affected_instance_ids).await;
Ok(())
}
fn normalize_screenshot_ids(
screenshot_ids: Vec<String>,
) -> crate::Result<Vec<String>> {
let mut unique = HashSet::new();
if screenshot_ids
.iter()
.all(|screenshot_id| unique.insert(screenshot_id.clone()))
{
return Ok(screenshot_ids);
}
Err(crate::ErrorKind::InputError(
"Screenshot group contains duplicate screenshots".to_string(),
)
.into())
}
fn normalize_membership_updates(
updates: Vec<ScreenshotGroupMembershipUpdate>,
) -> crate::Result<Vec<ScreenshotGroupMembershipUpdate>> {
let mut screenshot_ids = HashSet::new();
if updates
.iter()
.all(|update| screenshot_ids.insert(update.screenshot_id.clone()))
{
return Ok(updates);
}
Err(crate::ErrorKind::InputError(
"Screenshot membership update contains duplicate screenshots"
.to_string(),
)
.into())
}
async fn set_group_members(
group_id: &str,
screenshot_ids: Vec<String>,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<HashSet<String>> {
let mut instance_ids = HashSet::new();
for screenshot_id in screenshot_ids {
instance_ids.insert(screenshot_instance_id(&screenshot_id, tx).await?);
sqlx::query!(
"
INSERT INTO screenshot_group_memberships (screenshot_id, group_id)
VALUES (?, ?)
ON CONFLICT(screenshot_id) DO UPDATE SET group_id = excluded.group_id
",
screenshot_id,
group_id,
)
.execute(&mut **tx)
.await?;
}
Ok(instance_ids)
}
async fn ensure_group_exists(
group_id: &str,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
let exists = sqlx::query_scalar!(
r#"SELECT EXISTS(SELECT 1 FROM screenshot_groups WHERE id = ?) AS "exists!: bool""#,
group_id,
)
.fetch_one(&mut **tx)
.await?;
if !exists {
return Err(crate::ErrorKind::InputError(format!(
"Unknown screenshot group {group_id}"
))
.into());
}
Ok(())
}
async fn screenshot_instance_id(
screenshot_id: &str,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<String> {
sqlx::query_scalar!(
"SELECT instance_id FROM screenshots WHERE id = ?",
screenshot_id,
)
.fetch_optional(&mut **tx)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unknown screenshot {screenshot_id}"
))
})
.map_err(Into::into)
}
async fn emit_screenshot_updates(
instance_ids: impl IntoIterator<Item = String>,
) {
for instance_id in instance_ids {
let _ = emit_instance(
&instance_id,
InstancePayloadType::ScreenshotsUpdated,
)
.await;
}
}
@@ -0,0 +1,10 @@
mod operations;
mod reconciliation;
pub use self::operations::{
InstanceScreenshot, ScreenshotEditSaveMode, ScreenshotKey,
delete_screenshots, export_screenshots, get_screenshot_path,
list_all_screenshots, list_screenshots, list_synced_screenshots,
move_screenshots, save_edited_screenshot,
};
pub(crate) use self::reconciliation::reconcile_screenshots;
@@ -0,0 +1,650 @@
use chrono::{DateTime, Utc};
use futures::stream::{self, StreamExt, TryStreamExt};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::io::Cursor;
use std::path::{Component, Path, PathBuf};
use tokio::sync::OwnedMutexGuard;
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipWriter};
use super::reconciliation::{
list_source_screenshots, reconcile_source_screenshots,
scan_source_screenshots,
};
use crate::State;
use crate::event::InstancePayloadType;
use crate::event::emit::emit_instance;
use crate::state::instances::adapters::sqlite::{
instance_rows::{self, InstanceScreenshotSource},
screenshot_rows,
};
use crate::util::fetch::sha1_file_async;
use crate::util::io::{self, IOError};
const SCREENSHOTS_DIRECTORY: &str = "screenshots";
const SCREENSHOT_SCAN_CONCURRENCY: usize = 8;
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct ScreenshotKey {
pub instance_id: String,
pub file_name: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct InstanceScreenshot {
pub id: String,
pub instance_id: String,
pub instance_name: String,
pub file_name: String,
pub created_at: DateTime<Utc>,
pub modified_at: i64,
pub group_id: Option<String>,
#[serde(skip)]
pub path: PathBuf,
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScreenshotEditSaveMode {
CreateCopy,
ReplaceEdit,
}
pub async fn list_screenshots(
instance_id: &str,
) -> crate::Result<Vec<InstanceScreenshot>> {
let state = State::get().await?;
let source =
instance_rows::get_instance_screenshot_source(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
list_source_screenshots(&state, source).await
}
pub async fn list_synced_screenshots() -> crate::Result<Vec<InstanceScreenshot>>
{
list_all_screenshots().await
}
pub async fn list_all_screenshots() -> crate::Result<Vec<InstanceScreenshot>> {
let state = State::get().await?;
let sources = instance_rows::list_screenshot_sources(&state.pool).await?;
list_source_screenshot_sets(&state, sources).await
}
async fn list_source_screenshot_sets(
state: &State,
sources: Vec<InstanceScreenshotSource>,
) -> crate::Result<Vec<InstanceScreenshot>> {
let mut screenshots =
stream::iter(sources.into_iter().map(|source| async move {
list_source_screenshots(state, source).await
}))
.buffer_unordered(SCREENSHOT_SCAN_CONCURRENCY)
.try_collect::<Vec<Vec<InstanceScreenshot>>>()
.await?
.into_iter()
.flatten()
.collect::<Vec<_>>();
sort_screenshots(&mut screenshots);
Ok(screenshots)
}
async fn lock_instance_screenshots<'a>(
state: &State,
instance_ids: impl IntoIterator<Item = &'a str>,
) -> Vec<OwnedMutexGuard<()>> {
let mut locks = Vec::new();
for instance_id in instance_ids {
locks.push(state.lock_instance_screenshots(instance_id).await);
}
locks
}
pub async fn delete_screenshots(keys: &[ScreenshotKey]) -> crate::Result<()> {
ensure_unique_keys(keys)?;
let state = State::get().await?;
let mut instance_ids = keys
.iter()
.map(|key| key.instance_id.as_str())
.collect::<Vec<_>>();
instance_ids.sort_unstable();
instance_ids.dedup();
let _locks = lock_instance_screenshots(&state, instance_ids).await;
for key in keys {
io::remove_file(get_screenshot_path(key).await?).await?;
screenshot_rows::delete_screenshot_by_key(
&key.instance_id,
&key.file_name,
&state.pool,
)
.await?;
}
Ok(())
}
pub async fn export_screenshots(
keys: &[ScreenshotKey],
export_path: PathBuf,
) -> crate::Result<()> {
ensure_unique_keys(keys)?;
if keys.is_empty() {
return Err(crate::ErrorKind::InputError(
"At least one screenshot must be selected".to_string(),
)
.into());
}
let state = State::get().await?;
let mut sources = HashMap::new();
let mut archive_folders = HashMap::<String, String>::new();
let mut used_archive_folders = HashSet::new();
let mut screenshots = Vec::with_capacity(keys.len());
for key in keys {
let source = match sources.get(&key.instance_id) {
Some(source) => source,
None => {
let source = instance_rows::get_instance_screenshot_source(
&key.instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
sources.insert(key.instance_id.clone(), source);
&sources[&key.instance_id]
}
};
let archive_folder = match archive_folders.get(&key.instance_id) {
Some(folder) => folder.clone(),
None => {
let folder = unique_archive_folder(
&sanitize_archive_component(&source.name),
&mut used_archive_folders,
);
archive_folders.insert(key.instance_id.clone(), folder.clone());
folder
}
};
let archive_name = format!("{archive_folder}/{}", key.file_name);
screenshots.push((archive_name, get_screenshot_path(key).await?));
}
tokio::task::spawn_blocking(move || -> crate::Result<()> {
let file = std::fs::File::create(&export_path)
.map_err(|error| IOError::with_path(error, &export_path))?;
let mut writer = ZipWriter::new(file);
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Stored);
for (archive_name, path) in screenshots {
writer
.start_file(archive_name, options)
.map_err(std::io::Error::from)?;
let mut source = std::fs::File::open(&path)
.map_err(|error| IOError::with_path(error, &path))?;
std::io::copy(&mut source, &mut writer).map_err(IOError::from)?;
}
writer.finish().map_err(std::io::Error::from)?;
Ok(())
})
.await??;
Ok(())
}
pub async fn move_screenshots(
keys: &[ScreenshotKey],
target_instance_id: &str,
) -> crate::Result<Vec<ScreenshotKey>> {
ensure_unique_keys(keys)?;
if keys.is_empty() {
return Ok(Vec::new());
}
let state = State::get().await?;
let mut instance_ids = keys
.iter()
.map(|key| key.instance_id.as_str())
.chain(std::iter::once(target_instance_id))
.collect::<Vec<_>>();
instance_ids.sort_unstable();
instance_ids.dedup();
let _locks = lock_instance_screenshots(&state, instance_ids).await;
let target_source = instance_rows::get_instance_screenshot_source(
target_instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown target instance".to_string())
})?;
let target_dir = source_screenshots_dir(&state, &target_source).await?;
io::create_dir_all(&target_dir).await?;
ensure_directory_is_not_symlink(&target_dir).await?;
let mut moved_keys = Vec::with_capacity(keys.len());
for key in keys {
if key.instance_id == target_instance_id {
continue;
}
let source_path = get_screenshot_path(key).await?;
let target_path =
available_target_path(&target_dir, &key.file_name).await?;
io::rename_or_move(&source_path, &target_path)
.await
.map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not move screenshot: {error}"
))
})?;
let file_name = target_path
.file_name()
.and_then(|file_name| file_name.to_str())
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Could not determine moved screenshot file name"
.to_string(),
)
})?
.to_string();
screenshot_rows::move_screenshot(
&key.instance_id,
&key.file_name,
target_instance_id,
&file_name,
&state.pool,
)
.await?;
moved_keys.push(ScreenshotKey {
instance_id: target_instance_id.to_string(),
file_name,
});
}
Ok(moved_keys)
}
pub async fn get_screenshot_path(
key: &ScreenshotKey,
) -> crate::Result<PathBuf> {
validate_file_name(&key.file_name)?;
let state = State::get().await?;
let source = instance_rows::get_instance_screenshot_source(
&key.instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let screenshots_dir = source_screenshots_dir(&state, &source).await?;
let canonical_dir = tokio::fs::canonicalize(&screenshots_dir)
.await
.map_err(|error| IOError::with_path(error, &screenshots_dir))?;
let requested_path = screenshots_dir.join(&key.file_name);
let metadata = tokio::fs::symlink_metadata(&requested_path)
.await
.map_err(|error| IOError::with_path(error, &requested_path))?;
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err(crate::ErrorKind::InputError(
"Screenshot must be a regular file".to_string(),
)
.into());
}
let canonical_path = tokio::fs::canonicalize(&requested_path)
.await
.map_err(|error| IOError::with_path(error, &requested_path))?;
if !canonical_path.starts_with(&canonical_dir) {
return Err(crate::ErrorKind::InputError(
"Screenshot path is outside the instance screenshots directory"
.to_string(),
)
.into());
}
Ok(canonical_path)
}
pub async fn save_edited_screenshot(
key: ScreenshotKey,
png_bytes: Vec<u8>,
mode: ScreenshotEditSaveMode,
) -> crate::Result<InstanceScreenshot> {
validate_file_name(&key.file_name)?;
let state = State::get().await?;
let source = instance_rows::get_instance_screenshot_source(
&key.instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let _lock = state.lock_instance_screenshots(&source.id).await;
let scanned = scan_source_screenshots(&state, &source).await?;
let current =
reconcile_source_screenshots(&state, &source, scanned).await?;
let source_screenshot = current
.iter()
.find(|screenshot| screenshot.file_name == key.file_name)
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown screenshot".to_string())
})?;
let source_row = screenshot_rows::get_screenshot_by_key(
&key.instance_id,
&key.file_name,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown screenshot".to_string())
})?;
let png_bytes = tokio::task::spawn_blocking(move || {
validate_png_dimensions(&png_bytes)?;
Ok::<_, crate::Error>(png_bytes)
})
.await??;
let screenshots_dir = source_screenshots_dir(&state, &source).await?;
let (target_path, copy_group) = match mode {
ScreenshotEditSaveMode::CreateCopy => (
available_target_path(&screenshots_dir, &source_row.file_name)
.await?,
true,
),
ScreenshotEditSaveMode::ReplaceEdit => {
(source_screenshot.path.clone(), false)
}
};
let target_file_name = target_path
.file_name()
.and_then(|file_name| file_name.to_str())
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Could not determine edited screenshot file name".to_string(),
)
})?
.to_string();
io::write(&target_path, png_bytes).await?;
if !copy_group {
let (file_size, content_hash) = sha1_file_async(&target_path).await?;
let file_size = i64::try_from(file_size).map_err(|_| {
crate::ErrorKind::InputError(
"Screenshot is too large to index".to_string(),
)
})?;
let mut source_row = source_row.clone();
source_row.content_hash = content_hash;
source_row.file_size = file_size;
let mut tx = state.pool.begin().await?;
screenshot_rows::update_screenshot(&source_row, &mut tx).await?;
tx.commit().await?;
}
let scanned = scan_source_screenshots(&state, &source).await?;
let reconciled =
reconcile_source_screenshots(&state, &source, scanned).await?;
let mut saved = reconciled
.into_iter()
.find(|screenshot| screenshot.file_name == target_file_name)
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Could not index edited screenshot".to_string(),
)
})?;
if copy_group {
let result: crate::Result<()> = async {
let mut tx = state.pool.begin().await?;
screenshot_rows::copy_group_membership(
&source_row.id,
&saved.id,
&mut tx,
)
.await?;
tx.commit().await?;
Ok(())
}
.await;
if let Err(error) = result {
let _ = io::remove_file(&target_path).await;
let _ = screenshot_rows::delete_screenshot_by_key(
&source.id,
&target_file_name,
&state.pool,
)
.await;
return Err(error);
}
}
let mut saved_row = screenshot_rows::get_screenshot_by_key(
&saved.instance_id,
&saved.file_name,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Could not load edited screenshot".to_string(),
)
})?;
if !copy_group {
let mut tx = state.pool.begin().await?;
if saved_row.created_at != source_row.created_at {
saved_row.created_at = source_row.created_at;
screenshot_rows::update_screenshot(&saved_row, &mut tx).await?;
saved.created_at =
DateTime::from_timestamp_millis(source_row.created_at)
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Screenshot creation time is out of range"
.to_string(),
)
})?;
}
tx.commit().await?;
}
saved.modified_at = saved_row.modified_at;
saved.group_id = saved_row.group_id;
let _ = emit_instance(
&saved.instance_id,
InstancePayloadType::ScreenshotsUpdated,
)
.await;
Ok(saved)
}
pub(super) async fn source_screenshots_dir(
state: &State,
source: &InstanceScreenshotSource,
) -> crate::Result<PathBuf> {
// Directly associated instances keep their files in the external launcher's
// resolved game directory (which may be the shared `.minecraft` root or an
// isolated version directory). Use the same runtime resolver as launching
// and content browsing so screenshots are read from the directory the game
// actually uses.
let instance_dir =
match instance_rows::get_instance_by_id(&source.id, &state.pool).await?
{
Some(instance) => crate::launcher::linked_game_dir(&instance)
.unwrap_or_else(|| {
state.directories.instances_dir().join(&source.path)
}),
None => state.directories.instances_dir().join(&source.path),
};
let canonical_instance_dir =
tokio::fs::canonicalize(&instance_dir)
.await
.map_err(|error| IOError::with_path(error, &instance_dir))?;
let screenshots_dir = canonical_instance_dir.join(SCREENSHOTS_DIRECTORY);
ensure_directory_is_not_symlink(&screenshots_dir).await?;
Ok(screenshots_dir)
}
async fn ensure_directory_is_not_symlink(path: &Path) -> crate::Result<()> {
match tokio::fs::symlink_metadata(path).await {
Ok(metadata) if metadata.file_type().is_symlink() => {
Err(crate::ErrorKind::InputError(
"Instance screenshots directory cannot be a symbolic link"
.to_string(),
)
.into())
}
Ok(metadata) if !metadata.is_dir() => {
Err(crate::ErrorKind::InputError(
"Instance screenshots path must be a directory".to_string(),
)
.into())
}
Ok(_) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(IOError::with_path(error, path).into()),
}
}
async fn available_target_path(
target_dir: &Path,
file_name: &str,
) -> crate::Result<PathBuf> {
validate_file_name(file_name)?;
let original = target_dir.join(file_name);
if !tokio::fs::try_exists(&original)
.await
.map_err(|error| IOError::with_path(error, &original))?
{
return Ok(original);
}
let path = Path::new(file_name);
let stem = path.file_stem().and_then(|value| value.to_str()).unwrap();
let extension = path.extension().and_then(|value| value.to_str()).unwrap();
for suffix in 1_u32.. {
let candidate =
target_dir.join(format!("{stem} ({suffix}).{extension}"));
if !tokio::fs::try_exists(&candidate)
.await
.map_err(|error| IOError::with_path(error, &candidate))?
{
return Ok(candidate);
}
}
unreachable!()
}
fn validate_png_dimensions(bytes: &[u8]) -> crate::Result<(u32, u32)> {
let validate = || -> Result<_, png::DecodingError> {
let mut reader = png::Decoder::new(Cursor::new(bytes)).read_info()?;
let dimensions = (reader.info().width, reader.info().height);
while reader.next_row()?.is_some() {}
reader.finish()?;
Ok(dimensions)
};
validate().map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not decode screenshot as PNG: {error}"
))
.into()
})
}
fn ensure_unique_keys(keys: &[ScreenshotKey]) -> crate::Result<()> {
let mut unique = HashSet::new();
if keys.iter().all(|key| unique.insert(key)) {
return Ok(());
}
Err(crate::ErrorKind::InputError(
"Screenshot selection contains duplicates".to_string(),
)
.into())
}
fn validate_file_name(file_name: &str) -> crate::Result<()> {
let path = Path::new(file_name);
let mut components = path.components();
let is_single_file =
matches!(components.next(), Some(Component::Normal(_)))
&& components.next().is_none();
if !is_single_file || !has_png_extension(path) {
return Err(crate::ErrorKind::InputError(
"Invalid screenshot file name".to_string(),
)
.into());
}
Ok(())
}
pub(super) fn has_png_extension(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("png"))
}
pub(super) fn sort_screenshots(screenshots: &mut [InstanceScreenshot]) {
screenshots.sort_by(|left, right| {
right
.created_at
.cmp(&left.created_at)
.then_with(|| left.instance_name.cmp(&right.instance_name))
.then_with(|| left.file_name.cmp(&right.file_name))
});
}
fn sanitize_archive_component(value: &str) -> String {
let sanitized = value
.chars()
.map(|character| match character {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => character,
})
.collect::<String>();
if sanitized.is_empty() || sanitized == "." || sanitized == ".." {
"Instance".to_string()
} else {
sanitized
}
}
fn unique_archive_folder(
base: &str,
used_folders: &mut HashSet<String>,
) -> String {
if used_folders.insert(base.to_string()) {
return base.to_string();
}
for suffix in 2_u32.. {
let candidate = format!("{base} ({suffix})");
if used_folders.insert(candidate.clone()) {
return candidate;
}
}
unreachable!()
}
@@ -0,0 +1,303 @@
use super::operations::{
InstanceScreenshot, has_png_extension, sort_screenshots,
source_screenshots_dir,
};
use crate::State;
use crate::state::instances::adapters::sqlite::{
instance_rows::{self, InstanceScreenshotSource},
screenshot_rows::{self, ScreenshotRow},
};
use crate::util::fetch::sha1_file_async;
use crate::util::io::IOError;
use chrono::{DateTime, Utc};
use futures::stream::{self, StreamExt, TryStreamExt};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use uuid::Uuid;
const SCREENSHOT_HASH_CONCURRENCY: usize = 8;
pub(super) struct ScannedScreenshot {
file_name: String,
created_at: DateTime<Utc>,
modified_at: i64,
file_size: i64,
path: PathBuf,
}
struct ResolvedScreenshot {
scanned: ScannedScreenshot,
row: ScreenshotRow,
is_new: bool,
changed: bool,
}
fn resolve_scanned_screenshot(
source: &InstanceScreenshotSource,
scanned: ScannedScreenshot,
content_hash: String,
matched: Option<ScreenshotRow>,
) -> ResolvedScreenshot {
let (mut row, is_new) = match matched {
Some(row) => (row, false),
None => (
ScreenshotRow {
id: Uuid::new_v4().to_string(),
instance_id: source.id.clone(),
file_name: scanned.file_name.clone(),
content_hash: content_hash.clone(),
file_size: scanned.file_size,
modified_at: scanned.modified_at,
created_at: scanned.created_at.timestamp_millis(),
group_id: None,
},
true,
),
};
let changed = !is_new
&& (row.file_name != scanned.file_name
|| row.content_hash != content_hash
|| row.file_size != scanned.file_size
|| row.modified_at != scanned.modified_at
|| row.created_at != scanned.created_at.timestamp_millis());
row.instance_id.clone_from(&source.id);
row.file_name.clone_from(&scanned.file_name);
row.content_hash = content_hash;
row.file_size = scanned.file_size;
row.modified_at = scanned.modified_at;
row.created_at = scanned.created_at.timestamp_millis();
ResolvedScreenshot {
scanned,
row,
is_new,
changed,
}
}
pub(super) async fn list_source_screenshots(
state: &State,
source: InstanceScreenshotSource,
) -> crate::Result<Vec<InstanceScreenshot>> {
let _lock = state.lock_instance_screenshots(&source.id).await;
let scanned = scan_source_screenshots(state, &source).await?;
reconcile_source_screenshots(state, &source, scanned).await
}
#[allow(dead_code)]
pub(crate) async fn reconcile_screenshots(
instance_id: &str,
) -> crate::Result<()> {
let state = State::get().await?;
let Some(source) =
instance_rows::get_instance_screenshot_source(instance_id, &state.pool)
.await?
else {
return Ok(());
};
list_source_screenshots(&state, source).await?;
Ok(())
}
pub(super) async fn scan_source_screenshots(
state: &State,
source: &InstanceScreenshotSource,
) -> crate::Result<Vec<ScannedScreenshot>> {
let screenshots_dir = source_screenshots_dir(state, source).await?;
tokio::task::spawn_blocking(move || scan_screenshots_dir(&screenshots_dir))
.await?
}
fn scan_screenshots_dir(
screenshots_dir: &Path,
) -> crate::Result<Vec<ScannedScreenshot>> {
let entries = match std::fs::read_dir(screenshots_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Vec::new());
}
Err(error) => return Err(error.into()),
};
let mut screenshots = Vec::new();
for entry in entries {
let entry = entry
.map_err(|error| IOError::with_path(error, screenshots_dir))?;
let file_type = entry
.file_type()
.map_err(|error| IOError::with_path(error, entry.path()))?;
if !file_type.is_file() {
continue;
}
let path = entry.path();
if !has_png_extension(&path) {
continue;
}
let Some(file_name) = entry.file_name().to_str().map(str::to_owned)
else {
continue;
};
let metadata = entry
.metadata()
.map_err(|error| IOError::with_path(error, &path))?;
let created_at = metadata
.created()
.or_else(|_| metadata.modified())
.map(DateTime::<Utc>::from)
.map_err(|error| IOError::with_path(error, &path))?;
let modified_at = metadata
.modified()
.map(DateTime::<Utc>::from)
.map(|value| value.timestamp_millis())
.map_err(|error| IOError::with_path(error, &path))?;
let file_size = i64::try_from(metadata.len()).map_err(|_| {
crate::ErrorKind::InputError(
"Screenshot is too large to index".to_string(),
)
})?;
screenshots.push(ScannedScreenshot {
file_name,
created_at,
modified_at,
file_size,
path,
});
}
Ok(screenshots)
}
pub(super) async fn reconcile_source_screenshots(
state: &State,
source: &InstanceScreenshotSource,
scanned: Vec<ScannedScreenshot>,
) -> crate::Result<Vec<InstanceScreenshot>> {
let existing =
screenshot_rows::list_screenshots(&source.id, &state.pool).await?;
let mut unmatched_by_name = existing
.into_iter()
.map(|row| (row.file_name.clone(), row))
.collect::<HashMap<_, _>>();
let mut resolved = Vec::with_capacity(scanned.len());
let mut needs_hash = Vec::new();
for scanned in scanned {
match unmatched_by_name.remove(&scanned.file_name) {
Some(row)
if row.file_size == scanned.file_size
&& row.modified_at == scanned.modified_at
&& row.created_at
== scanned.created_at.timestamp_millis() =>
{
resolved.push(ResolvedScreenshot {
scanned,
row,
is_new: false,
changed: false,
});
}
matched => needs_hash.push((scanned, matched)),
}
}
let hashed = stream::iter(needs_hash.into_iter().map(
|(mut scanned, matched)| async move {
let (file_size, content_hash) =
sha1_file_async(&scanned.path).await?;
scanned.file_size = i64::try_from(file_size).map_err(|_| {
crate::ErrorKind::InputError(
"Screenshot is too large to index".to_string(),
)
})?;
Ok::<_, crate::Error>((scanned, content_hash, matched))
},
))
.buffer_unordered(SCREENSHOT_HASH_CONCURRENCY)
.try_collect::<Vec<_>>()
.await?;
let mut unmatched_by_hash =
HashMap::<(String, i64), Vec<ScreenshotRow>>::new();
for row in unmatched_by_name.into_values() {
unmatched_by_hash
.entry((row.content_hash.clone(), row.file_size))
.or_default()
.push(row);
}
for (scanned, content_hash, matched_by_name) in hashed {
if let Some(row) = matched_by_name {
resolved.push(resolve_scanned_screenshot(
source,
scanned,
content_hash,
Some(row),
));
continue;
}
let hash_key = (content_hash.clone(), scanned.file_size);
let matched = unmatched_by_hash.get_mut(&hash_key).and_then(|rows| {
if rows.is_empty() {
return None;
}
let created_at = scanned.created_at.timestamp_millis();
let index = rows
.iter()
.position(|row| {
row.modified_at == scanned.modified_at
&& row.created_at == created_at
})
.unwrap_or(rows.len() - 1);
Some(rows.swap_remove(index))
});
resolved.push(resolve_scanned_screenshot(
source,
scanned,
content_hash,
matched,
));
}
let mut tx = state.pool.begin().await?;
for row in unmatched_by_hash.into_values().flatten() {
screenshot_rows::delete_screenshot(&row.id, &mut tx).await?;
}
for screenshot in &resolved {
if !screenshot.is_new && screenshot.changed {
let mut row = screenshot.row.clone();
row.file_name = Uuid::new_v4().to_string();
screenshot_rows::update_screenshot(&row, &mut tx).await?;
}
}
for screenshot in &resolved {
if screenshot.is_new {
screenshot_rows::insert_screenshot(&screenshot.row, &mut tx)
.await?;
} else if screenshot.changed {
screenshot_rows::update_screenshot(&screenshot.row, &mut tx)
.await?;
}
}
tx.commit().await?;
let mut screenshots = resolved
.into_iter()
.map(|screenshot| InstanceScreenshot {
id: screenshot.row.id,
instance_id: source.id.clone(),
instance_name: source.name.clone(),
file_name: screenshot.scanned.file_name,
created_at: screenshot.scanned.created_at,
modified_at: screenshot.row.modified_at,
group_id: screenshot.row.group_id,
path: screenshot.scanned.path,
})
.collect::<Vec<_>>();
sort_screenshots(&mut screenshots);
Ok(screenshots)
}
+1
View File
@@ -360,6 +360,7 @@ pub enum InstancePayloadType {
revision: u64, revision: u64,
}, },
ServersUpdated, ServersUpdated,
ScreenshotsUpdated,
WorldUpdated { WorldUpdated {
world: String, world: String,
}, },
@@ -12,6 +12,34 @@ use chrono::{DateTime, TimeZone, Utc};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use sqlx::{Executor, Sqlite, SqlitePool, Transaction}; use sqlx::{Executor, Sqlite, SqlitePool, Transaction};
use uuid::Uuid; use uuid::Uuid;
#[derive(Clone, Debug, sqlx::FromRow)]
pub(crate) struct InstanceScreenshotSource {
pub id: String,
pub name: String,
pub path: String,
}
pub(crate) async fn get_instance_screenshot_source(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<Option<InstanceScreenshotSource>> {
Ok(sqlx::query_as::<_, InstanceScreenshotSource>(
"SELECT id, name, path FROM instances WHERE id = ?",
)
.bind(instance_id)
.fetch_optional(pool)
.await?)
}
pub(crate) async fn list_screenshot_sources(
pool: &SqlitePool,
) -> crate::Result<Vec<InstanceScreenshotSource>> {
Ok(sqlx::query_as::<_, InstanceScreenshotSource>(
"SELECT id, name, path FROM instances ORDER BY name, id",
)
.fetch_all(pool)
.await?)
}
#[derive(Debug, sqlx::FromRow)] #[derive(Debug, sqlx::FromRow)]
pub(crate) struct InstanceRow { pub(crate) struct InstanceRow {
@@ -2,3 +2,4 @@ pub(crate) mod config_sync_rows;
pub(crate) mod content_rows; pub(crate) mod content_rows;
pub(crate) mod instance_rows; pub(crate) mod instance_rows;
pub(crate) mod loader_component_rows; pub(crate) mod loader_component_rows;
pub(crate) mod screenshot_rows;
@@ -0,0 +1,236 @@
use sqlx::{Sqlite, SqlitePool, Transaction};
#[derive(Clone, Debug, sqlx::FromRow)]
pub(crate) struct ScreenshotRow {
pub id: String,
pub instance_id: String,
pub file_name: String,
pub content_hash: String,
pub file_size: i64,
pub modified_at: i64,
pub created_at: i64,
pub group_id: Option<String>,
}
#[derive(Clone, Debug, sqlx::FromRow)]
pub(crate) struct ScreenshotGroupRow {
pub id: String,
pub name: String,
}
pub(crate) async fn list_screenshots(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<Vec<ScreenshotRow>> {
Ok(sqlx::query_as!(
ScreenshotRow,
r#"
SELECT
screenshots.id,
screenshots.instance_id,
screenshots.file_name,
screenshots.content_hash,
screenshots.file_size,
screenshots.modified_at,
screenshots.created_at,
memberships.group_id AS "group_id?"
FROM screenshots
LEFT JOIN screenshot_group_memberships memberships
ON memberships.screenshot_id = screenshots.id
WHERE screenshots.instance_id = ?
ORDER BY screenshots.file_name, screenshots.id
"#,
instance_id,
)
.fetch_all(pool)
.await?)
}
pub(crate) async fn get_screenshot_by_key(
instance_id: &str,
file_name: &str,
pool: &SqlitePool,
) -> crate::Result<Option<ScreenshotRow>> {
Ok(sqlx::query_as!(
ScreenshotRow,
r#"
SELECT
screenshots.id,
screenshots.instance_id,
screenshots.file_name,
screenshots.content_hash,
screenshots.file_size,
screenshots.modified_at,
screenshots.created_at,
memberships.group_id AS "group_id?"
FROM screenshots
LEFT JOIN screenshot_group_memberships memberships
ON memberships.screenshot_id = screenshots.id
WHERE screenshots.instance_id = ? AND screenshots.file_name = ?
"#,
instance_id,
file_name,
)
.fetch_optional(pool)
.await?)
}
pub(crate) async fn copy_group_membership(
source_id: &str,
edited_id: &str,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
sqlx::query!(
r#"
INSERT INTO screenshot_group_memberships (screenshot_id, group_id)
SELECT ?, group_id
FROM screenshot_group_memberships
WHERE screenshot_id = ?
ON CONFLICT (screenshot_id) DO UPDATE SET group_id = excluded.group_id
"#,
edited_id,
source_id,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn insert_screenshot(
row: &ScreenshotRow,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
sqlx::query!(
"
INSERT INTO screenshots (
id,
instance_id,
file_name,
content_hash,
file_size,
modified_at,
created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
",
row.id,
row.instance_id,
row.file_name,
row.content_hash,
row.file_size,
row.modified_at,
row.created_at,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn update_screenshot(
row: &ScreenshotRow,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
sqlx::query!(
"
UPDATE screenshots
SET
instance_id = ?,
file_name = ?,
content_hash = ?,
file_size = ?,
modified_at = ?,
created_at = ?
WHERE id = ?
",
row.instance_id,
row.file_name,
row.content_hash,
row.file_size,
row.modified_at,
row.created_at,
row.id,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn delete_screenshot(
id: &str,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
sqlx::query!("DELETE FROM screenshots WHERE id = ?", id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn move_screenshot(
source_instance_id: &str,
source_file_name: &str,
target_instance_id: &str,
target_file_name: &str,
pool: &SqlitePool,
) -> crate::Result<()> {
let mut tx = pool.begin().await?;
sqlx::query!(
"DELETE FROM screenshots WHERE instance_id = ? AND file_name = ?",
target_instance_id,
target_file_name,
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"
UPDATE screenshots
SET instance_id = ?, file_name = ?
WHERE instance_id = ? AND file_name = ?
",
target_instance_id,
target_file_name,
source_instance_id,
source_file_name,
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub(crate) async fn delete_screenshot_by_key(
instance_id: &str,
file_name: &str,
pool: &SqlitePool,
) -> crate::Result<()> {
sqlx::query!(
"DELETE FROM screenshots WHERE instance_id = ? AND file_name = ?",
instance_id,
file_name,
)
.execute(pool)
.await?;
Ok(())
}
pub(crate) async fn list_groups(
pool: &SqlitePool,
) -> crate::Result<Vec<ScreenshotGroupRow>> {
Ok(sqlx::query_as!(
ScreenshotGroupRow,
"
SELECT id, name
FROM screenshot_groups
ORDER BY display_order, name, id
",
)
.fetch_all(pool)
.await?)
}
+15
View File
@@ -120,6 +120,7 @@ pub struct State {
pub(crate) install_job_cancellations: DashMap<Uuid, CancellationToken>, pub(crate) install_job_cancellations: DashMap<Uuid, CancellationToken>,
pub(crate) install_job_operation_locks: pub(crate) install_job_operation_locks:
DashMap<Uuid, Arc<AsyncMutex<InstallJobOperationState>>>, DashMap<Uuid, Arc<AsyncMutex<InstallJobOperationState>>>,
pub(crate) screenshot_locks: DashMap<String, Arc<AsyncMutex<()>>>,
/// Process manager /// Process manager
pub process_manager: ProcessManager, pub process_manager: ProcessManager,
@@ -904,6 +905,7 @@ impl State {
install_db_semaphore: Semaphore::new(1), install_db_semaphore: Semaphore::new(1),
install_job_cancellations: DashMap::new(), install_job_cancellations: DashMap::new(),
install_job_operation_locks: DashMap::new(), install_job_operation_locks: DashMap::new(),
screenshot_locks: DashMap::new(),
process_manager, process_manager,
friends_socket, friends_socket,
restart_after_pending_update: AtomicBool::new(false), restart_after_pending_update: AtomicBool::new(false),
@@ -931,6 +933,18 @@ impl State {
) -> InstanceLockGuard { ) -> InstanceLockGuard {
self.instance_locks.lock_exclusive(instance_id).await self.instance_locks.lock_exclusive(instance_id).await
} }
pub(crate) async fn lock_instance_screenshots(
&self,
instance_id: &str,
) -> tokio::sync::OwnedMutexGuard<()> {
self.screenshot_locks
.entry(instance_id.to_string())
.or_insert_with(|| Arc::new(AsyncMutex::new(())))
.clone()
.lock_owned()
.await
}
} }
#[cfg(test)] #[cfg(test)]
@@ -971,6 +985,7 @@ pub(crate) async fn test_state(
install_db_semaphore: Semaphore::new(1), install_db_semaphore: Semaphore::new(1),
install_job_cancellations: DashMap::new(), install_job_cancellations: DashMap::new(),
install_job_operation_locks: DashMap::new(), install_job_operation_locks: DashMap::new(),
screenshot_locks: DashMap::new(),
process_manager: ProcessManager::new(), process_manager: ProcessManager::new(),
friends_socket: FriendsSocket::new(), friends_socket: FriendsSocket::new(),
restart_after_pending_update: AtomicBool::new(false), restart_after_pending_update: AtomicBool::new(false),
+16
View File
@@ -79,6 +79,7 @@ import _ChevronDownIcon from './icons/chevron-down.svg?component'
import _ChevronLeftIcon from './icons/chevron-left.svg?component' import _ChevronLeftIcon from './icons/chevron-left.svg?component'
import _ChevronRightIcon from './icons/chevron-right.svg?component' import _ChevronRightIcon from './icons/chevron-right.svg?component'
import _ChevronUpIcon from './icons/chevron-up.svg?component' import _ChevronUpIcon from './icons/chevron-up.svg?component'
import _CircleIcon from './icons/circle.svg?component'
import _CircleAlertIcon from './icons/circle-alert.svg?component' import _CircleAlertIcon from './icons/circle-alert.svg?component'
import _CircleArrowRightIcon from './icons/circle-arrow-right.svg?component' import _CircleArrowRightIcon from './icons/circle-arrow-right.svg?component'
import _CircleDashedIcon from './icons/circle-dashed.svg?component' import _CircleDashedIcon from './icons/circle-dashed.svg?component'
@@ -103,6 +104,7 @@ import _CopyIcon from './icons/copy.svg?component'
import _CopyrightIcon from './icons/copyright.svg?component' import _CopyrightIcon from './icons/copyright.svg?component'
import _CpuIcon from './icons/cpu.svg?component' import _CpuIcon from './icons/cpu.svg?component'
import _CreateIcon from './icons/create.svg?component' import _CreateIcon from './icons/create.svg?component'
import _CropIcon from './icons/crop.svg?component'
import _CrownIcon from './icons/crown.svg?component' import _CrownIcon from './icons/crown.svg?component'
import _CubeIcon from './icons/cube.svg?component' import _CubeIcon from './icons/cube.svg?component'
import _CurrencyIcon from './icons/currency.svg?component' import _CurrencyIcon from './icons/currency.svg?component'
@@ -113,6 +115,7 @@ import _DownloadIcon from './icons/download.svg?component'
import _DropdownIcon from './icons/dropdown.svg?component' import _DropdownIcon from './icons/dropdown.svg?component'
import _EditIcon from './icons/edit.svg?component' import _EditIcon from './icons/edit.svg?component'
import _EllipsisVerticalIcon from './icons/ellipsis-vertical.svg?component' import _EllipsisVerticalIcon from './icons/ellipsis-vertical.svg?component'
import _EraserIcon from './icons/eraser.svg?component'
import _ExpandIcon from './icons/expand.svg?component' import _ExpandIcon from './icons/expand.svg?component'
import _ExternalIcon from './icons/external.svg?component' import _ExternalIcon from './icons/external.svg?component'
import _EyeIcon from './icons/eye.svg?component' import _EyeIcon from './icons/eye.svg?component'
@@ -155,6 +158,7 @@ import _HeartIcon from './icons/heart.svg?component'
import _HeartHandshakeIcon from './icons/heart-handshake.svg?component' import _HeartHandshakeIcon from './icons/heart-handshake.svg?component'
import _HeartMinusIcon from './icons/heart-minus.svg?component' import _HeartMinusIcon from './icons/heart-minus.svg?component'
import _HelpCircleIcon from './icons/help-circle.svg?component' import _HelpCircleIcon from './icons/help-circle.svg?component'
import _HighlighterIcon from './icons/highlighter.svg?component'
import _HistoryIcon from './icons/history.svg?component' import _HistoryIcon from './icons/history.svg?component'
import _HomeIcon from './icons/home.svg?component' import _HomeIcon from './icons/home.svg?component'
import _ImageIcon from './icons/image.svg?component' import _ImageIcon from './icons/image.svg?component'
@@ -200,7 +204,9 @@ import _MonitorSmartphoneIcon from './icons/monitor-smartphone.svg?component'
import _MoonIcon from './icons/moon.svg?component' import _MoonIcon from './icons/moon.svg?component'
import _MoreHorizontalIcon from './icons/more-horizontal.svg?component' import _MoreHorizontalIcon from './icons/more-horizontal.svg?component'
import _MoreVerticalIcon from './icons/more-vertical.svg?component' import _MoreVerticalIcon from './icons/more-vertical.svg?component'
import _MousePointer2Icon from './icons/mouse-pointer-2.svg?component'
import _MoveIcon from './icons/move.svg?component' import _MoveIcon from './icons/move.svg?component'
import _MoveUpRightIcon from './icons/move-up-right.svg?component'
import _NewspaperIcon from './icons/newspaper.svg?component' import _NewspaperIcon from './icons/newspaper.svg?component'
import _NoSignalIcon from './icons/no-signal.svg?component' import _NoSignalIcon from './icons/no-signal.svg?component'
import _NotepadTextIcon from './icons/notepad-text.svg?component' import _NotepadTextIcon from './icons/notepad-text.svg?component'
@@ -261,6 +267,7 @@ import _SortDescIcon from './icons/sort-desc.svg?component'
import _SparklesIcon from './icons/sparkles.svg?component' import _SparklesIcon from './icons/sparkles.svg?component'
import _SpinnerIcon from './icons/spinner.svg?component' import _SpinnerIcon from './icons/spinner.svg?component'
import _SplitIcon from './icons/split.svg?component' import _SplitIcon from './icons/split.svg?component'
import _SquareIcon from './icons/square.svg?component'
import _StarIcon from './icons/star.svg?component' import _StarIcon from './icons/star.svg?component'
import _StopCircleIcon from './icons/stop-circle.svg?component' import _StopCircleIcon from './icons/stop-circle.svg?component'
import _StoreIcon from './icons/store.svg?component' import _StoreIcon from './icons/store.svg?component'
@@ -415,6 +422,7 @@ import _TrashExclamationIcon from './icons/trash-exclamation.svg?component'
import _TrendingDownIcon from './icons/trending-down.svg?component' import _TrendingDownIcon from './icons/trending-down.svg?component'
import _TrendingUpIcon from './icons/trending-up.svg?component' import _TrendingUpIcon from './icons/trending-up.svg?component'
import _TriangleAlertIcon from './icons/triangle-alert.svg?component' import _TriangleAlertIcon from './icons/triangle-alert.svg?component'
import _TypeIcon from './icons/type.svg?component'
import _UnderlineIcon from './icons/underline.svg?component' import _UnderlineIcon from './icons/underline.svg?component'
import _UndoIcon from './icons/undo.svg?component' import _UndoIcon from './icons/undo.svg?component'
import _UnfoldHorizontalIcon from './icons/unfold-horizontal.svg?component' import _UnfoldHorizontalIcon from './icons/unfold-horizontal.svg?component'
@@ -519,6 +527,7 @@ export const ChevronDownIcon = _ChevronDownIcon
export const ChevronLeftIcon = _ChevronLeftIcon export const ChevronLeftIcon = _ChevronLeftIcon
export const ChevronRightIcon = _ChevronRightIcon export const ChevronRightIcon = _ChevronRightIcon
export const ChevronUpIcon = _ChevronUpIcon export const ChevronUpIcon = _ChevronUpIcon
export const CircleIcon = _CircleIcon
export const CircleAlertIcon = _CircleAlertIcon export const CircleAlertIcon = _CircleAlertIcon
export const CircleArrowRightIcon = _CircleArrowRightIcon export const CircleArrowRightIcon = _CircleArrowRightIcon
export const CircleDashedIcon = _CircleDashedIcon export const CircleDashedIcon = _CircleDashedIcon
@@ -543,6 +552,7 @@ export const CopyIcon = _CopyIcon
export const CopyrightIcon = _CopyrightIcon export const CopyrightIcon = _CopyrightIcon
export const CpuIcon = _CpuIcon export const CpuIcon = _CpuIcon
export const CreateIcon = _CreateIcon export const CreateIcon = _CreateIcon
export const CropIcon = _CropIcon
export const CrownIcon = _CrownIcon export const CrownIcon = _CrownIcon
export const CubeIcon = _CubeIcon export const CubeIcon = _CubeIcon
export const CurrencyIcon = _CurrencyIcon export const CurrencyIcon = _CurrencyIcon
@@ -553,6 +563,7 @@ export const DownloadIcon = _DownloadIcon
export const DropdownIcon = _DropdownIcon export const DropdownIcon = _DropdownIcon
export const EditIcon = _EditIcon export const EditIcon = _EditIcon
export const EllipsisVerticalIcon = _EllipsisVerticalIcon export const EllipsisVerticalIcon = _EllipsisVerticalIcon
export const EraserIcon = _EraserIcon
export const ExpandIcon = _ExpandIcon export const ExpandIcon = _ExpandIcon
export const ExternalIcon = _ExternalIcon export const ExternalIcon = _ExternalIcon
export const EyeIcon = _EyeIcon export const EyeIcon = _EyeIcon
@@ -595,6 +606,7 @@ export const HeartIcon = _HeartIcon
export const HeartHandshakeIcon = _HeartHandshakeIcon export const HeartHandshakeIcon = _HeartHandshakeIcon
export const HeartMinusIcon = _HeartMinusIcon export const HeartMinusIcon = _HeartMinusIcon
export const HelpCircleIcon = _HelpCircleIcon export const HelpCircleIcon = _HelpCircleIcon
export const HighlighterIcon = _HighlighterIcon
export const HistoryIcon = _HistoryIcon export const HistoryIcon = _HistoryIcon
export const HomeIcon = _HomeIcon export const HomeIcon = _HomeIcon
export const ImageIcon = _ImageIcon export const ImageIcon = _ImageIcon
@@ -640,7 +652,9 @@ export const MonitorSmartphoneIcon = _MonitorSmartphoneIcon
export const MoonIcon = _MoonIcon export const MoonIcon = _MoonIcon
export const MoreHorizontalIcon = _MoreHorizontalIcon export const MoreHorizontalIcon = _MoreHorizontalIcon
export const MoreVerticalIcon = _MoreVerticalIcon export const MoreVerticalIcon = _MoreVerticalIcon
export const MousePointer2Icon = _MousePointer2Icon
export const MoveIcon = _MoveIcon export const MoveIcon = _MoveIcon
export const MoveUpRightIcon = _MoveUpRightIcon
export const NewspaperIcon = _NewspaperIcon export const NewspaperIcon = _NewspaperIcon
export const NoSignalIcon = _NoSignalIcon export const NoSignalIcon = _NoSignalIcon
export const NotepadTextIcon = _NotepadTextIcon export const NotepadTextIcon = _NotepadTextIcon
@@ -701,6 +715,7 @@ export const SortDescIcon = _SortDescIcon
export const SparklesIcon = _SparklesIcon export const SparklesIcon = _SparklesIcon
export const SpinnerIcon = _SpinnerIcon export const SpinnerIcon = _SpinnerIcon
export const SplitIcon = _SplitIcon export const SplitIcon = _SplitIcon
export const SquareIcon = _SquareIcon
export const StarIcon = _StarIcon export const StarIcon = _StarIcon
export const StopCircleIcon = _StopCircleIcon export const StopCircleIcon = _StopCircleIcon
export const StoreIcon = _StoreIcon export const StoreIcon = _StoreIcon
@@ -855,6 +870,7 @@ export const TrashExclamationIcon = _TrashExclamationIcon
export const TrendingDownIcon = _TrendingDownIcon export const TrendingDownIcon = _TrendingDownIcon
export const TrendingUpIcon = _TrendingUpIcon export const TrendingUpIcon = _TrendingUpIcon
export const TriangleAlertIcon = _TriangleAlertIcon export const TriangleAlertIcon = _TriangleAlertIcon
export const TypeIcon = _TypeIcon
export const UnderlineIcon = _UnderlineIcon export const UnderlineIcon = _UnderlineIcon
export const UndoIcon = _UndoIcon export const UndoIcon = _UndoIcon
export const UnfoldHorizontalIcon = _UnfoldHorizontalIcon export const UnfoldHorizontalIcon = _UnfoldHorizontalIcon
+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-circle"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
</svg>

After

Width:  |  Height:  |  Size: 323 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-crop"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 2v14a2 2 0 0 0 2 2h14" />
<path d="M18 22V8a2 2 0 0 0-2-2H2" />
</svg>

After

Width:  |  Height:  |  Size: 365 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-eraser"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21" />
<path d="m5.082 11.09 8.828 8.828" />
</svg>

After

Width:  |  Height:  |  Size: 462 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-highlighter"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m9 11-6 6v3h9l3-3" />
<path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4" />
</svg>

After

Width:  |  Height:  |  Size: 401 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-mouse-pointer-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z" />
</svg>

After

Width:  |  Height:  |  Size: 444 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-move-up-right"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13 5H19V11" />
<path d="M19 5L5 19" />
</svg>

After

Width:  |  Height:  |  Size: 347 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-square"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 340 B

+18
View File
@@ -0,0 +1,18 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-type"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 4v16" />
<path d="M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2" />
<path d="M9 20h6" />
</svg>

After

Width:  |  Height:  |  Size: 387 B

+20 -20
View File
@@ -1,22 +1,22 @@
{ {
"name": "@modrinth/assets", "name": "@modrinth/assets",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"main": "./index.ts", "main": "./index.ts",
"types": "./index.ts", "types": "./index.ts",
"scripts": { "scripts": {
"lint": "pnpm run icons:validate && eslint . && prettier --check .", "lint": "pnpm run icons:validate && eslint . && prettier --check .",
"fix": "pnpm run icons:generate && eslint . --fix && prettier --write .", "fix": "pnpm run icons:generate && eslint . --fix && prettier --write .",
"icons:add": "jiti build/add-icons.ts", "icons:add": "jiti build/add-icons.ts",
"icons:test": "jiti build/generate-exports.ts --test", "icons:test": "jiti build/generate-exports.ts --test",
"icons:validate": "jiti build/generate-exports.ts --validate", "icons:validate": "jiti build/generate-exports.ts --validate",
"icons:generate": "jiti build/generate-exports.ts" "icons:generate": "jiti build/generate-exports.ts"
}, },
"devDependencies": { "devDependencies": {
"@modrinth/tooling-config": "workspace:*", "@modrinth/tooling-config": "workspace:*",
"@types/node": "^24", "@types/node": "^24",
"jiti": "^2.4.2", "jiti": "^2.4.2",
"lucide-static": "^0.562.0", "lucide-static": "^0.562.0",
"vue": "^3.5.13" "vue": "^3.5.13"
} }
} }
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"extends": "@modrinth/tooling-config/typescript/vue.json" "extends": "@modrinth/tooling-config/typescript/vue.json"
} }
+1
View File
@@ -65,6 +65,7 @@
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"dompurify": "^3.1.7", "dompurify": "^3.1.7",
"es-toolkit": "^1.44.0", "es-toolkit": "^1.44.0",
"fabric": "^7.4.0",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
"fuse.js": "^6.6.2", "fuse.js": "^6.6.2",
@@ -0,0 +1,248 @@
<script setup lang="ts">
import { EditIcon } from '@modrinth/assets'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
const model = defineModel<string>({ default: '' })
const props = withDefaults(
defineProps<{
placeholder?: string
defaultValue?: string
maxWidth?: string
maxLength?: number
editLabel?: string
activationMode?: 'text' | 'icon' | 'manual'
inputClass?: string
buttonClass?: string
iconTextClass?: string
validate?: (value: string) => boolean
onChange?: (value: string) => boolean | void | Promise<boolean | void>
}>(),
{
placeholder: '',
defaultValue: '',
maxWidth: '100%',
maxLength: undefined,
editLabel: 'Edit',
activationMode: 'text',
validate: undefined,
onChange: undefined,
},
)
const isEditing = ref(false)
const isSaving = ref(false)
const isInvalid = ref(false)
const input = ref<HTMLInputElement>()
const isEditIconVisible = ref(false)
const draft = ref(model.value)
const originalValue = ref('')
let editIconHideTimeout: ReturnType<typeof setTimeout> | undefined
const displayValue = computed(() => model.value || props.defaultValue || props.placeholder)
const sizingValue = computed(() => {
if (isEditing.value) {
return draft.value || props.placeholder || ' '
}
return displayValue.value || ' '
})
watch(model, (value) => {
if (!isEditing.value) {
draft.value = value
}
})
function clearEditIconHideTimeout() {
if (editIconHideTimeout) {
clearTimeout(editIconHideTimeout)
editIconHideTimeout = undefined
}
}
function showEditIcon() {
isEditIconVisible.value = true
}
function keepEditIconVisible() {
clearEditIconHideTimeout()
isEditIconVisible.value = true
}
function hideEditIcon() {
if (!editIconHideTimeout) {
isEditIconVisible.value = false
}
}
function handleEditIconMouseleave() {
clearEditIconHideTimeout()
editIconHideTimeout = setTimeout(() => {
isEditIconVisible.value = false
editIconHideTimeout = undefined
}, 1000)
}
async function startEditing() {
if (isEditing.value) return
clearEditIconHideTimeout()
originalValue.value = model.value
draft.value = model.value
isInvalid.value = false
isEditing.value = true
await nextTick()
input.value?.focus()
input.value?.select()
}
async function applyValue() {
if (!isEditing.value || isSaving.value) return
const nextValue = (draft.value || props.defaultValue).trim()
if (nextValue === originalValue.value) {
model.value = nextValue
isEditing.value = false
isInvalid.value = false
return
}
if (props.validate && !props.validate(nextValue)) {
isInvalid.value = true
await nextTick()
input.value?.focus()
return
}
isSaving.value = true
isInvalid.value = false
try {
const accepted = await props.onChange?.(nextValue)
if (accepted === false) {
isInvalid.value = true
isSaving.value = false
await nextTick()
input.value?.focus()
return
}
model.value = nextValue
isEditing.value = false
} catch {
isInvalid.value = true
isSaving.value = false
await nextTick()
input.value?.focus()
} finally {
isSaving.value = false
}
}
function cancelEditing() {
if (isSaving.value) return
draft.value = originalValue.value
isInvalid.value = false
isEditing.value = false
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault()
void applyValue()
return
}
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
cancelEditing()
}
}
onUnmounted(clearEditIconHideTimeout)
defineExpose({
isEditing,
startEditing,
})
</script>
<template>
<div
:data-value="sizingValue"
class="relative flex h-6 min-w-3 min-h-0 max-w-full flex-col justify-center border-b font-medium"
:class="
isEditing
? [
'after:invisible after:block after:w-full after:whitespace-pre after:content-[attr(data-value)]',
isInvalid ? 'border-red' : 'border-[var(--color-text-primary)]',
]
: 'border-transparent'
"
:style="{ maxWidth }"
>
<input
v-if="isEditing"
ref="input"
v-model="draft"
type="text"
:aria-invalid="isInvalid"
:aria-label="editLabel"
:disabled="isSaving"
:maxlength="maxLength"
:placeholder="placeholder"
class="absolute inset-0 top-px w-full !h-full !min-h-0 min-w-0 truncate bg-transparent !p-0 text-inherit !border-b-2 border-0 !border-brand !border-solid !shadow-none [font:inherit] !outline-none"
:class="inputClass"
@blur="applyValue"
@click.stop
@keydown="handleKeydown"
/>
<div
v-else-if="activationMode === 'icon'"
class="group/edit-icon flex min-w-0 max-w-full items-center"
@mouseenter="showEditIcon"
@mouseleave="hideEditIcon"
>
<span class="min-w-0 truncate" :class="iconTextClass" :title="model || displayValue">
{{ displayValue }}
</span>
<span
class="flex shrink-0 overflow-hidden transition-all duration-150"
:class="
isEditIconVisible
? 'max-w-6 translate-x-0 opacity-100'
: 'max-w-0 translate-x-1 opacity-0'
"
@mouseenter="keepEditIconVisible"
@mouseleave="handleEditIconMouseleave"
>
<button
type="button"
class="ml-1 flex size-5 cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-[var(--color-text-tertiary)] transition-colors hover:text-brand focus-visible:text-[var(--color-text-primary)]"
:aria-label="`${editLabel}: ${displayValue}`"
@click.stop="startEditing"
>
<EditIcon class="size-5" aria-hidden="true" />
</button>
</span>
</div>
<button
v-else-if="activationMode === 'text'"
type="button"
class="flex w-full max-w-full items-center gap-2 truncate border-0 bg-transparent p-0 text-left text-inherit transition-colors hover:text-brand focus-visible:text-[var(--color-text-primary)] [font:inherit]"
:class="buttonClass"
:aria-label="`${editLabel}: ${displayValue}`"
:title="model || displayValue"
@click="startEditing"
>
<span class="min-w-0 truncate">{{ displayValue }}</span>
</button>
<span v-else class="min-w-0 truncate" :title="model || displayValue">
{{ displayValue }}
</span>
</div>
</template>
+1
View File
@@ -53,6 +53,7 @@ export { default as FloatingActionBar } from './FloatingActionBar.vue'
export { default as FloatingPanel } from './FloatingPanel.vue' export { default as FloatingPanel } from './FloatingPanel.vue'
export { default as FormattedTag } from './FormattedTag.vue' export { default as FormattedTag } from './FormattedTag.vue'
export { default as HeadingLink } from './HeadingLink.vue' export { default as HeadingLink } from './HeadingLink.vue'
export { default as InlineEditableText } from './InlineEditableText.vue'
export { default as HorizontalRule } from './HorizontalRule.vue' export { default as HorizontalRule } from './HorizontalRule.vue'
export { default as I18nDebugPanel } from './I18nDebugPanel.vue' export { default as I18nDebugPanel } from './I18nDebugPanel.vue'
export { default as IconSelect } from './IconSelect.vue' export { default as IconSelect } from './IconSelect.vue'
@@ -0,0 +1,316 @@
<script setup lang="ts">
import {
RedoIcon,
SaveIcon,
TrashIcon,
UndoIcon,
XIcon,
ZoomInIcon,
ZoomOutIcon,
} from '@modrinth/assets'
import { computed } from 'vue'
import Button from '#ui/components/base/buttons/Button.vue'
import IconButton from '#ui/components/base/buttons/IconButton.vue'
import Chips from '#ui/components/base/Chips.vue'
import { useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import { imageViewerEditorMessages as messages } from './image-viewer-editor-messages'
import type { ScreenshotEraserMode } from './image-viewer-editor-types'
import type { useImageEditor } from './use-image-editor'
const props = defineProps<{
editor: ReturnType<typeof useImageEditor>
busy: boolean
}>()
const emit = defineEmits<{
cancel: []
save: [mode: 'create_copy' | 'replace_edit']
}>()
const { formatMessage } = useVIntl()
const {
color,
strokeWidth,
fontSize,
eraserMode,
zoom,
isFit,
canUndo,
canRedo,
canDelete,
canZoomOut,
canZoomIn,
hasColorProperty,
propertyValueKind,
showEraserMode,
showCropControls,
cropWidth,
cropHeight,
canResetCrop,
updateColor,
updateStrokeWidth,
updateFontSize,
beginPropertyEdit,
commitPropertyEdit,
deleteSelection,
resetCrop,
undo,
redo,
setZoom,
setFit,
} = props.editor
const propertyValue = computed(() =>
propertyValueKind.value === 'size' ? fontSize.value : strokeWidth.value,
)
const propertyMin = computed(() => (propertyValueKind.value === 'size' ? 12 : 1))
const propertyMax = computed(() => (propertyValueKind.value === 'size' ? 120 : 40))
const propertyProgress = computed(
() => ((propertyValue.value - propertyMin.value) / (propertyMax.value - propertyMin.value)) * 100,
)
const hasPropertyControls = computed(
() =>
showCropControls.value ||
showEraserMode.value ||
hasColorProperty.value ||
Boolean(propertyValueKind.value),
)
const eraserModes: ScreenshotEraserMode[] = ['element', 'area']
function updatePropertyValue(nextValue: number) {
if (!Number.isFinite(nextValue)) return
const normalizedValue = Math.min(
propertyMax.value,
Math.max(propertyMin.value, Math.round(nextValue)),
)
if (propertyValueKind.value === 'size') updateFontSize(normalizedValue)
else updateStrokeWidth(normalizedValue)
}
function handlePropertyInput(event: Event) {
updatePropertyValue(Number((event.target as HTMLInputElement).value))
}
function handleColorInput(event: Event) {
updateColor((event.target as HTMLInputElement).value)
}
function formatEraserMode(value: ScreenshotEraserMode) {
return formatMessage(value === 'element' ? messages.element : messages.area)
}
</script>
<template>
<div
class="absolute bottom-6 left-1/2 z-10 flex w-max max-w-[calc(100%_-_3rem)] -translate-x-1/2 items-center gap-2 rounded-[20px] border border-solid border-white/10 bg-surface-3 p-2 shadow-[0_1rem_3rem_rgb(0_0_0_/_32%)] max-[900px]:flex-wrap max-[900px]:justify-center"
@click.stop
>
<div v-if="hasPropertyControls" class="flex min-w-0 items-center gap-2">
<div v-if="showCropControls" class="flex items-center gap-2">
<span class="shrink-0 text-base font-semibold leading-5 tabular-nums text-white/60">
{{ formatMessage(messages.cropDimensions, { width: cropWidth, height: cropHeight }) }}
</span>
<Button type="quiet" :disabled="!canResetCrop" @click="resetCrop">
{{ formatMessage(messages.resetCrop) }}
</Button>
</div>
<Chips
v-if="showEraserMode"
v-model="eraserMode"
:items="eraserModes"
:format-label="formatEraserMode"
:aria-label="formatMessage(messages.eraserMode)"
size="small"
hide-checkmark-icon
/>
<input
v-if="hasColorProperty"
type="color"
:value="color"
:label="formatMessage(messages.colour)"
:aria-label="formatMessage(messages.colour)"
class="size-9 shrink-0 cursor-pointer rounded-xl border border-solid border-surface-5 bg-surface-4 p-1"
@focus="beginPropertyEdit"
@pointerdown="beginPropertyEdit"
@input="handleColorInput"
@change="commitPropertyEdit"
/>
<div
v-if="propertyValueKind"
class="flex w-52 min-w-0 items-center gap-2"
@focusin="beginPropertyEdit"
@pointerdown.capture="beginPropertyEdit"
@change.capture="commitPropertyEdit"
@focusout="commitPropertyEdit"
>
<span class="shrink-0 text-base font-semibold leading-5 text-white/60">
{{ formatMessage(propertyValueKind === 'size' ? messages.size : messages.width) }}
</span>
<input
type="range"
:value="propertyValue"
:min="propertyMin"
:max="propertyMax"
step="1"
:aria-label="formatMessage(propertyValueKind === 'size' ? messages.size : messages.width)"
class="editor-property-range min-w-0 flex-1"
:style="{ '--property-progress': `${propertyProgress}%` }"
@input="handlePropertyInput"
/>
<input
type="number"
:value="propertyValue"
:min="propertyMin"
:max="propertyMax"
step="1"
:aria-label="formatMessage(propertyValueKind === 'size' ? messages.size : messages.width)"
class="editor-property-value h-9 w-12 shrink-0 rounded-xl border border-solid border-surface-5 bg-surface-4 px-1 text-center text-base font-semibold leading-5 tabular-nums text-white outline-none focus:ring-4 focus:ring-brand-shadow"
@input="handlePropertyInput"
/>
</div>
</div>
<div v-if="hasPropertyControls" class="h-6 w-px bg-white/10" />
<div class="flex items-center gap-2">
<IconButton
v-if="canDelete"
v-tooltip="formatMessage(messages.deleteSelection)"
:label="formatMessage(messages.deleteSelection)"
type="quiet"
@click="deleteSelection"
>
<TrashIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.undo)"
:label="formatMessage(messages.undo)"
type="quiet"
:disabled="!canUndo"
@click="undo"
>
<UndoIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.redo)"
:label="formatMessage(messages.redo)"
type="quiet"
:disabled="!canRedo"
@click="redo"
>
<RedoIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.zoomOut)"
:label="formatMessage(messages.zoomOut)"
type="quiet"
:disabled="!canZoomOut"
@click="setZoom(zoom - 0.1)"
>
<ZoomOutIcon />
</IconButton>
<Button
v-tooltip="formatMessage(messages.fitToWorkspace)"
type="quiet"
class="w-16 px-2 tabular-nums text-white/60"
@click="setFit"
>
{{ isFit ? formatMessage(messages.fit) : `${Math.round(zoom * 100)}%` }}
</Button>
<IconButton
v-tooltip="formatMessage(messages.zoomIn)"
:label="formatMessage(messages.zoomIn)"
type="quiet"
:disabled="!canZoomIn"
@click="setZoom(zoom + 0.1)"
>
<ZoomInIcon />
</IconButton>
</div>
<div class="h-6 w-px bg-white/10" />
<Button type="quiet" :disabled="busy" @click="emit('cancel')">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" :disabled="busy" @click="emit('save', 'create_copy')">
<SaveIcon aria-hidden="true" />
{{ formatMessage(messages.saveAsCopy) }}
</Button>
<Button type="base" :disabled="busy" @click="emit('save', 'replace_edit')">
<SaveIcon aria-hidden="true" />
{{ formatMessage(messages.overwrite) }}
</Button>
</div>
</template>
<style scoped>
.editor-property-range {
height: 1rem;
padding: 0;
margin: 0;
cursor: pointer;
background: transparent;
outline: none;
appearance: none;
&::-webkit-slider-runnable-track {
height: 0.25rem;
border-radius: 999px;
background: linear-gradient(
to right,
var(--color-brand) 0%,
var(--color-brand) var(--property-progress),
var(--surface-5) var(--property-progress),
var(--surface-5) 100%
);
}
&::-webkit-slider-thumb {
width: 0.875rem;
height: 0.875rem;
margin-top: -0.3125rem;
border: 2px solid var(--surface-1);
border-radius: 999px;
background: var(--color-brand);
box-shadow: 0 1px 3px rgb(0 0 0 / 35%);
appearance: none;
}
&:focus-visible::-webkit-slider-thumb {
box-shadow: 0 0 0 0.25rem var(--color-brand-shadow);
}
&::-moz-range-track {
height: 0.25rem;
border-radius: 999px;
background: var(--surface-5);
}
&::-moz-range-progress {
height: 0.25rem;
border-radius: 999px;
background: var(--color-brand);
}
&::-moz-range-thumb {
width: 0.75rem;
height: 0.75rem;
border: 2px solid var(--surface-1);
border-radius: 999px;
background: var(--color-brand);
box-shadow: 0 1px 3px rgb(0 0 0 / 35%);
}
}
.editor-property-value {
appearance: textfield;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
}
</style>
@@ -0,0 +1,580 @@
<script setup lang="ts">
import {
EditIcon,
LeftArrowIcon,
RightArrowIcon,
XIcon,
ZoomInIcon,
ZoomOutIcon,
} from '@modrinth/assets'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Button from '#ui/components/base/buttons/Button.vue'
import IconButton from '#ui/components/base/buttons/IconButton.vue'
import { useVIntl } from '#ui/composables/i18n'
import { injectNotificationManager } from '#ui/providers'
import Controls from './controls.vue'
import { imageViewerEditorMessages as messages } from './image-viewer-editor-messages'
import type { ImageViewerEditorMode } from './image-viewer-editor-types'
import Toolbar from './toolbar.vue'
import type {
ImageViewerEditorData,
ImageViewerEditorItem,
ImageViewerEditorMetadata,
ImageViewerEditorSavePayload,
} from './types'
import { useImageEditor } from './use-image-editor'
const CLICK_ZOOM = 2
const MAX_VIEW_ZOOM = 5
const ZOOM_STEP = 1.25
const props = defineProps<{
item: ImageViewerEditorItem
mode: ImageViewerEditorMode
index: number
count: number
canEdit: boolean
saving: boolean
loadData: (item: ImageViewerEditorItem) => Promise<ImageViewerEditorData>
}>()
const emit = defineEmits<{
cancel: []
close: []
edit: []
next: []
previous: []
metadata: [metadata: ImageViewerEditorMetadata]
save: [payload: ImageViewerEditorSavePayload]
}>()
const canvasElement = ref<HTMLCanvasElement>()
const viewport = ref<HTMLElement>()
const fitBounds = ref<HTMLElement>()
const exporting = ref(false)
const discarding = ref(false)
const loadingEditorData = ref(true)
const nativeImageReady = ref(false)
const spacePressed = ref(false)
const panning = ref<{
x: number
y: number
scrollLeft: number
scrollTop: number
moved: boolean
}>()
const brushPointer = ref({ x: 0, y: 0, visible: false })
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const editor = useImageEditor()
const {
loading,
tool,
color,
strokeWidth,
eraserMode,
zoom,
fitScale,
isFit,
originalWidth,
originalHeight,
canZoomIn,
initialize,
dispose,
setTool,
setInteractionEnabled,
discardChanges,
fitToViewport,
setZoom,
setFit,
waitForRender,
exportPng,
handleKeyboardShortcut,
isTextEditing,
resetHistory,
} = editor
const busy = computed(
() =>
props.saving || exporting.value || discarding.value || loadingEditorData.value || loading.value,
)
const hasBrushPointer = computed(
() =>
props.mode === 'edit' &&
(tool.value === 'pen' ||
tool.value === 'highlight' ||
(tool.value === 'eraser' && eraserMode.value === 'area')),
)
const brushPointerSize = computed(() => Math.max(2, strokeWidth.value * zoom.value))
const brushPointerColor = computed(() => (tool.value === 'eraser' ? '#ffffff' : color.value))
const viewZoom = computed(() => zoom.value / Math.max(fitScale.value, Number.EPSILON))
const viewZoomPercent = computed(() => Math.round(viewZoom.value * 100))
const viewZoomed = computed(() => viewZoom.value > 1.001)
const canViewZoomIn = computed(() => canZoomIn.value && viewZoom.value < MAX_VIEW_ZOOM)
const nativeImageView = computed(
() =>
props.mode === 'view' &&
[props.item.src, props.item.editorSource?.path].some(isGifOrWebpSource),
)
const previewLoading = computed(
() => loadingEditorData.value || (nativeImageView.value && !nativeImageReady.value),
)
let resizeObserver: ResizeObserver | undefined
let initializationGeneration = 0
let initializationChain = Promise.resolve()
function isGifOrWebpSource(source?: string) {
if (!source) return false
let pathname = source
try {
pathname = new URL(source, 'https://modrinth.invalid').pathname
} catch {
// ... ignore
}
try {
pathname = decodeURIComponent(pathname)
} catch {
// ... ignore
}
return /\.(?:gif|webp)$/i.test(pathname)
}
function queueInitialization() {
const generation = ++initializationGeneration
loadingEditorData.value = true
nativeImageReady.value = false
const editorDataPromise = props.loadData(props.item)
initializationChain = initializationChain.then(async () => {
if (generation !== initializationGeneration) return
try {
const editorData = await editorDataPromise
if (generation !== initializationGeneration || !canvasElement.value) return
await initialize(canvasElement.value, editorData, getViewportSize())
if (generation !== initializationGeneration) return
setInteractionEnabled(props.mode === 'edit')
observeViewport()
await waitForRender()
if (generation !== initializationGeneration) return
emit('metadata', {
size: editorData.source.size,
width: originalWidth.value,
height: originalHeight.value,
})
} catch (error) {
if (generation !== initializationGeneration) return
handleError(error)
emit('close')
} finally {
if (generation === initializationGeneration) {
loadingEditorData.value = false
}
}
})
}
function observeViewport() {
resizeObserver?.disconnect()
if (!viewport.value || !fitBounds.value) return
resizeObserver = new ResizeObserver(fitEditorToViewport)
resizeObserver.observe(viewport.value)
resizeObserver.observe(fitBounds.value)
fitEditorToViewport()
}
function fitEditorToViewport() {
const viewportSize = getViewportSize()
if (!viewportSize) return
fitToViewport(viewportSize.width, viewportSize.height)
if (isFit.value) centerViewport()
}
function getViewportSize() {
if (!fitBounds.value) return undefined
return {
width: fitBounds.value.clientWidth,
height: fitBounds.value.clientHeight,
}
}
function centerViewport() {
requestAnimationFrame(() => {
if (!viewport.value) return
viewport.value.scrollLeft = Math.max(
0,
(viewport.value.scrollWidth - viewport.value.clientWidth) / 2,
)
viewport.value.scrollTop = Math.max(
0,
(viewport.value.scrollHeight - viewport.value.clientHeight) / 2,
)
})
}
function resetView() {
setFit()
centerViewport()
}
function setViewZoom(nextZoom: number) {
if (nextZoom <= 1.001) {
resetView()
return
}
setZoom(fitScale.value * Math.min(MAX_VIEW_ZOOM, nextZoom))
centerViewport()
}
async function requestSave(mode: 'create_copy' | 'replace_edit') {
if (busy.value) return
exporting.value = true
try {
emit('save', {
item: props.item,
pngBytes: await exportPng(),
mode,
})
} catch (error) {
handleError(error)
} finally {
exporting.value = false
}
}
async function cancel() {
if (busy.value) return
discarding.value = true
try {
await discardChanges()
emit('cancel')
} catch (error) {
handleError(error)
} finally {
discarding.value = false
}
}
function handleKeydown(event: KeyboardEvent) {
if (props.mode !== 'edit' || document.querySelector('.modal-root')) return
if (handleKeyboardShortcut(event)) return
if (event.code === 'Space' && !isTextEditing() && !isTypingTarget(event.target)) {
spacePressed.value = true
}
if (event.key === 'Escape') {
event.preventDefault()
void cancel()
}
}
function handleKeyup(event: KeyboardEvent) {
if (event.code === 'Space') spacePressed.value = false
}
function handleEditMenuUndo(event: Event) {
if (props.mode !== 'edit' || isTextEditing() || isTypingTarget(document.activeElement)) return
event.preventDefault()
void editor.undo()
}
function handleEditMenuRedo(event: Event) {
if (props.mode !== 'edit' || isTextEditing() || isTypingTarget(document.activeElement)) return
event.preventDefault()
void editor.redo()
}
function handlePointerDown(event: PointerEvent) {
if (!viewport.value) return
const target = event.target
if (props.mode === 'view' && event.button === 0 && target === event.currentTarget) {
emit('close')
return
}
const isCanvasTarget = target instanceof Element && Boolean(target.closest('.editor-canvas'))
const isViewPan = props.mode === 'view' && event.button === 0 && isCanvasTarget
const isEditPan =
props.mode === 'edit' && (event.button === 1 || (event.button === 0 && spacePressed.value))
if (!isViewPan && !isEditPan) return
event.preventDefault()
event.stopPropagation()
panning.value = {
x: event.clientX,
y: event.clientY,
scrollLeft: viewport.value.scrollLeft,
scrollTop: viewport.value.scrollTop,
moved: false,
}
viewport.value.setPointerCapture(event.pointerId)
}
function movePan(event: PointerEvent) {
if (!viewport.value || !panning.value) return
const deltaX = event.clientX - panning.value.x
const deltaY = event.clientY - panning.value.y
if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) panning.value.moved = true
viewport.value.scrollLeft = panning.value.scrollLeft - deltaX
viewport.value.scrollTop = panning.value.scrollTop - deltaY
}
function stopPan() {
const pan = panning.value
panning.value = undefined
if (!pan || props.mode !== 'view') return
if (pan.moved) return
if (viewZoomed.value) resetView()
else setViewZoom(CLICK_ZOOM)
}
function updateBrushPointer(event: PointerEvent) {
if (!hasBrushPointer.value || panning.value) {
brushPointer.value.visible = false
return
}
const bounds = (event.currentTarget as HTMLElement).getBoundingClientRect()
brushPointer.value = {
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
visible: true,
}
}
function handleWheel(event: WheelEvent) {
if (props.mode === 'view') {
event.preventDefault()
setViewZoom(viewZoom.value * (event.deltaY < 0 ? 1.1 : 1 / 1.1))
return
}
if (!event.ctrlKey && !event.metaKey) return
event.preventDefault()
setZoom(zoom.value + (event.deltaY < 0 ? 0.1 : -0.1))
}
function isTypingTarget(target: EventTarget | null) {
return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable)
)
}
function markSaved() {
resetHistory()
}
watch(() => [props.item.id, props.item.src], queueInitialization)
watch(
() => props.mode,
(mode) => {
spacePressed.value = false
panning.value = undefined
brushPointer.value.visible = false
if (mode === 'view' && nativeImageView.value) nativeImageReady.value = false
setInteractionEnabled(mode === 'edit')
},
)
onMounted(async () => {
document.addEventListener('keydown', handleKeydown)
document.addEventListener('keyup', handleKeyup)
document.addEventListener('edit-menu:undo', handleEditMenuUndo)
document.addEventListener('edit-menu:redo', handleEditMenuRedo)
await nextTick()
queueInitialization()
})
onBeforeUnmount(() => {
initializationGeneration++
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('keyup', handleKeyup)
document.removeEventListener('edit-menu:undo', handleEditMenuUndo)
document.removeEventListener('edit-menu:redo', handleEditMenuRedo)
resizeObserver?.disconnect()
void dispose()
})
defineExpose({ markSaved })
</script>
<template>
<div
ref="viewport"
class="editor-viewport absolute inset-0 flex min-h-0 min-w-0 select-none overflow-auto px-6 pb-[5.75rem] pt-[4.75rem] max-[900px]:px-4 max-[900px]:pb-[8.5rem] max-[900px]:pt-[4.5rem]"
:class="{
'is-view': mode === 'view',
'is-view-zoomed': mode === 'view' && viewZoomed,
'is-panning': panning,
'is-pan-ready': mode === 'edit' && spacePressed && !panning,
}"
@pointerdown.capture="handlePointerDown"
@pointermove="movePan"
@pointerup="stopPan"
@pointercancel="panning = undefined"
@wheel="handleWheel"
>
<div
v-if="previewLoading"
class="pointer-events-none relative z-[3] m-auto h-full w-full shrink-0 animate-pulse rounded-md bg-surface-2"
aria-hidden="true"
/>
<div
v-show="!previewLoading"
class="editor-canvas relative z-[2] m-auto shrink-0"
:class="{
'h-full w-full': nativeImageView && previewLoading,
'is-native-image-view': nativeImageView,
}"
@pointerenter="updateBrushPointer"
@pointermove="updateBrushPointer"
@pointerleave="brushPointer.visible = false"
>
<canvas ref="canvasElement" :aria-label="item.alt" role="img" />
<img
v-if="nativeImageView"
:src="item.src"
:alt="item.alt"
class="pointer-events-none absolute inset-0 z-[2] h-full w-full object-contain"
draggable="false"
@load="nativeImageReady = true"
/>
<div
v-if="hasBrushPointer && brushPointer.visible"
class="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-solid shadow-[0_0_0_1px_rgb(0_0_0_/_80%),inset_0_0_0_1px_rgb(255_255_255_/_35%)]"
:style="{
left: `${brushPointer.x}px`,
top: `${brushPointer.y}px`,
width: `${brushPointerSize}px`,
height: `${brushPointerSize}px`,
borderColor: brushPointerColor,
}"
>
<div
v-if="tool !== 'eraser'"
class="absolute inset-0 rounded-full opacity-[0.15]"
:style="{ backgroundColor: color }"
/>
</div>
</div>
</div>
<div
ref="fitBounds"
class="pointer-events-none absolute inset-x-6 bottom-[5.75rem] top-[4.75rem] z-[3] flex items-center justify-center max-[900px]:inset-x-4 max-[900px]:bottom-[8.5rem] max-[900px]:top-[4.5rem]"
aria-hidden="true"
/>
<template v-if="mode === 'edit'">
<Toolbar :tool="tool" @select="setTool" />
<Controls :editor="editor" :busy="busy" @cancel="cancel" @save="requestSave" />
</template>
<div
v-else
class="absolute bottom-6 left-1/2 z-10 flex max-w-[calc(100%_-_3rem)] -translate-x-1/2 items-center gap-2 rounded-[20px] border border-solid border-white/10 bg-surface-3 p-2 shadow-[0_1rem_3rem_rgb(0_0_0_/_32%)]"
@click.stop
>
<div v-if="count > 1" class="flex items-center gap-2">
<IconButton :label="formatMessage(messages.previous)" type="quiet" @click="emit('previous')">
<LeftArrowIcon aria-hidden="true" />
</IconButton>
<span
class="flex min-w-14 justify-center gap-1 text-base font-semibold leading-5 tabular-nums text-[var(--color-text-tertiary)]"
aria-live="polite"
>
<strong class="font-semibold text-[var(--color-text-primary)]">{{ index + 1 }}</strong>
<span>/ {{ count }}</span>
</span>
<IconButton :label="formatMessage(messages.next)" type="quiet" @click="emit('next')">
<RightArrowIcon aria-hidden="true" />
</IconButton>
</div>
<div v-if="count > 1" class="h-6 w-px bg-white/10" />
<div class="flex items-center gap-2">
<IconButton
v-tooltip="formatMessage(messages.zoomOut)"
:label="formatMessage(messages.zoomOut)"
type="quiet"
:disabled="!viewZoomed"
@click="setViewZoom(viewZoom / ZOOM_STEP)"
>
<ZoomOutIcon aria-hidden="true" />
</IconButton>
<Button
v-tooltip="formatMessage(messages.fitToWorkspace)"
type="quiet"
class="w-16 px-2 tabular-nums text-[var(--color-text-tertiary)]"
@click="resetView"
>
{{ viewZoomPercent }}%
</Button>
<IconButton
v-tooltip="formatMessage(messages.zoomIn)"
:label="formatMessage(messages.zoomIn)"
type="quiet"
:disabled="!canViewZoomIn"
@click="setViewZoom(viewZoom * ZOOM_STEP)"
>
<ZoomInIcon aria-hidden="true" />
</IconButton>
</div>
<div class="h-6 w-px bg-white/10" />
<IconButton
v-if="canEdit"
v-tooltip="formatMessage(messages.edit)"
:label="formatMessage(messages.edit)"
type="quiet"
@click="emit('edit')"
>
<EditIcon aria-hidden="true" />
</IconButton>
<div class="flex items-center gap-2">
<slot name="actions" />
</div>
<div class="h-6 w-px bg-white/10" />
<IconButton :label="formatMessage(messages.close)" type="quiet" @click="emit('close')">
<XIcon aria-hidden="true" />
</IconButton>
</div>
</template>
<style scoped>
.editor-viewport {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.editor-canvas :deep(.canvas-container) {
flex: none;
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 24%);
}
.editor-canvas.is-native-image-view :deep(.canvas-container) {
visibility: hidden;
}
.editor-viewport.is-view .editor-canvas.is-native-image-view {
cursor: zoom-in;
}
.editor-viewport.is-view-zoomed .editor-canvas.is-native-image-view {
cursor: grab;
}
.editor-viewport.is-panning .editor-canvas.is-native-image-view {
cursor: grabbing;
}
.editor-viewport.is-view :deep(.upper-canvas) {
cursor: zoom-in !important;
}
.editor-viewport.is-view-zoomed :deep(.upper-canvas),
.editor-viewport.is-pan-ready :deep(.upper-canvas) {
cursor: grab !important;
}
.editor-viewport.is-panning :deep(.upper-canvas) {
cursor: grabbing !important;
}
</style>
@@ -0,0 +1,15 @@
import type { ScreenshotEditorSourceRect } from './image-viewer-editor-types'
export function renderCensorRegion(sourceRect: ScreenshotEditorSourceRect, solidColor: string) {
const width = Math.max(1, Math.round(sourceRect.width))
const height = Math.max(1, Math.round(sourceRect.height))
const output = document.createElement('canvas')
output.width = width
output.height = height
const outputContext = output.getContext('2d')
if (!outputContext) throw new Error('Could not create censor canvas')
outputContext.fillStyle = solidColor
outputContext.fillRect(0, 0, width, height)
return output
}
@@ -0,0 +1,57 @@
import { defineMessages } from '#ui/composables/i18n'
export const imageViewerEditorMessages = defineMessages({
close: { id: 'image-viewer.close', defaultMessage: 'Close' },
previous: { id: 'image-viewer.previous', defaultMessage: 'Previous image' },
next: { id: 'image-viewer.next', defaultMessage: 'Next image' },
edit: { id: 'image-viewer.edit', defaultMessage: 'Edit' },
metadata: {
id: 'image-viewer.metadata',
defaultMessage: '{size} · {width} × {height} px',
},
saveAsCopy: { id: 'image-viewer.editor.save-as-new', defaultMessage: 'Save copy' },
overwrite: { id: 'image-viewer.editor.overwrite', defaultMessage: 'Overwrite' },
moreSaveOptions: {
id: 'image-viewer.editor.more-save-options',
defaultMessage: 'More save options',
},
saveImage: { id: 'image-viewer.editor.save-image', defaultMessage: 'Save image' },
select: { id: 'image-viewer.editor.tool.select', defaultMessage: 'Select' },
pen: { id: 'image-viewer.editor.tool.pen', defaultMessage: 'Pen' },
highlight: { id: 'image-viewer.editor.tool.highlight', defaultMessage: 'Highlight' },
eraser: { id: 'image-viewer.editor.tool.eraser', defaultMessage: 'Eraser' },
crop: { id: 'image-viewer.editor.tool.crop', defaultMessage: 'Crop' },
text: { id: 'image-viewer.editor.tool.text', defaultMessage: 'Text' },
arrow: { id: 'image-viewer.editor.tool.arrow', defaultMessage: 'Arrow' },
rectangle: { id: 'image-viewer.editor.tool.rectangle', defaultMessage: 'Rectangle' },
ellipse: { id: 'image-viewer.editor.tool.ellipse', defaultMessage: 'Ellipse' },
censor: { id: 'image-viewer.editor.tool.censor', defaultMessage: 'Censor' },
eraserMode: { id: 'image-viewer.editor.eraser.mode', defaultMessage: 'Eraser mode' },
element: { id: 'image-viewer.editor.eraser.element', defaultMessage: 'Element' },
area: { id: 'image-viewer.editor.eraser.area', defaultMessage: 'Area' },
cropDimensions: {
id: 'image-viewer.editor.crop.dimensions',
defaultMessage: '{width} × {height} px',
},
resetCrop: { id: 'image-viewer.editor.crop.reset', defaultMessage: 'Reset crop' },
colour: { id: 'image-viewer.editor.colour', defaultMessage: 'Colour' },
width: { id: 'image-viewer.editor.width', defaultMessage: 'Width' },
size: { id: 'image-viewer.editor.size', defaultMessage: 'Size' },
undo: { id: 'image-viewer.editor.undo', defaultMessage: 'Undo' },
redo: { id: 'image-viewer.editor.redo', defaultMessage: 'Redo' },
deleteSelection: {
id: 'image-viewer.editor.delete-selection',
defaultMessage: 'Delete selected annotation',
},
zoomIn: { id: 'image-viewer.editor.zoom-in', defaultMessage: 'Zoom in' },
zoomOut: { id: 'image-viewer.editor.zoom-out', defaultMessage: 'Zoom out' },
fit: { id: 'image-viewer.editor.zoom-fit', defaultMessage: 'Fit' },
fitToWorkspace: {
id: 'image-viewer.editor.zoom-fit-workspace',
defaultMessage: 'Fit to workspace',
},
annotationTools: {
id: 'image-viewer.editor.image-editing-tools',
defaultMessage: 'Image editing tools',
},
})
@@ -0,0 +1,49 @@
export type ImageViewerEditorMode = 'view' | 'edit'
export type ScreenshotEditorTool =
| 'select'
| 'pen'
| 'highlight'
| 'eraser'
| 'crop'
| 'text'
| 'arrow'
| 'rectangle'
| 'ellipse'
| 'censor'
export type ScreenshotEraserMode = 'element' | 'area'
export type ScreenshotEditorObjectKind =
| 'annotation'
| 'arrow'
| 'background'
| 'censor'
| 'ellipse'
| 'highlight'
| 'pen'
| 'rectangle'
| 'text'
export type ScreenshotEditorPropertyKind = Exclude<
ScreenshotEditorObjectKind,
'annotation' | 'background'
>
export type ScreenshotEditorSourceRect = {
left: number
top: number
width: number
height: number
}
export type ScreenshotEditorObjectState = Record<string, unknown> & {
editorKind?: ScreenshotEditorObjectKind
sourceRect?: ScreenshotEditorSourceRect
censorColor?: string
}
export type EditorHistoryEntry = {
objects: ScreenshotEditorObjectState[]
crop: ScreenshotEditorSourceRect
}
@@ -0,0 +1,319 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useFormatBytes } from '#ui/composables/format-bytes'
import { useVIntl } from '#ui/composables/i18n'
import { injectImageViewerEditor } from '#ui/providers/image-viewer-editor'
import Editor from './editor.vue'
import { imageViewerEditorMessages as messages } from './image-viewer-editor-messages'
import type { ImageViewerEditorMode } from './image-viewer-editor-types'
import type {
ImageViewerEditorData,
ImageViewerEditorItem,
ImageViewerEditorMetadata,
ImageViewerEditorSavePayload,
} from './types'
const MAX_CACHED_ITEMS = 5
const props = withDefaults(
defineProps<{
items: ImageViewerEditorItem[]
editor?: 'enabled' | 'disabled'
saving?: boolean
}>(),
{
editor: 'disabled',
saving: false,
},
)
const emit = defineEmits<{
show: [item: ImageViewerEditorItem, index: number]
hide: []
navigate: [item: ImageViewerEditorItem, index: number, direction: 'next' | 'previous']
save: [payload: ImageViewerEditorSavePayload]
}>()
const activeId = ref<string | null>(null)
const mode = ref<ImageViewerEditorMode>('view')
const closeAfterEditing = ref(false)
const editorComponent = ref<InstanceType<typeof Editor>>()
const activeMetadata = ref<ImageViewerEditorMetadata | null>(null)
const context = injectImageViewerEditor(null)
const itemDataCache = new Map<string, Promise<ImageViewerEditorData>>()
const itemImageCache = new Map<string, HTMLImageElement>()
const formatBytes = useFormatBytes()
const { formatMessage } = useVIntl()
const activeIndex = computed(() => props.items.findIndex((item) => item.id === activeId.value))
const activeItem = computed(() => props.items[activeIndex.value] ?? null)
const canEdit = computed(
() =>
props.editor === 'enabled' &&
Boolean(context?.loadEditorData) &&
Boolean(activeItem.value?.editorSource),
)
watch(activeItem, (item, previousItem) => {
if (item?.id !== previousItem?.id || item?.src !== previousItem?.src) activeMetadata.value = null
if (!item && activeId.value !== null) hide()
})
function getItemCacheKey(item: ImageViewerEditorItem) {
return [item.id, item.src, item.editorSource?.id, item.editorSource?.path].join('\0')
}
function loadItemData(item: ImageViewerEditorItem): Promise<ImageViewerEditorData> {
const key = getItemCacheKey(item)
const cached = itemDataCache.get(key)
if (cached) {
itemDataCache.delete(key)
itemDataCache.set(key, cached)
return cached
}
const promise = (async () => {
if (item.editorSource && context) return await context.loadEditorData(item.editorSource)
const response = await fetch(item.src)
if (!response.ok) throw new Error(`Could not load image: ${response.statusText}`)
return { source: await response.blob() }
})()
itemDataCache.set(key, promise)
while (itemDataCache.size > MAX_CACHED_ITEMS) {
const oldestKey = itemDataCache.keys().next().value
if (oldestKey === undefined) break
itemDataCache.delete(oldestKey)
}
void promise.catch(() => {
if (itemDataCache.get(key) === promise) itemDataCache.delete(key)
})
return promise
}
function preloadItemImage(item: ImageViewerEditorItem) {
const key = getItemCacheKey(item)
const cached = itemImageCache.get(key)
if (cached) {
itemImageCache.delete(key)
itemImageCache.set(key, cached)
return
}
const image = new Image()
image.src = item.src
itemImageCache.set(key, image)
while (itemImageCache.size > MAX_CACHED_ITEMS) {
const oldestKey = itemImageCache.keys().next().value
if (oldestKey === undefined) break
itemImageCache.delete(oldestKey)
}
void image.decode().catch(() => undefined)
}
function preloadItemsAround(index: number) {
if (!props.items.length) return
const indexes = new Set([
index,
(index - 1 + props.items.length) % props.items.length,
(index + 1) % props.items.length,
])
for (const itemIndex of indexes) {
const item = props.items[itemIndex]
if (item) {
preloadItemImage(item)
void loadItemData(item).catch(() => undefined)
}
}
}
function show(index: number) {
const item = props.items[index]
if (!item) return
preloadItemsAround(index)
if (activeId.value === null) {
context?.onShow?.()
}
activeId.value = item.id
mode.value = 'view'
closeAfterEditing.value = false
emit('show', item, index)
}
async function edit(index: number) {
show(index)
await beginEditing(true)
}
async function beginEditing(closeOnCancel = false) {
if (!canEdit.value || props.saving) return
closeAfterEditing.value = closeOnCancel
mode.value = 'edit'
await nextTick()
}
function finishEditing() {
if (props.saving) return
if (closeAfterEditing.value) {
hide()
} else {
mode.value = 'view'
}
}
function hide() {
if (activeId.value === null || props.saving) return
activeId.value = null
mode.value = 'view'
closeAfterEditing.value = false
itemDataCache.clear()
itemImageCache.clear()
context?.onHide?.()
emit('hide')
}
function navigate(offset: number, direction: 'next' | 'previous') {
if (mode.value !== 'view' || props.items.length < 2) return
const index = (activeIndex.value + offset + props.items.length) % props.items.length
preloadItemsAround(index)
activeId.value = props.items[index].id
emit('navigate', props.items[index], index, direction)
}
function next() {
navigate(1, 'next')
}
function previous() {
navigate(-1, 'previous')
}
async function markSavedAndView(itemId?: string) {
editorComponent.value?.markSaved()
mode.value = 'view'
closeAfterEditing.value = false
await nextTick()
if (itemId && props.items.some((item) => item.id === itemId)) activeId.value = itemId
}
function handleKeydown(event: KeyboardEvent) {
if (!activeItem.value || mode.value === 'edit' || document.querySelector('.modal-root')) return
if (event.key === 'Escape') {
event.preventDefault()
hide()
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
previous()
} else if (event.key === 'ArrowRight') {
event.preventDefault()
next()
}
}
onMounted(() => document.addEventListener('keydown', handleKeydown))
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeydown)
itemDataCache.clear()
itemImageCache.clear()
if (activeId.value !== null) context?.onHide?.()
})
defineExpose({ show, edit, hide, next, previous, markSavedAndView })
</script>
<template>
<Teleport to="body">
<Transition name="image-viewer">
<div
v-if="activeItem"
class="fixed inset-0 z-[110] overflow-hidden bg-black/95 text-white"
role="dialog"
aria-modal="true"
:aria-label="activeItem.title || activeItem.alt"
@click.self="mode === 'view' && hide()"
>
<header
v-if="activeItem.title || activeItem.description || activeMetadata"
class="absolute inset-x-6 top-[calc(var(--top-bar-height,3rem)_+_1.5rem)] z-10 min-w-0"
@click.stop
>
<div class="viewer-heading-text w-fit min-w-0 max-w-full">
<h2
v-if="activeItem.title"
class="m-0 max-w-[min(42rem,70vw)] truncate text-base font-semibold leading-snug text-[var(--color-text-primary)]"
>
{{ activeItem.title }}
</h2>
<p
v-if="activeItem.description"
class="mb-0 mt-1 max-w-[min(42rem,70vw)] truncate text-xs leading-snug text-white"
>
{{ activeItem.description }}
</p>
<p
v-if="activeMetadata"
class="mb-0 mt-1 max-w-[min(42rem,70vw)] truncate text-xs leading-snug text-white"
>
{{
formatMessage(messages.metadata, {
size: formatBytes(activeMetadata.size),
width: activeMetadata.width,
height: activeMetadata.height,
})
}}
</p>
</div>
</header>
<Editor
ref="editorComponent"
:item="activeItem"
:mode="mode"
:index="activeIndex"
:count="items.length"
:can-edit="canEdit"
:saving="saving"
:load-data="loadItemData"
@close="hide"
@edit="beginEditing"
@next="next"
@previous="previous"
@metadata="activeMetadata = $event"
@cancel="finishEditing"
@save="emit('save', $event)"
>
<template #actions>
<slot name="actions" :item="activeItem" :index="activeIndex" :hide="hide" />
</template>
</Editor>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.image-viewer-enter-active {
transition: opacity 180ms ease-out;
}
.image-viewer-leave-active {
transition: opacity 120ms ease-in;
}
.image-viewer-enter-from,
.image-viewer-leave-to {
opacity: 0;
}
.viewer-heading-text {
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 90%));
}
@media (prefers-reduced-motion: reduce) {
.image-viewer-enter-active,
.image-viewer-leave-active {
transition: none;
}
}
</style>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import {
CircleIcon,
CropIcon,
EraserIcon,
EyeOffIcon,
HighlighterIcon,
MousePointer2Icon,
MoveUpRightIcon,
PencilIcon,
SquareIcon,
TypeIcon,
} from '@modrinth/assets'
import IconButton from '#ui/components/base/buttons/IconButton.vue'
import { useVIntl } from '#ui/composables/i18n'
import { imageViewerEditorMessages as messages } from './image-viewer-editor-messages'
import type { ScreenshotEditorTool } from './image-viewer-editor-types'
const props = defineProps<{
tool: ScreenshotEditorTool
}>()
const emit = defineEmits<{
select: [tool: ScreenshotEditorTool]
}>()
const { formatMessage } = useVIntl()
const toolGroups = [
[
{ id: 'select', message: messages.select, icon: MousePointer2Icon, shortcut: 'V' },
{ id: 'crop', message: messages.crop, icon: CropIcon, shortcut: 'K' },
],
[
{ id: 'pen', message: messages.pen, icon: PencilIcon, shortcut: 'P' },
{ id: 'highlight', message: messages.highlight, icon: HighlighterIcon, shortcut: 'H' },
{ id: 'eraser', message: messages.eraser, icon: EraserIcon, shortcut: 'E' },
],
[
{ id: 'arrow', message: messages.arrow, icon: MoveUpRightIcon, shortcut: 'A' },
{ id: 'rectangle', message: messages.rectangle, icon: SquareIcon, shortcut: 'R' },
{ id: 'ellipse', message: messages.ellipse, icon: CircleIcon, shortcut: 'O' },
],
[
{ id: 'text', message: messages.text, icon: TypeIcon, shortcut: 'T' },
{ id: 'censor', message: messages.censor, icon: EyeOffIcon, shortcut: 'C' },
],
] satisfies Array<
Array<{
id: ScreenshotEditorTool
message: (typeof messages)[keyof typeof messages]
icon: typeof MousePointer2Icon
shortcut: string
}>
>
function tooltip(groupIndex: number, toolIndex: number) {
const option = toolGroups[groupIndex]?.[toolIndex]
return option ? `${formatMessage(option.message)} (${option.shortcut})` : ''
}
</script>
<template>
<div
class="absolute left-6 top-1/2 z-10 flex -translate-y-1/2 flex-col items-center gap-2 rounded-[20px] border border-solid border-white/10 bg-surface-3 px-3 py-2.5 shadow-[0_1rem_3rem_rgb(0_0_0_/_32%)] max-[900px]:bottom-[5.25rem] max-[900px]:left-1/2 max-[900px]:top-auto max-[900px]:-translate-x-1/2 max-[900px]:translate-y-0 max-[900px]:flex-row"
role="toolbar"
:aria-label="formatMessage(messages.annotationTools)"
@click.stop
>
<template v-for="(group, groupIndex) in toolGroups" :key="group[0]?.id">
<div v-if="groupIndex > 0" class="h-px w-6 bg-white/10 max-[900px]:h-6 max-[900px]:w-px" />
<IconButton
v-for="(option, toolIndex) in group"
:key="option.id"
v-tooltip="tooltip(groupIndex, toolIndex)"
:label="formatMessage(option.message)"
type="quiet"
:color="props.tool === option.id ? 'brand' : undefined"
:class="{
'!bg-brand-highlight shadow-[inset_0_0_0_1px_var(--color-brand)]':
props.tool === option.id,
}"
:aria-pressed="props.tool === option.id"
@click="emit('select', option.id)"
>
<component :is="option.icon" />
</IconButton>
</template>
</div>
</template>
@@ -0,0 +1,29 @@
export type ImageViewerEditorSource = {
id: string
path: string
}
export type ImageViewerEditorItem = {
id: string
src: string
alt: string
title?: string
description?: string
editorSource?: ImageViewerEditorSource
}
export type ImageViewerEditorData = {
source: Blob
}
export type ImageViewerEditorMetadata = {
size: number
width: number
height: number
}
export type ImageViewerEditorSavePayload = {
item: ImageViewerEditorItem
pngBytes: Uint8Array
mode: 'create_copy' | 'replace_edit'
}
File diff suppressed because it is too large. Load diff
+8
View File
@@ -1,6 +1,14 @@
export * from './base' export * from './base'
export * from './brand' export * from './brand'
export * from './flows' export * from './flows'
export { default as ImageViewerEditor } from './image-viewer-editor/index.vue'
export type {
ImageViewerEditorData,
ImageViewerEditorItem,
ImageViewerEditorMetadata,
ImageViewerEditorSavePayload,
ImageViewerEditorSource,
} from './image-viewer-editor/types'
export * from './modal' export * from './modal'
export * from './nav' export * from './nav'
export * from './notifications' export * from './notifications'
+129 -45
View File
@@ -305,6 +305,12 @@
"console.action.share": { "console.action.share": {
"defaultMessage": "Share" "defaultMessage": "Share"
}, },
"console.command-completions.no-results": {
"defaultMessage": "No matching completions"
},
"console.command-completions.search": {
"defaultMessage": "Search command completions"
},
"console.command.disabled-placeholder": { "console.command.disabled-placeholder": {
"defaultMessage": "Command input disabled" "defaultMessage": "Command input disabled"
}, },
@@ -314,12 +320,6 @@
"console.command.server-not-running-placeholder": { "console.command.server-not-running-placeholder": {
"defaultMessage": "Server is not running" "defaultMessage": "Server is not running"
}, },
"console.command-completions.no-results": {
"defaultMessage": "No matching completions"
},
"console.command-completions.search": {
"defaultMessage": "Search command completions"
},
"console.crash.export-context": { "console.crash.export-context": {
"defaultMessage": "Export crash context" "defaultMessage": "Export crash context"
}, },
@@ -860,6 +860,12 @@
"content.diff-modal.updated-count": { "content.diff-modal.updated-count": {
"defaultMessage": "{count} updated" "defaultMessage": "{count} updated"
}, },
"content.filter.client-only": {
"defaultMessage": "Client only"
},
"content.filter.client-only-description": {
"defaultMessage": "Includes client-only or singleplayer-only mods and mods that depend on them. These may be incompatible with dedicated servers."
},
"content.filter.disabled": { "content.filter.disabled": {
"defaultMessage": "Disabled" "defaultMessage": "Disabled"
}, },
@@ -872,15 +878,6 @@
"content.filter.updates": { "content.filter.updates": {
"defaultMessage": "Update available" "defaultMessage": "Update available"
}, },
"content.filter.client-only": {
"defaultMessage": "Client only"
},
"content.filter.client-only-description": {
"defaultMessage": "Includes client-only or singleplayer-only mods and mods that depend on them. These may be incompatible with dedicated servers."
},
"content.filter.warnings": {
"defaultMessage": "Warnings"
},
"content.metadata-filter.author": { "content.metadata-filter.author": {
"defaultMessage": "Author" "defaultMessage": "Author"
}, },
@@ -968,15 +965,6 @@
"content.metadata-filter.source.shared-instance": { "content.metadata-filter.source.shared-instance": {
"defaultMessage": "Shared instance" "defaultMessage": "Shared instance"
}, },
"content.metadata-filter.state": {
"defaultMessage": "Status"
},
"content.metadata-filter.state.disabled": {
"defaultMessage": "Disabled"
},
"content.metadata-filter.state.enabled": {
"defaultMessage": "Enabled"
},
"content.metadata-filter.toggle": { "content.metadata-filter.toggle": {
"defaultMessage": "Filter" "defaultMessage": "Filter"
}, },
@@ -986,15 +974,6 @@
"content.metadata-filter.unknown": { "content.metadata-filter.unknown": {
"defaultMessage": "Unknown" "defaultMessage": "Unknown"
}, },
"content.metadata-filter.update.available": {
"defaultMessage": "Update available"
},
"content.metadata-filter.update.up-to-date": {
"defaultMessage": "Up to date"
},
"content.metadata-filter.updates": {
"defaultMessage": "Updates"
},
"content.page-layout.browse-content": { "content.page-layout.browse-content": {
"defaultMessage": "Browse content" "defaultMessage": "Browse content"
}, },
@@ -2081,6 +2060,111 @@
"icon-select.select": { "icon-select.select": {
"defaultMessage": "Select icon" "defaultMessage": "Select icon"
}, },
"image-viewer.close": {
"defaultMessage": "Close"
},
"image-viewer.edit": {
"defaultMessage": "Edit"
},
"image-viewer.editor.colour": {
"defaultMessage": "Colour"
},
"image-viewer.editor.crop.dimensions": {
"defaultMessage": "{width} × {height} px"
},
"image-viewer.editor.crop.reset": {
"defaultMessage": "Reset crop"
},
"image-viewer.editor.delete-selection": {
"defaultMessage": "Delete selected annotation"
},
"image-viewer.editor.eraser.area": {
"defaultMessage": "Area"
},
"image-viewer.editor.eraser.element": {
"defaultMessage": "Element"
},
"image-viewer.editor.eraser.mode": {
"defaultMessage": "Eraser mode"
},
"image-viewer.editor.image-editing-tools": {
"defaultMessage": "Image editing tools"
},
"image-viewer.editor.more-save-options": {
"defaultMessage": "More save options"
},
"image-viewer.editor.overwrite": {
"defaultMessage": "Overwrite"
},
"image-viewer.editor.redo": {
"defaultMessage": "Redo"
},
"image-viewer.editor.save-as-new": {
"defaultMessage": "Save copy"
},
"image-viewer.editor.save-image": {
"defaultMessage": "Save image"
},
"image-viewer.editor.size": {
"defaultMessage": "Size"
},
"image-viewer.editor.tool.arrow": {
"defaultMessage": "Arrow"
},
"image-viewer.editor.tool.censor": {
"defaultMessage": "Censor"
},
"image-viewer.editor.tool.crop": {
"defaultMessage": "Crop"
},
"image-viewer.editor.tool.ellipse": {
"defaultMessage": "Ellipse"
},
"image-viewer.editor.tool.eraser": {
"defaultMessage": "Eraser"
},
"image-viewer.editor.tool.highlight": {
"defaultMessage": "Highlight"
},
"image-viewer.editor.tool.pen": {
"defaultMessage": "Pen"
},
"image-viewer.editor.tool.rectangle": {
"defaultMessage": "Rectangle"
},
"image-viewer.editor.tool.select": {
"defaultMessage": "Select"
},
"image-viewer.editor.tool.text": {
"defaultMessage": "Text"
},
"image-viewer.editor.undo": {
"defaultMessage": "Undo"
},
"image-viewer.editor.width": {
"defaultMessage": "Width"
},
"image-viewer.editor.zoom-fit": {
"defaultMessage": "Fit"
},
"image-viewer.editor.zoom-fit-workspace": {
"defaultMessage": "Fit to workspace"
},
"image-viewer.editor.zoom-in": {
"defaultMessage": "Zoom in"
},
"image-viewer.editor.zoom-out": {
"defaultMessage": "Zoom out"
},
"image-viewer.metadata": {
"defaultMessage": "{size} · {width} × {height} px"
},
"image-viewer.next": {
"defaultMessage": "Next image"
},
"image-viewer.previous": {
"defaultMessage": "Previous image"
},
"input.search-version.placeholder": { "input.search-version.placeholder": {
"defaultMessage": "Search version..." "defaultMessage": "Search version..."
}, },
@@ -2537,18 +2621,6 @@
"label.no-items": { "label.no-items": {
"defaultMessage": "No items" "defaultMessage": "No items"
}, },
"notification.panel.copy": {
"defaultMessage": "Copy to clipboard"
},
"notification.panel.copy-support": {
"defaultMessage": "Copy error details for support"
},
"notification.panel.dismiss": {
"defaultMessage": "Dismiss"
},
"notification.panel.export-error-logs": {
"defaultMessage": "Export error logs"
},
"label.notifications": { "label.notifications": {
"defaultMessage": "Notifications" "defaultMessage": "Notifications"
}, },
@@ -2939,6 +3011,18 @@
"notification.error.title": { "notification.error.title": {
"defaultMessage": "An error occurred" "defaultMessage": "An error occurred"
}, },
"notification.panel.copy": {
"defaultMessage": "Copy to clipboard"
},
"notification.panel.copy-support": {
"defaultMessage": "Copy error details for support"
},
"notification.panel.dismiss": {
"defaultMessage": "Dismiss"
},
"notification.panel.export-error-logs": {
"defaultMessage": "Export error logs"
},
"omorphia.component.badge.label.accepted": { "omorphia.component.badge.label.accepted": {
"defaultMessage": "Accepted" "defaultMessage": "Accepted"
}, },
+105
View File
@@ -4639,5 +4639,110 @@
}, },
"drop.confirm.as-data-pack-desc": { "drop.confirm.as-data-pack-desc": {
"defaultMessage": "将数据包安装到存档" "defaultMessage": "将数据包安装到存档"
},
"image-viewer.close": {
"defaultMessage": "关闭"
},
"image-viewer.edit": {
"defaultMessage": "编辑"
},
"image-viewer.editor.colour": {
"defaultMessage": "颜色"
},
"image-viewer.editor.crop.dimensions": {
"defaultMessage": "{width} × {height} 像素"
},
"image-viewer.editor.crop.reset": {
"defaultMessage": "重置裁剪"
},
"image-viewer.editor.delete-selection": {
"defaultMessage": "删除选中的标注"
},
"image-viewer.editor.eraser.area": {
"defaultMessage": "区域"
},
"image-viewer.editor.eraser.element": {
"defaultMessage": "元素"
},
"image-viewer.editor.eraser.mode": {
"defaultMessage": "橡皮擦模式"
},
"image-viewer.editor.image-editing-tools": {
"defaultMessage": "图像编辑工具"
},
"image-viewer.editor.more-save-options": {
"defaultMessage": "更多保存选项"
},
"image-viewer.editor.overwrite": {
"defaultMessage": "覆盖"
},
"image-viewer.editor.redo": {
"defaultMessage": "重做"
},
"image-viewer.editor.save-as-new": {
"defaultMessage": "保存副本"
},
"image-viewer.editor.save-image": {
"defaultMessage": "保存图像"
},
"image-viewer.editor.size": {
"defaultMessage": "大小"
},
"image-viewer.editor.tool.arrow": {
"defaultMessage": "箭头"
},
"image-viewer.editor.tool.censor": {
"defaultMessage": "打码"
},
"image-viewer.editor.tool.crop": {
"defaultMessage": "裁剪"
},
"image-viewer.editor.tool.ellipse": {
"defaultMessage": "椭圆"
},
"image-viewer.editor.tool.eraser": {
"defaultMessage": "橡皮擦"
},
"image-viewer.editor.tool.highlight": {
"defaultMessage": "高亮"
},
"image-viewer.editor.tool.pen": {
"defaultMessage": "画笔"
},
"image-viewer.editor.tool.rectangle": {
"defaultMessage": "矩形"
},
"image-viewer.editor.tool.select": {
"defaultMessage": "选择"
},
"image-viewer.editor.tool.text": {
"defaultMessage": "文字"
},
"image-viewer.editor.undo": {
"defaultMessage": "撤销"
},
"image-viewer.editor.width": {
"defaultMessage": "宽度"
},
"image-viewer.editor.zoom-fit": {
"defaultMessage": "适应"
},
"image-viewer.editor.zoom-fit-workspace": {
"defaultMessage": "适应工作区"
},
"image-viewer.editor.zoom-in": {
"defaultMessage": "放大"
},
"image-viewer.editor.zoom-out": {
"defaultMessage": "缩小"
},
"image-viewer.metadata": {
"defaultMessage": "{size} · {width} × {height} 像素"
},
"image-viewer.next": {
"defaultMessage": "下一张图像"
},
"image-viewer.previous": {
"defaultMessage": "上一张图像"
} }
} }
@@ -0,0 +1,15 @@
import type {
ImageViewerEditorData,
ImageViewerEditorSource,
} from '#ui/components/image-viewer-editor/types'
import { createContext } from '.'
export interface ImageViewerEditorContext {
loadEditorData: (source: ImageViewerEditorSource) => Promise<ImageViewerEditorData>
onShow?: () => void
onHide?: () => void
}
export const [injectImageViewerEditor, provideImageViewerEditor] =
createContext<ImageViewerEditorContext>('ImageViewerEditor')
+1
View File
@@ -6,6 +6,7 @@ export { createContext } from './create-context'
export * from './file-drop' export * from './file-drop'
export * from './file-picker' export * from './file-picker'
export * from './i18n' export * from './i18n'
export * from './image-viewer-editor'
export * from './instance-import' export * from './instance-import'
export * from './loading-state' export * from './loading-state'
export * from './modal-behavior' export * from './modal-behavior'
+624 -4
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -63,6 +63,9 @@ const allowedUntranslatedMessages = new Set([
'> {command}', '> {command}',
'Beta', 'Beta',
'Release', 'Release',
'{instance} · {date}',
'Modrinth screenshots.zip',
'{instance} screenshots.zip',
]) ])
function messageText(value) { function messageText(value) {