feat: 移植截图中心(浏览/分组/编辑/导出)
This commit is contained in:
1 parent
300df18aca
commit
b5406c2da0
34 files changed
+7954
-1124
No files matched your search
@@ -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:*",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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'
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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'
|
||||||
|
|||||||
Generated
+624
-4
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user