mirror of
https://github.com/misskey-dev/misskey.git
synced 2024-12-23 00:29:22 +09:00
Merge 0b23bebb7e
into 3c81926f71
This commit is contained in:
commit
76c0f7d184
@ -4,6 +4,8 @@
|
||||
-
|
||||
|
||||
### Client
|
||||
- Feat: 画像のアップロード時にウォーターマークを適用できるように
|
||||
(Based on https://github.com/MisskeyIO/misskey/pull/785)
|
||||
- Enhance: PC画面でチャンネルが複数列で表示されるように
|
||||
(Cherry-picked from https://github.com/Otaku-Social/maniakey/pull/13)
|
||||
- Enhance: 照会に失敗した場合、その理由を表示するように
|
||||
|
122
locales/index.d.ts
vendored
122
locales/index.d.ts
vendored
@ -1902,6 +1902,26 @@ export interface Locale extends ILocale {
|
||||
* 既定アップロード先
|
||||
*/
|
||||
"uploadFolder": string;
|
||||
/**
|
||||
* ウォーターマーク
|
||||
*/
|
||||
"watermark": string;
|
||||
/**
|
||||
* ウォーターマークをつけますか?
|
||||
*/
|
||||
"watermarkConfirm": string;
|
||||
/**
|
||||
* ウォーターマークをつける
|
||||
*/
|
||||
"useWatermark": string;
|
||||
/**
|
||||
* 画像のアップロード時にデフォルトでウォーターマークをつけるようにします。
|
||||
*/
|
||||
"useWatermarkDescription": string;
|
||||
/**
|
||||
* デフォルトの値にかかわらず、アップロードメニューの「ウォーターマークをつける」スイッチを操作して、一回限りの設定を適用することができます。
|
||||
*/
|
||||
"useWatermarkInfo": string;
|
||||
/**
|
||||
* すべての通知を既読にする
|
||||
*/
|
||||
@ -3143,13 +3163,25 @@ export interface Locale extends ILocale {
|
||||
*/
|
||||
"duplicate": string;
|
||||
/**
|
||||
* 左
|
||||
* 上
|
||||
*/
|
||||
"left": string;
|
||||
"top": string;
|
||||
/**
|
||||
* 下
|
||||
*/
|
||||
"bottom": string;
|
||||
/**
|
||||
* 中央
|
||||
*/
|
||||
"center": string;
|
||||
/**
|
||||
* 左
|
||||
*/
|
||||
"left": string;
|
||||
/**
|
||||
* 右
|
||||
*/
|
||||
"right": string;
|
||||
/**
|
||||
* 広い
|
||||
*/
|
||||
@ -4458,18 +4490,38 @@ export interface Locale extends ILocale {
|
||||
* 通知の表示
|
||||
*/
|
||||
"notificationDisplay": string;
|
||||
/**
|
||||
* 配置
|
||||
*/
|
||||
"placement": string;
|
||||
/**
|
||||
* 左上
|
||||
*/
|
||||
"leftTop": string;
|
||||
/**
|
||||
* 中上
|
||||
*/
|
||||
"centerTop": string;
|
||||
/**
|
||||
* 右上
|
||||
*/
|
||||
"rightTop": string;
|
||||
/**
|
||||
* 左中
|
||||
*/
|
||||
"leftCenter": string;
|
||||
/**
|
||||
* 右中
|
||||
*/
|
||||
"rightCenter": string;
|
||||
/**
|
||||
* 左下
|
||||
*/
|
||||
"leftBottom": string;
|
||||
/**
|
||||
* 中下
|
||||
*/
|
||||
"centerBottom": string;
|
||||
/**
|
||||
* 右下
|
||||
*/
|
||||
@ -4490,6 +4542,14 @@ export interface Locale extends ILocale {
|
||||
* 位置
|
||||
*/
|
||||
"position": string;
|
||||
/**
|
||||
* 回転
|
||||
*/
|
||||
"rotate": string;
|
||||
/**
|
||||
* 透明度
|
||||
*/
|
||||
"transparency": string;
|
||||
/**
|
||||
* サーバールール
|
||||
*/
|
||||
@ -5222,6 +5282,14 @@ export interface Locale extends ILocale {
|
||||
* 注意事項を理解した上でオンにします。
|
||||
*/
|
||||
"acknowledgeNotesAndEnable": string;
|
||||
/**
|
||||
* 常に確認する
|
||||
*/
|
||||
"alwaysConfirm": string;
|
||||
/**
|
||||
* デフォルトの設定を適用する
|
||||
*/
|
||||
"useDefaultSettings": string;
|
||||
"_accountSettings": {
|
||||
/**
|
||||
* コンテンツの表示にログインを必須にする
|
||||
@ -10660,6 +10728,56 @@ export interface Locale extends ILocale {
|
||||
"description": string;
|
||||
};
|
||||
};
|
||||
"_watermarkEditor": {
|
||||
/**
|
||||
* ウォーターマークをカスタマイズ
|
||||
*/
|
||||
"title": string;
|
||||
/**
|
||||
* このファイルは対応していません
|
||||
*/
|
||||
"driveFileTypeWarn": string;
|
||||
/**
|
||||
* 画像ファイルを選択してください
|
||||
*/
|
||||
"driveFileTypeWarnDescription": string;
|
||||
/**
|
||||
* 設定が不十分です
|
||||
*/
|
||||
"settingInvalidWarn": string;
|
||||
/**
|
||||
* プレビューが正常に表示されることを確認してから保存してください
|
||||
*/
|
||||
"settingInvalidWarnDescription": string;
|
||||
/**
|
||||
* ウォーターマーク用画像のファイルサイズが大きいと、処理の際にウォーターマークを読み込む時間が長くなり、アップロードに時間がかかるようになります。あらかじめ解像度を低くしたり、ファイルを圧縮したりしておくことを推奨します。
|
||||
*/
|
||||
"useSmallFile": string;
|
||||
/**
|
||||
* 描画モード
|
||||
*/
|
||||
"repeatSetting": string;
|
||||
/**
|
||||
* 全体を埋め尽くす
|
||||
*/
|
||||
"repeat": string;
|
||||
/**
|
||||
* 余白
|
||||
*/
|
||||
"padding": string;
|
||||
/**
|
||||
* 回転した分の面積を確保する
|
||||
*/
|
||||
"preserveBoundingRect": string;
|
||||
/**
|
||||
* 通常はオンで問題ありません。ウォーターマークを回転させた際に余白が不自然になった場合はオフにしてみてください。
|
||||
*/
|
||||
"preserveBoundingRectDescription": string;
|
||||
/**
|
||||
* クリップボード経由でのアップロード時の動作
|
||||
*/
|
||||
"clipboardUploadBehavior": string;
|
||||
};
|
||||
}
|
||||
declare const locales: {
|
||||
[lang: string]: Locale;
|
||||
|
@ -471,6 +471,11 @@ share: "共有"
|
||||
notFound: "見つかりません"
|
||||
notFoundDescription: "指定されたURLに該当するページはありませんでした。"
|
||||
uploadFolder: "既定アップロード先"
|
||||
watermark: "ウォーターマーク"
|
||||
watermarkConfirm: "ウォーターマークをつけますか?"
|
||||
useWatermark: "ウォーターマークをつける"
|
||||
useWatermarkDescription: "画像のアップロード時にデフォルトでウォーターマークをつけるようにします。"
|
||||
useWatermarkInfo: "デフォルトの値にかかわらず、アップロードメニューの「ウォーターマークをつける」スイッチを操作して、一回限りの設定を適用することができます。"
|
||||
markAsReadAllNotifications: "すべての通知を既読にする"
|
||||
markAsReadAllUnreadNotes: "すべての投稿を既読にする"
|
||||
markAsReadAllTalkMessages: "すべてのチャットを既読にする"
|
||||
@ -781,8 +786,11 @@ makeExplorable: "アカウントを見つけやすくする"
|
||||
makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らなくなります。"
|
||||
showGapBetweenNotesInTimeline: "タイムラインのノートを離して表示"
|
||||
duplicate: "複製"
|
||||
left: "左"
|
||||
top: "上"
|
||||
bottom: "下"
|
||||
center: "中央"
|
||||
left: "左"
|
||||
right: "右"
|
||||
wide: "広い"
|
||||
narrow: "狭い"
|
||||
reloadToApplySetting: "設定はページリロード後に反映されます。"
|
||||
@ -1110,14 +1118,21 @@ editMemo: "メモを編集"
|
||||
reactionsList: "リアクション一覧"
|
||||
renotesList: "リノート一覧"
|
||||
notificationDisplay: "通知の表示"
|
||||
placement: "配置"
|
||||
leftTop: "左上"
|
||||
centerTop: "中上"
|
||||
rightTop: "右上"
|
||||
leftCenter: "左中"
|
||||
rightCenter: "右中"
|
||||
leftBottom: "左下"
|
||||
centerBottom: "中下"
|
||||
rightBottom: "右下"
|
||||
stackAxis: "スタック方向"
|
||||
vertical: "縦"
|
||||
horizontal: "横"
|
||||
position: "位置"
|
||||
rotate: "回転"
|
||||
transparency: "透明度"
|
||||
serverRules: "サーバールール"
|
||||
pleaseConfirmBelowBeforeSignup: "このサーバーに登録するには、以下の内容を確認し同意する必要があります。"
|
||||
pleaseAgreeAllToContinue: "続けるには、全ての「同意する」にチェックが入っている必要があります。"
|
||||
@ -1301,6 +1316,8 @@ lockdown: "ロックダウン"
|
||||
pleaseSelectAccount: "アカウントを選択してください"
|
||||
availableRoles: "利用可能なロール"
|
||||
acknowledgeNotesAndEnable: "注意事項を理解した上でオンにします。"
|
||||
alwaysConfirm: "常に確認する"
|
||||
useDefaultSettings: "デフォルトの設定を適用する"
|
||||
|
||||
_accountSettings:
|
||||
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
|
||||
@ -2845,3 +2862,17 @@ _remoteLookupErrors:
|
||||
_noSuchObject:
|
||||
title: "見つかりません"
|
||||
description: "要求されたリソースは見つかりませんでした。URIをもう一度お確かめください。"
|
||||
|
||||
_watermarkEditor:
|
||||
title: "ウォーターマークをカスタマイズ"
|
||||
driveFileTypeWarn: "このファイルは対応していません"
|
||||
driveFileTypeWarnDescription: "画像ファイルを選択してください"
|
||||
settingInvalidWarn: "設定が不十分です"
|
||||
settingInvalidWarnDescription: "プレビューが正常に表示されることを確認してから保存してください"
|
||||
useSmallFile: "ウォーターマーク用画像のファイルサイズが大きいと、処理の際にウォーターマークを読み込む時間が長くなり、アップロードに時間がかかるようになります。あらかじめ解像度を低くしたり、ファイルを圧縮したりしておくことを推奨します。"
|
||||
repeatSetting: "描画モード"
|
||||
repeat: "全体を埋め尽くす"
|
||||
padding: "余白"
|
||||
preserveBoundingRect: "回転した分の面積を確保する"
|
||||
preserveBoundingRectDescription: "通常はオンで問題ありません。ウォーターマークを回転させた際に余白が不自然になった場合はオフにしてみてください。"
|
||||
clipboardUploadBehavior: "クリップボード経由でのアップロード時の動作"
|
||||
|
BIN
packages/frontend/assets/hill.webp
Normal file
BIN
packages/frontend/assets/hill.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 201 KiB |
@ -143,6 +143,7 @@ const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]);
|
||||
const uploadings = uploads;
|
||||
const connection = useStream().useChannel('drive');
|
||||
const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // 外部渡しが多いので$refは使わないほうがよい
|
||||
const useWatermark = ref<boolean>(defaultStore.state.useWatermark);
|
||||
|
||||
// ドロップされようとしているか
|
||||
const draghover = ref(false);
|
||||
@ -391,7 +392,7 @@ function onChangeFileInput() {
|
||||
}
|
||||
|
||||
function upload(file: File, folderToUpload?: Misskey.entities.DriveFolder | null) {
|
||||
uploadFile(file, (folderToUpload && typeof folderToUpload === 'object') ? folderToUpload.id : null, undefined, keepOriginal.value).then(res => {
|
||||
uploadFile(file, (folderToUpload && typeof folderToUpload === 'object') ? folderToUpload.id : null, undefined, keepOriginal.value, useWatermark.value).then(res => {
|
||||
addFile(res, true);
|
||||
});
|
||||
}
|
||||
@ -633,6 +634,10 @@ function getMenu() {
|
||||
type: 'switch',
|
||||
text: i18n.ts.keepOriginalUploading,
|
||||
ref: keepOriginal,
|
||||
}, {
|
||||
type: 'switch',
|
||||
text: i18n.ts.useWatermark,
|
||||
ref: useWatermark,
|
||||
}, { type: 'divider' }, {
|
||||
text: i18n.ts.addFile,
|
||||
type: 'label',
|
||||
|
@ -27,8 +27,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:list="id"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@keydown="onKeydown($event)"
|
||||
@input="onInput"
|
||||
>
|
||||
@ -80,6 +80,8 @@ const emit = defineEmits<{
|
||||
(ev: 'change', _ev: KeyboardEvent): void;
|
||||
(ev: 'keydown', _ev: KeyboardEvent): void;
|
||||
(ev: 'enter', _ev: KeyboardEvent): void;
|
||||
(ev: 'focus', _ev: FocusEvent): void;
|
||||
(ev: 'blur', _ev: FocusEvent): void;
|
||||
(ev: 'update:modelValue', value: string | number): void;
|
||||
}>();
|
||||
|
||||
@ -114,6 +116,14 @@ const onKeydown = (ev: KeyboardEvent) => {
|
||||
emit('enter', ev);
|
||||
}
|
||||
};
|
||||
const onFocus = (ev: FocusEvent) => {
|
||||
focused.value = true;
|
||||
emit('focus', ev);
|
||||
};
|
||||
const onBlur = (ev: FocusEvent) => {
|
||||
focused.value = false;
|
||||
emit('blur', ev);
|
||||
};
|
||||
|
||||
const updated = () => {
|
||||
changed.value = false;
|
||||
|
@ -116,7 +116,7 @@ import { formatTimeString } from '@/scripts/format-time-string.js';
|
||||
import { Autocomplete } from '@/scripts/autocomplete.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { selectFiles } from '@/scripts/select-file.js';
|
||||
import { selectFile } from '@/scripts/select-file.js';
|
||||
import { defaultStore, notePostInterruptors, postFormActions } from '@/store.js';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
@ -129,6 +129,7 @@ import { miLocalStorage } from '@/local-storage.js';
|
||||
import { claimAchievement } from '@/scripts/achievements.js';
|
||||
import { emojiPicker } from '@/scripts/emoji-picker.js';
|
||||
import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js';
|
||||
import { canApplyWatermark } from '@/scripts/watermark.js';
|
||||
import type { PostFormProps } from '@/types/post-form.js';
|
||||
|
||||
const $i = signinRequired();
|
||||
@ -413,7 +414,10 @@ function focus() {
|
||||
function chooseFileFrom(ev) {
|
||||
if (props.mock) return;
|
||||
|
||||
selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => {
|
||||
selectFile(ev.currentTarget ?? ev.target, {
|
||||
label: i18n.ts.attachFile,
|
||||
multiple: true,
|
||||
}).then(files_ => {
|
||||
for (const file of files_) {
|
||||
files.value.push(file);
|
||||
}
|
||||
@ -439,10 +443,10 @@ function replaceFile(file: Misskey.entities.DriveFile, newFile: Misskey.entities
|
||||
files.value[files.value.findIndex(x => x.id === file.id)] = newFile;
|
||||
}
|
||||
|
||||
function upload(file: File, name?: string): void {
|
||||
function upload(file: File, name?: string, watermark?: boolean): void {
|
||||
if (props.mock) return;
|
||||
|
||||
uploadFile(file, defaultStore.state.uploadFolder, name).then(res => {
|
||||
uploadFile(file, defaultStore.state.uploadFolder, name, undefined, watermark).then(res => {
|
||||
files.value.push(res);
|
||||
});
|
||||
}
|
||||
@ -584,6 +588,8 @@ async function onPaste(ev: ClipboardEvent) {
|
||||
if (props.mock) return;
|
||||
if (!ev.clipboardData) return;
|
||||
|
||||
let shouldApplyWatermark: boolean | undefined = undefined;
|
||||
|
||||
for (const { item, i } of Array.from(ev.clipboardData.items, (data, x) => ({ item: data, i: x }))) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
@ -591,7 +597,24 @@ async function onPaste(ev: ClipboardEvent) {
|
||||
const lio = file.name.lastIndexOf('.');
|
||||
const ext = lio >= 0 ? file.name.slice(lio) : '';
|
||||
const formatted = `${formatTimeString(new Date(file.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
|
||||
upload(file, formatted);
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
if (
|
||||
shouldApplyWatermark == null &&
|
||||
defaultStore.state.clipboardWatermarkBehavior === 'confirm' &&
|
||||
canApplyWatermark(defaultStore.reactiveState.watermarkConfig.value)
|
||||
) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'info',
|
||||
text: i18n.ts.watermarkConfirm,
|
||||
okText: i18n.ts.yes,
|
||||
cancelText: i18n.ts.no,
|
||||
});
|
||||
shouldApplyWatermark = !canceled;
|
||||
}
|
||||
}
|
||||
|
||||
upload(file, formatted, shouldApplyWatermark);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -68,7 +68,9 @@ watch(description, () => {
|
||||
});
|
||||
|
||||
function setAvatar(ev) {
|
||||
chooseFileFromPc(false).then(async (files) => {
|
||||
chooseFileFromPc({
|
||||
multiple: false,
|
||||
}).then(async (files) => {
|
||||
const file = files[0];
|
||||
|
||||
let originalOrCropped = file;
|
||||
|
@ -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 :id="`${id}-${anchor}`" v-model="value" type="radio" :name="id" :value="anchor" :class="$style.anchorGridItemRadio"/>
|
||||
<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.centerTop,
|
||||
'top-left': i18n.ts.leftTop,
|
||||
'top-right': i18n.ts.rightTop,
|
||||
'left': i18n.ts.leftCenter,
|
||||
'right': i18n.ts.rightCenter,
|
||||
'bottom': i18n.ts.centerBottom,
|
||||
'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>
|
@ -0,0 +1,66 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<svg version="1.1" viewBox="0 0 120 80" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" :class="$style.graphic">
|
||||
<defs>
|
||||
<marker id="Arrow3" overflow="visible" markerHeight="6" markerWidth="4.2069998" orient="auto-start-reverse" preserveAspectRatio="none" viewBox="0 0 4.2071068 7">
|
||||
<path transform="rotate(180 .125 0)" d="m3-3-3 3 3 3" fill="none" stroke="context-stroke" stroke-linecap="round" />
|
||||
</marker>
|
||||
<pattern id="pattern5942" patternTransform="matrix(1.452 1.452 -3.1368 3.8697 -1.7474 -2.9488)" xlink:href="#Strips1_1white" />
|
||||
<pattern id="Strips1_1white" width="2" height="1" patternTransform="translate(0) scale(10)" patternUnits="userSpaceOnUse">
|
||||
<rect y="-.5" width="1" height="2" fill="var(--MI_THEME-panel)" />
|
||||
</pattern>
|
||||
<marker id="Arrow3-6" overflow="visible" markerHeight="6" markerWidth="4.2069998" orient="auto-start-reverse" preserveAspectRatio="none" viewBox="0 0 4.2071068 7">
|
||||
<path transform="rotate(180 .125 0)" d="m3-3-3 3 3 3" fill="none" stroke="context-stroke" stroke-linecap="round" />
|
||||
</marker>
|
||||
<marker id="Arrow3-6-9" overflow="visible" markerHeight="6" markerWidth="4.2069998" orient="auto-start-reverse" preserveAspectRatio="none" viewBox="0 0 4.2071068 7">
|
||||
<path transform="rotate(180 .125 0)" d="m3-3-3 3 3 3" fill="none" stroke="context-stroke" stroke-linecap="round" />
|
||||
</marker>
|
||||
<marker id="Arrow3-6-9-1" overflow="visible" markerHeight="6" markerWidth="4.2069998" orient="auto-start-reverse" preserveAspectRatio="none" viewBox="0 0 4.2071068 7">
|
||||
<path transform="rotate(180 .125 0)" d="m3-3-3 3 3 3" fill="none" stroke="context-stroke" stroke-linecap="round" />
|
||||
</marker>
|
||||
</defs>
|
||||
<g transform="translate(-21.709 -14.787)" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g fill-rule="evenodd">
|
||||
<rect x="21.709" y="14.787" width="120" height="80" fill="var(--MI_THEME-bg)" stop-color="#000000" stroke-dasharray="1.1384, 3.4152" stroke-width="1.1384" style="mix-blend-mode:normal" />
|
||||
<rect x="21.709" y="14.787" width="120" height="80" fill="url(#pattern5942)" stop-color="#000000" stroke-dasharray="1.1384, 3.4152" stroke-width="1.1384" style="mix-blend-mode:normal" />
|
||||
<rect x="47.101" y="40.105" width="69.216" height="29.364" ry="5.3019" fill="var(--MI_THEME-accentDarken)" stop-color="#000000" stroke-dasharray="1.13855, 3.41565" stroke-width="1.1386" />
|
||||
</g>
|
||||
<g fill="none" stroke="var(--MI_THEME-error)">
|
||||
<path v-if="props.arrow === 'top'" d="m81.709 16.167 2e-6 22.601" marker-end="url(#Arrow3)" marker-start="url(#Arrow3)" stop-color="#000000" stroke-width="1.3038" />
|
||||
<path v-else-if="props.arrow === 'left'" d="m23.011 54.787 22.751-4e-6" marker-end="url(#Arrow3-6)" marker-start="url(#Arrow3-6)" stop-color="#000000" stroke-width="1.322" />
|
||||
<path v-else-if="props.arrow === 'bottom'" d="m81.709 70.772-1e-6 22.647" marker-end="url(#Arrow3-6-9)" marker-start="url(#Arrow3-6-9)" stop-color="#000000" stroke-width="1.2715" />
|
||||
<path v-else-if="props.arrow === 'right'" d="m117.58 54.787 22.828 5e-6" marker-end="url(#Arrow3-6-9-1)" marker-start="url(#Arrow3-6-9-1)" stop-color="#000000" stroke-width="1.285" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
arrow: 'top' | 'bottom' | 'left' | 'right' | null;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.root {
|
||||
border-radius: var(--MI-radius);
|
||||
overflow: clip;
|
||||
box-sizing: border-box;
|
||||
border: thin solid var(--MI_THEME-divider);
|
||||
max-width: 242px; /* 240px + 左右ボーダー2px */
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.graphic {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
394
packages/frontend/src/components/MkWatermarkEditorDialog.vue
Normal file
394
packages/frontend/src/components/MkWatermarkEditorDialog.vue
Normal file
@ -0,0 +1,394 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkModalWindow
|
||||
ref="dialogEl"
|
||||
:width="1000"
|
||||
:height="600"
|
||||
:scroll="false"
|
||||
:withOkButton="true"
|
||||
@close="cancel()"
|
||||
@ok="save()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header><i class="ti ti-ripple"></i> {{ i18n.ts._watermarkEditor.title }}</template>
|
||||
|
||||
<div :class="$style.watermarkEditorRoot">
|
||||
<div :class="$style.watermarkEditorInputRoot">
|
||||
<div :class="$style.watermarkEditorPreviewRoot">
|
||||
<canvas ref="canvasEl" :class="$style.watermarkEditorPreviewCanvas"></canvas>
|
||||
<MkLoading v-if="canvasLoading" :class="$style.watermarkEditorPreviewSpinner"/>
|
||||
<div :class="$style.watermarkEditorPreviewWrapper">
|
||||
<div class="_acrylic" :class="$style.watermarkEditorPreviewTitle">{{ i18n.ts.preview }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.watermarkEditorSettings" class="_gaps">
|
||||
<MkInfo warn>{{ i18n.ts._watermarkEditor.useSmallFile }}</MkInfo>
|
||||
|
||||
<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="transparency" :min="0" :max="1" :step="0.01" :textConverter="(v) => `${Math.floor(v * 100)}%`">
|
||||
<template #label>{{ i18n.ts.transparency }}</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>
|
||||
|
||||
<div>
|
||||
<div :class="$style.formLabel">{{ i18n.ts._watermarkEditor.padding }}</div>
|
||||
<div class="_gaps">
|
||||
<XPaddingView :arrow="focusedForm"/>
|
||||
<div class="_gaps_s">
|
||||
<MkInput v-model="paddingTop" type="number" debounce @focus="focusedForm = 'top'" @blur="focusedForm = null">
|
||||
<template #prefix><i class="ti ti-border-top"></i></template>
|
||||
<template #suffix>px</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="paddingLeft" type="number" debounce @focus="focusedForm = 'left'" @blur="focusedForm = null">
|
||||
<template #prefix><i class="ti ti-border-left"></i></template>
|
||||
<template #suffix>px</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="paddingRight" type="number" debounce @focus="focusedForm = 'right'" @blur="focusedForm = null">
|
||||
<template #prefix><i class="ti ti-border-right"></i></template>
|
||||
<template #suffix>px</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="paddingBottom" type="number" debounce @focus="focusedForm = 'bottom'" @blur="focusedForm = null">
|
||||
<template #prefix><i class="ti ti-border-bottom"></i></template>
|
||||
<template #suffix>px</template>
|
||||
</MkInput>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MkSwitch v-if="watermarkConfig?.repeat !== true" v-model="preserveBoundingRect">
|
||||
<template #label>{{ i18n.ts._watermarkEditor.preserveBoundingRect }}</template>
|
||||
<template #caption>{{ i18n.ts._watermarkEditor.preserveBoundingRectDescription }}</template>
|
||||
</MkSwitch>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, ref, useTemplateRef, computed, watch, onMounted } from '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 MkInput from '@/components/MkInput.vue';
|
||||
import MkRange from '@/components/MkRange.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import XAnchorSelector from '@/components/MkWatermarkEditorDialog.anchor.vue';
|
||||
import XPaddingView from '@/components/MkWatermarkEditorDialog.padding.vue';
|
||||
|
||||
import * as os from '@/os.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { selectFile } from '@/scripts/select-file.js';
|
||||
import { applyWatermark, canApplyWatermark } from '@/scripts/watermark.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
|
||||
import type { WatermarkUserConfig } from '@/scripts/watermark.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok'): void;
|
||||
(ev: 'cancel'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
//#region Modalの制御
|
||||
const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
|
||||
function cancel() {
|
||||
emit('cancel');
|
||||
dialogEl.value?.close();
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region 設定
|
||||
const watermarkConfig = ref<WatermarkUserConfig>(defaultStore.state.watermarkConfig ?? {
|
||||
opacity: 0.2,
|
||||
repeat: true,
|
||||
rotate: 15,
|
||||
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) {
|
||||
const { anchor, ...newValue } = watermarkConfig.value;
|
||||
watermarkConfig.value = newValue;
|
||||
} 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 transparency = computed({
|
||||
get: () => 1 - (watermarkConfig.value?.opacity ?? 0.2),
|
||||
set: (v) => watermarkConfig.value = { ...watermarkConfig.value, opacity: (1 - v) },
|
||||
});
|
||||
const rotate = computed({
|
||||
get: () => watermarkConfig.value?.rotate ?? 15,
|
||||
set: (v) => watermarkConfig.value = { ...watermarkConfig.value, rotate: v },
|
||||
});
|
||||
const preserveBoundingRect = computed({
|
||||
get: () => !(watermarkConfig.value?.noBoundingBoxExpansion ?? false),
|
||||
set: (v) => watermarkConfig.value = { ...watermarkConfig.value, noBoundingBoxExpansion: !v },
|
||||
});
|
||||
|
||||
function setPadding(pos: 'top' | 'left' | 'right' | 'bottom', val: number) {
|
||||
const padding = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
...watermarkConfig.value?.padding,
|
||||
[pos]: val,
|
||||
};
|
||||
watermarkConfig.value = { ...watermarkConfig.value, padding };
|
||||
}
|
||||
|
||||
const paddingTop = computed({
|
||||
get: () => watermarkConfig.value?.padding?.top ?? 0,
|
||||
set: (v) => setPadding('top', v),
|
||||
});
|
||||
const paddingLeft = computed({
|
||||
get: () => watermarkConfig.value?.padding?.left ?? 0,
|
||||
set: (v) => setPadding('left', v),
|
||||
});
|
||||
const paddingRight = computed({
|
||||
get: () => watermarkConfig.value?.padding?.right ?? 0,
|
||||
set: (v) => setPadding('right', v),
|
||||
});
|
||||
const paddingBottom = computed({
|
||||
get: () => watermarkConfig.value?.padding?.bottom ?? 0,
|
||||
set: (v) => setPadding('bottom', v),
|
||||
});
|
||||
|
||||
function save() {
|
||||
if (canApplyWatermark(watermarkConfig.value)) {
|
||||
defaultStore.set('watermarkConfig', watermarkConfig.value);
|
||||
} else {
|
||||
os.alert({
|
||||
type: 'warning',
|
||||
title: i18n.ts._watermarkEditor.settingInvalidWarn,
|
||||
text: i18n.ts._watermarkEditor.settingInvalidWarnDescription,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
emit('ok');
|
||||
dialogEl.value?.close();
|
||||
}
|
||||
//#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
|
||||
|
||||
//#region Canvasの制御
|
||||
const canvasLoading = ref(true);
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
onMounted(() => {
|
||||
watch(watermarkConfig, (watermarkConfigTo) => {
|
||||
canvasLoading.value = true;
|
||||
if (canvasEl.value) {
|
||||
// @/scripts/watermark.ts の DEFAULT_ASPECT_RATIO と同じ縦横比の画像を使用すること
|
||||
applyWatermark('/client-assets/hill.webp', canvasEl.value, canApplyWatermark(watermarkConfigTo) ? watermarkConfigTo : null).then(() => {
|
||||
canvasLoading.value = false;
|
||||
});
|
||||
}
|
||||
}, { immediate: true, deep: true });
|
||||
});
|
||||
//#endregion
|
||||
|
||||
//#region paddingViewの制御
|
||||
const focusedForm = ref<'top' | 'left' | 'right' | 'bottom' | null>(null);
|
||||
//#endregion
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.watermarkEditorRoot {
|
||||
container-type: inline-size;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.watermarkEditorInputRoot {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
}
|
||||
|
||||
.watermarkEditorPreviewRoot {
|
||||
position: relative;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-size: auto auto;
|
||||
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.watermarkEditorPreviewWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.watermarkEditorPreviewTitle {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.watermarkEditorPreviewSpinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.watermarkEditorPreviewCanvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.watermarkEditorSettings {
|
||||
padding: 24px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
font-size: 0.85em;
|
||||
padding: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.fileSelectorRoot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fileErrorRoot {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
font-weight: 700;
|
||||
color: var(--MI_THEME-error);
|
||||
}
|
||||
|
||||
.fileSelectorButton {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fileNotSelected {
|
||||
font-weight: 700;
|
||||
color: var(--MI_THEME-infoWarnFg);
|
||||
}
|
||||
|
||||
@container (max-width: 800px) {
|
||||
.watermarkEditorInputRoot {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -5,33 +5,32 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<div :class="[$style.root, { [$style.inline]: inline }]">
|
||||
<a v-if="external" :class="$style.main" class="_button" :href="to" target="_blank">
|
||||
<component
|
||||
:is="external ? 'a' : to ? MkA : 'button'"
|
||||
:class="[$style.main, { [$style.active]: active }]"
|
||||
class="_button"
|
||||
v-bind="external ? { href: to, target: '_blank', rel: 'noopener' } : to ? { to, behavior } : {}"
|
||||
>
|
||||
<span :class="$style.icon"><slot name="icon"></slot></span>
|
||||
<span :class="$style.text"><slot></slot></span>
|
||||
<span :class="$style.suffix">
|
||||
<span :class="$style.suffixText"><slot name="suffix"></slot></span>
|
||||
<i class="ti ti-external-link"></i>
|
||||
<i v-if="external" class="ti ti-external-link"></i>
|
||||
<i v-else class="ti ti-chevron-right"></i>
|
||||
</span>
|
||||
</a>
|
||||
<MkA v-else :class="[$style.main, { [$style.active]: active }]" class="_button" :to="to" :behavior="behavior">
|
||||
<span :class="$style.icon"><slot name="icon"></slot></span>
|
||||
<span :class="$style.text"><slot></slot></span>
|
||||
<span :class="$style.suffix">
|
||||
<span :class="$style.suffixText"><slot name="suffix"></slot></span>
|
||||
<i class="ti ti-chevron-right"></i>
|
||||
</span>
|
||||
</MkA>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import MkA, { type MkABehavior } from '@/components/global/MkA.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
to: string;
|
||||
to?: string;
|
||||
active?: boolean;
|
||||
external?: boolean;
|
||||
behavior?: null | 'window' | 'browser';
|
||||
behavior?: MkABehavior;
|
||||
inline?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
@ -89,14 +89,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkInput>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageAvatarDecorations, 'canManageAvatarDecorations'])">
|
||||
<template #label>{{ i18n.ts._role._options.canManageAvatarDecorations }}</template>
|
||||
<template #suffix>{{ policies.canManageAvatarDecorations ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||
<MkSwitch v-model="policies.canManageAvatarDecorations">
|
||||
<template #label>{{ i18n.ts.enable }}</template>
|
||||
</MkSwitch>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])">
|
||||
<template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template>
|
||||
<template #suffix>{{ policies.canManageCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||
@ -105,6 +97,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkSwitch>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageAvatarDecorations, 'canManageAvatarDecorations'])">
|
||||
<template #label>{{ i18n.ts._role._options.canManageAvatarDecorations }}</template>
|
||||
<template #suffix>{{ policies.canManageAvatarDecorations ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||
<MkSwitch v-model="policies.canManageAvatarDecorations">
|
||||
<template #label>{{ i18n.ts.enable }}</template>
|
||||
</MkSwitch>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])">
|
||||
<template #label>{{ i18n.ts._role._options.canSearchNotes }}</template>
|
||||
<template #suffix>{{ policies.canSearchNotes ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||
|
@ -189,7 +189,7 @@ async function archive() {
|
||||
}
|
||||
|
||||
function setBannerImage(evt) {
|
||||
selectFile(evt.currentTarget ?? evt.target, null).then(file => {
|
||||
selectFile(evt.currentTarget ?? evt.target).then(file => {
|
||||
bannerId.value = file.id;
|
||||
});
|
||||
}
|
||||
|
@ -121,7 +121,7 @@ watch(roleIdsThatCanBeUsedThisEmojiAsReaction, async () => {
|
||||
const imgUrl = computed(() => file.value ? file.value.url : props.emoji ? props.emoji.url : null);
|
||||
|
||||
async function changeImage(ev: Event) {
|
||||
file.value = await selectFile(ev.currentTarget ?? ev.target, null);
|
||||
file.value = await selectFile(ev.currentTarget ?? ev.target);
|
||||
const candidate = file.value.name.replace(/\.(.+)$/, '');
|
||||
if (candidate.match(/^[a-z0-9_]+$/)) {
|
||||
name.value = candidate;
|
||||
|
@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div class="name">{{ file.name }}</div>
|
||||
<button v-tooltip="i18n.ts.remove" class="remove _button" @click="remove(file)"><i class="ti ti-x"></i></button>
|
||||
</div>
|
||||
<MkButton primary @click="selectFile"><i class="ti ti-plus"></i> {{ i18n.ts.attachFile }}</MkButton>
|
||||
<MkButton primary @click="chooseFile"><i class="ti ti-plus"></i> {{ i18n.ts.attachFile }}</MkButton>
|
||||
</div>
|
||||
|
||||
<MkSwitch v-model="isSensitive">{{ i18n.ts.markAsSensitive }}</MkSwitch>
|
||||
@ -45,7 +45,7 @@ import MkInput from '@/components/MkInput.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import FormSuspense from '@/components/form/suspense.vue';
|
||||
import { selectFiles } from '@/scripts/select-file.js';
|
||||
import { selectFile } from '@/scripts/select-file.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
@ -64,8 +64,8 @@ const description = ref<string | null>(null);
|
||||
const title = ref<string | null>(null);
|
||||
const isSensitive = ref(false);
|
||||
|
||||
function selectFile(evt) {
|
||||
selectFiles(evt.currentTarget ?? evt.target, null).then(selected => {
|
||||
function chooseFile(evt) {
|
||||
selectFile(evt.currentTarget ?? evt.target, { multiple: true }).then(selected => {
|
||||
files.value = files.value.concat(selected);
|
||||
});
|
||||
}
|
||||
|
@ -217,7 +217,7 @@ async function add() {
|
||||
}
|
||||
|
||||
function setEyeCatchingImage(img) {
|
||||
selectFile(img.currentTarget ?? img.target, null).then(file => {
|
||||
selectFile(img.currentTarget ?? img.target).then(file => {
|
||||
eyeCatchingImageId.value = file.id;
|
||||
});
|
||||
}
|
||||
|
@ -33,13 +33,42 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<FormSection>
|
||||
<div class="_gaps_m">
|
||||
<FormLink @click="chooseUploadFolder()">
|
||||
<template #icon><i class="ti ti-folder"></i></template>
|
||||
{{ i18n.ts.uploadFolder }}
|
||||
<template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template>
|
||||
<template #suffixIcon><i class="ti ti-folder"></i></template>
|
||||
</FormLink>
|
||||
<FormLink to="/settings/drive/cleaner">
|
||||
<template #icon><i class="ti ti-file-shredder"></i></template>
|
||||
{{ i18n.ts.drivecleaner }}
|
||||
</FormLink>
|
||||
<MkFolder>
|
||||
<template #icon><i class="ti ti-ripple"></i></template>
|
||||
<template #label>{{ i18n.ts.watermark }}</template>
|
||||
|
||||
<div>
|
||||
<div class="_gaps">
|
||||
<MkInfo>{{ i18n.ts.useWatermarkInfo }}</MkInfo>
|
||||
|
||||
<MkSwitch v-model="useWatermark">
|
||||
<template #label>{{ i18n.ts.useWatermark }}</template>
|
||||
<template #caption>{{ i18n.ts.useWatermarkDescription }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkSelect v-model="clipboardWatermarkBehavior">
|
||||
<template #label>{{ i18n.ts._watermarkEditor.clipboardUploadBehavior }}</template>
|
||||
<option value="confirm">{{ i18n.ts.alwaysConfirm }}</option>
|
||||
<option value="default">{{ i18n.ts.useDefaultSettings }}</option>
|
||||
</MkSelect>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<FormLink @click="openWatermarkEditor">
|
||||
<template #icon><i class="ti ti-pencil"></i></template>
|
||||
{{ i18n.ts._watermarkEditor.title }}
|
||||
</FormLink>
|
||||
</div>
|
||||
</MkFolder>
|
||||
<MkSwitch v-model="keepOriginalUploading">
|
||||
<template #label>{{ i18n.ts.keepOriginalUploading }}</template>
|
||||
<template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template>
|
||||
@ -61,10 +90,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import FormLink from '@/components/form/link.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import FormSection from '@/components/form/section.vue';
|
||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||
@ -77,6 +109,7 @@ import MkChart from '@/components/MkChart.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { signinRequired } from '@/account.js';
|
||||
import { reloadAsk } from '@/scripts/reload-ask';
|
||||
|
||||
const $i = signinRequired();
|
||||
|
||||
@ -99,9 +132,19 @@ const meterStyle = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const useWatermark = computed(defaultStore.makeGetterSetter('useWatermark'));
|
||||
const clipboardWatermarkBehavior = computed(defaultStore.makeGetterSetter('clipboardWatermarkBehavior'));
|
||||
|
||||
const keepOriginalUploading = computed(defaultStore.makeGetterSetter('keepOriginalUploading'));
|
||||
const keepOriginalFilename = computed(defaultStore.makeGetterSetter('keepOriginalFilename'));
|
||||
|
||||
watch([
|
||||
useWatermark,
|
||||
clipboardWatermarkBehavior,
|
||||
], () => {
|
||||
reloadAsk({ unison: true, reason: i18n.ts.reloadRequiredToApplySettings });
|
||||
});
|
||||
|
||||
misskeyApi('drive').then(info => {
|
||||
capacity.value = info.capacity;
|
||||
usage.value = info.usage;
|
||||
@ -130,6 +173,12 @@ function chooseUploadFolder() {
|
||||
});
|
||||
}
|
||||
|
||||
function openWatermarkEditor() {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWatermarkEditorDialog.vue')), {}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
function saveProfile() {
|
||||
misskeyApi('i/update', {
|
||||
alwaysMarkNsfw: !!alwaysMarkNsfw.value,
|
||||
|
@ -223,7 +223,7 @@ function save() {
|
||||
}
|
||||
|
||||
function changeAvatar(ev) {
|
||||
selectFile(ev.currentTarget ?? ev.target, i18n.ts.avatar).then(async (file) => {
|
||||
selectFile(ev.currentTarget ?? ev.target, { label: i18n.ts.avatar }).then(async (file) => {
|
||||
let originalOrCropped = file;
|
||||
|
||||
const { canceled } = await os.confirm({
|
||||
@ -250,7 +250,7 @@ function changeAvatar(ev) {
|
||||
}
|
||||
|
||||
function changeBanner(ev) {
|
||||
selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner).then(async (file) => {
|
||||
selectFile(ev.currentTarget ?? ev.target, { label: i18n.ts.banner }).then(async (file) => {
|
||||
let originalOrCropped = file;
|
||||
|
||||
const { canceled } = await os.confirm({
|
||||
|
@ -94,7 +94,9 @@ const friendlyFileName = computed<string>(() => {
|
||||
});
|
||||
|
||||
function selectSound(ev) {
|
||||
selectFile(ev.currentTarget ?? ev.target, i18n.ts._soundSettings.driveFile).then(async (file) => {
|
||||
selectFile(ev.currentTarget ?? ev.target, {
|
||||
label: i18n.ts._soundSettings.driveFile,
|
||||
}).then(async (file) => {
|
||||
if (!file.type.startsWith('audio')) {
|
||||
os.alert({
|
||||
type: 'warning',
|
||||
|
@ -158,7 +158,7 @@ fetchThemes().then(() => {
|
||||
});
|
||||
|
||||
function setWallpaper(event) {
|
||||
selectFile(event.currentTarget ?? event.target, null).then(file => {
|
||||
selectFile(event.currentTarget ?? event.target).then(file => {
|
||||
wallpaper.value = file.url;
|
||||
});
|
||||
}
|
||||
|
@ -11,15 +11,21 @@ import { useStream } from '@/stream.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { uploadFile } from '@/scripts/upload.js';
|
||||
import { canApplyWatermark } from '@/scripts/watermark.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
|
||||
export function chooseFileFromPc(multiple: boolean, keepOriginal = false): Promise<Misskey.entities.DriveFile[]> {
|
||||
export function chooseFileFromPc(opts?: {
|
||||
multiple?: boolean;
|
||||
keepOriginal?: boolean;
|
||||
useWatermark?: boolean;
|
||||
}): Promise<Misskey.entities.DriveFile[]> {
|
||||
return new Promise((res, rej) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = multiple;
|
||||
input.multiple = opts?.multiple ?? false;
|
||||
input.onchange = () => {
|
||||
if (!input.files) return res([]);
|
||||
const promises = Array.from(input.files, file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal));
|
||||
const promises = Array.from(input.files, file => uploadFile(file, defaultStore.state.uploadFolder, undefined, opts?.keepOriginal, opts?.useWatermark));
|
||||
|
||||
Promise.all(promises).then(driveFiles => {
|
||||
res(driveFiles);
|
||||
@ -39,9 +45,11 @@ export function chooseFileFromPc(multiple: boolean, keepOriginal = false): Promi
|
||||
});
|
||||
}
|
||||
|
||||
export function chooseFileFromDrive(multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
|
||||
export function chooseFileFromDrive(opts?: {
|
||||
multiple?: boolean;
|
||||
}): Promise<Misskey.entities.DriveFile[]> {
|
||||
return new Promise((res, rej) => {
|
||||
os.selectDriveFile(multiple).then(files => {
|
||||
os.selectDriveFile(opts?.multiple ?? false).then(files => {
|
||||
res(files);
|
||||
});
|
||||
});
|
||||
@ -80,37 +88,79 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> {
|
||||
});
|
||||
}
|
||||
|
||||
function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
|
||||
function select(src: HTMLElement | EventTarget | null, opts?: {
|
||||
label?: string;
|
||||
multiple?: boolean;
|
||||
dontUseWatermark?: boolean;
|
||||
}): Promise<Misskey.entities.DriveFile[]> {
|
||||
return new Promise((res, rej) => {
|
||||
const keepOriginal = ref(defaultStore.state.keepOriginalUploading);
|
||||
|
||||
os.popupMenu([label ? {
|
||||
text: label,
|
||||
type: 'label',
|
||||
} : undefined, {
|
||||
const watermarkCanApply = canApplyWatermark(defaultStore.state.watermarkConfig);
|
||||
const useWatermark = ref(opts?.dontUseWatermark || !watermarkCanApply ? false : defaultStore.state.useWatermark);
|
||||
|
||||
const menu: MenuItem[] = [];
|
||||
|
||||
if (opts?.label) {
|
||||
menu.push({
|
||||
text: opts.label,
|
||||
type: 'label',
|
||||
});
|
||||
}
|
||||
|
||||
menu.push({
|
||||
type: 'switch',
|
||||
text: i18n.ts.keepOriginalUploading,
|
||||
ref: keepOriginal,
|
||||
}, {
|
||||
});
|
||||
|
||||
if (!opts?.dontUseWatermark) {
|
||||
menu.push({
|
||||
type: 'switch',
|
||||
text: i18n.ts.useWatermark,
|
||||
disabled: !watermarkCanApply,
|
||||
ref: useWatermark,
|
||||
});
|
||||
}
|
||||
|
||||
menu.push({
|
||||
text: i18n.ts.upload,
|
||||
icon: 'ti ti-upload',
|
||||
action: () => chooseFileFromPc(multiple, keepOriginal.value).then(files => res(files)),
|
||||
action: () => chooseFileFromPc({
|
||||
multiple: opts?.multiple,
|
||||
keepOriginal: keepOriginal.value,
|
||||
useWatermark: useWatermark.value,
|
||||
}).then(files => res(files)),
|
||||
}, {
|
||||
text: i18n.ts.fromDrive,
|
||||
icon: 'ti ti-cloud',
|
||||
action: () => chooseFileFromDrive(multiple).then(files => res(files)),
|
||||
action: () => chooseFileFromDrive({ multiple: opts?.multiple }).then(files => res(files)),
|
||||
}, {
|
||||
text: i18n.ts.fromUrl,
|
||||
icon: 'ti ti-link',
|
||||
action: () => chooseFileFromUrl().then(file => res([file])),
|
||||
}], src);
|
||||
});
|
||||
|
||||
os.popupMenu(menu, src);
|
||||
});
|
||||
}
|
||||
|
||||
export function selectFile(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile> {
|
||||
return select(src, label, false).then(files => files[0]);
|
||||
}
|
||||
|
||||
export function selectFiles(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile[]> {
|
||||
return select(src, label, true);
|
||||
export function selectFile(src: HTMLElement | EventTarget | null, opts: {
|
||||
label?: string;
|
||||
multiple: true;
|
||||
dontUseWatermark?: boolean;
|
||||
}): Promise<Misskey.entities.DriveFile[]>;
|
||||
export function selectFile(src: HTMLElement | EventTarget | null, opts?: {
|
||||
label?: string;
|
||||
multiple?: false;
|
||||
dontUseWatermark?: boolean;
|
||||
}): Promise<Misskey.entities.DriveFile>;
|
||||
export function selectFile(src: HTMLElement | EventTarget | null, opts?: {
|
||||
label?: string;
|
||||
multiple?: boolean;
|
||||
dontUseWatermark?: boolean;
|
||||
}): Promise<Misskey.entities.DriveFile | Misskey.entities.DriveFile[]> {
|
||||
return select(src, opts).then(files => {
|
||||
return opts?.multiple ? files : files[0];
|
||||
});
|
||||
}
|
||||
|
@ -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 { canApplyWatermark, getWatermarkAppliedImage } from '@/scripts/watermark.js';
|
||||
|
||||
type Uploading = {
|
||||
id: string;
|
||||
@ -32,9 +33,10 @@ const mimeTypeMap = {
|
||||
|
||||
export function uploadFile(
|
||||
file: File,
|
||||
folder?: string | Misskey.entities.DriveFolder,
|
||||
folder?: string | null | Misskey.entities.DriveFolder,
|
||||
name?: string,
|
||||
keepOriginal: boolean = defaultStore.state.keepOriginalUploading,
|
||||
watermark: boolean = defaultStore.state.useWatermark,
|
||||
): Promise<Misskey.entities.DriveFile> {
|
||||
if ($i == null) throw new Error('Not logged in');
|
||||
|
||||
@ -67,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 && canApplyWatermark(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);
|
||||
}
|
||||
@ -91,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);
|
||||
|
||||
|
@ -21,7 +21,7 @@ const compressTypeMapFallback = {
|
||||
'image/svg+xml': { quality: 1, mimeType: 'image/png' },
|
||||
} 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];
|
||||
if (!imgConfig || await isAnimated(file)) {
|
||||
return;
|
||||
|
296
packages/frontend/src/scripts/watermark.ts
Normal file
296
packages/frontend/src/scripts/watermark.ts
Normal file
@ -0,0 +1,296 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { getProxiedImageUrl } from '@/scripts/media-proxy.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { defaultStore } from '@/store.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];
|
||||
|
||||
/**
|
||||
* Storeへの保存やエディタで使用するための、条件別のプロパティを排除したバージョンの型。
|
||||
* `canApplyWatermark`で`WatermarkConfig`に変換可能かどうかを判定できる。
|
||||
*
|
||||
* どちらかの型を変更したら、もう一方も変更すること。
|
||||
*/
|
||||
export type WatermarkUserConfig = {
|
||||
/** ドライブファイルのID */
|
||||
fileId?: string;
|
||||
/** 画像URL */
|
||||
fileUrl?: string;
|
||||
/** 親画像に対するウォーターマークの幅比率。ない場合は1。親画像が縦長の場合は幅の比率として、横長の場合は高さ比率として使用される */
|
||||
sizeRatio?: number;
|
||||
/** 不透明度 */
|
||||
opacity?: number;
|
||||
/** 回転角度(度数) */
|
||||
rotate?: number;
|
||||
/** パディング */
|
||||
padding?: {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
/** 繰り返し */
|
||||
repeat?: boolean;
|
||||
/** 画像の始祖点。repeatがtrueの場合は使用されないが、それ以外の場合は必須 */
|
||||
anchor?: WatermarkAnchor;
|
||||
/** 回転の際に領域を自動で拡張するかどうか。repeatがtrueの場合は使用されない */
|
||||
noBoundingBoxExpansion?: boolean;
|
||||
|
||||
/** @internal */
|
||||
__bypassMediaProxy?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Canvasへの描画などで使用できる、動作に必要な値を網羅した型。
|
||||
* `WatermarkUserConfig`を`canApplyWatermark`でアサートすることで型を変換できる。
|
||||
*
|
||||
* どちらかの型を変更したら、もう一方も変更すること。
|
||||
*/
|
||||
export type WatermarkConfig = {
|
||||
/** ドライブファイルのID */
|
||||
fileId?: string;
|
||||
/** 画像URL */
|
||||
fileUrl?: string;
|
||||
/** 親画像に対するウォーターマークの幅比率。ない場合は1。親画像が縦長の場合は幅の比率として、横長の場合は高さ比率として使用される */
|
||||
sizeRatio?: number;
|
||||
/** 不透明度 */
|
||||
opacity?: number;
|
||||
/** 回転角度(度数) */
|
||||
rotate?: number;
|
||||
/** パディング */
|
||||
padding?: {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
__bypassMediaProxy?: boolean;
|
||||
} & ({
|
||||
/** 繰り返し */
|
||||
repeat?: false;
|
||||
/** 画像の始祖点 */
|
||||
anchor: WatermarkAnchor;
|
||||
/** 回転の際に領域を自動で拡張するかどうか */
|
||||
noBoundingBoxExpansion?: boolean;
|
||||
} | {
|
||||
/** 繰り返し */
|
||||
repeat: true;
|
||||
});
|
||||
|
||||
/** 基準とするアスペクト比(変えたら今後付加されるウォーターマークの大きさが全部変わるので変えるべきではない。プレビュー画像を変えたいなら画像の縦横比をこれに合わせること) */
|
||||
const DEFAULT_ASPECT_RATIO = 4 / 3;
|
||||
|
||||
/**
|
||||
* プレビューに必要な値が全部揃っているかどうかを判定する
|
||||
*/
|
||||
export function canApplyWatermark(config: Partial<WatermarkConfig | WatermarkUserConfig> | null): config is WatermarkConfig {
|
||||
return (
|
||||
config != null &&
|
||||
(config.fileUrl != null || config.fileId != null) &&
|
||||
((config.repeat !== true && 'anchor' in config && config.anchor != null) || (config.repeat === true))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ウォーターマークを適用してキャンバスに描画する
|
||||
*
|
||||
* @param img ウォーターマークを適用する画像(stringは画像URL)
|
||||
* @param el ウォーターマークを適用するキャンバス
|
||||
* @param config ウォーターマークの設定
|
||||
*/
|
||||
export function applyWatermark(img: string | Blob, el: HTMLCanvasElement | OffscreenCanvas, config: WatermarkConfig | null) {
|
||||
return new Promise<void>(async (resolve) => {
|
||||
const canvas = el;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const imgEl = new Image();
|
||||
imgEl.onload = async () => {
|
||||
canvas.width = imgEl.width;
|
||||
canvas.height = imgEl.height;
|
||||
ctx.drawImage(imgEl, 0, 0);
|
||||
|
||||
if (config != null) {
|
||||
if (config.fileUrl != null || config.fileId != null) {
|
||||
const watermark = new Image();
|
||||
watermark.onload = () => {
|
||||
const watermarkAspectRatio = watermark.width / watermark.height; // 横長は1より大きい
|
||||
const { width, height } = (() => {
|
||||
// 1. 画像を覆うサイズのプレビュー画像相当の領域を計算
|
||||
let canvasPreviewWidth: number;
|
||||
let canvasPreviewHeight: number;
|
||||
if (canvas.width > canvas.height) {
|
||||
canvasPreviewWidth = canvas.width;
|
||||
canvasPreviewHeight = canvas.width / DEFAULT_ASPECT_RATIO;
|
||||
} else {
|
||||
canvasPreviewWidth = canvas.height * DEFAULT_ASPECT_RATIO;
|
||||
canvasPreviewHeight = canvas.height;
|
||||
}
|
||||
|
||||
// 2. プレビュー画像相当の領域から、幅・高さそれぞれをベースにリサイズした場合の
|
||||
// ウォーターマークのサイズを計算
|
||||
let width = canvasPreviewWidth * (config.sizeRatio ?? 1);
|
||||
let height = canvasPreviewHeight * (config.sizeRatio ?? 1);
|
||||
|
||||
// 3. ウォーターマークのアスペクト比に合わせてリサイズ
|
||||
if (watermarkAspectRatio > 1) {
|
||||
// ウォーターマークが横長(横幅を基準に縮小)
|
||||
height = width / watermarkAspectRatio;
|
||||
} else {
|
||||
// ウォーターマークが縦長(縦幅を基準に縮小)
|
||||
width = height * watermarkAspectRatio;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
})();
|
||||
|
||||
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, '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 != null && config.rotate !== 0 && !config.noBoundingBoxExpansion) {
|
||||
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;
|
||||
case 'top':
|
||||
case 'top-left':
|
||||
case 'top-right':
|
||||
return rotateY + (config.padding ? config.padding.top ?? 0 : 0);
|
||||
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(x + width / 2, y + height / 2);
|
||||
ctx.rotate(rotateRad);
|
||||
ctx.translate(-x - width / 2, -y - height / 2);
|
||||
}
|
||||
ctx.drawImage(watermark, x, y, width, height);
|
||||
}
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
watermark.onerror = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
let watermarkUrl: string;
|
||||
if (config.fileUrl == null && config.fileId != null) {
|
||||
const res = await misskeyApi('drive/files/show', { fileId: config.fileId });
|
||||
watermarkUrl = res.url;
|
||||
// 抜けてたら保存
|
||||
defaultStore.set('watermarkConfig', { ...config, fileUrl: watermarkUrl });
|
||||
} else {
|
||||
watermarkUrl = config.fileUrl!;
|
||||
}
|
||||
|
||||
watermark.src = config.__bypassMediaProxy ? watermarkUrl : getProxiedImageUrl(watermarkUrl, undefined, true);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
imgEl.onerror = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
if (typeof img === 'string') {
|
||||
imgEl.src = img;
|
||||
} else {
|
||||
imgEl.src = URL.createObjectURL(img);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ウォーターマークを適用した画像をBlobとして取得する
|
||||
*
|
||||
* @param img ウォーターマークを適用する画像
|
||||
* @param config ウォーターマークの設定
|
||||
* @returns ウォーターマークを適用した画像のBlob
|
||||
*/
|
||||
export async function getWatermarkAppliedImage(img: Blob, config: WatermarkConfig): Promise<Blob> {
|
||||
const canvas = document.createElement('canvas');
|
||||
await applyWatermark(img, canvas, config);
|
||||
return new Promise(resolve => canvas.toBlob(blob => resolve(blob!)));
|
||||
}
|
@ -9,6 +9,7 @@ import { hemisphere } from '@@/js/intl-const.js';
|
||||
import lightTheme from '@@/themes/l-light.json5';
|
||||
import darkTheme from '@@/themes/d-green-lime.json5';
|
||||
import type { SoundType } from '@/scripts/sound.js';
|
||||
import type { WatermarkUserConfig } from '@/scripts/watermark.js';
|
||||
import { DEFAULT_DEVICE_KIND, type DeviceKind } from '@/scripts/device-kind.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { Storage } from '@/pizzax.js';
|
||||
@ -474,6 +475,18 @@ export const defaultStore = markRaw(new Storage('base', {
|
||||
where: 'device',
|
||||
default: true,
|
||||
},
|
||||
useWatermark: {
|
||||
where: 'device',
|
||||
default: false,
|
||||
},
|
||||
clipboardWatermarkBehavior: {
|
||||
where: 'device',
|
||||
default: 'default' as 'default' | 'confirm',
|
||||
},
|
||||
watermarkConfig: {
|
||||
where: 'account',
|
||||
default: null as WatermarkUserConfig | null,
|
||||
},
|
||||
|
||||
sound_masterVolume: {
|
||||
where: 'device',
|
||||
|
@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div class="top">
|
||||
<p class="name"><MkLoading :em="true"/>{{ ctx.name }}</p>
|
||||
<p class="status">
|
||||
<span v-if="ctx.progressValue === undefined" class="initing">{{ i18n.ts.waiting }}<MkEllipsis/></span>
|
||||
<span v-if="ctx.progressValue === undefined" class="initing">{{ i18n.ts.processing }}<MkEllipsis/></span>
|
||||
<span v-if="ctx.progressValue !== undefined" class="kb">{{ String(Math.floor(ctx.progressValue / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progressMax / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span>
|
||||
<span v-if="ctx.progressValue !== undefined" class="percentage">{{ Math.floor((ctx.progressValue / ctx.progressMax) * 100) }}</span>
|
||||
</p>
|
||||
|
Loading…
Reference in New Issue
Block a user