mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
feat(instance-sync): screenshots sync (#7218)
* pnpm prepr * feat(instance-sync): screenshots sync * fix: prepr + fmt * fix: qa * feat: screenshit editor * feat: screenshot* editor qa * fix: qa * fix: lint * fix: aecsocket rev * qa: a11y tab navigation focus is cut off * qa: Change “Edited” badge to be bg-highlight-green * qa: friends list input style wrong * qa: toolbar width + labels * qa: multiselect changes * qa: anim changes * qa: card hover colors * qa: copy feedback * qa: hide date * qa: instance icon in overflow/contextmenu * qa: spacing * qa: group empty state text * qa: drag preview badge * qa: group deletion skip warning if no screenshots * qa: redesign editor * qa: remove screenshot parent/edited badge stuff * qa: control font size consistency * qa: viewer copy control * qa: editor fixes * qa: redirect on disable screenshot sync * qa: margin police * fix: crop * fix: qa * feat: initial start on basic instance file syncing (#7220) * qa: final * qa: final 2 * fix: lint + prepr * fix: copy * fix: screenshot editing outside of app causing ghost files in db + sync fail rollback impl * chore: split up * fix: fmt --------- Co-authored-by: tdgao <mr.trumgao@gmail.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ButtonFrame from './ButtonFrame.vue'
|
||||
import type { ButtonNativeType } from './types'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
nativeType?: ButtonNativeType
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
nativeType: 'button',
|
||||
},
|
||||
)
|
||||
|
||||
const frame = ref<InstanceType<typeof ButtonFrame> | null>(null)
|
||||
const element = computed(() => frame.value?.element ?? null)
|
||||
|
||||
defineExpose({ element })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonFrame
|
||||
ref="frame"
|
||||
as="button"
|
||||
type="quiet"
|
||||
size="lg"
|
||||
interaction="none"
|
||||
:disabled="disabled"
|
||||
:native-type="nativeType"
|
||||
role="radio"
|
||||
:aria-checked="checked"
|
||||
class="w-full !justify-between !gap-4 !whitespace-normal !border !border-solid !px-2 !text-left !transition-colors"
|
||||
:class="
|
||||
checked
|
||||
? '!border-brand !bg-brand-highlight !text-contrast'
|
||||
: '!border-transparent !bg-transparent !text-contrast enabled:hover:!bg-surface-3'
|
||||
"
|
||||
>
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<slot />
|
||||
</span>
|
||||
<span
|
||||
v-if="checked"
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-full bg-brand text-brand-inverted"
|
||||
>
|
||||
<CheckIcon class="size-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span v-else class="size-6 shrink-0 rounded-full border border-solid border-surface-5" />
|
||||
</ButtonFrame>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
export { default as Button } from './Button.vue'
|
||||
export { default as ButtonGroup } from './ButtonGroup.vue'
|
||||
export { default as ButtonLink } from './ButtonLink.vue'
|
||||
export { default as CheckCircleButton } from './CheckCircleButton.vue'
|
||||
export { default as ContextMenu } from './ContextMenu.vue'
|
||||
export { default as FileButton } from './FileButton.vue'
|
||||
export { default as IconButton } from './IconButton.vue'
|
||||
|
||||
@@ -13,6 +13,7 @@ export { default as BulletDivider } from './BulletDivider.vue'
|
||||
export { default as Button } from './buttons/Button.vue'
|
||||
export { default as ButtonGroup } from './buttons/ButtonGroup.vue'
|
||||
export { default as ButtonLink } from './buttons/ButtonLink.vue'
|
||||
export { default as CheckCircleButton } from './buttons/CheckCircleButton.vue'
|
||||
export { default as ContextMenu } from './buttons/ContextMenu.vue'
|
||||
export { default as FileButton } from './buttons/FileButton.vue'
|
||||
export { default as IconButton } from './buttons/IconButton.vue'
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
<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 SplitButton from '#ui/components/base/buttons/SplitButton.vue'
|
||||
import type { OverflowMenuOption } from '#ui/components/base/buttons/types'
|
||||
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 { ScreenshotCensorMode, 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,
|
||||
censorMode,
|
||||
eraserMode,
|
||||
zoom,
|
||||
isFit,
|
||||
canUndo,
|
||||
canRedo,
|
||||
canDelete,
|
||||
canZoomOut,
|
||||
canZoomIn,
|
||||
hasColorProperty,
|
||||
propertyValueKind,
|
||||
showCensorMode,
|
||||
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 ||
|
||||
showCensorMode.value ||
|
||||
hasColorProperty.value ||
|
||||
Boolean(propertyValueKind.value),
|
||||
)
|
||||
const censorModes: ScreenshotCensorMode[] = ['blur', 'solid']
|
||||
const eraserModes: ScreenshotEraserMode[] = ['element', 'area']
|
||||
const saveOptions = computed<OverflowMenuOption[]>(() => [
|
||||
{
|
||||
id: 'overwrite',
|
||||
label: formatMessage(messages.overwrite),
|
||||
icon: SaveIcon,
|
||||
action: () => emit('save', 'replace_edit'),
|
||||
},
|
||||
])
|
||||
|
||||
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 formatCensorMode(value: ScreenshotCensorMode) {
|
||||
return formatMessage(value === 'blur' ? messages.blur : messages.solid)
|
||||
}
|
||||
|
||||
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
|
||||
/>
|
||||
<Chips
|
||||
v-if="showCensorMode"
|
||||
v-model="censorMode"
|
||||
:items="censorModes"
|
||||
:format-label="formatCensorMode"
|
||||
:aria-label="formatMessage(messages.censorMode)"
|
||||
size="small"
|
||||
hide-checkmark-icon
|
||||
/>
|
||||
<label
|
||||
v-if="hasColorProperty"
|
||||
v-tooltip="formatMessage(messages.colour)"
|
||||
class="relative flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-xl transition-[filter,box-shadow] hover:brightness-125 focus-within:ring-4 focus-within:ring-brand-shadow"
|
||||
>
|
||||
<span
|
||||
class="size-full rounded-xl border border-solid border-surface-5"
|
||||
:style="{ backgroundColor: color }"
|
||||
/>
|
||||
<input
|
||||
:value="color"
|
||||
type="color"
|
||||
:aria-label="formatMessage(messages.colour)"
|
||||
class="absolute inset-0 cursor-pointer opacity-0"
|
||||
@focus="beginPropertyEdit"
|
||||
@pointerdown="beginPropertyEdit"
|
||||
@input="handleColorInput"
|
||||
@change="commitPropertyEdit"
|
||||
@blur="commitPropertyEdit"
|
||||
/>
|
||||
</label>
|
||||
<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>
|
||||
<SplitButton
|
||||
type="colored"
|
||||
color="green"
|
||||
:disabled="busy"
|
||||
:options="saveOptions"
|
||||
:menu-label="formatMessage(messages.moreSaveOptions)"
|
||||
:group-label="formatMessage(messages.saveImage)"
|
||||
@click="emit('save', 'create_copy')"
|
||||
>
|
||||
<SaveIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.saveAsCopy) }}
|
||||
</SplitButton>
|
||||
</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,597 @@
|
||||
<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,
|
||||
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: []
|
||||
imageReady: []
|
||||
next: []
|
||||
previous: []
|
||||
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 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,
|
||||
canZoomIn,
|
||||
initialize,
|
||||
dispose,
|
||||
setTool,
|
||||
setInteractionEnabled,
|
||||
discardChanges,
|
||||
fitToViewport,
|
||||
setZoom,
|
||||
setFit,
|
||||
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)
|
||||
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
let initializationGeneration = 0
|
||||
let initializationChain = Promise.resolve()
|
||||
let imageReadyFrame: number | undefined
|
||||
|
||||
function queueInitialization() {
|
||||
const generation = ++initializationGeneration
|
||||
loadingEditorData.value = true
|
||||
const editorDataPromise = props.loadData(props.item)
|
||||
initializationChain = initializationChain.then(async () => {
|
||||
if (generation !== initializationGeneration) return
|
||||
let initialized = false
|
||||
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()
|
||||
initialized = true
|
||||
} catch (error) {
|
||||
if (generation !== initializationGeneration) return
|
||||
handleError(error)
|
||||
emit('close')
|
||||
} finally {
|
||||
if (generation === initializationGeneration) {
|
||||
loadingEditorData.value = false
|
||||
if (initialized) notifyImageReady()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function notifyImageReady() {
|
||||
if (imageReadyFrame !== undefined) cancelAnimationFrame(imageReadyFrame)
|
||||
imageReadyFrame = requestAnimationFrame(() => {
|
||||
imageReadyFrame = undefined
|
||||
emit('imageReady')
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
else notifyImageReady()
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
notifyImageReady()
|
||||
})
|
||||
}
|
||||
|
||||
function getTextContrast(target: HTMLElement): 'dark' | 'light' {
|
||||
const renderedCanvas = viewport.value?.querySelector<HTMLCanvasElement>('canvas.lower-canvas')
|
||||
const context = renderedCanvas?.getContext('2d', { willReadFrequently: true })
|
||||
if (!renderedCanvas || !context) return 'light'
|
||||
|
||||
const targetBounds = target.getBoundingClientRect()
|
||||
const canvasBounds = renderedCanvas.getBoundingClientRect()
|
||||
const intersection = {
|
||||
left: Math.max(targetBounds.left, canvasBounds.left),
|
||||
top: Math.max(targetBounds.top, canvasBounds.top),
|
||||
right: Math.min(targetBounds.right, canvasBounds.right),
|
||||
bottom: Math.min(targetBounds.bottom, canvasBounds.bottom),
|
||||
}
|
||||
if (intersection.right <= intersection.left || intersection.bottom <= intersection.top)
|
||||
return 'light'
|
||||
|
||||
const scaleX = renderedCanvas.width / canvasBounds.width
|
||||
const scaleY = renderedCanvas.height / canvasBounds.height
|
||||
const sourceX = Math.max(0, Math.floor((intersection.left - canvasBounds.left) * scaleX))
|
||||
const sourceY = Math.max(0, Math.floor((intersection.top - canvasBounds.top) * scaleY))
|
||||
const sourceWidth = Math.min(
|
||||
renderedCanvas.width - sourceX,
|
||||
Math.max(1, Math.ceil((intersection.right - intersection.left) * scaleX)),
|
||||
)
|
||||
const sourceHeight = Math.min(
|
||||
renderedCanvas.height - sourceY,
|
||||
Math.max(1, Math.ceil((intersection.bottom - intersection.top) * scaleY)),
|
||||
)
|
||||
|
||||
try {
|
||||
const pixels = context.getImageData(sourceX, sourceY, sourceWidth, sourceHeight).data
|
||||
const sampleStride = Math.max(1, Math.floor(Math.sqrt((sourceWidth * sourceHeight) / 4096)))
|
||||
let luminanceTotal = 0
|
||||
let sampleCount = 0
|
||||
for (let y = 0; y < sourceHeight; y += sampleStride) {
|
||||
for (let x = 0; x < sourceWidth; x += sampleStride) {
|
||||
const offset = (y * sourceWidth + x) * 4
|
||||
const red = srgbToLinear(pixels[offset] / 255)
|
||||
const green = srgbToLinear(pixels[offset + 1] / 255)
|
||||
const blue = srgbToLinear(pixels[offset + 2] / 255)
|
||||
const alpha = pixels[offset + 3] / 255
|
||||
luminanceTotal += (0.2126 * red + 0.7152 * green + 0.0722 * blue) * alpha
|
||||
sampleCount++
|
||||
}
|
||||
}
|
||||
|
||||
const targetArea = targetBounds.width * targetBounds.height
|
||||
const intersectionArea =
|
||||
(intersection.right - intersection.left) * (intersection.bottom - intersection.top)
|
||||
const coverage = targetArea > 0 ? intersectionArea / targetArea : 0
|
||||
if (coverage < 0.9) return 'light'
|
||||
const averageLuminance = sampleCount > 0 ? luminanceTotal / sampleCount : 0
|
||||
return averageLuminance > 0.179 ? 'dark' : 'light'
|
||||
} catch {
|
||||
return 'light'
|
||||
}
|
||||
}
|
||||
|
||||
function srgbToLinear(value: number) {
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
|
||||
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) {
|
||||
notifyImageReady()
|
||||
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
|
||||
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++
|
||||
if (imageReadyFrame !== undefined) cancelAnimationFrame(imageReadyFrame)
|
||||
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({ getTextContrast, 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"
|
||||
>
|
||||
<img
|
||||
v-if="mode === 'view' && loadingEditorData"
|
||||
:src="item.src"
|
||||
:alt="item.alt"
|
||||
class="pointer-events-none relative z-[2] m-auto block max-h-full max-w-full shrink-0 object-contain"
|
||||
draggable="false"
|
||||
/>
|
||||
<div
|
||||
v-show="!loadingEditorData"
|
||||
class="editor-canvas relative z-[2] m-auto shrink-0"
|
||||
@pointerenter="updateBrushPointer"
|
||||
@pointermove="updateBrushPointer"
|
||||
@pointerleave="brushPointer.visible = false"
|
||||
>
|
||||
<canvas ref="canvasElement" :aria-label="item.alt" role="img" />
|
||||
<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-white/50"
|
||||
aria-live="polite"
|
||||
>
|
||||
<strong class="font-semibold text-white">{{ 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-white/60"
|
||||
@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-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,136 @@
|
||||
import type { ScreenshotCensorMode, ScreenshotEditorSourceRect } from './image-viewer-editor-types'
|
||||
|
||||
const BLUR_DOWNSAMPLE = 0.25
|
||||
const BLUR_SIGMA = 12
|
||||
const BLUR_RADIUS = Math.ceil(BLUR_SIGMA * 3)
|
||||
const BLUR_KERNEL = createGaussianKernel()
|
||||
|
||||
type CensorTransform = readonly [number, number, number, number, number, number]
|
||||
|
||||
export function renderCensorRegion(
|
||||
source: CanvasImageSource,
|
||||
sourceRect: ScreenshotEditorSourceRect,
|
||||
mode: ScreenshotCensorMode,
|
||||
solidColor: string,
|
||||
transform?: CensorTransform,
|
||||
) {
|
||||
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')
|
||||
|
||||
if (mode === 'solid') {
|
||||
outputContext.fillStyle = solidColor
|
||||
outputContext.fillRect(0, 0, width, height)
|
||||
return output
|
||||
}
|
||||
|
||||
const sampleWidth = Math.max(1, Math.round(width * BLUR_DOWNSAMPLE))
|
||||
const sampleHeight = Math.max(1, Math.round(height * BLUR_DOWNSAMPLE))
|
||||
const sample = document.createElement('canvas')
|
||||
sample.width = sampleWidth
|
||||
sample.height = sampleHeight
|
||||
const sampleContext = sample.getContext('2d')
|
||||
if (!sampleContext) throw new Error('Could not create blur canvas')
|
||||
|
||||
if (transform) {
|
||||
const inverse = invertTransform(transform)
|
||||
const scaleX = sampleWidth / width
|
||||
const scaleY = sampleHeight / height
|
||||
sampleContext.setTransform(
|
||||
inverse[0] * scaleX,
|
||||
inverse[1] * scaleY,
|
||||
inverse[2] * scaleX,
|
||||
inverse[3] * scaleY,
|
||||
(inverse[4] + width / 2) * scaleX,
|
||||
(inverse[5] + height / 2) * scaleY,
|
||||
)
|
||||
sampleContext.drawImage(source, 0, 0)
|
||||
sampleContext.resetTransform()
|
||||
} else {
|
||||
sampleContext.drawImage(
|
||||
source,
|
||||
sourceRect.left,
|
||||
sourceRect.top,
|
||||
sourceRect.width,
|
||||
sourceRect.height,
|
||||
0,
|
||||
0,
|
||||
sampleWidth,
|
||||
sampleHeight,
|
||||
)
|
||||
}
|
||||
const blurredPixels = gaussianBlur(
|
||||
sampleContext.getImageData(0, 0, sampleWidth, sampleHeight),
|
||||
sampleWidth,
|
||||
sampleHeight,
|
||||
)
|
||||
sampleContext.putImageData(blurredPixels, 0, 0)
|
||||
outputContext.imageSmoothingEnabled = true
|
||||
outputContext.imageSmoothingQuality = 'high'
|
||||
outputContext.drawImage(sample, 0, 0, sampleWidth, sampleHeight, 0, 0, width, height)
|
||||
return output
|
||||
}
|
||||
|
||||
function invertTransform(transform: CensorTransform): CensorTransform {
|
||||
const [a, b, c, d, e, f] = transform
|
||||
const determinant = a * d - b * c
|
||||
if (Math.abs(determinant) < Number.EPSILON) return [1, 0, 0, 1, 0, 0]
|
||||
return [
|
||||
d / determinant,
|
||||
-b / determinant,
|
||||
-c / determinant,
|
||||
a / determinant,
|
||||
(c * f - d * e) / determinant,
|
||||
(b * e - a * f) / determinant,
|
||||
]
|
||||
}
|
||||
|
||||
function createGaussianKernel() {
|
||||
const kernel = new Float32Array(BLUR_RADIUS * 2 + 1)
|
||||
let total = 0
|
||||
for (let index = -BLUR_RADIUS; index <= BLUR_RADIUS; index++) {
|
||||
const weight = Math.exp(-(index * index) / (2 * BLUR_SIGMA * BLUR_SIGMA))
|
||||
kernel[index + BLUR_RADIUS] = weight
|
||||
total += weight
|
||||
}
|
||||
for (let index = 0; index < kernel.length; index++) kernel[index] /= total
|
||||
return kernel
|
||||
}
|
||||
|
||||
function gaussianBlur(imageData: ImageData, width: number, height: number) {
|
||||
const horizontal = new Float32Array(imageData.data.length)
|
||||
const output = new ImageData(width, height)
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
for (let offset = -BLUR_RADIUS; offset <= BLUR_RADIUS; offset++) {
|
||||
const sampleX = Math.max(0, Math.min(width - 1, x + offset))
|
||||
const sourceIndex = (y * width + sampleX) * 4
|
||||
const weight = BLUR_KERNEL[offset + BLUR_RADIUS]!
|
||||
for (let channel = 0; channel < 4; channel++) {
|
||||
horizontal[(y * width + x) * 4 + channel] +=
|
||||
imageData.data[sourceIndex + channel]! * weight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
for (let offset = -BLUR_RADIUS; offset <= BLUR_RADIUS; offset++) {
|
||||
const sampleY = Math.max(0, Math.min(height - 1, y + offset))
|
||||
const sourceIndex = (sampleY * width + x) * 4
|
||||
const weight = BLUR_KERNEL[offset + BLUR_RADIUS]!
|
||||
for (let channel = 0; channel < 4; channel++) {
|
||||
output.data[(y * width + x) * 4 + channel] += horizontal[sourceIndex + channel]! * weight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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' },
|
||||
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' },
|
||||
blur: { id: 'image-viewer.editor.censor.blur', defaultMessage: 'Blur' },
|
||||
solid: { id: 'image-viewer.editor.censor.solid', defaultMessage: 'Solid' },
|
||||
censorMode: { id: 'image-viewer.editor.censor.mode', defaultMessage: 'Censor type' },
|
||||
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,52 @@
|
||||
export type ImageViewerEditorMode = 'view' | 'edit'
|
||||
|
||||
export type ScreenshotEditorTool =
|
||||
| 'select'
|
||||
| 'pen'
|
||||
| 'highlight'
|
||||
| 'eraser'
|
||||
| 'crop'
|
||||
| 'text'
|
||||
| 'arrow'
|
||||
| 'rectangle'
|
||||
| 'ellipse'
|
||||
| 'censor'
|
||||
|
||||
export type ScreenshotCensorMode = 'blur' | 'solid'
|
||||
|
||||
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
|
||||
censorMode?: ScreenshotCensorMode
|
||||
censorColor?: string
|
||||
}
|
||||
|
||||
export type EditorHistoryEntry = {
|
||||
objects: ScreenshotEditorObjectState[]
|
||||
crop: ScreenshotEditorSourceRect
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { injectImageViewerEditor } from '#ui/providers/image-viewer-editor'
|
||||
|
||||
import Editor from './editor.vue'
|
||||
import type { ImageViewerEditorMode } from './image-viewer-editor-types'
|
||||
import type {
|
||||
ImageViewerEditorData,
|
||||
ImageViewerEditorItem,
|
||||
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 titleContrast = ref<'dark' | 'light'>('light')
|
||||
const descriptionContrast = ref<'dark' | 'light'>('light')
|
||||
const closeAfterEditing = ref(false)
|
||||
const editorComponent = ref<InstanceType<typeof Editor>>()
|
||||
const titleElement = ref<HTMLElement>()
|
||||
const descriptionElement = ref<HTMLElement>()
|
||||
const context = injectImageViewerEditor(null)
|
||||
const itemDataCache = new Map<string, Promise<ImageViewerEditorData>>()
|
||||
const itemImageCache = new Map<string, HTMLImageElement>()
|
||||
let headingContrastTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
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) => {
|
||||
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
|
||||
cancelHeadingContrastUpdate()
|
||||
preloadItemsAround(index)
|
||||
if (activeId.value === null) {
|
||||
titleContrast.value = 'light'
|
||||
descriptionContrast.value = 'light'
|
||||
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
|
||||
cancelHeadingContrastUpdate()
|
||||
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
|
||||
cancelHeadingContrastUpdate()
|
||||
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 updateHeadingContrast() {
|
||||
cancelHeadingContrastUpdate()
|
||||
headingContrastTimer = setTimeout(() => {
|
||||
headingContrastTimer = undefined
|
||||
if (titleElement.value) {
|
||||
titleContrast.value = editorComponent.value?.getTextContrast(titleElement.value) ?? 'light'
|
||||
}
|
||||
if (descriptionElement.value) {
|
||||
descriptionContrast.value =
|
||||
editorComponent.value?.getTextContrast(descriptionElement.value) ?? 'light'
|
||||
}
|
||||
}, 120)
|
||||
}
|
||||
|
||||
function cancelHeadingContrastUpdate() {
|
||||
if (headingContrastTimer === undefined) return
|
||||
clearTimeout(headingContrastTimer)
|
||||
headingContrastTimer = undefined
|
||||
}
|
||||
|
||||
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)
|
||||
cancelHeadingContrastUpdate()
|
||||
itemDataCache.clear()
|
||||
itemImageCache.clear()
|
||||
if (activeId.value !== null) context?.onHide?.()
|
||||
})
|
||||
|
||||
defineExpose({ show, edit, hide, next, previous, markSavedAndView })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<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"
|
||||
class="absolute inset-x-6 top-[calc(var(--top-bar-height,3rem)_+_1.5rem)] z-10 min-w-0"
|
||||
@click.stop
|
||||
>
|
||||
<div class="w-fit min-w-0 max-w-full">
|
||||
<h2
|
||||
v-if="activeItem.title"
|
||||
ref="titleElement"
|
||||
class="m-0 max-w-[min(42rem,70vw)] truncate text-base font-semibold leading-snug transition-colors duration-200 ease-out"
|
||||
:class="titleContrast === 'dark' ? 'text-gray-950' : 'text-white'"
|
||||
>
|
||||
{{ activeItem.title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="activeItem.description"
|
||||
ref="descriptionElement"
|
||||
class="mb-0 mt-1 max-w-[min(42rem,70vw)] truncate text-xs leading-snug opacity-70 transition-colors duration-200 ease-out"
|
||||
:class="descriptionContrast === 'dark' ? 'text-gray-950' : 'text-white'"
|
||||
>
|
||||
{{ activeItem.description }}
|
||||
</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"
|
||||
@cancel="finishEditing"
|
||||
@save="emit('save', $event)"
|
||||
@image-ready="updateHeadingContrast"
|
||||
>
|
||||
<template #actions>
|
||||
<slot name="actions" :item="activeItem" :index="activeIndex" :hide="hide" />
|
||||
</template>
|
||||
</Editor>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -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 ? 'green' : undefined"
|
||||
:class="{
|
||||
'!bg-highlight-green shadow-[inset_0_0_0_1px_var(--color-green)]':
|
||||
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,23 @@
|
||||
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 ImageViewerEditorSavePayload = {
|
||||
item: ImageViewerEditorItem
|
||||
pngBytes: Uint8Array
|
||||
mode: 'create_copy' | 'replace_edit'
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,13 @@ export * from './changelog'
|
||||
export * from './chart'
|
||||
export * from './content'
|
||||
export * from './external_files'
|
||||
export { default as ImageViewerEditor } from './image-viewer-editor/index.vue'
|
||||
export type {
|
||||
ImageViewerEditorData,
|
||||
ImageViewerEditorItem,
|
||||
ImageViewerEditorSavePayload,
|
||||
ImageViewerEditorSource,
|
||||
} from './image-viewer-editor/types'
|
||||
export * from './modal'
|
||||
export * from './nav'
|
||||
export * from './notifications'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="modal-root">
|
||||
<div v-if="open" class="modal-root" data-modal-root :data-modal-id="modalId">
|
||||
<div
|
||||
:class="{ shown: visible }"
|
||||
class="tauri-overlay"
|
||||
@@ -267,6 +267,12 @@ function getFocusableElements(): HTMLElement[] {
|
||||
return Array.from(modalBodyRef.value.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
|
||||
}
|
||||
|
||||
function renderedModalDepth(): number {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('[data-modal-root]')).filter(
|
||||
(root) => root.dataset.modalId !== modalId,
|
||||
).length
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout)
|
||||
@@ -274,7 +280,7 @@ function show(event?: MouseEvent) {
|
||||
}
|
||||
props.onShow?.()
|
||||
const wasEmpty = modalStackSize() === 0
|
||||
stackDepth.value = modalStackSize()
|
||||
stackDepth.value = Math.max(modalStackSize(), renderedModalDepth())
|
||||
open.value = true
|
||||
previousFocusEl = document.activeElement
|
||||
pushModal()
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { computed, type Ref, ref } from 'vue'
|
||||
|
||||
const isClient = typeof window !== 'undefined'
|
||||
const stack: symbol[] = []
|
||||
const stackSizeRef = ref(0)
|
||||
|
||||
type ModalStackState = {
|
||||
stack: symbol[]
|
||||
stackSizeRef: Ref<number>
|
||||
}
|
||||
|
||||
const MODAL_STACK_STATE_KEY = '__modrinth_ui_modal_stack_state__' as const
|
||||
const globalScope = globalThis as typeof globalThis & {
|
||||
[MODAL_STACK_STATE_KEY]?: ModalStackState
|
||||
}
|
||||
const modalStackState: ModalStackState = globalScope[MODAL_STACK_STATE_KEY] ?? {
|
||||
stack: [],
|
||||
stackSizeRef: ref(0),
|
||||
}
|
||||
globalScope[MODAL_STACK_STATE_KEY] = modalStackState
|
||||
|
||||
const { stack, stackSizeRef } = modalStackState
|
||||
|
||||
export function useModalStack() {
|
||||
const id = Symbol()
|
||||
|
||||
@@ -255,7 +255,7 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
|
||||
:provided-message="lockedMessages?.providedBy"
|
||||
/>
|
||||
|
||||
<div class="search [overflow-anchor:none]">
|
||||
<div class="search mt-1 [overflow-anchor:none]">
|
||||
<section v-if="ctx.loading.value" class="offline">
|
||||
<component :is="ctx.loadingComponent ?? LoadingIndicator" />
|
||||
</section>
|
||||
|
||||
@@ -14,49 +14,51 @@
|
||||
@dismiss="ctx.onDismissCrash?.()"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
placeholder="Search logs"
|
||||
wrapper-class="flex-1"
|
||||
size="medium"
|
||||
clearable
|
||||
/>
|
||||
<div v-if="ctx.logSources?.value && ctx.activeLogSourceIndex" class="w-[220px]">
|
||||
<Combobox
|
||||
:model-value="ctx.activeLogSourceIndex.value"
|
||||
:options="logSourceOptions"
|
||||
trigger-size="lg"
|
||||
@update:model-value="(v) => (ctx.activeLogSourceIndex!.value = v)"
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
placeholder="Search logs"
|
||||
wrapper-class="flex-1"
|
||||
size="medium"
|
||||
clearable
|
||||
/>
|
||||
<div v-if="ctx.logSources?.value && ctx.activeLogSourceIndex" class="w-[220px]">
|
||||
<Combobox
|
||||
:model-value="ctx.activeLogSourceIndex.value"
|
||||
:options="logSourceOptions"
|
||||
trigger-size="lg"
|
||||
@update:model-value="(v) => (ctx.activeLogSourceIndex!.value = v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<ConsoleFilterPills
|
||||
v-model="activeFilters"
|
||||
:present-levels="presentLevels"
|
||||
@toggle="handleFilterToggle"
|
||||
/>
|
||||
<ConsoleActionButtons
|
||||
:show-clear="isLiveSource"
|
||||
:has-logs="hasLogs"
|
||||
:share-disabled="resolvedShareDisabled"
|
||||
:sharing="isSharing"
|
||||
:fullscreen="isFullscreen"
|
||||
:clear-disabled="resolvedClearDisabled"
|
||||
:clear-disabled-tooltip="resolvedClearDisabledTooltip"
|
||||
:show-delete="showDelete"
|
||||
:delete-disabled="resolvedDeleteDisabled"
|
||||
:delete-disabled-tooltip="ctx.deleteDisabledTooltip"
|
||||
@clear="handleClear"
|
||||
@share="handleShare"
|
||||
@toggle-fullscreen="toggleFullscreen"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<ConsoleFilterPills
|
||||
v-model="activeFilters"
|
||||
:present-levels="presentLevels"
|
||||
@toggle="handleFilterToggle"
|
||||
/>
|
||||
<ConsoleActionButtons
|
||||
:show-clear="isLiveSource"
|
||||
:has-logs="hasLogs"
|
||||
:share-disabled="resolvedShareDisabled"
|
||||
:sharing="isSharing"
|
||||
:fullscreen="isFullscreen"
|
||||
:clear-disabled="resolvedClearDisabled"
|
||||
:clear-disabled-tooltip="resolvedClearDisabledTooltip"
|
||||
:show-delete="showDelete"
|
||||
:delete-disabled="resolvedDeleteDisabled"
|
||||
:delete-disabled-tooltip="ctx.deleteDisabledTooltip"
|
||||
@clear="handleClear"
|
||||
@share="handleShare"
|
||||
@toggle-fullscreen="toggleFullscreen"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BaseTerminal
|
||||
ref="terminalRef"
|
||||
class="min-h-0 flex-1"
|
||||
|
||||
@@ -936,8 +936,8 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
/>
|
||||
|
||||
<template v-if="ctx.items.value.length > 0">
|
||||
<div class="flex flex-col gap-4">
|
||||
<span v-if="ctx.managedContent.value" class="text-xl font-semibold text-contrast">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span v-if="ctx.managedContent.value" class="mb-2 text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.additionalContent) }}
|
||||
</span>
|
||||
|
||||
@@ -1217,6 +1217,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
|
||||
<ContentCardTable
|
||||
v-model:selected-ids="selectedIds"
|
||||
class="mt-2"
|
||||
:items="tableItems"
|
||||
:show-selection="true"
|
||||
@update:enabled="handleToggleEnabledById"
|
||||
|
||||
+20
-54
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { SearchIcon } from '@modrinth/assets'
|
||||
import Fuse from 'fuse.js/dist/fuse.basic'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '#ui/components/base/buttons/Button.vue'
|
||||
import CheckCircleButton from '#ui/components/base/buttons/CheckCircleButton.vue'
|
||||
import Input from '#ui/components/base/inputs/Input.vue'
|
||||
import {
|
||||
buildLocaleMessages,
|
||||
@@ -71,7 +71,7 @@ type LocaleInfo = {
|
||||
category: Category
|
||||
tag: string
|
||||
displayName: string
|
||||
browserDisplayName: string
|
||||
translatedName: string
|
||||
flagUrl?: string
|
||||
searchTerms?: string[]
|
||||
coverage?: LanguageCoverageStats
|
||||
@@ -82,34 +82,6 @@ const localeFlagRegions: Record<string, string> = {
|
||||
'sr-CS': 'rs',
|
||||
}
|
||||
|
||||
const $browserLocales = ref([props.currentLocale])
|
||||
|
||||
onMounted(() => {
|
||||
$browserLocales.value = navigator.languages.length
|
||||
? [...navigator.languages]
|
||||
: [navigator.language]
|
||||
})
|
||||
|
||||
const $browserDisplayNames = computed(() => {
|
||||
try {
|
||||
return new Intl.DisplayNames($browserLocales.value, { type: 'language' })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
function getBrowserDisplayName(tag: string, fallback: string): string {
|
||||
try {
|
||||
return $browserDisplayNames.value?.of(tag) ?? fallback
|
||||
} catch {
|
||||
try {
|
||||
return $browserDisplayNames.value?.of(tag.split('-')[0]) ?? fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFlagUrl(tag: string): string | undefined {
|
||||
const region = localeFlagRegions[tag] ?? tag.split('-').at(-1)
|
||||
if (!region || !/^[a-z]{2}$/i.test(region)) return undefined
|
||||
@@ -125,14 +97,13 @@ const $locales = computed(() => {
|
||||
const meta = localeMetas[tag] ?? null
|
||||
const displayName = meta?.displayName ?? loc.name
|
||||
const translatedName = formatMessage(loc.translatedName)
|
||||
const browserDisplayName = getBrowserDisplayName(tag, translatedName)
|
||||
const searchTerms = meta?.searchTerms === '-' ? undefined : meta?.searchTerms?.split('\n')
|
||||
|
||||
result.push({
|
||||
tag,
|
||||
category: 'default',
|
||||
displayName,
|
||||
browserDisplayName,
|
||||
translatedName,
|
||||
flagUrl: getFlagUrl(tag),
|
||||
searchTerms,
|
||||
coverage: props.coverageByLocale?.[tag],
|
||||
@@ -149,7 +120,7 @@ const isQueryEmpty = () => $query.value.trim().length === 0
|
||||
const fuse = computed(
|
||||
() =>
|
||||
new Fuse<LocaleInfo>($locales.value, {
|
||||
keys: ['tag', 'displayName', 'browserDisplayName', 'searchTerms'],
|
||||
keys: ['tag', 'displayName', 'translatedName', 'searchTerms'],
|
||||
threshold: 0.4,
|
||||
distance: 100,
|
||||
}),
|
||||
@@ -215,18 +186,16 @@ function onItemClick(e: MouseEvent, loc: LocaleInfo) {
|
||||
changeLocale(loc.tag)
|
||||
}
|
||||
|
||||
function showBrowserDisplayName(loc: LocaleInfo): boolean {
|
||||
return (
|
||||
loc.browserDisplayName.localeCompare(loc.displayName, undefined, { sensitivity: 'base' }) !== 0
|
||||
)
|
||||
function showTranslatedName(loc: LocaleInfo): boolean {
|
||||
return loc.translatedName.localeCompare(loc.displayName, undefined, { sensitivity: 'base' }) !== 0
|
||||
}
|
||||
|
||||
function getItemLabel(loc: LocaleInfo) {
|
||||
const coverageLabel = loc.coverage
|
||||
? `. ${formatMessage(messages.coverageLabel, { percentage: loc.coverage.percentage })}`
|
||||
: ''
|
||||
const browserDisplayName = showBrowserDisplayName(loc) ? `. ${loc.browserDisplayName}` : ''
|
||||
return `${loc.displayName}${browserDisplayName}${coverageLabel}`
|
||||
const translatedName = showTranslatedName(loc) ? `. ${loc.translatedName}` : ''
|
||||
return `${loc.displayName}${translatedName}${coverageLabel}`
|
||||
}
|
||||
|
||||
function getCoverageTooltip(coverage: LanguageCoverageStats): string {
|
||||
@@ -272,7 +241,12 @@ function getCategoryName(category: Category): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="$languagesList" class="flex flex-col gap-2.5">
|
||||
<div
|
||||
ref="$languagesList"
|
||||
role="radiogroup"
|
||||
:aria-label="getCategoryName(isQueryEmpty() ? 'default' : 'searchResult')"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<template v-for="[category, categoryLocales] in $displayCategories" :key="category">
|
||||
<strong class="mt-4 font-semibold text-contrast">
|
||||
{{ getCategoryName(category) }}
|
||||
@@ -287,18 +261,10 @@ function getCategoryName(category: Category): string {
|
||||
</div>
|
||||
|
||||
<template v-for="loc in categoryLocales" :key="loc.tag">
|
||||
<Button
|
||||
:type="$activeLocale === loc.tag ? 'colored' : 'base'"
|
||||
:color="$activeLocale === loc.tag ? 'green' : undefined"
|
||||
:aria-pressed="$activeLocale === loc.tag"
|
||||
<CheckCircleButton
|
||||
:checked="$activeLocale === loc.tag"
|
||||
:disabled="isChangingLocale() && $changingTo !== loc.tag"
|
||||
:aria-label="getItemLabel(loc)"
|
||||
class="w-full !justify-start !gap-2 !text-left sm:!h-10"
|
||||
:class="
|
||||
$activeLocale === loc.tag
|
||||
? '!bg-[var(--color-button-bg-selected)] !text-[var(--color-button-text-selected)]'
|
||||
: ''
|
||||
"
|
||||
@click="(e) => onItemClick(e, loc)"
|
||||
>
|
||||
<img
|
||||
@@ -313,10 +279,10 @@ function getCategoryName(category: Category): string {
|
||||
<span class="flex min-w-0 flex-1 items-baseline gap-2 overflow-hidden">
|
||||
<span class="truncate text-sm sm:text-base">{{ loc.displayName }}</span>
|
||||
<span
|
||||
v-if="showBrowserDisplayName(loc)"
|
||||
v-if="showTranslatedName(loc)"
|
||||
class="truncate text-xs font-normal text-secondary sm:text-sm"
|
||||
>
|
||||
{{ loc.browserDisplayName }}
|
||||
{{ loc.translatedName }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -327,7 +293,7 @@ function getCategoryName(category: Category): string {
|
||||
>
|
||||
{{ loc.coverage.percentage }}%
|
||||
</span>
|
||||
</Button>
|
||||
</CheckCircleButton>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -2018,6 +2018,117 @@
|
||||
"icon-select.select": {
|
||||
"defaultMessage": "Select icon"
|
||||
},
|
||||
"image-viewer.close": {
|
||||
"defaultMessage": "Close"
|
||||
},
|
||||
"image-viewer.edit": {
|
||||
"defaultMessage": "Edit"
|
||||
},
|
||||
"image-viewer.editor.censor.blur": {
|
||||
"defaultMessage": "Blur"
|
||||
},
|
||||
"image-viewer.editor.censor.mode": {
|
||||
"defaultMessage": "Censor type"
|
||||
},
|
||||
"image-viewer.editor.censor.solid": {
|
||||
"defaultMessage": "Solid"
|
||||
},
|
||||
"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.next": {
|
||||
"defaultMessage": "Next image"
|
||||
},
|
||||
"image-viewer.previous": {
|
||||
"defaultMessage": "Previous image"
|
||||
},
|
||||
"input.search-version.placeholder": {
|
||||
"defaultMessage": "Search version..."
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
@@ -8,6 +8,7 @@ export * from './file-drop'
|
||||
export * from './file-picker'
|
||||
export * from './hosting-purchase-intent'
|
||||
export * from './i18n'
|
||||
export * from './image-viewer-editor'
|
||||
export * from './instance-import'
|
||||
export * from './loading-state'
|
||||
export * from './modal-behavior'
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { Button } from '../../components/base/buttons'
|
||||
import ImageViewerEditor from '../../components/image-viewer-editor/index.vue'
|
||||
import type { ImageViewerEditorItem } from '../../components/image-viewer-editor/types'
|
||||
import { provideImageViewerEditor } from '../../providers'
|
||||
|
||||
const IMAGE_SVG = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop stop-color="#172554" />
|
||||
<stop offset="1" stop-color="#14532d" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1600" height="900" fill="url(#sky)" />
|
||||
<circle cx="1260" cy="210" r="110" fill="#fde68a" opacity="0.9" />
|
||||
<path d="M0 720 360 390 680 690 1040 330 1600 760V900H0Z" fill="#0f172a" />
|
||||
<path d="M0 790 390 560 720 780 1110 510 1600 800V900H0Z" fill="#166534" />
|
||||
</svg>
|
||||
`
|
||||
const IMAGE_URL = `data:image/svg+xml,${encodeURIComponent(IMAGE_SVG)}`
|
||||
const items: ImageViewerEditorItem[] = [
|
||||
{
|
||||
id: 'mountains',
|
||||
src: IMAGE_URL,
|
||||
alt: 'Stylised mountain landscape',
|
||||
title: 'Mountain base at sunset',
|
||||
description: 'Survival world · August 24 at 4:12 PM',
|
||||
editorSource: { id: 'mountains', path: 'mountains.svg' },
|
||||
},
|
||||
{
|
||||
id: 'valley',
|
||||
src: IMAGE_URL,
|
||||
alt: 'Stylised valley landscape',
|
||||
title: 'View from the valley',
|
||||
description: 'Survival world · August 24 at 4:18 PM',
|
||||
editorSource: { id: 'valley', path: 'valley.svg' },
|
||||
},
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'Base/ImageViewerEditor',
|
||||
component: ImageViewerEditor,
|
||||
} satisfies Meta<typeof ImageViewerEditor>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof ImageViewerEditor>
|
||||
|
||||
function render(editor: 'enabled' | 'disabled') {
|
||||
return () => ({
|
||||
components: { Button, ImageViewerEditor },
|
||||
setup() {
|
||||
const viewer = ref<InstanceType<typeof ImageViewerEditor>>()
|
||||
provideImageViewerEditor({
|
||||
loadEditorData: async () => ({
|
||||
source: new Blob([IMAGE_SVG], { type: 'image/svg+xml' }),
|
||||
}),
|
||||
})
|
||||
return {
|
||||
editor,
|
||||
items,
|
||||
open: () => viewer.value?.show(0),
|
||||
openEditor: () => viewer.value?.edit(0),
|
||||
viewer,
|
||||
}
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div class="flex gap-2">
|
||||
<Button type="colored" color="brand" @click="open">Open image viewer</Button>
|
||||
<Button v-if="editor === 'enabled'" type="outlined" @click="openEditor">
|
||||
Open image editor
|
||||
</Button>
|
||||
</div>
|
||||
<ImageViewerEditor ref="viewer" :items="items" :editor="editor" />
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
export const ViewerOnly: Story = {
|
||||
render: render('disabled'),
|
||||
}
|
||||
|
||||
export const Editable: Story = {
|
||||
render: render('enabled'),
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import CheckCircleButton from '../../components/base/buttons/CheckCircleButton.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Buttons/CheckCircleButton',
|
||||
component: CheckCircleButton,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { CheckCircleButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div class="w-96">
|
||||
<CheckCircleButton v-bind="args">Fabric 26.2</CheckCircleButton>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
} satisfies Meta<typeof CheckCircleButton>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const States: Story = {
|
||||
render: () => ({
|
||||
components: { CheckCircleButton },
|
||||
template: /*html*/ `
|
||||
<div class="flex w-96 flex-col gap-1" role="radiogroup" aria-label="Example choices">
|
||||
<CheckCircleButton :checked="true">Selected choice</CheckCircleButton>
|
||||
<CheckCircleButton :checked="false">Unselected choice</CheckCircleButton>
|
||||
<CheckCircleButton :checked="false" disabled>Disabled choice</CheckCircleButton>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
@@ -57,6 +57,31 @@ export const WithActions: Story = {
|
||||
}),
|
||||
}
|
||||
|
||||
export const Stacked: Story = {
|
||||
render: () => ({
|
||||
components: { NewModal, Button },
|
||||
setup() {
|
||||
const parentModalRef = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const childModalRef = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const openParentModal = () => parentModalRef.value?.show()
|
||||
const openChildModal = () => childModalRef.value?.show()
|
||||
return { parentModalRef, childModalRef, openParentModal, openChildModal }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<Button type="colored" color="brand" @click="openParentModal">Open Parent Modal</Button>
|
||||
<NewModal ref="parentModalRef" header="Parent Modal">
|
||||
<p>The child modal should appear above this surface and backdrop.</p>
|
||||
<Button type="colored" color="brand" @click="openChildModal">Open Child Modal</Button>
|
||||
</NewModal>
|
||||
<NewModal ref="childModalRef" header="Child Modal" max-width="500px">
|
||||
<p>This modal is the topmost layer.</p>
|
||||
</NewModal>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const DangerFade: Story = {
|
||||
render: () => ({
|
||||
components: { NewModal, Button },
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import ace from 'ace-builds'
|
||||
|
||||
ace['define'](
|
||||
'ace/mode/mcfunction_highlight_rules',
|
||||
['require', 'exports', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
|
||||
function (require: any, exports: any) {
|
||||
const oop = require('ace/lib/oop')
|
||||
const TextHighlightRules = require('ace/mode/text_highlight_rules').TextHighlightRules
|
||||
|
||||
const McfunctionHighlightRules = function (this: any) {
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: 'comment.doc',
|
||||
regex: /^\s*#>.*$/.source,
|
||||
},
|
||||
{
|
||||
token: 'comment',
|
||||
regex: /^\s*#!.*$/.source,
|
||||
},
|
||||
{
|
||||
token: 'comment',
|
||||
regex: /^\s*##.*$/.source,
|
||||
},
|
||||
{
|
||||
token: 'comment',
|
||||
regex: /^\s*#(?![a-z0-9_.]+:).*$/i.source,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: /"(?:\\.|[^"\\])*"/.source,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: /'(?:\\.|[^'\\])*'/.source,
|
||||
},
|
||||
{
|
||||
token: 'variable',
|
||||
regex: /\$\([a-zA-Z0-9_]+\)/.source,
|
||||
},
|
||||
{
|
||||
token: 'constant.language',
|
||||
regex: /@[apers]\b/i.source,
|
||||
},
|
||||
{
|
||||
token: 'constant.language',
|
||||
regex: /\b[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\b/i.source,
|
||||
},
|
||||
{
|
||||
token: 'support.constant',
|
||||
regex: /#?[a-z0-9_.]+:[a-z0-9_./-]+/i.source,
|
||||
},
|
||||
{
|
||||
token: 'keyword',
|
||||
regex: /\brun\s+\/?[a-z][a-z0-9_]*/i.source,
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /[~^]-?(?:\d*\.)?\d+/.source,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator',
|
||||
regex: /[~^]/.source,
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /-?(?:\d*\.)?\d+[bdfils]?\b/i.source,
|
||||
},
|
||||
{
|
||||
token: 'constant.language',
|
||||
regex: /\.\.|\b(?:true|false)\b/i.source,
|
||||
},
|
||||
{
|
||||
token: 'keyword',
|
||||
regex: /^\s*\/?[a-z][a-z0-9_]*/i.source,
|
||||
},
|
||||
],
|
||||
}
|
||||
this.normalizeRules()
|
||||
}
|
||||
|
||||
oop.inherits(McfunctionHighlightRules, TextHighlightRules)
|
||||
exports.McfunctionHighlightRules = McfunctionHighlightRules
|
||||
},
|
||||
)
|
||||
|
||||
ace['define'](
|
||||
'ace/mode/mcfunction',
|
||||
['require', 'exports', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/mcfunction_highlight_rules'],
|
||||
function (require: any, exports: any) {
|
||||
const oop = require('ace/lib/oop')
|
||||
const TextMode = require('ace/mode/text').Mode
|
||||
const McfunctionHighlightRules =
|
||||
require('ace/mode/mcfunction_highlight_rules').McfunctionHighlightRules
|
||||
|
||||
const Mode = function (this: any) {
|
||||
this.HighlightRules = McfunctionHighlightRules
|
||||
this.$id = 'ace/mode/mcfunction'
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
exports.Mode = Mode
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user