diff --git a/locales/index.d.ts b/locales/index.d.ts
index beccac4a1d..a3cb8e1799 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -10661,6 +10661,24 @@ export interface Locale extends ILocale {
*/
"sent": string;
};
+ "_watermarkEditor": {
+ /**
+ * このファイルは対応していません
+ */
+ "driveFileTypeWarn": string;
+ /**
+ * 画像ファイルを選択してください
+ */
+ "driveFileTypeWarnDescription": string;
+ /**
+ * 描画モード
+ */
+ "repeatSetting": string;
+ /**
+ * 全体を埋め尽くす
+ */
+ "repeat": string;
+ };
}
declare const locales: {
[lang: string]: Locale;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 01fdd36abe..7f098e0e0d 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2841,3 +2841,9 @@ _selfXssPrevention:
_followRequest:
recieved: "受け取った申請"
sent: "送った申請"
+
+_watermarkEditor:
+ driveFileTypeWarn: "このファイルは対応していません"
+ driveFileTypeWarnDescription: "画像ファイルを選択してください"
+ repeatSetting: "描画モード"
+ repeat: "全体を埋め尽くす"
diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.anchor.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.anchor.vue
new file mode 100644
index 0000000000..2728b0576c
--- /dev/null
+++ b/packages/frontend/src/components/MkWatermarkEditorDialog.anchor.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.vue
index f114824835..6fcbb46946 100644
--- a/packages/frontend/src/components/MkWatermarkEditorDialog.vue
+++ b/packages/frontend/src/components/MkWatermarkEditorDialog.vue
@@ -19,8 +19,8 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
@@ -30,6 +30,39 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.useWatermark }}
{{ i18n.ts.useWatermarkDescription }}
+
+
+
{{ i18n.ts.watermark }}
+
+
{{ i18n.ts.selectFile }}
+
{{ friendlyFileName }}
+
+
+
+
+
+ {{ i18n.ts.size }}
+
+
+
+ {{ i18n.ts.opacity }}
+
+
+
+ {{ i18n.ts.rotate }}
+
+
+
+ {{ i18n.ts._watermarkEditor.repeatSetting }}
+
+
+
+
+
+
{{ i18n.ts.position }}
+
+
+
@@ -37,13 +70,21 @@ SPDX-License-Identifier: AGPL-3.0-only
@@ -168,45 +292,31 @@ onMounted(() => {
overflow-y: scroll;
}
-.watermarkEditorResultRoot {
- box-sizing: border-box;
- padding: 24px;
- height: 100%;
- max-width: 700px;
- margin: 0 auto;
+.formLabel {
+ font-size: 0.85em;
+ padding: 0 0 8px 0;
+}
+
+.fileSelectorRoot {
display: flex;
align-items: center;
+ gap: 8px;
}
-.watermarkEditorResultHeading {
- text-align: center;
- font-size: 1.2em;
+.fileErrorRoot {
+ flex-grow: 1;
+ min-width: 0;
+ font-weight: 700;
+ color: var(--MI_THEME-error);
}
-.watermarkEditorResultHeadingIcon {
- margin: 0 auto;
- 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%;
+.fileSelectorButton {
+ flex-shrink: 0;
}
-.watermarkEditorResultDescription {
- text-align: center;
- white-space: pre-wrap;
-}
-
-.watermarkEditorResultWrapper,
-.watermarkEditorResultCode {
- width: 100%;
-}
-
-.watermarkEditorResultButtons {
- margin: 0 auto;
+.fileNotSelected {
+ font-weight: 700;
+ color: var(--MI_THEME-infoWarnFg);
}
@container (max-width: 800px) {
diff --git a/packages/frontend/src/scripts/upload.ts b/packages/frontend/src/scripts/upload.ts
index e9991ba69c..bc19fefc2d 100644
--- a/packages/frontend/src/scripts/upload.ts
+++ b/packages/frontend/src/scripts/upload.ts
@@ -14,6 +14,7 @@ import { $i } from '@/account.js';
import { alert } from '@/os.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
+import { canPreview, getWatermarkAppliedImage } from './watermark.js';
type Uploading = {
id: string;
@@ -68,22 +69,27 @@ export function uploadFile(
uploads.value.push(ctx);
- const config = !keepOriginal ? await getCompressionConfig(file) : undefined;
- let resizedImage: Blob | undefined;
+ let _file: Blob = file;
+
+ 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) {
try {
- const resized = await readAndCompressImage(file, config);
- if (resized.size < file.size || file.type === 'image/webp') {
+ const resized = await readAndCompressImage(_file, config);
+ if (resized.size < _file.size || _file.type === 'image/webp') {
// The compression may not always reduce the file size
// (and WebP is not browser safe yet)
- resizedImage = resized;
+ _file = resized;
}
if (_DEV_) {
- const saved = ((1 - resized.size / file.size) * 100).toFixed(2);
- console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`);
+ const saved = ((1 - resized.size / _file.size) * 100).toFixed(2);
+ 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) {
console.error('Failed to resize image', err);
}
@@ -92,7 +98,7 @@ export function uploadFile(
const formData = new FormData();
formData.append('i', $i!.token);
formData.append('force', 'true');
- formData.append('file', resizedImage ?? file);
+ formData.append('file', _file);
formData.append('name', ctx.name);
if (_folder) formData.append('folderId', _folder);
diff --git a/packages/frontend/src/scripts/upload/compress-config.ts b/packages/frontend/src/scripts/upload/compress-config.ts
index 3046b7f518..bd39527147 100644
--- a/packages/frontend/src/scripts/upload/compress-config.ts
+++ b/packages/frontend/src/scripts/upload/compress-config.ts
@@ -21,7 +21,7 @@ const compressTypeMapFallback = {
'image/svg+xml': { quality: 1, mimeType: 'image/png' },
} as const;
-export async function getCompressionConfig(file: File): Promise {
+export async function getCompressionConfig(file: Blob): Promise {
const imgConfig = (isWebpSupported() ? compressTypeMap : compressTypeMapFallback)[file.type];
if (!imgConfig || await isAnimated(file)) {
return;
diff --git a/packages/frontend/src/scripts/watermark.ts b/packages/frontend/src/scripts/watermark.ts
index 36add1b61f..b810a36dc1 100644
--- a/packages/frontend/src/scripts/watermark.ts
+++ b/packages/frontend/src/scripts/watermark.ts
@@ -5,26 +5,57 @@
import { getProxiedImageUrl } from "@/scripts/media-proxy.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 = {
+ /** ドライブファイルのID */
fileId?: string;
+ /** 画像URL */
fileUrl?: string;
- width?: number;
- height?: number;
- enlargement: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
- gravity: 'auto' | 'left' | 'right' | 'top' | 'bottom';
+ /** 親画像に対するウォーターマークの幅比率。ない場合は1。親画像が縦長の場合は幅の比率として、横長の場合は高さ比率として使用される */
+ sizeRatio?: 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;
+ /** パディング */
+ padding?: {
+ top: number;
+ right: number;
+ bottom: number;
+ left: number;
+ };
/** @internal */
__bypassMediaProxy?: boolean;
-};
+} & ({
+ /** 繰り返し */
+ repeat?: false;
+ /** 画像の始祖点 */
+ anchor: WatermarkAnchor;
+} | {
+ /** 繰り返し */
+ repeat: true;
+});
+
+/**
+ * プレビューに必要な値が全部揃っているかどうかを判定する
+ */
+export function canPreview(config: Partial | null): config is WatermarkConfig {
+ return config != null && (config.fileUrl != null || config.fileId != null);
+}
/**
* ウォーターマークを適用してキャンバスに描画する
@@ -33,7 +64,7 @@ export type WatermarkConfig = {
* @param el ウォーターマークを適用するキャンバス
* @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 ctx = canvas.getContext('2d')!;
const imgEl = new Image();
@@ -41,91 +72,134 @@ export async function applyWatermark(img: string | Blob, el: HTMLCanvasElement,
canvas.width = imgEl.width;
canvas.height = imgEl.height;
ctx.drawImage(imgEl, 0, 0);
- if (config.fileUrl) {
- const watermark = new Image();
- watermark.onload = () => {
- const width = config.width || watermark.width;
- const height = config.height || watermark.height;
- ctx.globalAlpha = config.opacity ?? 1;
- if (config.repeat !== false) {
- const resizedWatermark = document.createElement('canvas');
- resizedWatermark.width = width;
- resizedWatermark.height = height;
- const resizedCtx = resizedWatermark.getContext('2d')!;
- resizedCtx.drawImage(watermark, 0, 0, width, height);
- const pattern = ctx.createPattern(resizedWatermark, config.repeat === true ? 'repeat' : `repeat-${config.repeat}`);
- if (pattern) {
- ctx.fillStyle = pattern;
+
+ if (config != null) {
+ if (config.fileUrl) {
+ const watermark = new Image();
+ watermark.onload = () => {
+ const canvasAspectRatio = canvas.width / canvas.height; // 横長は1より大きい
+ const watermarkAspectRatio = watermark.width / watermark.height; // 横長は1より大きい
+ const { width, height } = (() => {
+ const desiredWidth = canvas.width * (config.sizeRatio ?? 1);
+ const desiredHeight = canvas.height * (config.sizeRatio ?? 1);
+
+ if (
+ (watermarkAspectRatio > 1 && canvasAspectRatio > 1) ||
+ (watermarkAspectRatio < 1 && canvasAspectRatio < 1)
+ ) {
+ 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) {
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.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);
+ ctx.translate(-x - width / 2, -y - height / 2);
}
+ 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 {
- 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;
- 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);
+ watermarkUrl = config.fileUrl!;
}
- };
- let watermarkUrl: string;
- 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);
}
-
- watermark.src = config.__bypassMediaProxy ? config.fileUrl : getProxiedImageUrl(watermarkUrl, undefined, true);
}
+
};
if (typeof img === 'string') {
imgEl.src = img;
} else {
- const reader = new FileReader();
- reader.onload = () => {
- imgEl.src = reader.result as string;
- };
- reader.readAsDataURL(img);
+ imgEl.src = URL.createObjectURL(img);
}
}