From dc197da0555ec4c89ed4f0f2741a4f00e7634326 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Mon, 16 Dec 2024 20:52:07 +0900 Subject: [PATCH] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit repeat-x, repeat-yは実装が難しすぎるので断念 --- locales/index.d.ts | 18 ++ locales/ja-JP.yml | 6 + .../MkWatermarkEditorDialog.anchor.vue | 94 +++++++ .../components/MkWatermarkEditorDialog.vue | 198 ++++++++++---- packages/frontend/src/scripts/upload.ts | 24 +- .../src/scripts/upload/compress-config.ts | 2 +- packages/frontend/src/scripts/watermark.ts | 242 ++++++++++++------ 7 files changed, 446 insertions(+), 138 deletions(-) create mode 100644 packages/frontend/src/components/MkWatermarkEditorDialog.anchor.vue 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
- +
{{ i18n.ts.preview }}
@@ -30,6 +30,39 @@ SPDX-License-Identifier: AGPL-3.0-only + +
+
{{ i18n.ts.watermark }}
+
+ {{ i18n.ts.selectFile }} +
{{ friendlyFileName }}
+
+
+ +
@@ -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); } }