Compare commits

..

7 Commits

Author SHA1 Message Date
zyoshoka 65ba33867b
fix(backend): avoid deadlock when deleting account (#16162) 2025-06-04 19:14:11 +09:00
syuilo b55cc03621
New Crowdin updates (#16155)
* New translations ja-jp.yml (Korean)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (Catalan)

* New translations ja-jp.yml (Catalan)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (French)

* New translations ja-jp.yml (Spanish)

* New translations ja-jp.yml (Catalan)

* New translations ja-jp.yml (German)

* New translations ja-jp.yml (Italian)

* New translations ja-jp.yml (Korean)

* New translations ja-jp.yml (Portuguese)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (Indonesian)

* New translations ja-jp.yml (Thai)

* New translations ja-jp.yml (Japanese, Kansai)
2025-06-04 18:25:39 +09:00
かっこかり 80f73c6712
enhance(gh): renovateのicons-subsetterをfrontendグループに統合 (#16161) 2025-06-04 16:39:06 +09:00
syuilo 60fc9a5195 🎨 2025-06-04 16:33:55 +09:00
かっこかり b43dfa260b
fix/refactor(frontend): 画像編集機能の修正・型強化 (#16156)
* enhance: refine uploadFile

* fix: missing locale

* refactor: harden types

* refactor: シェーダーファイルをlazy-loadingできるように

* fix(frontend): omit console.log in production environment

* fix: glslのバージョン表記は最初の行になければならない

* fix: シェーダーの読み込みが完了してからレンダリングを行うように

* fix merge failure

* fix: ウォーターマークのプリセットがない場合にdividerが2重に表示される問題を修正

* fix: アップローダーダイアログの機能設定でウォーターマークが無効な場合でもデフォルトのプリセットが適用されてしまう問題を修正

* fix lint

* Revert "fix: シェーダーの読み込みが完了してからレンダリングを行うように"

This reverts commit e06f37a7d4.

* Revert "fix: glslのバージョン表記は最初の行になければならない"

This reverts commit afcc37d886.

* Revert "refactor: シェーダーファイルをlazy-loadingできるように"

This reverts commit a1ab2fa38c.

* fix: ウォーターマークのFX定義を分ける

* Update packages/frontend/src/components/MkWatermarkEditorDialog.vue

* Update packages/frontend/src/components/MkWatermarkEditorDialog.vue

* Update packages/frontend/src/components/MkWatermarkEditorDialog.vue

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2025-06-04 16:22:09 +09:00
syuilo e3b57a118d 🎨 2025-06-04 16:15:06 +09:00
syuilo fdcb6a09a9 fix(frontend): タッチ操作時にチャートのツールチップが消えなくなる場合がある問題を修正 2025-06-04 16:10:57 +09:00
42 changed files with 384 additions and 84 deletions

View File

@ -11,9 +11,11 @@
- Fix: ドライブファイルの選択が不安定な問題を修正
- Fix: コントロールパネルのファイル欄などのデザインが崩れている問題を修正
- Fix: ユーザーの検索結果を追加で読み込むことができない問題を修正
- Fix: タッチ操作時にチャートのツールチップが消えなくなる場合がある問題を修正
### Server
- Feat: 全てのチャットメッセージを既読にするAPIを追加(chat/read-all)
- Fix: アカウント削除が正常に行われないことがあった問題を修正
## 2025.6.0

View File

@ -1365,6 +1365,8 @@ abort: "Cancel·lar"
tip: "Trucs i consells"
redisplayAllTips: "Torna ha mostrat tots els trucs i consells"
hideAllTips: "Amagar tots els trucs i consells"
defaultImageCompressionLevel: "Nivell de comprensió de la imatge per defecte"
defaultImageCompressionLevel_description: "Baixa, conserva la qualitat de la imatge però la mida de l'arxiu és més gran. <br>Alta, redueix la mida de l'arxiu però també la qualitat de la imatge."
_chat:
noMessagesYet: "Encara no tens missatges "
newMessage: "Missatge nou"
@ -1452,6 +1454,7 @@ _settings:
contentsUpdateFrequency_description: "Com més alt sigui l'adquisició de contingut en temps real, més baixa el rendiment i més consum de dades i bateria."
contentsUpdateFrequency_description2: "Quan s'activa el mode en temps real, el contingut s'actualitza en temps real, independentment d'aquesta configuració."
showUrlPreview: "Mostrar vista prèvia d'URL"
showAvailableReactionsFirstInNote: "Mostra les reacciones que pots fer servir al damunt"
_chat:
showSenderName: "Mostrar el nom del remitent"
sendOnEnter: "Introdueix per enviar"
@ -3117,7 +3120,15 @@ _clip:
tip: "Clip és una funció que permet organitzar les teves notes."
_userLists:
tip: "Es poden crear llistes amb qualsevol usuari. La llista creada es pot mostrar com una línia de temps."
watermark: "Marca d'aigua "
defaultPreset: "Per defecte"
_watermarkEditor:
tip: "A la imatge es pot afegir una marca d'aigua com informació sobre drets."
quitWithoutSaveConfirm: "Sortir sense desar?"
driveFileTypeWarn: "Fitxer no suportat "
title: "Editar la marca d'aigua "
cover: "Cobrir-ho tot"
repeat: "Repetir"
opacity: "Opacitat"
scale: "Mida"
text: "Text"
@ -3125,4 +3136,32 @@ _watermarkEditor:
type: "Tipus"
image: "Imatges"
advanced: "Avançat"
stripe: "Bandes"
stripeWidth: "Amplada de la banda"
stripeFrequency: "Freqüència de la banda"
angle: "Angle"
polkadot: "Lunars"
checker: "Escacs"
polkadotMainDotOpacity: "Opacitat del lunar principal"
polkadotMainDotRadius: "Mida del lunar principal"
polkadotSubDotOpacity: "Opacitat del lunar secundari"
polkadotSubDotRadius: "Mida del lunar secundari"
polkadotSubDotDivisions: "Nombre de punts secundaris"
_imageEffector:
title: "Efecte"
addEffect: "Afegeix un efecte"
discardChangesConfirm: "Vols descartar els canvis i sortir?"
_fxs:
chromaticAberration: "Aberració cromàtica"
glitch: "Glitch"
mirror: "Mirall"
invert: "Inversió cromàtica "
grayscale: "Monocrom "
colorClamp: "Compressió cromàtica "
colorClampAdvanced: "Compressió de cromàtica avançada "
distort: "Distorsió "
threshold: "Binarització"
zoomLines: "Saturació de línies "
stripe: "Bandes"
polkadot: "Lunars"
checker: "Escacs"

View File

@ -3002,6 +3002,7 @@ _search:
pleaseSelectUser: "Benutzer auswählen"
serverHostPlaceholder: "Beispiel: misskey.example.com"
_watermarkEditor:
driveFileTypeWarn: "Diese Datei wird nicht unterstützt"
opacity: "Transparenz"
scale: "Größe"
text: "Text"

View File

@ -3118,6 +3118,7 @@ _clip:
_userLists:
tip: "Lists can contain any user you specify when creating, the created list can then be displayed as a timeline showing only the specified users."
_watermarkEditor:
driveFileTypeWarn: "This file is not supported"
opacity: "Opacity"
scale: "Size"
text: "Text"

View File

@ -2934,6 +2934,7 @@ _search:
_uploader:
allowedTypes: "Tipos de archivos que se pueden cargar."
_watermarkEditor:
driveFileTypeWarn: "Este archivo es incompatible"
opacity: "Opacidad"
scale: "Tamaño"
text: "Texto"

View File

@ -2361,6 +2361,7 @@ _search:
searchScopeLocal: "Local"
searchScopeUser: "Spécifier l'utilisateur·rice"
_watermarkEditor:
driveFileTypeWarn: "Ce fichier n'est pas pris en charge"
opacity: "Transparence"
scale: "Taille"
text: "Texte"

View File

@ -2609,6 +2609,7 @@ _search:
searchScopeLocal: "Lokal"
searchScopeUser: "Pengguna spesifik"
_watermarkEditor:
driveFileTypeWarn: "Berkas ini tidak didukung"
opacity: "Opasitas"
scale: "Ukuran"
text: "Teks"

8
locales/index.d.ts vendored
View File

@ -12049,6 +12049,14 @@ export interface Locale extends ILocale {
*
*/
"quitWithoutSaveConfirm": string;
/**
*
*/
"driveFileTypeWarn": string;
/**
*
*/
"driveFileTypeWarnDescription": string;
/**
*
*/

View File

@ -3113,6 +3113,7 @@ _clip:
_userLists:
tip: "Puoi creare un elenco di Note create da qualsiasi profilo. L'elenco è visualizzato come una sequenza temporale."
_watermarkEditor:
driveFileTypeWarn: "Formato file non supportato"
opacity: "Opacità"
scale: "Dimensioni"
text: "Testo"

View File

@ -3227,6 +3227,8 @@ defaultPreset: "デフォルトのプリセット"
_watermarkEditor:
tip: "画像にクレジット情報などのウォーターマークを追加することができます。"
quitWithoutSaveConfirm: "保存せずに終了しますか?"
driveFileTypeWarn: "このファイルは対応していません"
driveFileTypeWarnDescription: "画像ファイルを選択してください"
title: "ウォーターマークの編集"
cover: "全体に被せる"
repeat: "敷き詰める"

View File

@ -2849,6 +2849,7 @@ _search:
searchScopeLocal: "ローカル"
searchScopeUser: "ユーザー指定"
_watermarkEditor:
driveFileTypeWarn: "このファイルは対応しとらへん"
opacity: "不透明度"
scale: "大きさ"
text: "テキスト"

View File

@ -1365,6 +1365,8 @@ abort: "중지"
tip: "팁과 유용한 정보"
redisplayAllTips: "모든 '팁과 유용한 정보'를 재표시"
hideAllTips: "모든 '팁과 유용한 정보'를 비표시"
defaultImageCompressionLevel: "기본 이미지 압축 정도"
defaultImageCompressionLevel_description: "낮추면 화질을 유지합니다만 파일 크기는 증가합니다. <br>높이면 파일 크기를 줄일 수 있습니다만 화질은 저하됩니다."
_chat:
noMessagesYet: "아직 메시지가 없습니다"
newMessage: "새로운 메시지"
@ -1452,6 +1454,7 @@ _settings:
contentsUpdateFrequency_description: "높을수록 실시간으로 콘텐츠가 업데이트됩니다만, 성능이 저하되고 데이터 사용량과 배터리의 소비가 증가합니다."
contentsUpdateFrequency_description2: "실시간 모드가 켜져 있을 때는 이 설정과 상관없이 실시간으로 콘텐츠가 업데이트됩니다."
showUrlPreview: "URL 미리보기 표시"
showAvailableReactionsFirstInNote: "이용 가능한 리액션을 선두로 표시"
_chat:
showSenderName: "발신자 이름 표시"
sendOnEnter: "엔터로 보내기"
@ -3117,7 +3120,15 @@ _clip:
tip: "클립은 노트를 정리할 수 있는 기능입니다."
_userLists:
tip: "임의의 유저가 포함된 리스트를 작성할 수 있습니다. 작성한 리스트는 타임라인으로 표시가 가능합니다."
watermark: "워터마크"
defaultPreset: "기본 프리셋"
_watermarkEditor:
tip: "이미지에 크레딧 정보 등의 워터마크를 추가할 수 있습니다."
quitWithoutSaveConfirm: "보존하지 않고 종료하시겠습니까?"
driveFileTypeWarn: "이 파이"
title: "워터마크 편집"
cover: "전체에 붙이기"
repeat: "전면에 깔기"
opacity: "불투명도"
scale: "크기"
text: "텍스트"
@ -3125,4 +3136,32 @@ _watermarkEditor:
type: "종류"
image: "이미지"
advanced: "고급"
stripe: "줄무늬"
stripeWidth: "라인의 폭"
stripeFrequency: "라인의 수"
angle: "각도"
polkadot: "물방울 무늬"
checker: "체크 무늬"
polkadotMainDotOpacity: "주요 물방울의 불투명도"
polkadotMainDotRadius: "주요 물방울의 크기"
polkadotSubDotOpacity: "서브 물방울의 불투명도"
polkadotSubDotRadius: "서브 물방울의 크기"
polkadotSubDotDivisions: "서브 물방울의 수"
_imageEffector:
title: "이펙트"
addEffect: "이펙트를 추가"
discardChangesConfirm: "변경을 취소하고 종료하시겠습니까?"
_fxs:
chromaticAberration: "색수차"
glitch: "글리치"
mirror: "미러"
invert: "색 반전"
grayscale: "흑백"
colorClamp: "색 압축"
colorClampAdvanced: "색 압축(고급)"
distort: "뒤틀림"
threshold: "이진화"
zoomLines: "집중선"
stripe: "줄무늬"
polkadot: "물방울 무늬"
checker: "체크 무늬"

View File

@ -3085,6 +3085,7 @@ _clientPerformanceIssueTip:
makeSureDisabledAddons: "Desabilite extensões"
makeSureDisabledAddons_description: "Algumas extensões podem afetar comportamentos do cliente e afetar o desempenho. Por favor, desative as extensões do seu navegador e veja se isso melhora a situação."
_watermarkEditor:
driveFileTypeWarn: "Esse arquivo não é compatível"
opacity: "Opacidade"
scale: "Tamanho"
text: "Texto"

View File

@ -2723,6 +2723,7 @@ _search:
searchScopeLocal: "ท้องถิ่น"
searchScopeUser: "ผู้ใช้เฉพาะ"
_watermarkEditor:
driveFileTypeWarn: "ไม่รองรับไฟล์นี้"
opacity: "ความทึบแสง"
scale: "ขนาด"
text: "ข้อความ"

View File

@ -1365,6 +1365,8 @@ abort: "中止"
tip: "提示和技巧"
redisplayAllTips: "重新显示所有的提示和技巧"
hideAllTips: "隐藏所有的提示和技巧"
defaultImageCompressionLevel: "默认图像压缩等级"
defaultImageCompressionLevel_description: "较低的等级可以保持画质,但会增加文件大小。<br>较高的等级可以减少文件大小,但相对应的画质将会降低。"
_chat:
noMessagesYet: "还没有消息"
newMessage: "新消息"
@ -1452,6 +1454,7 @@ _settings:
contentsUpdateFrequency_description: "设置越高,内容更新越实时,但性能会降低,并且会消耗更多的流量和电池。"
contentsUpdateFrequency_description2: "当实时模式开启时,无论此设置如何,内容都会实时更新。"
showUrlPreview: "显示 URL 预览"
showAvailableReactionsFirstInNote: "在顶部显示可用的回应"
_chat:
showSenderName: "显示发送者的名字"
sendOnEnter: "回车键发送"
@ -3117,7 +3120,15 @@ _clip:
tip: "便签功能可以将帖子合并在一起。"
_userLists:
tip: "可创建包含任意用户的列表。已创建的列表可作为时间线查看。"
watermark: "水印"
defaultPreset: "默认预设"
_watermarkEditor:
tip: "可在图像内增加包含作者等信息的水印。"
quitWithoutSaveConfirm: "不保存就退出吗?"
driveFileTypeWarn: "不支持此文件"
title: "编辑水印"
cover: "覆盖全体"
repeat: "平铺"
opacity: "不透明度"
scale: "大小"
text: "文本"
@ -3125,4 +3136,32 @@ _watermarkEditor:
type: "类型"
image: "图片"
advanced: "高级"
stripe: "条纹"
stripeWidth: "线条宽度"
stripeFrequency: "线条数量"
angle: "角度"
polkadot: "波点"
checker: "检查"
polkadotMainDotOpacity: "主波点的不透明度"
polkadotMainDotRadius: "主波点的大小"
polkadotSubDotOpacity: "副波点的不透明度"
polkadotSubDotRadius: "副波点的大小"
polkadotSubDotDivisions: "副波点的数量"
_imageEffector:
title: "效果"
addEffect: "添加效果"
discardChangesConfirm: "丢弃当前设置并退出?"
_fxs:
chromaticAberration: "色差"
glitch: "故障"
mirror: "镜像"
invert: "反转颜色"
grayscale: "黑白"
colorClamp: "颜色限制"
colorClampAdvanced: "颜色限制(高级)"
distort: "失真"
threshold: "二值化"
zoomLines: "集中线"
stripe: "条纹"
polkadot: "波点"
checker: "检查"

View File

@ -1365,6 +1365,8 @@ abort: "取消"
tip: "提示與技巧"
redisplayAllTips: "重新顯示所有「提示與技巧」"
hideAllTips: "隱藏所有「提示與技巧」"
defaultImageCompressionLevel: "預設的影像壓縮程度"
defaultImageCompressionLevel_description: "低的話可以保留畫質,但是會增加檔案的大小。<br>高的話可以減少檔案大小,但是會降低畫質。"
_chat:
noMessagesYet: "尚無訊息"
newMessage: "新訊息"
@ -1452,6 +1454,7 @@ _settings:
contentsUpdateFrequency_description: "頻率越高,內容更新越即時,但可能會降低效能,並增加資料傳輸量與電池消耗。\n"
contentsUpdateFrequency_description2: "當即時模式開啟時,不論此設定為何,內容都會即時更新。"
showUrlPreview: "顯示網址預覽"
showAvailableReactionsFirstInNote: "將可用的反應顯示在頂部"
_chat:
showSenderName: "顯示發送者的名稱"
sendOnEnter: "按下 Enter 發送訊息"
@ -3117,7 +3120,15 @@ _clip:
tip: "摘錄是一項可以用來整理貼文的功能。"
_userLists:
tip: "您可以建立包含任意使用者的清單。建立後的清單可以作為時間軸顯示。\n"
watermark: "浮水印"
defaultPreset: "預設值"
_watermarkEditor:
tip: "可以在圖片中以浮水印加上出處等資訊。"
quitWithoutSaveConfirm: "不儲存就退出嗎?"
driveFileTypeWarn: "不支援此檔案"
title: "編輯浮水印"
cover: "覆蓋整體"
repeat: "佈局"
opacity: "透明度"
scale: "大小"
text: "文字"
@ -3125,4 +3136,32 @@ _watermarkEditor:
type: "類型"
image: "圖片"
advanced: "進階"
stripe: "條紋"
stripeWidth: "線條寬度"
stripeFrequency: "線條數量"
angle: "角度"
polkadot: "波卡圓點"
checker: "棋盤格"
polkadotMainDotOpacity: "主圓點的不透明度"
polkadotMainDotRadius: "主圓點的尺寸"
polkadotSubDotOpacity: "子圓點的不透明度"
polkadotSubDotRadius: "子圓點的尺寸"
polkadotSubDotDivisions: "子圓點的數量"
_imageEffector:
title: "特效"
addEffect: "新增特效"
discardChangesConfirm: "捨棄更改並退出嗎?"
_fxs:
chromaticAberration: "色差"
glitch: "異常雜訊效果"
mirror: "鏡像"
invert: "反轉色彩"
grayscale: "黑白"
colorClamp: "壓縮色彩"
colorClampAdvanced: "壓縮色彩(進階)"
distort: "變形"
threshold: "閾值"
zoomLines: "速度線"
stripe: "條紋"
polkadot: "波卡圓點"
checker: "棋盤格"

View File

@ -803,14 +803,14 @@ export class DriveService {
await Promise.all(promises);
}
this.deletePostProcess(file, isExpired, deleter);
await this.deletePostProcess(file, isExpired, deleter);
}
@bindThis
private async deletePostProcess(file: MiDriveFile, isExpired = false, deleter?: MiUser) {
// リモートファイル期限切れ削除後は直リンクにする
if (isExpired && file.userHost !== null && file.uri != null) {
this.driveFilesRepository.update(file.id, {
await this.driveFilesRepository.update(file.id, {
isLink: true,
url: file.uri,
thumbnailUrl: null,
@ -822,7 +822,7 @@ export class DriveService {
webpublicAccessKey: 'webpublic-' + randomUUID(),
});
} else {
this.driveFilesRepository.delete(file.id);
await this.driveFilesRepository.delete(file.id);
}
this.driveChart.update(file, false);

View File

@ -380,9 +380,7 @@ describe('User', () => {
strictEqual(followers.length, 1); // followed by Bob
await alice.client.request('i/delete-account', { password: alice.password });
// NOTE: user deletion query is slow
// FIXME: ensure user is removed successfully
await sleep(10000);
await sleep();
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 0); // no following relation
@ -480,9 +478,7 @@ describe('User', () => {
strictEqual(followers.length, 1); // followed by Bob
await aAdmin.client.request('admin/suspend-user', { userId: alice.id });
// NOTE: user deletion query is slow
// FIXME: ensure user is removed successfully
await sleep(10000);
await sleep();
const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 0); // no following relation

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

@ -77,6 +77,7 @@ const dialog = useTemplateRef('dialog');
async function cancel() {
if (layers.length > 0) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts._imageEffector.discardChangesConfirm,
});
if (canceled) return;
@ -132,7 +133,7 @@ function onLayerDelete(layer: ImageEffectorLayer) {
const canvasEl = useTemplateRef('canvasEl');
let renderer: ImageEffector | null = null;
let renderer: ImageEffector<typeof FXS> | null = null;
let imageBitmap: ImageBitmap | null = null;
onMounted(async () => {

View File

@ -90,6 +90,14 @@ defineExpose({
&.asDrawer {
height: calc(100dvh - 30px);
border-radius: 0;
.body {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.footer {
padding-bottom: max(12px, env(safe-area-inset-bottom, 0px));
}
}
}

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

@ -79,8 +79,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkModalWindow>
</template>
<script lang="ts">
export type UploaderDialogFeatures = {
effect?: boolean;
watermark?: boolean;
crop?: boolean;
};
</script>
<script lang="ts" setup>
import { computed, defineAsyncComponent, markRaw, onMounted, onUnmounted, ref, triggerRef, useTemplateRef, watch } from 'vue';
import { computed, markRaw, onMounted, onUnmounted, ref, triggerRef, useTemplateRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { genId } from '@/utility/id.js';
import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
@ -91,7 +99,6 @@ import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import MkButton from '@/components/MkButton.vue';
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 * as os from '@/os.js';
@ -131,17 +138,26 @@ const props = withDefaults(defineProps<{
files: File[];
folderId?: string | null;
multiple?: boolean;
features?: UploaderDialogFeatures;
}>(), {
multiple: true,
});
const uploaderFeatures = computed<Required<UploaderDialogFeatures>>(() => {
return {
effect: props.features?.effect ?? true,
watermark: props.features?.watermark ?? true,
crop: props.features?.crop ?? true,
};
});
const emit = defineEmits<{
(ev: 'done', driveFiles: Misskey.entities.DriveFile[]): void;
(ev: 'canceled'): void;
(ev: 'closed'): void;
}>();
const items = ref<{
type UploaderItem = {
id: string;
name: string;
uploadName?: string;
@ -152,13 +168,15 @@ const items = ref<{
uploaded: Misskey.entities.DriveFile | null;
uploadFailed: boolean;
aborted: boolean;
compressionLevel: number;
compressionLevel: 0 | 1 | 2 | 3;
compressedSize?: number | null;
preprocessedFile?: Blob | null;
file: File;
watermarkPresetId: string | null;
abort?: (() => void) | null;
}[]>([]);
};
const items = ref<UploaderItem[]>([]);
const dialog = useTemplateRef('dialog');
@ -252,7 +270,7 @@ async function done() {
dialog.value?.close();
}
function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
function showMenu(ev: MouseEvent, item: UploaderItem) {
const menu: MenuItem[] = [];
menu.push({
@ -272,7 +290,13 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
},
});
if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
if (
uploaderFeatures.value.crop &&
CROPPING_SUPPORTED_TYPES.includes(item.file.type) &&
!item.preprocessing &&
!item.uploading &&
!item.uploaded
) {
menu.push({
icon: 'ti ti-crop',
text: i18n.ts.cropImage,
@ -292,7 +316,13 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
});
}
if (IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
if (
uploaderFeatures.value.effect &&
IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) &&
!item.preprocessing &&
!item.uploading &&
!item.uploaded
) {
menu.push({
icon: 'ti ti-sparkles',
text: i18n.ts._imageEffector.title + ' (BETA)',
@ -318,7 +348,13 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
});
}
if (WATERMARK_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
if (
uploaderFeatures.value.watermark &&
WATERMARK_SUPPORTED_TYPES.includes(item.file.type) &&
!item.preprocessing &&
!item.uploading &&
!item.uploaded
) {
function changeWatermarkPreset(presetId: string | null) {
item.watermarkPresetId = presetId;
preprocess(item).then(() => {
@ -338,13 +374,13 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
}, {
type: 'divider',
}, ...prefer.s.watermarkPresets.map(preset => ({
type: 'radioOption',
type: 'radioOption' as const,
text: preset.name,
active: computed(() => item.watermarkPresetId === preset.id),
action: () => changeWatermarkPreset(preset.id),
})), {
type: 'divider',
}, {
})), ...(prefer.s.watermarkPresets.length > 0 ? [{
type: 'divider' as const,
}] : []), {
type: 'button',
icon: 'ti ti-plus',
text: i18n.ts.add,
@ -397,8 +433,7 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
text: i18n.ts.high,
active: computed(() => item.compressionLevel === 3),
action: () => changeCompressionLevel(3),
},
],
}],
});
}
@ -590,9 +625,9 @@ function initializeFile(file: File) {
uploaded: null,
uploadFailed: false,
compressionLevel: prefer.s.defaultImageCompressionLevel,
watermarkPresetId: prefer.s.defaultWatermarkPresetId,
watermarkPresetId: uploaderFeatures.value.watermark ? prefer.s.defaultWatermarkPresetId : null,
file: markRaw(file),
};
} satisfies UploaderItem;
items.value.push(item);
preprocess(item).then(() => {
triggerRef(items);

View File

@ -262,10 +262,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script setup lang="ts">
import { ref, useTemplateRef, watch, onMounted, onUnmounted } from 'vue';
import { ref, onMounted } from 'vue';
import * as Misskey from 'misskey-js';
import type { WatermarkPreset } from '@/utility/watermark.js';
import { i18n } from '@/i18n.js';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue';
@ -275,11 +275,10 @@ import MkPositionSelector from '@/components/MkPositionSelector.vue';
import * as os from '@/os.js';
import { selectFile } from '@/utility/drive.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { prefer } from '@/preferences.js';
const layer = defineModel<WatermarkPreset['layers'][number]>('layer', { required: true });
const driveFile = ref();
const driveFile = ref<Misskey.entities.DriveFile | null>(null);
const driveFileError = ref(false);
onMounted(async () => {
if (layer.value.type === 'image' && layer.value.imageId != null) {
@ -294,7 +293,15 @@ onMounted(async () => {
});
function chooseFile(ev: MouseEvent) {
selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then((file) => {
selectFile({
anchorElement: ev.currentTarget ?? ev.target,
multiple: false,
label: i18n.ts.selectFile,
features: {
watermark: false,
},
}).then((file) => {
if (layer.value.type !== 'image') return;
if (!file.type.startsWith('image')) {
os.alert({
type: 'warning',

View File

@ -124,7 +124,7 @@ function createStripeLayer(): WatermarkPreset['layers'][number] {
angle: 0.5,
frequency: 10,
threshold: 0.1,
black: false,
color: [1, 1, 1],
opacity: 0.75,
};
}
@ -140,7 +140,7 @@ function createPolkadotLayer(): WatermarkPreset['layers'][number] {
majorOpacity: 0.75,
minorOpacity: 0.5,
minorDivisions: 4,
black: false,
color: [1, 1, 1],
opacity: 0.75,
};
}
@ -151,7 +151,7 @@ function createCheckerLayer(): WatermarkPreset['layers'][number] {
type: 'checker',
angle: 0.5,
scale: 3,
black: false,
color: [1, 1, 1],
opacity: 0.75,
};
}
@ -177,6 +177,7 @@ const dialog = useTemplateRef('dialog');
async function cancel() {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm,
});
if (canceled) return;

View File

@ -25,7 +25,14 @@ export function useChartTooltip(opts: { position: 'top' | 'middle' } = { positio
series: tooltipSeries,
}, {});
function windowTouchendHandler() {
tooltipShowing.value = false;
}
window.addEventListener('touchend', windowTouchendHandler, { passive: true });
onUnmounted(() => {
window.removeEventListener('touchend', windowTouchendHandler);
disposeTooltipComponent();
});

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';
@ -836,6 +837,7 @@ export function launchUploader(
options?: {
folderId?: string | null;
multiple?: boolean;
features?: UploaderDialogFeatures;
},
): Promise<Misskey.entities.DriveFile[]> {
return new Promise(async (res, rej) => {
@ -844,6 +846,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: true,
}).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

@ -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

@ -422,7 +422,7 @@ export const PREF_DEF = definePreferences({
default: null as WatermarkPreset['id'] | null,
},
defaultImageCompressionLevel: {
default: 2,
default: 2 as 0 | 1 | 2 | 3,
},
'sound.masterVolume': {

View File

@ -16,6 +16,7 @@ import { instance } from '@/instance.js';
import { globalEvents } from '@/events.js';
import { getProxiedImageUrl } from '@/utility/media-proxy.js';
import { genId } from '@/utility/id.js';
import type { UploaderDialogFeatures } from '@/components/MkUploaderDialog.vue';
type UploadReturnType = {
filePromise: Promise<Misskey.entities.DriveFile>;
@ -155,6 +156,7 @@ export function uploadFile(file: File | Blob, options: {
export function chooseFileFromPcAndUpload(
options: {
multiple?: boolean;
features?: UploaderDialogFeatures;
folderId?: string | null;
} = {},
): Promise<Misskey.entities.DriveFile[]> {
@ -163,6 +165,7 @@ export function chooseFileFromPcAndUpload(
if (files.length === 0) return;
os.launchUploader(files, {
folderId: options.folderId,
features: options.features,
}).then(driveFiles => {
res(driveFiles);
});
@ -194,7 +197,7 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> {
type: 'url',
placeholder: i18n.ts.uploadFromUrlDescription,
}).then(({ canceled, result: url }) => {
if (canceled) return;
if (canceled || url == null) return;
const marker = genId();
@ -221,7 +224,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,
@ -229,7 +232,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',
@ -242,12 +245,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

@ -57,7 +57,7 @@ function getValue<T extends keyof ParamTypeToPrimitive>(params: Record<string, a
return params[k];
}
export class ImageEffector {
export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, any>>> {
private gl: WebGL2RenderingContext;
private canvas: HTMLCanvasElement | null = null;
private renderTextureProgram: WebGLProgram;
@ -70,7 +70,7 @@ export class ImageEffector {
private shaderCache: Map<string, WebGLProgram> = new Map();
private perLayerResultTextures: Map<string, WebGLTexture> = new Map();
private perLayerResultFrameBuffers: Map<string, WebGLFramebuffer> = new Map();
private fxs: ImageEffectorFx[];
private fxs: [...IEX];
private paramTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
constructor(options: {
@ -78,7 +78,7 @@ export class ImageEffector {
renderWidth: number;
renderHeight: number;
image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
fxs: ImageEffectorFx[];
fxs: [...IEX];
}) {
this.canvas = options.canvas;
this.renderWidth = options.renderWidth;
@ -230,7 +230,7 @@ export class ImageEffector {
gl: gl,
program: shaderProgram,
params: Object.fromEntries(
Object.entries(fx.params).map(([key, param]) => {
Object.entries(fx.params as ImageEffectorFxParamDefs).map(([key, param]) => {
return [key, layer.params[key] ?? param.default];
}),
),
@ -238,7 +238,7 @@ export class ImageEffector {
width: this.renderWidth,
height: this.renderHeight,
textures: Object.fromEntries(
Object.entries(fx.params).map(([k, v]) => {
Object.entries(fx.params as ImageEffectorFxParamDefs).map(([k, v]) => {
if (v.type !== 'texture') return [k, null];
const param = getValue<typeof v.type>(layer.params, k);
if (param == null) return [k, null];
@ -329,7 +329,7 @@ export class ImageEffector {
unused.delete(textureKey);
if (this.paramTextures.has(textureKey)) continue;
console.log(`Baking texture of <${textureKey}>...`);
if (_DEV_) console.log(`Baking texture of <${textureKey}>...`);
const texture = v.type === 'text' ? await createTextureFromText(this.gl, v.text) : v.type === 'url' ? await createTextureFromUrl(this.gl, v.url) : null;
if (texture == null) continue;
@ -339,7 +339,7 @@ export class ImageEffector {
}
for (const k of unused) {
console.log(`Dispose unused texture <${k}>...`);
if (_DEV_) console.log(`Dispose unused texture <${k}>...`);
this.gl.deleteTexture(this.paramTextures.get(k)!.texture);
this.paramTextures.delete(k);
}

View File

@ -3,13 +3,20 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { FX_watermarkPlacement } from './image-effector/fxs/watermarkPlacement.js';
import { FX_stripe } from './image-effector/fxs/stripe.js';
import { FX_polkadot } from './image-effector/fxs/polkadot.js';
import { FX_checker } from './image-effector/fxs/checker.js';
import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
import { FX_watermarkPlacement } from '@/utility/image-effector/fxs/watermarkPlacement.js';
import { FX_stripe } from '@/utility/image-effector/fxs/stripe.js';
import { FX_polkadot } from '@/utility/image-effector/fxs/polkadot.js';
import { FX_checker } from '@/utility/image-effector/fxs/checker.js';
import type { ImageEffectorFx, ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
const WATERMARK_FXS = [
FX_watermarkPlacement,
FX_stripe,
FX_polkadot,
FX_checker,
] as const satisfies ImageEffectorFx<string, any>[];
export type WatermarkPreset = {
id: string;
name: string;
@ -64,7 +71,7 @@ export type WatermarkPreset = {
};
export class WatermarkRenderer {
private effector: ImageEffector;
private effector: ImageEffector<typeof WATERMARK_FXS>;
private layers: WatermarkPreset['layers'] = [];
constructor(options: {
@ -78,7 +85,7 @@ export class WatermarkRenderer {
renderWidth: options.renderWidth,
renderHeight: options.renderHeight,
image: options.image,
fxs: [FX_watermarkPlacement, FX_stripe, FX_polkadot, FX_checker],
fxs: WATERMARK_FXS,
});
}
@ -157,6 +164,8 @@ export class WatermarkRenderer {
opacity: layer.opacity,
},
};
} else {
throw new Error(`Unknown layer type`);
}
});
}

View File

@ -40,6 +40,7 @@
'packages/misskey-bubble-game/**/package.json',
'packages/misskey-reversi/**/package.json',
'packages/sw/**/package.json',
'packages/icons-subsetter/**/package.json',
],
// prevent wastage of Chromatic snapshots
rebaseWhen: 'never',
@ -62,12 +63,6 @@
'scripts/**/package.json',
],
},
{
groupName: '[icons-subsetter] Update dependencies',
matchFileNames: [
'packages/icons-subsetter/**/package.json',
],
},
{
groupName: '[GitHub Actions] Update dependencies',
matchFileNames: [