feat: 移植截图中心(浏览/分组/编辑/导出)
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled

This commit is contained in:
AxTps committed 2026-10-01 22:45:02 +08:00
1 parent 300df18aca
commit b5406c2da0
34 files changed
+7954 -1124

No files matched your search

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