This commit is contained in:
かっこかり 2025-05-30 20:40:29 +09:00 committed by GitHub
commit f5c2cf096f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1243 additions and 76 deletions

View File

@ -39,6 +39,8 @@
- Feat: 絵文字をミュート可能にする機能
- 絵文字(ユニコードの絵文字・カスタム絵文字)毎にミュートし、不可視化することができるようになりました
- Feat: モバイルデバイスで折りたたまれたUIの展開表示に全画面ページを使用できるように(実験的)
- Feat: 画像のアップロード時にウォーターマークを適用できるように
(Based on https://github.com/MisskeyIO/misskey/pull/785)
- Enhance: メモリ使用量を軽減しました
- Enhance: 画像の高品質なプレースホルダを無効化してパフォーマンスを向上させるオプションを追加
- Enhance: 招待されているが参加していないルームを開いたときに、招待を承認するかどうか尋ねるように

134
locales/index.d.ts vendored
View File

@ -1918,6 +1918,26 @@ export interface Locale extends ILocale {
*
*/
"uploadFolder": string;
/**
*
*/
"watermark": string;
/**
*
*/
"watermarkConfirm": string;
/**
*
*/
"useWatermark": string;
/**
*
*/
"useWatermarkDescription": string;
/**
*
*/
"useWatermarkInfo": string;
/**
*
*/
@ -3175,13 +3195,25 @@ export interface Locale extends ILocale {
*/
"duplicate": string;
/**
*
*
*/
"left": string;
"top": string;
/**
*
*/
"bottom": string;
/**
*
*/
"center": string;
/**
*
*/
"left": string;
/**
*
*/
"right": string;
/**
*
*/
@ -4502,18 +4534,38 @@ export interface Locale extends ILocale {
*
*/
"notificationDisplay": string;
/**
*
*/
"placement": string;
/**
*
*/
"leftTop": string;
/**
*
*/
"centerTop": string;
/**
*
*/
"rightTop": string;
/**
*
*/
"leftCenter": string;
/**
*
*/
"rightCenter": string;
/**
*
*/
"leftBottom": string;
/**
*
*/
"centerBottom": string;
/**
*
*/
@ -4534,6 +4586,14 @@ export interface Locale extends ILocale {
*
*/
"position": string;
/**
*
*/
"rotate": string;
/**
*
*/
"transparency": string;
/**
*
*/
@ -5386,18 +5446,6 @@ export interface Locale extends ILocale {
*
*/
"compress": string;
/**
*
*/
"right": string;
/**
*
*/
"bottom": string;
/**
*
*/
"top": string;
/**
*
*/
@ -5477,6 +5525,14 @@ export interface Locale extends ILocale {
*
*/
"hideAllTips": string;
/**
*
*/
"alwaysConfirm": string;
/**
*
*/
"useDefaultSettings": string;
"_chat": {
/**
*
@ -12016,6 +12072,56 @@ export interface Locale extends ILocale {
*/
"tip": 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;

View File

@ -475,6 +475,11 @@ share: "共有"
notFound: "見つかりません"
notFoundDescription: "指定されたURLに該当するページはありませんでした。"
uploadFolder: "既定アップロード先"
watermark: "ウォーターマーク"
watermarkConfirm: "ウォーターマークをつけますか?"
useWatermark: "ウォーターマークをつける"
useWatermarkDescription: "画像のアップロード時にデフォルトでウォーターマークをつけるようにします。"
useWatermarkInfo: "デフォルトの値にかかわらず、アップロードメニューの「ウォーターマークをつける」スイッチを操作して、一回限りの設定を適用することができます。"
markAsReadAllNotifications: "すべての通知を既読にする"
markAsReadAllUnreadNotes: "すべての投稿を既読にする"
markAsReadAllTalkMessages: "すべてのチャットを既読にする"
@ -789,8 +794,11 @@ developer: "開発者"
makeExplorable: "アカウントを見つけやすくする"
makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らなくなります。"
duplicate: "複製"
left: "左"
top: "上"
bottom: "下"
center: "中央"
left: "左"
right: "右"
wide: "広い"
narrow: "狭い"
reloadToApplySetting: "設定はページリロード後に反映されます。"
@ -1121,14 +1129,21 @@ editMemo: "メモを編集"
reactionsList: "リアクション一覧"
renotesList: "リノート一覧"
notificationDisplay: "通知の表示"
placement: "配置"
leftTop: "左上"
centerTop: "中上"
rightTop: "右上"
leftCenter: "左中"
rightCenter: "右中"
leftBottom: "左下"
centerBottom: "中下"
rightBottom: "右下"
stackAxis: "スタック方向"
vertical: "縦"
horizontal: "横"
position: "位置"
rotate: "回転"
transparency: "透明度"
serverRules: "サーバールール"
pleaseConfirmBelowBeforeSignup: "このサーバーに登録するには、以下の内容を確認し同意する必要があります。"
pleaseAgreeAllToContinue: "続けるには、全ての「同意する」にチェックが入っている必要があります。"
@ -1342,9 +1357,6 @@ chat: "チャット"
migrateOldSettings: "旧設定情報を移行"
migrateOldSettings_description: "通常これは自動で行われていますが、何らかの理由により上手く移行されなかった場合は手動で移行処理をトリガーできます。現在の設定情報は上書きされます。"
compress: "圧縮"
right: "右"
bottom: "下"
top: "上"
embed: "埋め込み"
settingsMigrating: "設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます)"
readonly: "読み取り専用"
@ -1364,6 +1376,8 @@ abort: "中止"
tip: "ヒントとコツ"
redisplayAllTips: "全ての「ヒントとコツ」を再表示"
hideAllTips: "全ての「ヒントとコツ」を非表示"
alwaysConfirm: "常に確認する"
useDefaultSettings: "デフォルトの設定を適用する"
_chat:
noMessagesYet: "まだメッセージはありません"
@ -3217,3 +3231,17 @@ _clip:
_userLists:
tip: "任意のユーザーが含まれるリストを作成できます。作成したリストはタイムラインとして表示可能です。"
_watermarkEditor:
title: "ウォーターマークをカスタマイズ"
driveFileTypeWarn: "このファイルは対応していません"
driveFileTypeWarnDescription: "画像ファイルを選択してください"
settingInvalidWarn: "設定が不十分です"
settingInvalidWarnDescription: "プレビューが正常に表示されることを確認してから保存してください"
useSmallFile: "ウォーターマーク用画像のファイルサイズが大きいと、処理の際にウォーターマークを読み込む時間が長くなり、アップロードに時間がかかるようになります。あらかじめ解像度を低くしたり、ファイルを圧縮したりしておくことを推奨します。"
repeatSetting: "描画モード"
repeat: "全体を埋め尽くす"
padding: "余白"
preserveBoundingRect: "回転した分の面積を確保する"
preserveBoundingRectDescription: "通常はオンで問題ありません。ウォーターマークを回転させた際に余白が不自然になった場合はオフにしてみてください。"
clipboardUploadBehavior: "クリップボード経由でのアップロード時の動作"

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

View File

@ -51,7 +51,10 @@ if (props.fileId) {
}
function selectButton(ev: MouseEvent) {
selectFile(ev.currentTarget ?? ev.target).then(async (file) => {
selectFile({
anchorElement: ev.currentTarget ?? ev.target,
multiple: false,
}).then(async (file) => {
if (!file) return;
if (props.validate && !await props.validate(file)) return;

View File

@ -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"
>
@ -82,6 +82,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;
}>();
@ -116,6 +118,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;

View File

@ -120,7 +120,7 @@ import { formatTimeString } from '@/utility/format-time-string.js';
import { Autocomplete } from '@/utility/autocomplete.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { selectFiles } from '@/utility/drive.js';
import { selectFile } from '@/utility/drive.js';
import { store } from '@/store.js';
import MkInfo from '@/components/MkInfo.vue';
import { i18n } from '@/i18n.js';
@ -437,7 +437,11 @@ function focus() {
function chooseFileFrom(ev) {
if (props.mock) return;
selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => {
selectFile({
anchorElement: ev.currentTarget ?? ev.target,
multiple: true,
label: i18n.ts.attachFile,
}).then(files_ => {
for (const file of files_) {
files.value.push(file);
}

View File

@ -92,6 +92,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkModalWindow>
</template>
<script lang="ts">
export type UploaderDialogFeatures = {
watermark?: boolean;
crop?: boolean;
};
</script>
<script lang="ts" setup>
import { computed, markRaw, onMounted, ref, useTemplateRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
@ -107,6 +114,7 @@ import bytes from '@/filters/bytes.js';
import MkSelect from '@/components/MkSelect.vue';
import { isWebpSupported } from '@/utility/isWebpSupported.js';
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
import { canApplyWatermark, getWatermarkAppliedImage } from '@/utility/watermark.js';
import * as os from '@/os.js';
import { ensureSignin } from '@/i.js';
@ -119,7 +127,7 @@ const COMPRESSION_SUPPORTED_TYPES = [
'image/svg+xml',
];
const CROPPING_SUPPORTED_TYPES = [
const IMGEDIT_SUPPORTED_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
@ -135,10 +143,18 @@ const props = withDefaults(defineProps<{
files: File[];
folderId?: string | null;
multiple?: boolean;
features?: UploaderDialogFeatures;
}>(), {
multiple: true,
});
const uploaderFeatures = computed<Required<UploaderDialogFeatures>>(() => {
return {
watermark: props.features?.watermark ?? true,
crop: props.features?.crop ?? true,
};
});
const emit = defineEmits<{
(ev: 'done', driveFiles: Misskey.entities.DriveFile[]): void;
(ev: 'canceled'): void;
@ -157,6 +173,7 @@ const items = ref<{
aborted: boolean;
compressedSize?: number | null;
compressedImage?: Blob | null;
applyWatermark?: boolean | null;
file: File;
abort?: (() => void) | null;
}[]>([]);
@ -274,19 +291,37 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
},
});
if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.waiting && !item.uploading && !item.uploaded) {
menu.push({
icon: 'ti ti-crop',
text: i18n.ts.cropImage,
action: async () => {
const cropped = await os.cropImageFile(item.file, { aspectRatio: null });
items.value.splice(items.value.indexOf(item), 1, {
...item,
file: markRaw(cropped),
thumbnail: window.URL.createObjectURL(cropped),
});
},
});
if (IMGEDIT_SUPPORTED_TYPES.includes(item.file.type) && !item.waiting && !item.uploading && !item.uploaded) {
if (uploaderFeatures.value.watermark) {
const _applyWatermark = computed({
get: () => item.applyWatermark ?? prefer.s.useWatermark,
set: (v) => {
item.applyWatermark = v;
},
});
menu.push({
icon: 'ti ti-ripple',
text: i18n.ts.useWatermark,
type: 'switch',
ref: _applyWatermark,
});
}
if (uploaderFeatures.value.crop) {
menu.push({
icon: 'ti ti-crop',
text: i18n.ts.cropImage,
action: async () => {
const cropped = await os.cropImageFile(item.file, { aspectRatio: null });
items.value.splice(items.value.indexOf(item), 1, {
...item,
file: markRaw(cropped),
thumbnail: window.URL.createObjectURL(cropped),
});
},
});
}
}
if (!item.waiting && !item.uploading && !item.uploaded) {
@ -333,6 +368,15 @@ async function upload() { // エラーハンドリングなどを考慮してシ
item.waiting = true;
item.uploadFailed = false;
if (
item.applyWatermark === true &&
uploaderFeatures.value.watermark &&
IMGEDIT_SUPPORTED_TYPES.includes(item.file.type) &&
canApplyWatermark(prefer.s.watermarkConfig)
) {
item.file = await getWatermarkAppliedImage(item.file, prefer.s.watermarkConfig);
}
const shouldCompress = item.compressedImage == null && compressionLevel.value !== 0 && compressionSettings.value && COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !(await isAnimated(item.file));
if (shouldCompress) {

View 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 '@/utility/watermark.js';
import type { WatermarkAnchor } from '@/utility/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>

View File

@ -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-accent)" 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>

View File

@ -0,0 +1,398 @@
<!--
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 { prefer } from '@/preferences.js';
import { i18n } from '@/i18n.js';
import { selectFile } from '@/utility/drive.js';
import { applyWatermark, canApplyWatermark } from '@/utility/watermark.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import type { WatermarkUserConfig } from '@/utility/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>(prefer.s.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)) {
prefer.commit('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({
anchorElement: ev.currentTarget ?? ev.target,
multiple: false,
label: i18n.ts.selectFile,
features: {
watermark: false,
},
}).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) {
// @/utility/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>

View File

@ -5,33 +5,33 @@ 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 from '@/components/global/MkA.vue';
import 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>

View File

@ -13,6 +13,7 @@ import type { ComponentProps as CP } from 'vue-component-type-helpers';
import type { Form, GetFormResultType } from '@/utility/form.js';
import type { MenuItem } from '@/types/menu.js';
import type { PostFormProps } from '@/types/post-form.js';
import type { UploaderDialogFeatures } from '@/components/MkUploaderDialog.vue';
import type MkRoleSelectDialog_TypeReferenceOnly from '@/components/MkRoleSelectDialog.vue';
import type MkEmojiPickerDialog_TypeReferenceOnly from '@/components/MkEmojiPickerDialog.vue';
import { misskeyApi } from '@/utility/misskey-api.js';
@ -773,6 +774,7 @@ export function launchUploader(
options?: {
folderId?: string | null;
multiple?: boolean;
features?: UploaderDialogFeatures;
},
): Promise<Misskey.entities.DriveFile[]> {
return new Promise((res, rej) => {
@ -781,6 +783,7 @@ export function launchUploader(
files: markRaw(files),
folderId: options?.folderId,
multiple: options?.multiple,
features: options?.features,
}, {
done: driveFiles => {
if (driveFiles.length === 0) return rej();

View File

@ -174,7 +174,10 @@ function setupGrid(): GridSetting {
{
bindTo: 'url', icon: 'ti-icons', type: 'image', editable: true, width: 'auto', validators: [required],
async customValueEditor(row, col, value, cellElement) {
const file = await selectFile(cellElement);
const file = await selectFile({
anchorElement: cellElement,
multiple: false,
});
gridItems.value[row.index].url = file.url;
gridItems.value[row.index].fileId = file.id;

View File

@ -188,7 +188,10 @@ async function archive() {
}
function setBannerImage(evt) {
selectFile(evt.currentTarget ?? evt.target, null).then(file => {
selectFile({
anchorElement: evt.currentTarget ?? evt.target,
multiple: false,
}).then(file => {
bannerId.value = file.id;
});
}

View File

@ -168,7 +168,11 @@ function onKeydown(ev: KeyboardEvent) {
}
function chooseFile(ev: MouseEvent) {
selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => {
selectFile({
anchorElement: ev.currentTarget ?? ev.target,
multiple: false,
label: i18n.ts.selectFile,
}).then(selectedFile => {
file.value = selectedFile;
});
}

View File

@ -214,7 +214,10 @@ const menu = (ev: MouseEvent) => {
icon: 'ti ti-upload',
text: i18n.ts.import,
action: async () => {
const file = await selectFile(ev.currentTarget ?? ev.target);
const file = await selectFile({
anchorElement: ev.currentTarget ?? ev.target,
multiple: false,
});
misskeyApi('admin/emoji/import-zip', {
fileId: file.id,
})

View File

@ -121,7 +121,10 @@ 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({
anchorElement: ev.currentTarget ?? ev.target,
multiple: false,
});
const candidate = file.value.name.replace(/\.(.+)$/, '');
if (candidate.match(/^[a-z0-9_]+$/)) {
name.value = candidate;

View File

@ -20,7 +20,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>
@ -44,7 +44,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 '@/utility/drive.js';
import { selectFile } from '@/utility/drive.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { definePage } from '@/page.js';
@ -63,8 +63,11 @@ 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({
anchorElement: evt.currentTarget ?? evt.target,
multiple: false,
}).then(selected => {
files.value = files.value.concat(selected);
});
}

View File

@ -205,7 +205,10 @@ async function add() {
}
function setEyeCatchingImage(img: Event) {
selectFile(img.currentTarget ?? img.target, null).then(file => {
selectFile({
anchorElement: img.currentTarget ?? img.target,
multiple: false,
}).then(file => {
eyeCatchingImageId.value = file.id;
});
}

View File

@ -233,7 +233,10 @@ const exportAntennas = () => {
};
const importFollowing = async (ev) => {
const file = await selectFile(ev.currentTarget ?? ev.target);
const file = await selectFile({
anchorElement: ev.currentTarget ?? ev.target,
multiple: false,
});
misskeyApi('i/import-following', {
fileId: file.id,
withReplies: withReplies.value,
@ -241,22 +244,34 @@ const importFollowing = async (ev) => {
};
const importUserLists = async (ev) => {
const file = await selectFile(ev.currentTarget ?? ev.target);
const file = await selectFile({
anchorElement: ev.currentTarget ?? ev.target,
multiple: false,
});
misskeyApi('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError);
};
const importMuting = async (ev) => {
const file = await selectFile(ev.currentTarget ?? ev.target);
const file = await selectFile({
anchorElement: ev.currentTarget ?? ev.target,
multiple: false,
});
misskeyApi('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError);
};
const importBlocking = async (ev) => {
const file = await selectFile(ev.currentTarget ?? ev.target);
const file = await selectFile({
anchorElement: ev.currentTarget ?? ev.target,
multiple: false,
});
misskeyApi('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError);
};
const importAntennas = async (ev) => {
const file = await selectFile(ev.currentTarget ?? ev.target);
const file = await selectFile({
anchorElement: ev.currentTarget ?? ev.target,
multiple: false,
});
misskeyApi('i/import-antennas', { fileId: file.id }).then(onImportSuccess).catch(onError);
};

View File

@ -114,7 +114,10 @@ watch(wallpaper, async () => {
});
function setWallpaper(ev: MouseEvent) {
selectFile(ev.currentTarget ?? ev.target, null).then(file => {
selectFile({
anchorElement: ev.currentTarget ?? ev.target,
multiple: false,
}).then(file => {
wallpaper.value = file.url;
});
}

View File

@ -53,6 +53,29 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.drivecleaner }}
</FormLink>
<MkFolder>
<template #icon><i class="ti ti-ripple"></i></template>
<template #label>{{ i18n.ts.watermark }}</template>
<div class="_gaps_m">
<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>
</div>
<hr/>
<FormLink @click="openWatermarkEditor">
<template #icon><i class="ti ti-pencil"></i></template>
{{ i18n.ts._watermarkEditor.title }}
</FormLink>
</div>
</MkFolder>
<SearchMarker :keywords="['keep', 'original', 'filename']">
<MkPreferenceContainer k="keepOriginalFilename">
<MkSwitch v-model="keepOriginalFilename">
@ -81,10 +104,12 @@ 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 MkSwitch from '@/components/MkSwitch.vue';
import FormSection from '@/components/form/section.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
@ -94,8 +119,9 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import bytes from '@/filters/bytes.js';
import MkChart from '@/components/MkChart.vue';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { ensureSignin } from '@/i.js';
import { reloadAsk } from '@/utility/reload-ask.js';
import { definePage } from '@/page.js';
import { prefer } from '@/preferences.js';
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
@ -122,8 +148,16 @@ const meterStyle = computed(() => {
};
});
const useWatermark = prefer.model('useWatermark');
const keepOriginalFilename = prefer.model('keepOriginalFilename');
watch([
useWatermark,
], () => {
reloadAsk({ unison: true, reason: i18n.ts.reloadRequiredToApplySettings });
});
misskeyApi('drive').then(info => {
capacity.value = info.capacity;
usage.value = info.usage;
@ -152,6 +186,12 @@ function chooseUploadFolder() {
});
}
function openWatermarkEditor() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWatermarkEditorDialog.vue')), {}, {
closed: () => dispose(),
});
}
function saveProfile() {
misskeyApi('i/update', {
alwaysMarkNsfw: !!alwaysMarkNsfw.value,

View File

@ -94,7 +94,11 @@ const friendlyFileName = computed<string>(() => {
});
function selectSound(ev) {
selectFile(ev.currentTarget ?? ev.target, i18n.ts._soundSettings.driveFile).then(async (file) => {
selectFile({
anchorElement: ev.currentTarget ?? ev.target,
multiple: false,
label: i18n.ts._soundSettings.driveFile,
}).then(async (file) => {
if (!file.type.startsWith('audio')) {
os.alert({
type: 'warning',

View File

@ -9,6 +9,7 @@ import type { Theme } from '@/theme.js';
import type { SoundType } from '@/utility/sound.js';
import type { Plugin } from '@/plugin.js';
import type { DeviceKind } from '@/utility/device-kind.js';
import type { WatermarkConfig } from '@/utility/watermark.js';
import type { DeckProfile } from '@/deck.js';
import type { PreferencesDefinition } from './manager.js';
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
@ -349,6 +350,13 @@ export const PREF_DEF = {
mutingEmojis: {
default: [] as string[],
},
useWatermark: {
default: false,
},
watermarkConfig: {
accountDependent: true,
default: null as WatermarkConfig | null,
},
'sound.masterVolume': {
default: 0.5,

View File

@ -15,6 +15,7 @@ import { $i } from '@/i.js';
import { instance } from '@/instance.js';
import { globalEvents } from '@/events.js';
import { getProxiedImageUrl } from '@/utility/media-proxy.js';
import type { UploaderDialogFeatures } from '@/components/MkUploaderDialog.vue';
type UploadReturnType = {
filePromise: Promise<Misskey.entities.DriveFile>;
@ -154,6 +155,7 @@ export function uploadFile(file: File | Blob, options: {
export function chooseFileFromPcAndUpload(
options: {
multiple?: boolean;
features?: UploaderDialogFeatures;
folderId?: string | null;
} = {},
): Promise<Misskey.entities.DriveFile[]> {
@ -162,6 +164,7 @@ export function chooseFileFromPcAndUpload(
if (files.length === 0) return;
os.launchUploader(files, {
folderId: options.folderId,
features: options.features,
}).then(driveFiles => {
res(driveFiles);
});
@ -220,7 +223,7 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> {
});
}
function select(anchorElement: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
function select(anchorElement: HTMLElement | EventTarget | null, label: string | null, multiple: boolean, features?: UploaderDialogFeatures): Promise<Misskey.entities.DriveFile[]> {
return new Promise((res, rej) => {
os.popupMenu([label ? {
text: label,
@ -228,7 +231,7 @@ function select(anchorElement: HTMLElement | EventTarget | null, label: string |
} : undefined, {
text: i18n.ts.upload,
icon: 'ti ti-upload',
action: () => chooseFileFromPcAndUpload({ multiple }).then(files => res(files)),
action: () => chooseFileFromPcAndUpload({ multiple, features }).then(files => res(files)),
}, {
text: i18n.ts.fromDrive,
icon: 'ti ti-cloud',
@ -241,12 +244,19 @@ function select(anchorElement: HTMLElement | EventTarget | null, label: string |
});
}
export function selectFile(anchorElement: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile> {
return select(anchorElement, label, false).then(files => files[0]);
}
type SelectFileOptions<M extends boolean> = {
anchorElement: HTMLElement | EventTarget | null;
multiple: M;
label?: string | null;
features?: UploaderDialogFeatures;
};
export function selectFiles(anchorElement: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile[]> {
return select(anchorElement, label, true);
export async function selectFile<
M extends boolean,
MR extends M extends true ? Misskey.entities.DriveFile[] : Misskey.entities.DriveFile
>(opts: SelectFileOptions<M>): Promise<MR> {
const files = await select(opts.anchorElement, opts.label ?? null, opts.multiple ?? false, opts.features);
return opts.multiple ? (files as MR) : (files[0]! as MR);
}
export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFile: Misskey.entities.DriveFile, options: {

View File

@ -0,0 +1,307 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getProxiedImageUrl } from '@/utility/media-proxy.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { prefer } from '@/preferences.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 };
})();
const rotateRad = (config.rotate ?? 0) * Math.PI / 180;
ctx.globalAlpha = config.opacity ?? 1;
if (config.repeat) {
// 余白をもたせた状態のウォーターマークを作成しておく(それをパターン繰り返しする)
const resizedWatermark = window.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) {
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 = (() => {
let rotateX = 0; // 回転によるX座標の補正
if (config.rotate != null && config.rotate !== 0 && !config.noBoundingBoxExpansion) {
rotateX = Math.abs(Math.abs(width * Math.cos(rotateRad)) + Math.abs(height * Math.sin(rotateRad)) - width) / 2;
}
switch (config.anchor) {
case 'center':
case 'top':
case 'bottom':
return (canvas.width - width) / 2;
case 'left':
case 'top-left':
case 'bottom-left':
return rotateX + (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) - rotateX;
}
})();
const y = (() => {
let rotateY = 0; // 回転によるY座標の補正
if (config.rotate != null && config.rotate !== 0 && !config.noBoundingBoxExpansion) {
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;
// 抜けてたら保存
prefer.commit('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<F extends Blob | File>(img: F, config: WatermarkConfig): Promise<F> {
const canvas = window.document.createElement('canvas');
await applyWatermark(img, canvas, config);
return new Promise(resolve => canvas.toBlob(blob => {
if (img instanceof File) {
resolve(new File([blob!], img.name) as F);
} else {
resolve(blob as F);
}
}, img.type || 'image/png'));
}