diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-06-03 19:18:29 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-06-03 19:18:29 +0900 |
| commit | cd9322a8243b12632db2dd9a29a702d7531a5aa0 (patch) | |
| tree | 2828957ed7c27c537386cda13ace2372903185b8 /packages/frontend/src/pages | |
| parent | chore(frontend): remove duplicate declarations (diff) | |
| download | misskey-cd9322a8243b12632db2dd9a29a702d7531a5aa0.tar.gz misskey-cd9322a8243b12632db2dd9a29a702d7531a5aa0.tar.bz2 misskey-cd9322a8243b12632db2dd9a29a702d7531a5aa0.zip | |
feat(frontend): 画像編集機能 (#16121)
* wip
* wip
* wip
* wip
* Update watermarker.ts
* wip
* wip
* Update watermarker.ts
* Update MkUploaderDialog.vue
* wip
* Update ImageEffector.ts
* Update ImageEffector.ts
* wip
* wip
* wip
* wip
* wip
* wip
* Update MkRange.vue
* Update MkRange.vue
* wip
* wip
* Update MkImageEffectorDialog.vue
* Update MkImageEffectorDialog.Layer.vue
* wip
* Update zoomLines.ts
* Update zoomLines.ts
* wip
* wip
* Update ImageEffector.ts
* wip
* Update ImageEffector.ts
* wip
* Update ImageEffector.ts
* swip
* wip
* Update ImageEffector.ts
* wop
* Update MkUploaderDialog.vue
* Update ImageEffector.ts
* wip
* wip
* wip
* Update def.ts
* Update def.ts
* test
* test
* Update manager.ts
* Update manager.ts
* Update manager.ts
* Update manager.ts
* Update MkImageEffectorDialog.vue
* wip
* use WEBGL_lose_context
* wip
* Update MkUploaderDialog.vue
* Update drive.vue
* wip
* Update MkUploaderDialog.vue
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
Diffstat (limited to 'packages/frontend/src/pages')
| -rw-r--r-- | packages/frontend/src/pages/settings/drive.WatermarkItem.vue | 112 | ||||
| -rw-r--r-- | packages/frontend/src/pages/settings/drive.vue | 186 |
2 files changed, 266 insertions, 32 deletions
diff --git a/packages/frontend/src/pages/settings/drive.WatermarkItem.vue b/packages/frontend/src/pages/settings/drive.WatermarkItem.vue new file mode 100644 index 0000000000..b466f35fc5 --- /dev/null +++ b/packages/frontend/src/pages/settings/drive.WatermarkItem.vue @@ -0,0 +1,112 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkFolder :defaultOpen="false" :canPage="false"> + <template #icon><i class="ti ti-pencil"></i></template> + <template #label>{{ i18n.ts.preset }}: {{ preset.name === '' ? '(' + i18n.ts.noName + ')' : preset.name }}</template> + <template #footer> + <div class="_buttons"> + <MkButton @click="edit"><i class="ti ti-pencil"></i> {{ i18n.ts.edit }}</MkButton> + <MkButton danger iconOnly style="margin-left: auto;" @click="del"><i class="ti ti-trash"></i></MkButton> + </div> + </template> + + <div> + <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> + </div> +</MkFolder> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'; +import type { WatermarkPreset } from '@/utility/watermark.js'; +import { WatermarkRenderer } from '@/utility/watermark.js'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import { deepClone } from '@/utility/clone.js'; +import MkFolder from '@/components/MkFolder.vue'; + +const props = defineProps<{ + preset: WatermarkPreset; +}>(); + +const emit = defineEmits<{ + (ev: 'updatePreset', preset: WatermarkPreset): void, + (ev: 'del'): void, +}>(); + +async function edit() { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWatermarkEditorDialog.vue')), { + preset: deepClone(props.preset), + }, { + ok: (preset: WatermarkPreset) => { + emit('updatePreset', preset); + }, + closed: () => dispose(), + }); +} + +function del(ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts.delete, + action: () => { + emit('del'); + }, + }], ev.currentTarget ?? ev.target); +} + +const canvasEl = useTemplateRef('canvasEl'); + +const sampleImage = new Image(); +sampleImage.src = '/client-assets/sample/3-2.jpg'; + +let renderer: WatermarkRenderer | null = null; + +onMounted(() => { + sampleImage.onload = async () => { + watch(canvasEl, async () => { + if (canvasEl.value == null) return; + + renderer = new WatermarkRenderer({ + canvas: canvasEl.value, + renderWidth: 1500, + renderHeight: 1000, + image: sampleImage, + }); + + await renderer.setLayers(props.preset.layers); + + renderer.render(); + }, { immediate: true }); + }; +}); + +onUnmounted(() => { + if (renderer != null) { + renderer.destroy(); + renderer = null; + } +}); + +watch(() => props.preset, async () => { + if (renderer != null) { + await renderer.setLayers(props.preset.layers); + renderer.render(); + } +}, { deep: true }); +</script> + +<style lang="scss" module> +.previewCanvas { + display: block; + width: 100%; + height: 100%; + max-height: 200px; + box-sizing: border-box; + object-fit: contain; +} +</style> diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index d62e487341..0614b1242b 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -39,53 +39,122 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSection> </SearchMarker> - <FormSection> - <div class="_gaps_m"> - <SearchMarker :keywords="['default', 'upload', 'folder']"> - <FormLink @click="chooseUploadFolder()"> - <SearchLabel>{{ i18n.ts.uploadFolder }}</SearchLabel> - <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> - <template #suffixIcon><i class="ti ti-folder"></i></template> + <SearchMarker :keywords="['general']"> + <FormSection> + <template #label><SearchLabel>{{ i18n.ts.general }}</SearchLabel></template> + + <div class="_gaps_m"> + <SearchMarker :keywords="['default', 'upload', 'folder']"> + <FormLink @click="chooseUploadFolder()"> + <SearchLabel>{{ i18n.ts.uploadFolder }}</SearchLabel> + <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> + <template #suffixIcon><i class="ti ti-folder"></i></template> + </FormLink> + </SearchMarker> + + <FormLink to="/settings/drive/cleaner"> + {{ i18n.ts.drivecleaner }} </FormLink> - </SearchMarker> - <FormLink to="/settings/drive/cleaner"> - {{ i18n.ts.drivecleaner }} - </FormLink> + <SearchMarker :keywords="['keep', 'original', 'filename']"> + <MkPreferenceContainer k="keepOriginalFilename"> + <MkSwitch v-model="keepOriginalFilename"> + <template #label><SearchLabel>{{ i18n.ts.keepOriginalFilename }}</SearchLabel></template> + <template #caption><SearchKeyword>{{ i18n.ts.keepOriginalFilenameDescription }}</SearchKeyword></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + + <SearchMarker :keywords="['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file']"> + <MkSwitch v-model="alwaysMarkNsfw" @update:modelValue="saveProfile()"> + <template #label><SearchLabel>{{ i18n.ts.alwaysMarkSensitive }}</SearchLabel></template> + </MkSwitch> + </SearchMarker> - <SearchMarker :keywords="['keep', 'original', 'filename']"> - <MkPreferenceContainer k="keepOriginalFilename"> - <MkSwitch v-model="keepOriginalFilename"> - <template #label><SearchLabel>{{ i18n.ts.keepOriginalFilename }}</SearchLabel></template> - <template #caption><SearchKeyword>{{ i18n.ts.keepOriginalFilenameDescription }}</SearchKeyword></template> + <SearchMarker :keywords="['auto', 'nsfw', 'sensitive', 'media', 'file']"> + <MkSwitch v-model="autoSensitive" @update:modelValue="saveProfile()"> + <template #label><SearchLabel>{{ i18n.ts.enableAutoSensitive }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template> + <template #caption><SearchKeyword>{{ i18n.ts.enableAutoSensitiveDescription }}</SearchKeyword></template> </MkSwitch> - </MkPreferenceContainer> - </SearchMarker> + </SearchMarker> + </div> + </FormSection> + </SearchMarker> - <SearchMarker :keywords="['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file']"> - <MkSwitch v-model="alwaysMarkNsfw" @update:modelValue="saveProfile()"> - <template #label><SearchLabel>{{ i18n.ts.alwaysMarkSensitive }}</SearchLabel></template> - </MkSwitch> - </SearchMarker> + <SearchMarker :keywords="['image']"> + <FormSection> + <template #label><SearchLabel>{{ i18n.ts.image }}</SearchLabel></template> + + <div class="_gaps_m"> + <SearchMarker :keywords="['watermark', 'credit']"> + <MkFolder> + <template #icon><i class="ti ti-copyright"></i></template> + <template #label><SearchLabel>{{ i18n.ts.watermark }}</SearchLabel></template> + <template #caption>{{ i18n.ts._watermarkEditor.tip }}</template> + + <div class="_gaps"> + <div class="_gaps_s"> + <XWatermarkItem + v-for="(preset, i) in prefer.r.watermarkPresets.value" + :key="preset.id" + :preset="preset" + @updatePreset="onUpdateWatermarkPreset(preset.id, $event)" + @del="onDeleteWatermarkPreset(preset.id)" + /> + + <MkButton iconOnly rounded style="margin: 0 auto;" @click="addWatermarkPreset"><i class="ti ti-plus"></i></MkButton> - <SearchMarker :keywords="['auto', 'nsfw', 'sensitive', 'media', 'file']"> - <MkSwitch v-model="autoSensitive" @update:modelValue="saveProfile()"> - <template #label><SearchLabel>{{ i18n.ts.enableAutoSensitive }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template> - <template #caption><SearchKeyword>{{ i18n.ts.enableAutoSensitiveDescription }}</SearchKeyword></template> - </MkSwitch> - </SearchMarker> - </div> - </FormSection> + <SearchMarker :keywords="['sync', 'watermark', 'preset', 'devices']"> + <MkSwitch :modelValue="watermarkPresetsSyncEnabled" @update:modelValue="changeWatermarkPresetsSyncEnabled"> + <template #label><i class="ti ti-cloud-cog"></i> <SearchLabel>{{ i18n.ts.syncBetweenDevices }}</SearchLabel></template> + </MkSwitch> + </SearchMarker> + </div> + + <hr> + + <SearchMarker :keywords="['default', 'watermark', 'preset']"> + <MkPreferenceContainer k="defaultWatermarkPresetId"> + <MkSelect v-model="defaultWatermarkPresetId" :items="[{ label: i18n.ts.none, value: null }, ...prefer.r.watermarkPresets.value.map(p => ({ label: p.name || i18n.ts.noName, value: p.id }))]"> + <template #label><SearchLabel>{{ i18n.ts.defaultPreset }}</SearchLabel></template> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + </div> + </MkFolder> + </SearchMarker> + + <SearchMarker :keywords="['default', 'image', 'compression']"> + <MkPreferenceContainer k="defaultImageCompressionLevel"> + <MkSelect + v-model="defaultImageCompressionLevel" :items="[ + { label: i18n.ts.none, value: 0 }, + { label: i18n.ts.low, value: 1 }, + { label: i18n.ts.medium, value: 2 }, + { label: i18n.ts.high, value: 3 }, + ]" + > + <template #label><SearchLabel>{{ i18n.ts.defaultImageCompressionLevel }}</SearchLabel></template> + <template #caption><div v-html="i18n.ts.defaultImageCompressionLevel_description"></div></template> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + </div> + </FormSection> + </SearchMarker> </div> </SearchMarker> </template> <script lang="ts" setup> -import { computed, ref } from 'vue'; +import { computed, defineAsyncComponent, ref } from 'vue'; import * as Misskey from 'misskey-js'; import tinycolor from 'tinycolor2'; +import XWatermarkItem from './drive.WatermarkItem.vue'; +import type { WatermarkPreset } from '@/utility/watermark.js'; import FormLink from '@/components/form/link.vue'; import MkSwitch from '@/components/MkSwitch.vue'; +import MkSelect from '@/components/MkSelect.vue'; import FormSection from '@/components/form/section.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import FormSplit from '@/components/form/split.vue'; @@ -100,6 +169,8 @@ import { prefer } from '@/preferences.js'; import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; import { selectDriveFolder } from '@/utility/drive.js'; +import MkFolder from '@/components/MkFolder.vue'; +import MkButton from '@/components/MkButton.vue'; const $i = ensureSignin(); @@ -123,6 +194,22 @@ const meterStyle = computed(() => { }); const keepOriginalFilename = prefer.model('keepOriginalFilename'); +const defaultWatermarkPresetId = prefer.model('defaultWatermarkPresetId'); +const defaultImageCompressionLevel = prefer.model('defaultImageCompressionLevel'); + +const watermarkPresetsSyncEnabled = ref(prefer.isSyncEnabled('watermarkPresets')); + +function changeWatermarkPresetsSyncEnabled(value: boolean) { + if (value) { + prefer.enableSync('watermarkPresets').then((res) => { + if (res == null) return; + if (res.enabled) watermarkPresetsSyncEnabled.value = true; + }); + } else { + prefer.disableSync('watermarkPresets'); + watermarkPresetsSyncEnabled.value = false; + } +} misskeyApi('drive').then(info => { capacity.value = info.capacity; @@ -152,6 +239,41 @@ function chooseUploadFolder() { }); } +async function addWatermarkPreset() { + const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), { + }, { + ok: (preset: WatermarkPreset) => { + prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]); + }, + closed: () => dispose(), + }); +} + +function onUpdateWatermarkPreset(id: string, preset: WatermarkPreset) { + const index = prefer.s.watermarkPresets.findIndex(p => p.id === id); + if (index !== -1) { + prefer.commit('watermarkPresets', [ + ...prefer.s.watermarkPresets.slice(0, index), + preset, + ...prefer.s.watermarkPresets.slice(index + 1), + ]); + } +} + +function onDeleteWatermarkPreset(id: string) { + const index = prefer.s.watermarkPresets.findIndex(p => p.id === id); + if (index !== -1) { + prefer.commit('watermarkPresets', [ + ...prefer.s.watermarkPresets.slice(0, index), + ...prefer.s.watermarkPresets.slice(index + 1), + ]); + + if (prefer.s.defaultWatermarkPresetId === id) { + prefer.commit('defaultWatermarkPresetId', null); + } + } +} + function saveProfile() { misskeyApi('i/update', { alwaysMarkNsfw: !!alwaysMarkNsfw.value, |