summaryrefslogtreecommitdiff
path: root/packages/frontend/src/pages
diff options
context:
space:
mode:
authorsyuilo <4439005+syuilo@users.noreply.github.com>2025-06-03 19:18:29 +0900
committerGitHub <noreply@github.com>2025-06-03 19:18:29 +0900
commitcd9322a8243b12632db2dd9a29a702d7531a5aa0 (patch)
tree2828957ed7c27c537386cda13ace2372903185b8 /packages/frontend/src/pages
parentchore(frontend): remove duplicate declarations (diff)
downloadmisskey-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.vue112
-rw-r--r--packages/frontend/src/pages/settings/drive.vue186
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,