diff options
Diffstat (limited to 'packages/frontend/src/components')
6 files changed, 690 insertions, 105 deletions
diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue index 0cb8499699..4f16149caa 100644 --- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue +++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue @@ -24,9 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveToClass="$style.transition_x_leaveTo" > <div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot"> - <div - :class="$style.embedCodeGenPreviewRoot" - > + <div :class="[$style.embedCodeGenPreviewRoot, prefer.s.animation ? $style.animatedBg : null]"> <MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/> <div :class="$style.embedCodeGenPreviewWrapper"> <div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div> @@ -91,20 +89,18 @@ import { url } from '@@/js/config.js'; import { embedRouteWithScrollbar } from '@@/js/embed-page.js'; import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; - import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkButton from '@/components/MkButton.vue'; - import MkCode from '@/components/MkCode.vue'; import MkInfo from '@/components/MkInfo.vue'; - import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { useMkSelect } from '@/composables/use-mkselect.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js'; +import { prefer } from '@/preferences.js'; const emit = defineEmits<{ (ev: 'ok'): void; @@ -314,10 +310,19 @@ onUnmounted(() => { .embedCodeGenPreviewRoot { position: relative; - background-color: var(--MI_THEME-bg); - background-size: auto auto; - background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px); cursor: not-allowed; + background-color: var(--MI_THEME-bg); + background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%); + background-size: 20px 20px; +} + +.animatedBg { + animation: bg 1.2s linear infinite; +} + +@keyframes bg { + 0% { background-position: 0 0; } + 100% { background-position: -20px -20px; } } .embedCodeGenPreviewWrapper { diff --git a/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue b/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue index f734325039..bc7e8b0946 100644 --- a/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue +++ b/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkFolder :defaultOpen="true" :canPage="false"> - <template #label>{{ fx.name }}</template> + <template #label>{{ fx.uiDefinition.name }}</template> <template #footer> <div class="_buttons"> <MkButton iconOnly @click="emit('del')"><i class="ti ti-trash"></i></MkButton> @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> - <MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.params" /> + <MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.uiDefinition.params"/> </MkFolder> </template> @@ -26,14 +26,14 @@ import MkImageEffectorFxForm from '@/components/MkImageEffectorFxForm.vue'; import { FXS } from '@/utility/image-effector/fxs.js'; const layer = defineModel<ImageEffectorLayer>('layer', { required: true }); -const fx = FXS.find((fx) => fx.id === layer.value.fxId); +const fx = FXS[layer.value.fxId]; if (fx == null) { throw new Error(`Unrecognized effect: ${layer.value.fxId}`); } const emit = defineEmits<{ - (e: 'del'): void; - (e: 'swapUp'): void; - (e: 'swapDown'): void; + (ev: 'del'): void; + (ev: 'swapUp'): void; + (ev: 'swapDown'): void; }>(); </script> diff --git a/packages/frontend/src/components/MkImageEffectorDialog.vue b/packages/frontend/src/components/MkImageEffectorDialog.vue index 19ddb81919..3d7801f925 100644 --- a/packages/frontend/src/components/MkImageEffectorDialog.vue +++ b/packages/frontend/src/components/MkImageEffectorDialog.vue @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.root"> <div :class="$style.container"> - <div :class="$style.preview"> + <div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]"> <canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown.prevent.stop="onImagePointerdown"></canvas> <div :class="$style.previewContainer"> <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> @@ -64,6 +64,7 @@ import * as os from '@/os.js'; import { deepClone } from '@/utility/clone.js'; import { FXS } from '@/utility/image-effector/fxs.js'; import { genId } from '@/utility/id.js'; +import { prefer } from '@/preferences.js'; const props = defineProps<{ image: File; @@ -94,19 +95,19 @@ const layers = reactive<ImageEffectorLayer[]>([]); watch(layers, async () => { if (renderer != null) { - renderer.setLayers(layers); + renderer.render(layers); } }, { deep: true }); function addEffect(ev: MouseEvent) { - os.popupMenu(FXS.map((fx) => ({ - text: fx.name, + os.popupMenu(Object.entries(FXS).map(([id, fx]) => ({ + text: fx.uiDefinition.name, action: () => { layers.push({ id: genId(), - fxId: fx.id, - params: Object.fromEntries(Object.entries(fx.params).map(([k, v]) => [k, v.default])), - }); + fxId: id as keyof typeof FXS, + params: Object.fromEntries(Object.entries(fx.uiDefinition.params).map(([k, v]) => [k, v.default])), + } as ImageEffectorLayer); }, })), ev.currentTarget ?? ev.target); } @@ -136,7 +137,7 @@ function onLayerDelete(layer: ImageEffectorLayer) { const canvasEl = useTemplateRef('canvasEl'); -let renderer: ImageEffector<typeof FXS> | null = null; +let renderer: ImageEffector | null = null; let imageBitmap: ImageBitmap | null = null; onMounted(async () => { @@ -146,30 +147,35 @@ onMounted(async () => { await nextTick(); // waitingがレンダリングされるまで待つ - imageBitmap = await window.createImageBitmap(props.image); + try { + imageBitmap = await window.createImageBitmap(props.image); - const MAX_W = 1000; - const MAX_H = 1000; - let w = imageBitmap.width; - let h = imageBitmap.height; + const MAX_W = 1000; + const MAX_H = 1000; + let w = imageBitmap.width; + let h = imageBitmap.height; - if (w > MAX_W || h > MAX_H) { - const scale = Math.min(MAX_W / w, MAX_H / h); - w *= scale; - h *= scale; - } - - renderer = new ImageEffector({ - canvas: canvasEl.value, - renderWidth: w, - renderHeight: h, - image: imageBitmap, - fxs: FXS, - }); + if (w > MAX_W || h > MAX_H) { + const scale = Math.min(MAX_W / w, MAX_H / h); + w = Math.floor(w * scale); + h = Math.floor(h * scale); + } - await renderer.setLayers(layers); + renderer = new ImageEffector({ + canvas: canvasEl.value, + renderWidth: w, + renderHeight: h, + image: imageBitmap, + }); - renderer.render(); + await renderer.render(layers); + } catch (err) { + console.error(err); + os.alert({ + type: 'error', + text: i18n.ts._imageEffector.failedToLoadImage, + }); + } closeWaiting(); }); @@ -196,7 +202,7 @@ async function save() { await nextTick(); // waitingがレンダリングされるまで待つ renderer.changeResolution(imageBitmap.width, imageBitmap.height); // 本番レンダリングのためオリジナル画質に戻す - renderer.render(); // toBlobの直前にレンダリングしないと何故か壊れる + await renderer.render(layers); // toBlobの直前にレンダリングしないと何故か壊れる canvasEl.value.toBlob((blob) => { emit('ok', new File([blob!], `image-${Date.now()}.png`, { type: 'image/png' })); dialog.value?.close(); @@ -208,11 +214,10 @@ const enabled = ref(true); watch(enabled, () => { if (renderer != null) { if (enabled.value) { - renderer.setLayers(layers); + renderer.render(layers); } else { - renderer.setLayers([]); + renderer.render([]); } - renderer.render(); } }); @@ -281,6 +286,7 @@ function onImagePointerdown(ev: PointerEvent) { angle: 0, opacity: 1, color: [1, 1, 1], + ellipse: false, }, }); } else if (penMode.value === 'blur') { @@ -294,6 +300,7 @@ function onImagePointerdown(ev: PointerEvent) { scaleY: 0.1, angle: 0, radius: 3, + ellipse: false, }, }); } else if (penMode.value === 'pixelate') { @@ -307,6 +314,7 @@ function onImagePointerdown(ev: PointerEvent) { scaleY: 0.1, angle: 0, strength: 0.2, + ellipse: false, }, }); } @@ -329,7 +337,7 @@ function onImagePointerdown(ev: PointerEvent) { const scaleY = Math.abs(y - startY); const layerIndex = layers.findIndex((l) => l.id === id); - const layer = layerIndex !== -1 ? layers[layerIndex] : null; + const layer = layerIndex !== -1 ? (layers[layerIndex] as Extract<ImageEffectorLayer, { fxId: 'fill' } | { fxId: 'blur' } | { fxId: 'pixelate' }>) : null; if (layer != null) { layer.params.offsetX = (x + startX) - 1; layer.params.offsetY = (y + startY) - 1; @@ -373,8 +381,17 @@ function onImagePointerdown(ev: PointerEvent) { .preview { position: relative; background-color: var(--MI_THEME-bg); - background-size: auto auto; - background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px); + background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%); + background-size: 20px 20px; +} + +.animatedBg { + animation: bg 1.2s linear infinite; +} + +@keyframes bg { + 0% { background-position: 0 0; } + 100% { background-position: -20px -20px; } } .previewContainer { diff --git a/packages/frontend/src/components/MkImageFrameEditorDialog.vue b/packages/frontend/src/components/MkImageFrameEditorDialog.vue new file mode 100644 index 0000000000..2a91c85952 --- /dev/null +++ b/packages/frontend/src/components/MkImageFrameEditorDialog.vue @@ -0,0 +1,509 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialog" + :width="1000" + :height="600" + :scroll="false" + :withOkButton="true" + @close="cancel()" + @ok="save()" + @closed="emit('closed')" +> + <template #header><i class="ti ti-device-ipad-horizontal"></i> {{ i18n.ts._imageFrameEditor.title }}</template> + + <div :class="$style.root"> + <div :class="$style.container"> + <div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]"> + <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> + <div :class="$style.previewContainer"> + <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> + <div v-if="props.image == null" class="_acrylic" :class="$style.previewControls"> + <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button> + <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button> + <button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button> + </div> + </div> + </div> + <div :class="$style.controls"> + <div class="_spacer _gaps"> + <MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true"> + <template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template> + </MkRange> + + <MkInput :modelValue="getHex(params.bgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.bgColor = c; }"> + <template #label>{{ i18n.ts._imageFrameEditor.backgroundColor }}</template> + </MkInput> + + <MkInput :modelValue="getHex(params.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.fgColor = c; }"> + <template #label>{{ i18n.ts._imageFrameEditor.textColor }}</template> + </MkInput> + + <MkSelect + v-model="params.font" :items="[ + { label: i18n.ts._imageFrameEditor.fontSansSerif, value: 'sans-serif' }, + { label: i18n.ts._imageFrameEditor.fontSerif, value: 'serif' }, + ]" + > + <template #label>{{ i18n.ts._imageFrameEditor.font }}</template> + </MkSelect> + + <MkFolder :defaultOpen="params.labelTop.enabled"> + <template #label>{{ i18n.ts._imageFrameEditor.header }}</template> + + <div class="_gaps"> + <MkSwitch v-model="params.labelTop.enabled"> + <template #label>{{ i18n.ts.show }}</template> + </MkSwitch> + + <MkRange v-model="params.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true"> + <template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template> + </MkRange> + + <MkRange v-model="params.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true"> + <template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template> + </MkRange> + + <MkSwitch v-model="params.labelTop.centered"> + <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template> + </MkSwitch> + + <MkInput v-model="params.labelTop.textBig"> + <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template> + </MkInput> + + <MkTextarea v-model="params.labelTop.textSmall"> + <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template> + </MkTextarea> + + <MkSwitch v-model="params.labelTop.withQrCode"> + <template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template> + </MkSwitch> + </div> + </MkFolder> + + <MkFolder :defaultOpen="params.labelBottom.enabled"> + <template #label>{{ i18n.ts._imageFrameEditor.footer }}</template> + + <div class="_gaps"> + <MkSwitch v-model="params.labelBottom.enabled"> + <template #label>{{ i18n.ts.show }}</template> + </MkSwitch> + + <MkRange v-model="params.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true"> + <template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template> + </MkRange> + + <MkRange v-model="params.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true"> + <template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template> + </MkRange> + + <MkSwitch v-model="params.labelBottom.centered"> + <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template> + </MkSwitch> + + <MkInput v-model="params.labelBottom.textBig"> + <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template> + </MkInput> + + <MkTextarea v-model="params.labelBottom.textSmall"> + <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template> + </MkTextarea> + + <MkSwitch v-model="params.labelBottom.withQrCode"> + <template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template> + </MkSwitch> + </div> + </MkFolder> + + <MkInfo> + <div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div> + <div><code class="_selectableAtomic">{filename}</code> - {{ i18n.ts._imageEditing._vars.filename }}</div> + <div><code class="_selectableAtomic">{filename_without_ext}</code> - {{ i18n.ts._imageEditing._vars.filename_without_ext }}</div> + <div><code class="_selectableAtomic">{caption}</code> - {{ i18n.ts._imageEditing._vars.caption }}</div> + <div><code class="_selectableAtomic">{year}</code> - {{ i18n.ts._imageEditing._vars.year }}</div> + <div><code class="_selectableAtomic">{month}</code> - {{ i18n.ts._imageEditing._vars.month }}</div> + <div><code class="_selectableAtomic">{day}</code> - {{ i18n.ts._imageEditing._vars.day }}</div> + <div><code class="_selectableAtomic">{hour}</code> - {{ i18n.ts._imageEditing._vars.hour }}</div> + <div><code class="_selectableAtomic">{minute}</code> - {{ i18n.ts._imageEditing._vars.minute }}</div> + <div><code class="_selectableAtomic">{second}</code> - {{ i18n.ts._imageEditing._vars.second }}</div> + <div><code class="_selectableAtomic">{0month}</code> - {{ i18n.ts._imageEditing._vars.month }} ({{ i18n.ts.zeroPadding }})</div> + <div><code class="_selectableAtomic">{0day}</code> - {{ i18n.ts._imageEditing._vars.day }} ({{ i18n.ts.zeroPadding }})</div> + <div><code class="_selectableAtomic">{0hour}</code> - {{ i18n.ts._imageEditing._vars.hour }} ({{ i18n.ts.zeroPadding }})</div> + <div><code class="_selectableAtomic">{0minute}</code> - {{ i18n.ts._imageEditing._vars.minute }} ({{ i18n.ts.zeroPadding }})</div> + <div><code class="_selectableAtomic">{0second}</code> - {{ i18n.ts._imageEditing._vars.second }} ({{ i18n.ts.zeroPadding }})</div> + <div><code class="_selectableAtomic">{camera_model}</code> - {{ i18n.ts._imageEditing._vars.camera_model }}</div> + <div><code class="_selectableAtomic">{camera_lens_model}</code> - {{ i18n.ts._imageEditing._vars.camera_lens_model }}</div> + <div><code class="_selectableAtomic">{camera_mm}</code> - {{ i18n.ts._imageEditing._vars.camera_mm }}</div> + <div><code class="_selectableAtomic">{camera_mm_35}</code> - {{ i18n.ts._imageEditing._vars.camera_mm_35 }}</div> + <div><code class="_selectableAtomic">{camera_f}</code> - {{ i18n.ts._imageEditing._vars.camera_f }}</div> + <div><code class="_selectableAtomic">{camera_s}</code> - {{ i18n.ts._imageEditing._vars.camera_s }}</div> + <div><code class="_selectableAtomic">{camera_iso}</code> - {{ i18n.ts._imageEditing._vars.camera_iso }}</div> + <div><code class="_selectableAtomic">{gps_lat}</code> - {{ i18n.ts._imageEditing._vars.gps_lat }}</div> + <div><code class="_selectableAtomic">{gps_long}</code> - {{ i18n.ts._imageEditing._vars.gps_long }}</div> + </MkInfo> + </div> + </div> + </div> + </div> +</MkModalWindow> +</template> + +<script setup lang="ts"> +import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue'; +import ExifReader from 'exifreader'; +import { throttle } from 'throttle-debounce'; +import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js'; +import { ImageFrameRenderer } from '@/utility/image-frame-renderer/ImageFrameRenderer.js'; +import { i18n } from '@/i18n.js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkRange from '@/components/MkRange.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import * as os from '@/os.js'; +import { deepClone } from '@/utility/clone.js'; +import { ensureSignin } from '@/i.js'; +import { genId } from '@/utility/id.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; +import { prefer } from '@/preferences.js'; + +const $i = ensureSignin(); + +const props = defineProps<{ + presetEditMode?: boolean; + preset?: ImageFramePreset | null; + params?: ImageFrameParams | null; + image?: File | null; + imageCaption?: string | null; + imageFilename?: string | null; +}>(); + +const preset = deepClone(props.preset) ?? { + id: genId(), + name: '', +}; + +const params = reactive<ImageFrameParams>(deepClone(props.params) ?? { + borderThickness: 0.05, + borderRadius: 0, + labelTop: { + enabled: false, + scale: 1.0, + padding: 0.2, + textBig: '', + textSmall: '', + centered: false, + withQrCode: false, + }, + labelBottom: { + enabled: true, + scale: 1.0, + padding: 0.2, + textBig: '{year}/{0month}/{0day}', + textSmall: '{camera_mm}mm f/{camera_f} {camera_s}s ISO{camera_iso}', + centered: false, + withQrCode: true, + }, + bgColor: [1, 1, 1], + fgColor: [0, 0, 0], + font: 'sans-serif', +}); + +const emit = defineEmits<{ + (ev: 'ok', frame: ImageFrameParams): void; + (ev: 'presetOk', preset: ImageFramePreset): void; + (ev: 'cancel'): void; + (ev: 'closed'): void; +}>(); + +const dialog = useTemplateRef('dialog'); + +async function cancel() { + if (props.presetEditMode) { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts._imageFrameEditor.quitWithoutSaveConfirm, + }); + if (canceled) return; + } + + dialog.value?.close(); +} + +const updateThrottled = throttle(50, () => { + if (renderer != null) { + renderer.render(params); + } +}); + +watch(params, async (newValue, oldValue) => { + updateThrottled(); +}, { deep: true }); + +const canvasEl = useTemplateRef('canvasEl'); + +const sampleImage_3_2 = new Image(); +sampleImage_3_2.src = '/client-assets/sample/3-2.jpg'; +const sampleImage_3_2_loading = new Promise<void>(resolve => { + sampleImage_3_2.onload = () => resolve(); +}); + +const sampleImage_2_3 = new Image(); +sampleImage_2_3.src = '/client-assets/sample/2-3.jpg'; +const sampleImage_2_3_loading = new Promise<void>(resolve => { + sampleImage_2_3.onload = () => resolve(); +}); + +const sampleImageType = ref(props.image != null ? 'provided' : '3_2'); +watch(sampleImageType, async () => { + if (sampleImageType.value === 'provided') return; + if (renderer != null) { + renderer.destroy(false); + renderer = null; + initRenderer(); + } +}); + +let imageFile = props.image; + +async function choiceImage() { + const files = await os.chooseFileFromPc({ multiple: false }); + if (files.length === 0) return; + imageFile = files[0]; + sampleImageType.value = 'provided'; + if (renderer != null) { + renderer.destroy(false); + renderer = null; + initRenderer(); + } +} + +let renderer: ImageFrameRenderer | null = null; +let imageBitmap: ImageBitmap | null = null; + +async function initRenderer() { + if (canvasEl.value == null) return; + + if (sampleImageType.value === '3_2') { + renderer = new ImageFrameRenderer({ + canvas: canvasEl.value, + image: sampleImage_3_2, + exif: null, + caption: 'Example caption', + filename: 'example_file_name.jpg', + renderAsPreview: true, + }); + } else if (sampleImageType.value === '2_3') { + renderer = new ImageFrameRenderer({ + canvas: canvasEl.value, + image: sampleImage_2_3, + exif: null, + caption: 'Example caption', + filename: 'example_file_name.jpg', + renderAsPreview: true, + }); + } else if (imageFile != null) { + imageBitmap = await window.createImageBitmap(imageFile); + + const exif = ExifReader.load(await imageFile.arrayBuffer()); + + renderer = new ImageFrameRenderer({ + canvas: canvasEl.value, + image: imageBitmap, + exif: exif, + caption: props.imageCaption ?? null, + filename: props.imageFilename ?? null, + renderAsPreview: true, + }); + } + + await renderer!.render(params); +} + +onMounted(async () => { + const closeWaiting = os.waiting(); + + await nextTick(); // waitingがレンダリングされるまで待つ + + await sampleImage_3_2_loading; + await sampleImage_2_3_loading; + + try { + await initRenderer(); + } catch (err) { + console.error(err); + os.alert({ + type: 'error', + text: i18n.ts._imageFrameEditor.failedToLoadImage, + }); + } + + closeWaiting(); +}); + +onUnmounted(() => { + if (renderer != null) { + renderer.destroy(); + renderer = null; + } + if (imageBitmap != null) { + imageBitmap.close(); + imageBitmap = null; + } +}); + +async function save() { + if (props.presetEditMode) { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts.name, + default: preset.name, + }); + if (canceled) return; + + preset.name = name || ''; + + dialog.value?.close(); + if (renderer != null) { + renderer.destroy(); + renderer = null; + } + + emit('presetOk', { + ...preset, + params: deepClone(params), + }); + } else { + dialog.value?.close(); + if (renderer != null) { + renderer.destroy(); + renderer = null; + } + + emit('ok', params); + } +} + +function getHex(c: [number, number, number]) { + return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`; +} + +function getRgb(hex: string | number): [number, number, number] | null { + if ( + typeof hex === 'number' || + typeof hex !== 'string' || + !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex) + ) { + return null; + } + + const m = hex.slice(1).match(/[0-9a-fA-F]{2}/g); + if (m == null) return [0, 0, 0]; + return m.map(x => parseInt(x, 16) / 255) as [number, number, number]; +} +</script> + +<style module> +.root { + container-type: inline-size; + height: 100%; +} + +.container { + height: 100%; + display: grid; + grid-template-columns: 1fr 400px; +} + +.preview { + position: relative; + background-color: var(--MI_THEME-bg); + background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%); + background-size: 20px 20px; +} + +.animatedBg { + animation: bg 1.2s linear infinite; +} + +@keyframes bg { + 0% { background-position: 0 0; } + 100% { background-position: -20px -20px; } +} + +.previewContainer { + display: flex; + flex-direction: column; + height: 100%; + user-select: none; + -webkit-user-drag: none; +} + +.previewTitle { + position: absolute; + z-index: 100; + top: 8px; + left: 8px; + padding: 6px 10px; + border-radius: 6px; + font-size: 85%; +} + +.previewControls { + position: absolute; + z-index: 100; + bottom: 8px; + right: 8px; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 6px; +} + +.previewControlsButton { + &.active { + color: var(--MI_THEME-accent); + } +} + +.previewSpinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} + +.previewCanvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 20px; + box-sizing: border-box; + object-fit: contain; +} + +.controls { + overflow-y: scroll; +} + +@container (max-width: 800px) { + .container { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + } +} +</style> diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue index b34181e5cc..154b3ffc27 100644 --- a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue @@ -345,7 +345,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { ref, onMounted, computed } from 'vue'; import * as Misskey from 'misskey-js'; -import type { WatermarkPreset } from '@/utility/watermark.js'; +import type { WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.vue index 3b3f20d8d1..6cd2111598 100644 --- a/packages/frontend/src/components/MkWatermarkEditorDialog.vue +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.vue @@ -18,20 +18,21 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.root"> <div :class="$style.container"> - <div :class="$style.preview"> + <div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]"> <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> <div :class="$style.previewContainer"> <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> <div v-if="props.image == null" class="_acrylic" :class="$style.previewControls"> <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button> <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button> + <button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button> </div> </div> </div> <div :class="$style.controls"> <div class="_spacer _gaps"> <div class="_gaps_s"> - <MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false"> + <MkFolder v-for="(layer, i) in layers" :key="layer.id" :defaultOpen="false" :canPage="false"> <template #label> <div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div> <div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div> @@ -49,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <XLayer - v-model:layer="preset.layers[i]" + v-model:layer="layers[i]" ></XLayer> </MkFolder> @@ -64,8 +65,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue'; -import type { WatermarkPreset } from '@/utility/watermark.js'; -import { WatermarkRenderer } from '@/utility/watermark.js'; +import type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js'; +import { WatermarkRenderer } from '@/utility/watermark/WatermarkRenderer.js'; import { i18n } from '@/i18n.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkSelect from '@/components/MkSelect.vue'; @@ -77,6 +78,7 @@ import { deepClone } from '@/utility/clone.js'; import { ensureSignin } from '@/i.js'; import { genId } from '@/utility/id.js'; import { useMkSelect } from '@/composables/use-mkselect.js'; +import { prefer } from '@/preferences.js'; const $i = ensureSignin(); @@ -161,18 +163,22 @@ function createCheckerLayer(): WatermarkPreset['layers'][number] { } const props = defineProps<{ + presetEditMode?: boolean; preset?: WatermarkPreset | null; + layers?: WatermarkLayers | null; image?: File | null; }>(); -const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? { +const preset = deepClone(props.preset) ?? { id: genId(), name: '', - layers: [], -}); +}; + +const layers = reactive<WatermarkLayers>(props.layers ?? []); const emit = defineEmits<{ - (ev: 'ok', preset: WatermarkPreset): void; + (ev: 'ok', layers: WatermarkLayers): void; + (ev: 'presetOk', preset: WatermarkPreset): void; (ev: 'cancel'): void; (ev: 'closed'): void; }>(); @@ -180,19 +186,21 @@ const emit = defineEmits<{ const dialog = useTemplateRef('dialog'); async function cancel() { - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm, - }); - if (canceled) return; + if (props.presetEditMode) { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm, + }); + if (canceled) return; + } emit('cancel'); dialog.value?.close(); } -watch(preset, async (newValue, oldValue) => { +watch(layers, async (newValue, oldValue) => { if (renderer != null) { - renderer.setLayers(preset.layers); + renderer.render(layers); } }, { deep: true }); @@ -212,6 +220,7 @@ const sampleImage_2_3_loading = new Promise<void>(resolve => { const sampleImageType = ref(props.image != null ? 'provided' : '3_2'); watch(sampleImageType, async () => { + if (sampleImageType.value === 'provided') return; if (renderer != null) { renderer.destroy(false); renderer = null; @@ -219,6 +228,20 @@ watch(sampleImageType, async () => { } }); +let imageFile = props.image; + +async function choiceImage() { + const files = await os.chooseFileFromPc({ multiple: false }); + if (files.length === 0) return; + imageFile = files[0]; + sampleImageType.value = 'provided'; + if (renderer != null) { + renderer.destroy(false); + renderer = null; + initRenderer(); + } +} + let renderer: WatermarkRenderer | null = null; let imageBitmap: ImageBitmap | null = null; @@ -239,8 +262,8 @@ async function initRenderer() { renderHeight: 1500, image: sampleImage_2_3, }); - } else if (props.image != null) { - imageBitmap = await window.createImageBitmap(props.image); + } else if (imageFile != null) { + imageBitmap = await window.createImageBitmap(imageFile); const MAX_W = 1000; const MAX_H = 1000; @@ -249,8 +272,8 @@ async function initRenderer() { if (w > MAX_W || h > MAX_H) { const scale = Math.min(MAX_W / w, MAX_H / h); - w *= scale; - h *= scale; + w = Math.floor(w * scale); + h = Math.floor(h * scale); } renderer = new WatermarkRenderer({ @@ -261,9 +284,7 @@ async function initRenderer() { }); } - await renderer!.setLayers(preset.layers); - - renderer!.render(); + await renderer!.render(layers); } onMounted(async () => { @@ -274,7 +295,15 @@ onMounted(async () => { await sampleImage_3_2_loading; await sampleImage_2_3_loading; - await initRenderer(); + try { + await initRenderer(); + } catch (err) { + console.error(err); + os.alert({ + type: 'error', + text: i18n.ts._watermarkEditor.failedToLoadImage, + }); + } closeWaiting(); }); @@ -291,77 +320,93 @@ onUnmounted(() => { }); async function save() { - const { canceled, result: name } = await os.inputText({ - title: i18n.ts.name, - default: preset.name, - }); - if (canceled) return; + if (props.presetEditMode) { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts.name, + default: preset.name, + }); + if (canceled) return; - preset.name = name || ''; + preset.name = name || ''; - dialog.value?.close(); - if (renderer != null) { - renderer.destroy(); - renderer = null; - } + dialog.value?.close(); + if (renderer != null) { + renderer.destroy(); + renderer = null; + } - emit('ok', preset); + emit('presetOk', { + ...preset, + layers: deepClone(layers), + }); + } else { + dialog.value?.close(); + if (renderer != null) { + renderer.destroy(); + renderer = null; + } + + emit('ok', layers); + } } function addLayer(ev: MouseEvent) { os.popupMenu([{ text: i18n.ts._watermarkEditor.text, action: () => { - preset.layers.push(createTextLayer()); + layers.push(createTextLayer()); }, }, { text: i18n.ts._watermarkEditor.image, action: () => { - preset.layers.push(createImageLayer()); + layers.push(createImageLayer()); }, }, { text: i18n.ts._watermarkEditor.qr, action: () => { - preset.layers.push(createQrLayer()); + layers.push(createQrLayer()); }, }, { text: i18n.ts._watermarkEditor.stripe, action: () => { - preset.layers.push(createStripeLayer()); + layers.push(createStripeLayer()); }, }, { text: i18n.ts._watermarkEditor.polkadot, action: () => { - preset.layers.push(createPolkadotLayer()); + layers.push(createPolkadotLayer()); }, }, { text: i18n.ts._watermarkEditor.checker, action: () => { - preset.layers.push(createCheckerLayer()); + layers.push(createCheckerLayer()); }, }], ev.currentTarget ?? ev.target); } function swapUpLayer(layer: WatermarkPreset['layers'][number]) { - const index = preset.layers.findIndex(l => l.id === layer.id); + const index = layers.findIndex(l => l.id === layer.id); if (index > 0) { - const tmp = preset.layers[index - 1]; - preset.layers[index - 1] = preset.layers[index]; - preset.layers[index] = tmp; + const tmp = layers[index - 1]; + layers[index - 1] = layers[index]; + layers[index] = tmp; } } function swapDownLayer(layer: WatermarkPreset['layers'][number]) { - const index = preset.layers.findIndex(l => l.id === layer.id); - if (index < preset.layers.length - 1) { - const tmp = preset.layers[index + 1]; - preset.layers[index + 1] = preset.layers[index]; - preset.layers[index] = tmp; + const index = layers.findIndex(l => l.id === layer.id); + if (index < layers.length - 1) { + const tmp = layers[index + 1]; + layers[index + 1] = layers[index]; + layers[index] = tmp; } } function removeLayer(layer: WatermarkPreset['layers'][number]) { - preset.layers = preset.layers.filter(l => l.id !== layer.id); + const index = layers.findIndex(l => l.id === layer.id); + if (index !== -1) { + layers.splice(index, 1); + } } </script> @@ -380,8 +425,17 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) { .preview { position: relative; background-color: var(--MI_THEME-bg); - background-size: auto auto; - background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px); + background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%); + background-size: 20px 20px; +} + +.animatedBg { + animation: bg 1.2s linear infinite; +} + +@keyframes bg { + 0% { background-position: 0 0; } + 100% { background-position: -20px -20px; } } .previewContainer { |