mirror of
https://github.com/misskey-dev/misskey.git
synced 2024-12-28 03:40:25 +01:00
wip
repeat-x, repeat-yは実装が難しすぎるので断念
This commit is contained in:
parent
79882895c5
commit
dc197da055
7 changed files with 446 additions and 138 deletions
18
locales/index.d.ts
vendored
18
locales/index.d.ts
vendored
|
@ -10661,6 +10661,24 @@ export interface Locale extends ILocale {
|
||||||
*/
|
*/
|
||||||
"sent": string;
|
"sent": string;
|
||||||
};
|
};
|
||||||
|
"_watermarkEditor": {
|
||||||
|
/**
|
||||||
|
* このファイルは対応していません
|
||||||
|
*/
|
||||||
|
"driveFileTypeWarn": string;
|
||||||
|
/**
|
||||||
|
* 画像ファイルを選択してください
|
||||||
|
*/
|
||||||
|
"driveFileTypeWarnDescription": string;
|
||||||
|
/**
|
||||||
|
* 描画モード
|
||||||
|
*/
|
||||||
|
"repeatSetting": string;
|
||||||
|
/**
|
||||||
|
* 全体を埋め尽くす
|
||||||
|
*/
|
||||||
|
"repeat": string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
declare const locales: {
|
declare const locales: {
|
||||||
[lang: string]: Locale;
|
[lang: string]: Locale;
|
||||||
|
|
|
@ -2841,3 +2841,9 @@ _selfXssPrevention:
|
||||||
_followRequest:
|
_followRequest:
|
||||||
recieved: "受け取った申請"
|
recieved: "受け取った申請"
|
||||||
sent: "送った申請"
|
sent: "送った申請"
|
||||||
|
|
||||||
|
_watermarkEditor:
|
||||||
|
driveFileTypeWarn: "このファイルは対応していません"
|
||||||
|
driveFileTypeWarnDescription: "画像ファイルを選択してください"
|
||||||
|
repeatSetting: "描画モード"
|
||||||
|
repeat: "全体を埋め尽くす"
|
||||||
|
|
|
@ -0,0 +1,94 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div :class="$style.anchorGridRoot">
|
||||||
|
<div v-for="anchor in watermarkAnchor" :class="$style.anchorGridItem">
|
||||||
|
<input type="radio" :name="id" :id="`${id}-${anchor}`" :value="anchor" :class="$style.anchorGridItemRadio" v-model="value"/>
|
||||||
|
<label :for="`${id}-${anchor}`" :class="$style.anchorGridItemLabel">
|
||||||
|
<div :class="$style.anchorGridItemInner">{{ langMap[anchor] }}</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useId } from 'vue';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import { watermarkAnchor } from '@/scripts/watermark.js';
|
||||||
|
import type { WatermarkAnchor } from '@/scripts/watermark.js';
|
||||||
|
|
||||||
|
const langMap = {
|
||||||
|
'top': i18n.ts.top,
|
||||||
|
'top-left': i18n.ts.leftTop,
|
||||||
|
'top-right': i18n.ts.rightTop,
|
||||||
|
'left': i18n.ts.left,
|
||||||
|
'right': i18n.ts.right,
|
||||||
|
'bottom': i18n.ts.bottom,
|
||||||
|
'bottom-left': i18n.ts.leftBottom,
|
||||||
|
'bottom-right': i18n.ts.rightBottom,
|
||||||
|
'center': i18n.ts.center,
|
||||||
|
} satisfies Record<WatermarkAnchor, string>;
|
||||||
|
|
||||||
|
const value = defineModel<WatermarkAnchor | undefined | null>({ required: true });
|
||||||
|
|
||||||
|
const id = useId();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style module>
|
||||||
|
.anchorGridRoot {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
grid-template-rows: repeat(3, 1fr);
|
||||||
|
border-radius: var(--MI-radius);
|
||||||
|
overflow: clip;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: thin solid var(--MI_THEME-divider);
|
||||||
|
background-color: var(--MI_THEME-divider);
|
||||||
|
gap: 1px;
|
||||||
|
max-width: 242px; /* 240px + 左右ボーダー2px */
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 3/2;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorGridItemRadio {
|
||||||
|
position: absolute;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorGridItem {
|
||||||
|
background-color: var(--MI_THEME-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorGridItemLabel {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorGridItemInner {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorGridItemInner:hover {
|
||||||
|
background-color: var(--MI_THEME-buttonHoverBg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorGridItemRadio:checked + .anchorGridItemLabel .anchorGridItemInner {
|
||||||
|
background-color: var(--MI_THEME-accentedBg);
|
||||||
|
color: var(--MI_THEME-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchorGridItemRadio:focus-visible + .anchorGridItemLabel .anchorGridItemInner {
|
||||||
|
outline: 2px solid var(--MI_THEME-accent);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -19,8 +19,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div :class="$style.watermarkEditorRoot">
|
<div :class="$style.watermarkEditorRoot">
|
||||||
<div :class="$style.watermarkEditorInputRoot">
|
<div :class="$style.watermarkEditorInputRoot">
|
||||||
<div :class="$style.watermarkEditorPreviewRoot">
|
<div :class="$style.watermarkEditorPreviewRoot">
|
||||||
<MkLoading v-if="canvasLoading" :class="$style.watermarkEditorPreviewSpinner"/>
|
|
||||||
<canvas ref="canvasEl" :class="$style.watermarkEditorPreviewCanvas"></canvas>
|
<canvas ref="canvasEl" :class="$style.watermarkEditorPreviewCanvas"></canvas>
|
||||||
|
<MkLoading v-if="canvasLoading" :class="$style.watermarkEditorPreviewSpinner"/>
|
||||||
<div :class="$style.watermarkEditorPreviewWrapper">
|
<div :class="$style.watermarkEditorPreviewWrapper">
|
||||||
<div class="_acrylic" :class="$style.watermarkEditorPreviewTitle">{{ i18n.ts.preview }}</div>
|
<div class="_acrylic" :class="$style.watermarkEditorPreviewTitle">{{ i18n.ts.preview }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -30,6 +30,39 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template #label>{{ i18n.ts.useWatermark }}</template>
|
<template #label>{{ i18n.ts.useWatermark }}</template>
|
||||||
<template #caption>{{ i18n.ts.useWatermarkDescription }}</template>
|
<template #caption>{{ i18n.ts.useWatermarkDescription }}</template>
|
||||||
</MkSwitch>
|
</MkSwitch>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div :class="$style.formLabel">{{ i18n.ts.watermark }}</div>
|
||||||
|
<div :class="$style.fileSelectorRoot">
|
||||||
|
<MkButton :class="$style.fileSelectorButton" inline rounded primary @click="chooseFile">{{ i18n.ts.selectFile }}</MkButton>
|
||||||
|
<div :class="['_nowrap', !fileUrl && $style.fileNotSelected]">{{ friendlyFileName }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="fileId != null || fileUrl != null">
|
||||||
|
<MkRange v-model="sizeRatio" :min="0" :max="1" :step="0.01" :textConverter="(v) => `${Math.floor(v * 100)}%`">
|
||||||
|
<template #label>{{ i18n.ts.size }}</template>
|
||||||
|
</MkRange>
|
||||||
|
|
||||||
|
<MkRange v-model="opacity" :min="0" :max="1" :step="0.01" :textConverter="(v) => `${Math.floor(v * 100)}%`">
|
||||||
|
<template #label>{{ i18n.ts.opacity }}</template>
|
||||||
|
</MkRange>
|
||||||
|
|
||||||
|
<MkRange v-model="rotate" :min="-45" :max="45" :textConverter="(v) => `${Math.floor(v)}°`">
|
||||||
|
<template #label>{{ i18n.ts.rotate }}</template>
|
||||||
|
</MkRange>
|
||||||
|
|
||||||
|
<MkRadios v-model="repeat">
|
||||||
|
<template #label>{{ i18n.ts._watermarkEditor.repeatSetting }}</template>
|
||||||
|
<option :value="true">{{ i18n.ts._watermarkEditor.repeat }}</option>
|
||||||
|
<option :value="false">{{ i18n.ts.normal }}</option>
|
||||||
|
</MkRadios>
|
||||||
|
|
||||||
|
<div v-if="watermarkConfig?.repeat !== true">
|
||||||
|
<div :class="$style.formLabel">{{ i18n.ts.position }}</div>
|
||||||
|
<XAnchorSelector v-model="anchor"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -37,13 +70,21 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { shallowRef, ref, useTemplateRef, computed, onMounted } from 'vue';
|
import { shallowRef, ref, useTemplateRef, computed, watch, onMounted } from 'vue';
|
||||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import MkRadios from '@/components/MkRadios.vue';
|
||||||
import MkSwitch from '@/components/MkSwitch.vue';
|
import MkSwitch from '@/components/MkSwitch.vue';
|
||||||
|
import MkInput from '@/components/MkInput.vue';
|
||||||
|
import MkRange from '@/components/MkRange.vue';
|
||||||
|
import XAnchorSelector from '@/components/MkWatermarkEditorDialog.anchor.vue';
|
||||||
|
|
||||||
|
import * as os from '@/os.js';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { applyWatermark, WatermarkConfig } from '@/scripts/watermark.js';
|
import { selectFile } from '@/scripts/select-file.js';
|
||||||
|
import { applyWatermark, canPreview, WatermarkConfig } from '@/scripts/watermark.js';
|
||||||
|
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'ok'): void;
|
(ev: 'ok'): void;
|
||||||
|
@ -67,26 +108,109 @@ function save() {
|
||||||
|
|
||||||
//#region 設定
|
//#region 設定
|
||||||
const useWatermark = computed(defaultStore.makeGetterSetter('useWatermark'));
|
const useWatermark = computed(defaultStore.makeGetterSetter('useWatermark'));
|
||||||
const watermarkConfig = ref<WatermarkConfig>(defaultStore.state.watermarkConfig ?? {
|
const watermarkConfig = ref<Partial<WatermarkConfig> | null>(defaultStore.state.watermarkConfig ?? {
|
||||||
fileUrl: '/client-assets/default-watermark.png',
|
opacity: 0.2,
|
||||||
enlargement: 'contain',
|
|
||||||
opacity: 0.5,
|
|
||||||
anchor: 'bottom-right',
|
|
||||||
gravity: 'auto',
|
|
||||||
repeat: true,
|
repeat: true,
|
||||||
rotate: 15,
|
rotate: 15,
|
||||||
__bypassMediaProxy: true,
|
sizeRatio: 0.2,
|
||||||
});
|
});
|
||||||
|
const anchor = computed({
|
||||||
|
get: () => watermarkConfig.value != null && 'anchor' in watermarkConfig.value ? watermarkConfig.value.anchor : null,
|
||||||
|
set: (v) => {
|
||||||
|
if (v == null || watermarkConfig.value?.repeat === true) {
|
||||||
|
watermarkConfig.value = { ...watermarkConfig.value, anchor: undefined };
|
||||||
|
} else if (watermarkConfig.value?.repeat === false) {
|
||||||
|
watermarkConfig.value = { ...watermarkConfig.value, anchor: v };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const sizeRatio = computed({
|
||||||
|
get: () => watermarkConfig.value?.sizeRatio ?? 0.2,
|
||||||
|
set: (v) => watermarkConfig.value = { ...watermarkConfig.value, sizeRatio: v },
|
||||||
|
});
|
||||||
|
const repeat = computed({
|
||||||
|
get: () => watermarkConfig.value?.repeat ?? true,
|
||||||
|
set: (v) => watermarkConfig.value = { ...watermarkConfig.value, repeat: v },
|
||||||
|
});
|
||||||
|
const opacity = computed({
|
||||||
|
get: () => watermarkConfig.value?.opacity ?? 0.2,
|
||||||
|
set: (v) => watermarkConfig.value = { ...watermarkConfig.value, opacity: v },
|
||||||
|
});
|
||||||
|
const rotate = computed({
|
||||||
|
get: () => watermarkConfig.value?.rotate ?? 15,
|
||||||
|
set: (v) => watermarkConfig.value = { ...watermarkConfig.value, rotate: v },
|
||||||
|
});
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
//#region ファイル選択
|
||||||
|
const fileId = computed({
|
||||||
|
get: () => watermarkConfig.value?.fileId,
|
||||||
|
set: (v) => watermarkConfig.value = { ...watermarkConfig.value, fileId: v },
|
||||||
|
});
|
||||||
|
const fileUrl = computed({
|
||||||
|
get: () => watermarkConfig.value?.fileUrl,
|
||||||
|
set: (v) => watermarkConfig.value = { ...watermarkConfig.value, fileUrl: v },
|
||||||
|
});
|
||||||
|
const fileName = ref<string>('');
|
||||||
|
const driveFileError = ref(false);
|
||||||
|
onMounted(async () => {
|
||||||
|
if (watermarkConfig.value?.fileId != null) {
|
||||||
|
await misskeyApi('drive/files/show', {
|
||||||
|
fileId: watermarkConfig.value.fileId,
|
||||||
|
}).then((res) => {
|
||||||
|
fileName.value = res.name;
|
||||||
|
}).catch((err) => {
|
||||||
|
driveFileError.value = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const friendlyFileName = computed<string>(() => {
|
||||||
|
if (fileName.value) {
|
||||||
|
return fileName.value;
|
||||||
|
}
|
||||||
|
if (fileUrl.value) {
|
||||||
|
return fileUrl.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return i18n.ts._soundSettings.driveFileWarn;
|
||||||
|
});
|
||||||
|
|
||||||
|
function chooseFile(ev: MouseEvent) {
|
||||||
|
selectFile(ev.currentTarget ?? ev.target, {
|
||||||
|
label: i18n.ts.selectFile,
|
||||||
|
dontUseWatermark: true,
|
||||||
|
}).then((file) => {
|
||||||
|
if (!file.type.startsWith('image')) {
|
||||||
|
os.alert({
|
||||||
|
type: 'warning',
|
||||||
|
title: i18n.ts._watermarkEditor.driveFileTypeWarn,
|
||||||
|
text: i18n.ts._watermarkEditor.driveFileTypeWarnDescription,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileId.value = file.id;
|
||||||
|
fileUrl.value = file.url;
|
||||||
|
fileName.value = file.name;
|
||||||
|
driveFileError.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
//#region Canvasの制御
|
//#region Canvasの制御
|
||||||
const canvasLoading = ref(true);
|
const canvasLoading = ref(true);
|
||||||
const canvasEl = useTemplateRef('canvasEl');
|
const canvasEl = useTemplateRef('canvasEl');
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (canvasEl.value) {
|
watch([useWatermark, watermarkConfig], ([useWatermarkTo, watermarkConfigTo]) => {
|
||||||
applyWatermark('/client-assets/hill.webp', canvasEl.value, watermarkConfig.value);
|
canvasLoading.value = true;
|
||||||
}
|
if (canvasEl.value) {
|
||||||
|
applyWatermark('/client-assets/hill.webp', canvasEl.value, useWatermarkTo && canPreview(watermarkConfigTo) ? watermarkConfigTo : null).then(() => {
|
||||||
|
canvasLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, { immediate: true, deep: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
//#endregion
|
//#endregion
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -168,45 +292,31 @@ onMounted(() => {
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watermarkEditorResultRoot {
|
.formLabel {
|
||||||
box-sizing: border-box;
|
font-size: 0.85em;
|
||||||
padding: 24px;
|
padding: 0 0 8px 0;
|
||||||
height: 100%;
|
}
|
||||||
max-width: 700px;
|
|
||||||
margin: 0 auto;
|
.fileSelectorRoot {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watermarkEditorResultHeading {
|
.fileErrorRoot {
|
||||||
text-align: center;
|
flex-grow: 1;
|
||||||
font-size: 1.2em;
|
min-width: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--MI_THEME-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.watermarkEditorResultHeadingIcon {
|
.fileSelectorButton {
|
||||||
margin: 0 auto;
|
flex-shrink: 0;
|
||||||
background-color: var(--MI_THEME-accentedBg);
|
|
||||||
color: var(--MI_THEME-accent);
|
|
||||||
text-align: center;
|
|
||||||
height: 64px;
|
|
||||||
width: 64px;
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 64px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.watermarkEditorResultDescription {
|
.fileNotSelected {
|
||||||
text-align: center;
|
font-weight: 700;
|
||||||
white-space: pre-wrap;
|
color: var(--MI_THEME-infoWarnFg);
|
||||||
}
|
|
||||||
|
|
||||||
.watermarkEditorResultWrapper,
|
|
||||||
.watermarkEditorResultCode {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.watermarkEditorResultButtons {
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (max-width: 800px) {
|
@container (max-width: 800px) {
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { $i } from '@/account.js';
|
||||||
import { alert } from '@/os.js';
|
import { alert } from '@/os.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { instance } from '@/instance.js';
|
import { instance } from '@/instance.js';
|
||||||
|
import { canPreview, getWatermarkAppliedImage } from './watermark.js';
|
||||||
|
|
||||||
type Uploading = {
|
type Uploading = {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -68,22 +69,27 @@ export function uploadFile(
|
||||||
|
|
||||||
uploads.value.push(ctx);
|
uploads.value.push(ctx);
|
||||||
|
|
||||||
const config = !keepOriginal ? await getCompressionConfig(file) : undefined;
|
let _file: Blob = file;
|
||||||
let resizedImage: Blob | undefined;
|
|
||||||
|
if (_file.type.startsWith('image/') && watermark && canPreview(defaultStore.state.watermarkConfig)) {
|
||||||
|
_file = await getWatermarkAppliedImage(_file, defaultStore.state.watermarkConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = !keepOriginal ? await getCompressionConfig(_file) : undefined;
|
||||||
if (config) {
|
if (config) {
|
||||||
try {
|
try {
|
||||||
const resized = await readAndCompressImage(file, config);
|
const resized = await readAndCompressImage(_file, config);
|
||||||
if (resized.size < file.size || file.type === 'image/webp') {
|
if (resized.size < _file.size || _file.type === 'image/webp') {
|
||||||
// The compression may not always reduce the file size
|
// The compression may not always reduce the file size
|
||||||
// (and WebP is not browser safe yet)
|
// (and WebP is not browser safe yet)
|
||||||
resizedImage = resized;
|
_file = resized;
|
||||||
}
|
}
|
||||||
if (_DEV_) {
|
if (_DEV_) {
|
||||||
const saved = ((1 - resized.size / file.size) * 100).toFixed(2);
|
const saved = ((1 - resized.size / _file.size) * 100).toFixed(2);
|
||||||
console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`);
|
console.log(`Image compression: before ${_file.size} bytes, after ${resized.size} bytes, saved ${saved}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name;
|
ctx.name = _file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to resize image', err);
|
console.error('Failed to resize image', err);
|
||||||
}
|
}
|
||||||
|
@ -92,7 +98,7 @@ export function uploadFile(
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('i', $i!.token);
|
formData.append('i', $i!.token);
|
||||||
formData.append('force', 'true');
|
formData.append('force', 'true');
|
||||||
formData.append('file', resizedImage ?? file);
|
formData.append('file', _file);
|
||||||
formData.append('name', ctx.name);
|
formData.append('name', ctx.name);
|
||||||
if (_folder) formData.append('folderId', _folder);
|
if (_folder) formData.append('folderId', _folder);
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ const compressTypeMapFallback = {
|
||||||
'image/svg+xml': { quality: 1, mimeType: 'image/png' },
|
'image/svg+xml': { quality: 1, mimeType: 'image/png' },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export async function getCompressionConfig(file: File): Promise<BrowserImageResizerConfigWithConvertedOutput | undefined> {
|
export async function getCompressionConfig(file: Blob): Promise<BrowserImageResizerConfigWithConvertedOutput | undefined> {
|
||||||
const imgConfig = (isWebpSupported() ? compressTypeMap : compressTypeMapFallback)[file.type];
|
const imgConfig = (isWebpSupported() ? compressTypeMap : compressTypeMapFallback)[file.type];
|
||||||
if (!imgConfig || await isAnimated(file)) {
|
if (!imgConfig || await isAnimated(file)) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -5,26 +5,57 @@
|
||||||
import { getProxiedImageUrl } from "@/scripts/media-proxy.js";
|
import { getProxiedImageUrl } from "@/scripts/media-proxy.js";
|
||||||
import { misskeyApi } from "@/scripts/misskey-api.js";
|
import { misskeyApi } from "@/scripts/misskey-api.js";
|
||||||
|
|
||||||
|
export const watermarkAnchor = [
|
||||||
|
'top-left',
|
||||||
|
'top',
|
||||||
|
'top-right',
|
||||||
|
'left',
|
||||||
|
'center',
|
||||||
|
'right',
|
||||||
|
'bottom-left',
|
||||||
|
'bottom',
|
||||||
|
'bottom-right',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type WatermarkAnchor = typeof watermarkAnchor[number];
|
||||||
|
|
||||||
export type WatermarkConfig = {
|
export type WatermarkConfig = {
|
||||||
|
/** ドライブファイルのID */
|
||||||
fileId?: string;
|
fileId?: string;
|
||||||
|
/** 画像URL */
|
||||||
fileUrl?: string;
|
fileUrl?: string;
|
||||||
width?: number;
|
/** 親画像に対するウォーターマークの幅比率。ない場合は1。親画像が縦長の場合は幅の比率として、横長の場合は高さ比率として使用される */
|
||||||
height?: number;
|
sizeRatio?: number;
|
||||||
enlargement: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
|
/** 透明度 */
|
||||||
gravity: 'auto' | 'left' | 'right' | 'top' | 'bottom';
|
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
repeat: true | false | 'x' | 'y';
|
/** 回転角度(度数) */
|
||||||
anchor: 'center' | 'top' | 'left' | 'bottom' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
||||||
offsetTop?: number;
|
|
||||||
offsetLeft?: number;
|
|
||||||
offsetBottom?: number;
|
|
||||||
offsetRight?: number;
|
|
||||||
backgroundColor?: string;
|
|
||||||
rotate?: number;
|
rotate?: number;
|
||||||
|
/** パディング */
|
||||||
|
padding?: {
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
left: number;
|
||||||
|
};
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
__bypassMediaProxy?: boolean;
|
__bypassMediaProxy?: boolean;
|
||||||
};
|
} & ({
|
||||||
|
/** 繰り返し */
|
||||||
|
repeat?: false;
|
||||||
|
/** 画像の始祖点 */
|
||||||
|
anchor: WatermarkAnchor;
|
||||||
|
} | {
|
||||||
|
/** 繰り返し */
|
||||||
|
repeat: true;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* プレビューに必要な値が全部揃っているかどうかを判定する
|
||||||
|
*/
|
||||||
|
export function canPreview(config: Partial<WatermarkConfig> | null): config is WatermarkConfig {
|
||||||
|
return config != null && (config.fileUrl != null || config.fileId != null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ウォーターマークを適用してキャンバスに描画する
|
* ウォーターマークを適用してキャンバスに描画する
|
||||||
|
@ -33,7 +64,7 @@ export type WatermarkConfig = {
|
||||||
* @param el ウォーターマークを適用するキャンバス
|
* @param el ウォーターマークを適用するキャンバス
|
||||||
* @param config ウォーターマークの設定
|
* @param config ウォーターマークの設定
|
||||||
*/
|
*/
|
||||||
export async function applyWatermark(img: string | Blob, el: HTMLCanvasElement, config: WatermarkConfig) {
|
export async function applyWatermark(img: string | Blob, el: HTMLCanvasElement, config: WatermarkConfig | null) {
|
||||||
const canvas = el;
|
const canvas = el;
|
||||||
const ctx = canvas.getContext('2d')!;
|
const ctx = canvas.getContext('2d')!;
|
||||||
const imgEl = new Image();
|
const imgEl = new Image();
|
||||||
|
@ -41,91 +72,134 @@ export async function applyWatermark(img: string | Blob, el: HTMLCanvasElement,
|
||||||
canvas.width = imgEl.width;
|
canvas.width = imgEl.width;
|
||||||
canvas.height = imgEl.height;
|
canvas.height = imgEl.height;
|
||||||
ctx.drawImage(imgEl, 0, 0);
|
ctx.drawImage(imgEl, 0, 0);
|
||||||
if (config.fileUrl) {
|
|
||||||
const watermark = new Image();
|
if (config != null) {
|
||||||
watermark.onload = () => {
|
if (config.fileUrl) {
|
||||||
const width = config.width || watermark.width;
|
const watermark = new Image();
|
||||||
const height = config.height || watermark.height;
|
watermark.onload = () => {
|
||||||
ctx.globalAlpha = config.opacity ?? 1;
|
const canvasAspectRatio = canvas.width / canvas.height; // 横長は1より大きい
|
||||||
if (config.repeat !== false) {
|
const watermarkAspectRatio = watermark.width / watermark.height; // 横長は1より大きい
|
||||||
const resizedWatermark = document.createElement('canvas');
|
const { width, height } = (() => {
|
||||||
resizedWatermark.width = width;
|
const desiredWidth = canvas.width * (config.sizeRatio ?? 1);
|
||||||
resizedWatermark.height = height;
|
const desiredHeight = canvas.height * (config.sizeRatio ?? 1);
|
||||||
const resizedCtx = resizedWatermark.getContext('2d')!;
|
|
||||||
resizedCtx.drawImage(watermark, 0, 0, width, height);
|
if (
|
||||||
const pattern = ctx.createPattern(resizedWatermark, config.repeat === true ? 'repeat' : `repeat-${config.repeat}`);
|
(watermarkAspectRatio > 1 && canvasAspectRatio > 1) ||
|
||||||
if (pattern) {
|
(watermarkAspectRatio < 1 && canvasAspectRatio < 1)
|
||||||
ctx.fillStyle = pattern;
|
) {
|
||||||
|
return {
|
||||||
|
width: desiredWidth,
|
||||||
|
height: desiredWidth / watermarkAspectRatio
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
width: desiredHeight * watermarkAspectRatio,
|
||||||
|
height: desiredHeight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
ctx.globalAlpha = config.opacity ?? 1;
|
||||||
|
|
||||||
|
if (config.repeat) {
|
||||||
|
// 余白をもたせた状態のウォーターマークを作成しておく
|
||||||
|
const resizedWatermark = document.createElement('canvas');
|
||||||
|
resizedWatermark.width = width + (config.padding ? (config.padding.left ?? 0) + (config.padding.right ?? 0) : 0);
|
||||||
|
resizedWatermark.height = height + (config.padding ? (config.padding.top ?? 0) + (config.padding.bottom ?? 0) : 0);
|
||||||
|
const resizedCtx = resizedWatermark.getContext('2d')!;
|
||||||
|
resizedCtx.drawImage(
|
||||||
|
watermark,
|
||||||
|
(config.padding ? config.padding.left ?? 0 : 0),
|
||||||
|
(config.padding ? config.padding.top ?? 0 : 0),
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
);
|
||||||
|
|
||||||
|
const pattern = ctx.createPattern(resizedWatermark, config.repeat === true ? 'repeat' : `repeat-${config.repeat}`);
|
||||||
|
if (pattern) {
|
||||||
|
ctx.fillStyle = pattern;
|
||||||
|
if (config.rotate != null && config.rotate !== 0) {
|
||||||
|
const rotateRad = config.rotate * Math.PI / 180;
|
||||||
|
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||||
|
ctx.rotate(rotateRad);
|
||||||
|
ctx.translate(-canvas.width / 2, -canvas.height / 2);
|
||||||
|
const rotatedWidth = Math.abs(canvas.width * Math.cos(rotateRad)) + Math.abs(canvas.height * Math.sin(rotateRad));
|
||||||
|
const rotatedHeight = Math.abs(canvas.width * Math.sin(rotateRad)) + Math.abs(canvas.height * Math.cos(rotateRad));
|
||||||
|
const x = Math.abs(rotatedWidth - canvas.width) / -2;
|
||||||
|
const y = Math.abs(rotatedHeight - canvas.height) / -2;
|
||||||
|
ctx.fillRect(x, y, rotatedWidth, rotatedHeight);
|
||||||
|
} else {
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const x = (() => {
|
||||||
|
switch (config.anchor) {
|
||||||
|
case 'center':
|
||||||
|
case 'top':
|
||||||
|
case 'bottom':
|
||||||
|
return (canvas.width - width) / 2;
|
||||||
|
case 'left':
|
||||||
|
case 'top-left':
|
||||||
|
case 'bottom-left':
|
||||||
|
return 0 + (config.padding ? config.padding.left ?? 0 : 0);
|
||||||
|
case 'right':
|
||||||
|
case 'top-right':
|
||||||
|
case 'bottom-right':
|
||||||
|
return canvas.width - width - (config.padding ? config.padding.right ?? 0 : 0);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const y = (() => {
|
||||||
|
let rotateY = 0; // 回転によるY座標の補正
|
||||||
|
|
||||||
|
if (config.rotate) {
|
||||||
|
const rotateRad = config.rotate * Math.PI / 180;
|
||||||
|
rotateY = Math.abs(Math.abs(width * Math.sin(rotateRad)) + Math.abs(height * Math.cos(rotateRad)) - height) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (config.anchor) {
|
||||||
|
case 'center':
|
||||||
|
case 'left':
|
||||||
|
case 'right':
|
||||||
|
return (canvas.height - height) / 2 + (config.padding ? config.padding.top ?? 0 : 0);
|
||||||
|
case 'top':
|
||||||
|
case 'top-left':
|
||||||
|
case 'top-right':
|
||||||
|
return rotateY;
|
||||||
|
case 'bottom':
|
||||||
|
case 'bottom-left':
|
||||||
|
case 'bottom-right':
|
||||||
|
return canvas.height - height - (config.padding ? config.padding.bottom ?? 0 : 0) - rotateY;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
if (config.rotate) {
|
if (config.rotate) {
|
||||||
const rotateRad = config.rotate * Math.PI / 180;
|
const rotateRad = config.rotate * Math.PI / 180;
|
||||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
ctx.translate(x + width / 2, y + height / 2);
|
||||||
ctx.rotate(rotateRad);
|
ctx.rotate(rotateRad);
|
||||||
ctx.translate(-canvas.width / 2, -canvas.height / 2);
|
ctx.translate(-x - width / 2, -y - height / 2);
|
||||||
const rotatedWidth = Math.abs(canvas.width * Math.cos(rotateRad)) + Math.abs(canvas.height * Math.sin(rotateRad));
|
|
||||||
const rotatedHeight = Math.abs(canvas.width * Math.sin(rotateRad)) + Math.abs(canvas.height * Math.cos(rotateRad));
|
|
||||||
const x = Math.abs(rotatedWidth - canvas.width) / -2;
|
|
||||||
const y = Math.abs(rotatedHeight - canvas.height) / -2;
|
|
||||||
ctx.fillRect(x, y, rotatedWidth, rotatedHeight);
|
|
||||||
} else {
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
}
|
}
|
||||||
|
ctx.drawImage(watermark, x, y, width, height);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let watermarkUrl: string;
|
||||||
|
if (config.fileUrl == null && config.fileId != null) {
|
||||||
|
const res = await misskeyApi('drive/files/show', { fileId: config.fileId });
|
||||||
|
watermarkUrl = res.url;
|
||||||
} else {
|
} else {
|
||||||
const x = (() => {
|
watermarkUrl = config.fileUrl!;
|
||||||
switch (config.anchor) {
|
|
||||||
case 'center':
|
|
||||||
case 'top':
|
|
||||||
case 'bottom':
|
|
||||||
return (canvas.width - width) / 2;
|
|
||||||
case 'left':
|
|
||||||
case 'top-left':
|
|
||||||
case 'bottom-left':
|
|
||||||
return 0;
|
|
||||||
case 'right':
|
|
||||||
case 'top-right':
|
|
||||||
case 'bottom-right':
|
|
||||||
return canvas.width - width;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
const y = (() => {
|
|
||||||
switch (config.anchor) {
|
|
||||||
case 'center':
|
|
||||||
case 'left':
|
|
||||||
case 'right':
|
|
||||||
return (canvas.height - height) / 2;
|
|
||||||
case 'top':
|
|
||||||
case 'top-left':
|
|
||||||
case 'top-right':
|
|
||||||
return 0;
|
|
||||||
case 'bottom':
|
|
||||||
case 'bottom-left':
|
|
||||||
case 'bottom-right':
|
|
||||||
return canvas.height - height;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
ctx.drawImage(watermark, x, y, width, height);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let watermarkUrl: string;
|
watermark.src = config.__bypassMediaProxy ? config.fileUrl : getProxiedImageUrl(watermarkUrl, undefined, true);
|
||||||
if (config.fileUrl == null && config.fileId != null) {
|
|
||||||
const res = await misskeyApi('drive/files/show', { fileId: config.fileId });
|
|
||||||
watermarkUrl = res.url;
|
|
||||||
} else {
|
|
||||||
watermarkUrl = config.fileUrl!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watermark.src = config.__bypassMediaProxy ? config.fileUrl : getProxiedImageUrl(watermarkUrl, undefined, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
if (typeof img === 'string') {
|
if (typeof img === 'string') {
|
||||||
imgEl.src = img;
|
imgEl.src = img;
|
||||||
} else {
|
} else {
|
||||||
const reader = new FileReader();
|
imgEl.src = URL.createObjectURL(img);
|
||||||
reader.onload = () => {
|
|
||||||
imgEl.src = reader.result as string;
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(img);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue