Compare commits

..

31 Commits

Author SHA1 Message Date
syuilo e393ac7f97 Update zoomLines.ts 2025-05-29 19:59:50 +09:00
syuilo 864927865d Update zoomLines.ts 2025-05-29 19:09:29 +09:00
syuilo 0ead9c0877 wip 2025-05-29 18:19:31 +09:00
syuilo c8f0833da1 Merge branch 'develop' into watermark 2025-05-29 17:46:40 +09:00
syuilo 647e19f128 Merge branch 'develop' into watermark 2025-05-29 12:41:46 +09:00
syuilo 7ca86da2f2 Merge branch 'develop' into watermark 2025-05-29 12:41:33 +09:00
syuilo 6bcc9a47fd Update MkImageEffectorDialog.Layer.vue 2025-05-29 12:25:34 +09:00
syuilo 854a384fd9 Update MkImageEffectorDialog.vue 2025-05-29 12:18:18 +09:00
syuilo b8e11516ff wip 2025-05-29 10:00:58 +09:00
syuilo 98f0de6c56 wip 2025-05-29 09:36:47 +09:00
syuilo 16a3321287 Update MkRange.vue 2025-05-29 08:30:37 +09:00
syuilo 88f38e5cd7 Update MkRange.vue 2025-05-29 08:12:11 +09:00
syuilo dd4d3a4b61 wip 2025-05-29 08:03:18 +09:00
syuilo 65e0ab810e wip 2025-05-28 21:00:03 +09:00
syuilo 9fb0f7357a wip 2025-05-28 17:47:17 +09:00
syuilo bd8d0d78bf wip 2025-05-28 17:19:19 +09:00
syuilo 31c4237748 wip 2025-05-28 16:43:15 +09:00
syuilo fad3aed79e wip 2025-05-28 13:59:08 +09:00
syuilo 26819689bd Update ImageEffector.ts 2025-05-28 13:26:09 +09:00
syuilo a8cbbdff63 Update ImageEffector.ts 2025-05-28 12:57:28 +09:00
syuilo 09eb631fdc wip 2025-05-28 12:54:48 +09:00
syuilo 33486ebdf2 Update MkUploaderDialog.vue 2025-05-28 12:40:42 +09:00
syuilo 092048e2e9 Update watermarker.ts 2025-05-28 12:32:31 +09:00
syuilo 0a4ca368d4 Merge branch 'develop' into watermark 2025-05-28 11:47:24 +09:00
syuilo eea0fb2636 wip 2025-05-28 11:47:09 +09:00
syuilo de90b606c1 wip 2025-05-28 11:15:57 +09:00
syuilo 7754ccb73f Update watermarker.ts 2025-05-28 09:46:38 +09:00
syuilo cd296d60d8 wip 2025-05-28 09:44:09 +09:00
syuilo e3aae009b4 wip 2025-05-28 09:20:10 +09:00
syuilo 44212a31c9 wip 2025-05-28 09:00:22 +09:00
syuilo 2a8920f8c3 wip 2025-05-28 08:31:36 +09:00
85 changed files with 3165 additions and 825 deletions
+1 -17
View File
@@ -1,17 +1,3 @@
## 2025.6.0
### General
-
### Client
- Enhance: 非同期的なコンポーネントの読み込み時のハンドリングを強化
- Fix: リアクションの一部の絵文字が重複して表示されることがある問題を修正
- Fix: 非利用者に対するユーザー作成コンテンツの公開範囲が全て非公開になっている場合にログインできない問題を修正
### Server
- Fix: 非利用者に対するユーザー作成コンテンツの公開範囲が全て非公開になっている場合でもusers/showを許可するように
## 2025.5.1
### Note
@@ -53,7 +39,6 @@
- Feat: 絵文字をミュート可能にする機能
- 絵文字(ユニコードの絵文字・カスタム絵文字)毎にミュートし、不可視化することができるようになりました
- Feat: モバイルデバイスで折りたたまれたUIの展開表示に全画面ページを使用できるように(実験的)
- Enhance: 設定の同期をオンにするときに競合したときに値をマージできるように
- Enhance: メモリ使用量を軽減しました
- Enhance: 画像の高品質なプレースホルダを無効化してパフォーマンスを向上させるオプションを追加
- Enhance: 招待されているが参加していないルームを開いたときに、招待を承認するかどうか尋ねるように
@@ -68,7 +53,6 @@
- フロントエンドの読み込みサイズを軽量化しました
- ほとんどの言語のハイライトは問題なく行えますが、互換性の問題により一部の言語が正常にハイライトできなくなる可能性があります。詳しくは https://shiki.style/references/engine-js-compat をご覧ください。
- Fix: チャットに動画ファイルを送付すると、動画の表示が崩れてしまい視聴出来ない問題を修正
- Fix: アカウント依存かつ初期状態である設定値をサーバー同期しようとした際に正しくコンフリクト検出されない問題を修正
- Fix: "時計"ウィジェット(Clock)において、Transparent設定が有効でも、その背景が透過されない問題を修正
- Fix: 一定時間操作がなかったら動画プレイヤーのコントロールを隠すように
- Fix: Twitchのクリップがプレイヤーで再生できない問題を修正
@@ -86,7 +70,7 @@
- Fix: ミュート対象ユーザーが引用されているノートがRNされたときにミュートを貫通してしまう問題を修正 #16009
- Fix: 連合モードが「なし」の場合に、生成されるHTML内のactivity jsonへのリンクタグを省略するように
- Fix: コントロールパネルから招待コードを作成すると作成者の情報が記録されない問題を修正
- Fix: コントロールパネルのジョブキューページからPausedなジョブ一覧を閲覧できない問題を修正
## 2025.5.0
-2
View File
@@ -327,7 +327,6 @@ dark: "Fosc"
lightThemes: "Temes clars"
darkThemes: "Temes foscos"
syncDeviceDarkMode: "Sincronitza el mode fosc amb la configuració del dispositiu"
switchDarkModeManuallyWhenSyncEnabledConfirm: "\"{x}\" es troba activat. Vols desactivar la sincronització i canviar de mode manualment?"
drive: "Disc"
fileName: "Nom del Fitxer"
selectFile: "Selecciona un fitxer"
@@ -1330,7 +1329,6 @@ restore: "Restaurar "
syncBetweenDevices: "Sincronització entre dispositius"
preferenceSyncConflictTitle: "Els valors de la configuració ja existeixen al dispositiu"
preferenceSyncConflictText: "Un element de la configuració amb sincronització activada desa els seus valors al servidor, però s'ha trobat un valor a la configuració desat al servidor per aquest element de la configuració. Quin valor us sobreescriure?"
preferenceSyncConflictChoiceMerge: "Integració "
preferenceSyncConflictChoiceServer: "Valors de configuració del servidor"
preferenceSyncConflictChoiceDevice: "Punts d'ajustos del dispositiu "
preferenceSyncConflictChoiceCancel: "Cancel·lar l'activació de la sincronització "
-2
View File
@@ -327,7 +327,6 @@ dark: "Dark"
lightThemes: "Light themes"
darkThemes: "Dark themes"
syncDeviceDarkMode: "Sync Dark Mode with your device settings"
switchDarkModeManuallyWhenSyncEnabledConfirm: "\"{x}\" is turned on, Would you like to turn off synchronization and switch modes manually?"
drive: "Drive"
fileName: "Filename"
selectFile: "Select a file"
@@ -1330,7 +1329,6 @@ restore: "Restore"
syncBetweenDevices: "Sync between devices"
preferenceSyncConflictTitle: "The configured value exists on the server."
preferenceSyncConflictText: "The sync enabled settings will save their values to the server. However, there are existing values on the server. Which set of values would you like to overwrite?"
preferenceSyncConflictChoiceMerge: "Merge"
preferenceSyncConflictChoiceServer: "Configured value on server"
preferenceSyncConflictChoiceDevice: "Configured value on device"
preferenceSyncConflictChoiceCancel: "Cancel enabling sync"
-205
View File
@@ -298,7 +298,6 @@ uploadFromUrl: "Subir desde una URL"
uploadFromUrlDescription: "URL del fichero que quieres subir"
uploadFromUrlRequested: "Subida solicitada"
uploadFromUrlMayTakeTime: "Subir el fichero puede tardar un tiempo."
uploadNFiles: "Subir {n} archivos"
explore: "Explorar"
messageRead: "Ya leído"
noMoreHistory: "El historial se ha acabado"
@@ -327,7 +326,6 @@ dark: "Oscuro"
lightThemes: "Tema claro"
darkThemes: "Tema oscuro"
syncDeviceDarkMode: "Sincronice el Modo Oscuro con la configuración de su dispositivo"
switchDarkModeManuallyWhenSyncEnabledConfirm: "{x} está activado ¿Te gustaría desactivar la sincronización y cambiar al modo manual?"
drive: "Drive"
fileName: "Nombre de archivo"
selectFile: "Elegir archivo"
@@ -580,7 +578,6 @@ newNoteRecived: "Tienes una nota nueva"
newNote: "Nueva nota"
sounds: "Sonidos"
sound: "Sonidos"
notificationSoundSettings: "Configuración del sonido de las notificaciones"
listen: "Escuchar"
none: "Ninguna"
showInPage: "Mostrar en la página"
@@ -1002,7 +999,6 @@ failedToUpload: "La subida falló"
cannotUploadBecauseInappropriate: "Este archivo no se puede subir debido a que algunas partes han sido detectadas comoNSFW."
cannotUploadBecauseNoFreeSpace: "La subida falló debido a falta de espacio libre en la unidad del usuario."
cannotUploadBecauseExceedsFileSizeLimit: "Este archivo supera el peso máximo y no puede ser subido."
cannotUploadBecauseUnallowedFileType: "Incapaz de subir el archivo debido a que es un tipo de archivo no autorizado."
beta: "Beta"
enableAutoSensitive: "Marcar automáticamente contenido NSFW"
enableAutoSensitiveDescription: "Permite la detección y marcado automático de contenido NSFW usando 'Machine Learning' cuando sea posible. Incluso si esta opción está desactivada, puede ser activado para toda la instancia."
@@ -1330,7 +1326,6 @@ restore: "Restaurar"
syncBetweenDevices: "Sincronizar entre dispositivos"
preferenceSyncConflictTitle: "Los valores configurados existen en el servidor."
preferenceSyncConflictText: "Los ajustes de sincronización activados guardarán sus valores en el servidor. Sin embargo, hay valores existentes en el servidor. ¿Qué conjunto de valores desea sobrescribir?"
preferenceSyncConflictChoiceMerge: "Fusionar"
preferenceSyncConflictChoiceServer: "Valores de configuración del servidor"
preferenceSyncConflictChoiceDevice: "Valor configurado en el dispositivo"
preferenceSyncConflictChoiceCancel: "Cancelar la activación de la sincronización"
@@ -1361,10 +1356,6 @@ emojiMute: "Silenciar emojis"
emojiUnmute: "No Silenciar emojis"
muteX: "Silenciar {x}"
unmuteX: "Dejar de silenciar {x}"
abort: "Abortar"
tip: "Consejos y trucos"
redisplayAllTips: "Volver a mostrar todos \"Trucos y consejos\""
hideAllTips: "Ocultar todos los \"Trucos y consejos\""
_chat:
noMessagesYet: "Aún no hay mensajes"
newMessage: "Mensajes nuevos"
@@ -1430,28 +1421,15 @@ _settings:
accountDataBanner: "Exportación e importación para gestionar los datos de la cuenta."
muteAndBlockBanner: "Puedes configurar y gestionar ajustes para ocultar contenidos y restringir acciones a usuarios específicos."
accessibilityBanner: "Puedes personalizar los visuales y el comportamiento del cliente, y configurar los ajustes para optimizar el uso."
privacyBanner: "Puedes configurar opciones relacionadas con la privacidad de la cuenta, como la visibilidad del contenido, la posibilidad de descubrir la cuenta y la aprobación de seguimiento."
securityBanner: "Puedes configurar opciones relacionadas con la seguridad de la cuenta, como la contraseña, los métodos de inicio de sesión, las aplicaciones de autenticación y Passkeys."
preferencesBanner: "Puedes configurar el comportamiento general del cliente según tus preferencias."
appearanceBanner: "Puedes configurar el aspecto y la visualización del cliente según tus preferencias."
soundsBanner: "Puedes configurar los ajustes de sonido para la reproducción en el cliente."
timelineAndNote: "Líneas del tiempo y notas"
makeEveryTextElementsSelectable: "Hacer que todos los elementos de texto sean seleccionables"
makeEveryTextElementsSelectable_description: "Activar esta opción puede reducir la usabilidad en algunas situaciones."
useStickyIcons: "Hacer que los iconos te sigan cuando desplaces"
enableHighQualityImagePlaceholders: "Mostrar marcadores de posición para imágenes de alta calidad"
uiAnimations: "Animaciones de la interfaz de usuario"
showNavbarSubButtons: "Mostrar los sub-botones en la barra de navegación."
ifOn: "Si está activado"
ifOff: "Si está desactivado"
enableSyncThemesBetweenDevices: "Sincronizar los temas instalados entre dispositivos."
enablePullToRefresh: "Tirar para actualizar"
enablePullToRefresh_description: "Si utiliza un ratón, arrastre mientras pulsa la rueda de desplazamiento."
realtimeMode_description: "Establece una conexión con el servidor y actualiza el contenido en tiempo real. Esto puede aumentar el tráfico y el consumo de memoria."
contentsUpdateFrequency: "Frecuencia de adquisición del contenido."
contentsUpdateFrequency_description: "Cuanto mayor sea el valor, más se actualiza el contenido, pero disminuye el rendimiento y aumenta el tráfico y el consumo de memoria."
contentsUpdateFrequency_description2: "Cuando el modo en tiempo real está activado, el contenido se actualiza en tiempo real independientemente de esta configuración."
showUrlPreview: "Mostrar la vista previa de la URL"
_chat:
showSenderName: "Mostrar el nombre del remitente"
sendOnEnter: "Intro para enviar"
@@ -1459,46 +1437,20 @@ _preferencesProfile:
profileName: "Nombre de perfil"
profileNameDescription: "Establece un nombre que identifique al dispositivo"
profileNameDescription2: "Por ejemplo: \"PC Principal\",\"Teléfono\""
manageProfiles: "Administrar perfiles"
_preferencesBackup:
autoBackup: "Respaldo automático"
restoreFromBackup: "Restaurar desde copia de seguridad"
noBackupsFoundTitle: "No se encontró una copia de seguridad"
noBackupsFoundDescription: "No se han encontrado copias de seguridad creadas automáticamente, pero si has guardado manualmente un archivo de copia de seguridad, puedes importarlo y restaurarlo."
selectBackupToRestore: "Selecciona una copia de seguridad para restaurar"
youNeedToNameYourProfileToEnableAutoBackup: "Se debe establecer un nombre de perfil para activar la copia de seguridad automática."
autoPreferencesBackupIsNotEnabledForThisDevice: "La copia de seguridad automática de los ajustes no está activada en este dispositivo."
backupFound: "Copia de seguridad de los ajustes encontrada "
_accountSettings:
requireSigninToViewContents: "Se requiere iniciar sesión para ver el contenido"
requireSigninToViewContentsDescription1: "Requiere iniciar sesión para ver todas las notas y otros contenidos que hayas creado. Se espera que esto evite que los rastreadores recopilen información."
requireSigninToViewContentsDescription2: "El contenido no se mostrará en vistas previas de URL (OGP), incrustado en páginas web o en servidores que no admitan citas de notas."
requireSigninToViewContentsDescription3: "Estas restricciones pueden no aplicarse a los contenidos federados de otros servidores remotos."
makeNotesFollowersOnlyBefore: "Hacer que las notas antiguas sólo se muestren a los seguidores"
makeNotesFollowersOnlyBeforeDescription: "Mientras esta función esté activada, sólo los seguidores podrán ver las notas que hayan superado la fecha y hora establecidas o que hayan estado visibles durante un tiempo determinado. Cuando se desactive, también se restablecerá el estado de publicación de la nota."
makeNotesHiddenBefore: "Hacer privadas las notas antiguas "
makeNotesHiddenBeforeDescription: "Mientras esta función esté activada, las notas que hayan pasado la fecha y hora fijadas o hayan transcurrido el tiempo establecido sólo serán visibles para ti (se harán privadas). Si la desactivas, también se restablecerá el estado público de las notas."
mayNotEffectForFederatedNotes: "Notas federadas por un servidor remoto pueden no verse afectadas."
mayNotEffectSomeSituations: "Estas restricciones son simplificadas. Pueden no aplicarse en algunas situaciones, como cuando se visualiza en un servidor remoto o durante la moderación."
notesHavePassedSpecifiedPeriod: "Ten en cuenta que el tiempo especificado ha pasado"
notesOlderThanSpecifiedDateAndTime: "Notas antes de la fecha y hora especificadas"
_abuseUserReport:
forward: "Reenviar"
forwardDescription: "Reenvía el informe a un servidor/instancia remoto como cuenta anónima del sistema."
resolve: "Resuelto"
accept: "Acepte"
reject: "repudio"
resolveTutorial: "Si el contenido del informe es legítimo, selecciona \"Aceptar\" para marcarlo como resuelto.\nSi el contenido del informe es ilegítimo, selecciona \"Rechazar\" para ignorarlo."
_delivery:
status: "Estado de la entrega"
stop: "Suspendido"
resume: "Resumen de entrega"
_type:
none: "Publicando"
manuallySuspended: "Suspendido manualmente"
goneSuspended: "El servidor se ha suspendido debido a la eliminación del servidor"
autoSuspendedForNotResponding: "El servidor se suspende debido a que el servidor no responde."
softwareSuspended: "Suspendido porque este software ya no se distribuye a"
_bubbleGame:
howToPlay: "Cómo jugar"
hold: "Mantener"
@@ -1624,29 +1576,6 @@ _serverSettings:
fanoutTimelineDescription: "Incrementa el rendimiento de forma significativa cuando se obtienen las líneas de tiempo y reduce la carga en la base de datos. A cambio, el uso de la memoria en Redis incrementará. Considera desactivar esta opción en caso de que tu servidor tenga poca memoria o detectes inestabilidad."
fanoutTimelineDbFallback: "Cargar desde la base de datos"
fanoutTimelineDbFallbackDescription: "Cuando esta opción está habilitada, la carga de peticiones adicionales de la línea de tiempo se hará desde la base de datos cuando éstas no se encuentren en la caché. Al deshabilitar esta opción se reduce la carga del servidor, pero limita el número de líneas de tiempo que pueden obtenerse."
reactionsBufferingDescription: "Cuando se activa, el rendimiento durante la creación de reacciones mejorará considerablemente, reduciendo la carga de la base de datos. Sin embargo, aumentará el uso de memoria de Redis."
inquiryUrl: "URL de consulta "
inquiryUrlDescription: "Especifica una URL para el formulario de consulta al responsable del servidor o una página web para la información de contacto."
openRegistration: "Registros Abiertos"
openRegistrationWarning: "Abrir registros conlleva riesgos. Se recomienda solo habilitarlos si tienes un sistema en el cual puedes monitorear continuamente el servidor y respondes inmediatamente en caso de que haya cualquier problema."
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Si no se ha detectado por un tiempo actividad de un moderador, este ajuste será automáticamente desactivado para prevenir el spam. "
deliverSuspendedSoftware: "Software suspendido."
deliverSuspendedSoftwareDescription: "Puede especificar un rango de nombres y versiones del software del servidor para detener la entrega, por ejemplo, debido a vulnerabilidades. Esta información sobre la versión la proporciona el servidor y su fiabilidad no está garantizada. Se puede utilizar una especificación de rango para especificar una versión, pero se recomienda especificar una versión previa, como >= 2024.3.1-0, ya que especificar >= 2024.3.1 no incluirá versiones personalizadas como 2024.3.1-custom.0."
singleUserMode: "Modo de usuario único"
singleUserMode_description: "Si eres el único usuario de este servidor, activar este modo optimizará su rendimiento."
signToActivityPubGet: "Firmar solicitudes GET de Activitypub."
signToActivityPubGet_description: "Normalmente, debería estar activada. Deshabilitarlo puede mejorar los problemas relacionados con la federación, pero por otro lado podría deshabilitar la federación hacia otros servidores."
proxyRemoteFiles: "Proxy de archivos remotos"
proxyRemoteFiles_description: "Cuando se activa, el servidor proxy sirve archivos remotos. Esto es útil para generar miniaturas de imágenes y proteger la privacidad del usuario."
allowExternalApRedirect: "Permitir redirecciones para consultas vía ActivityPub"
allowExternalApRedirect_description: "Si se activa, otros servidores pueden consultar contenidos de terceros a través de este servidor, pero esto puede dar lugar a la suplantación de contenidos."
userGeneratedContentsVisibilityForVisitor: "Visibilidad de contenido generado por un usuario a invitados"
userGeneratedContentsVisibilityForVisitor_description: "Esto es útil para evitar problemas causados por contenidos remotos inapropiados que no estén bien moderados y que se publiquen involuntariamente en Internet a través de su propio servidor."
userGeneratedContentsVisibilityForVisitor_description2: "Publicar incondicionalmente todo el contenido del servidor en Internet, incluido el contenido remoto recibido por el servidor, es arriesgado. Esto es especialmente importante para los invitados que desconocen la naturaleza distribuida del contenido, ya que pueden creer erróneamente que incluso el contenido remoto es contenido creado por usuarios en el servidor."
_userGeneratedContentsVisibilityForVisitor:
all: "Todo es público."
localOnly: "Sólo se publica el contenido local, el remoto se mantiene privado"
none: "Todo es privado"
_accountMigration:
moveFrom: "Trasladar de otra cuenta a ésta"
moveFromSub: "Crear un alias para otra cuenta."
@@ -1943,8 +1872,6 @@ _role:
descriptionOfIsExplorable: "La línea de tiempo de éste rol y la lista de usuarios serán públicos si se activa.."
displayOrder: "Posición"
descriptionOfDisplayOrder: "Entre más alto el número, mayor es la posición en la interfaz."
preserveAssignmentOnMoveAccount: "Preservar los roles asignados durante la migración"
preserveAssignmentOnMoveAccount_description: "Si está activada, este rol se transferirá a la cuenta de destino cuando se migre una cuenta con este rol."
canEditMembersByModerator: "Permitir a los moderadores editar los miembros"
descriptionOfCanEditMembersByModerator: "Si se activa, los moderadores, al igual que los administradores, serán capaces de asignar/quitar usuarios a éste rol. Si se desactiva, sólo los administradores podrán hacerlo."
priority: "Prioridad"
@@ -1964,9 +1891,7 @@ _role:
canManageCustomEmojis: "Administrar emojis personalizados"
canManageAvatarDecorations: "Administrar decoraciones de avatar"
driveCapacity: "Capacidad del drive"
maxFileSize: "Tamaño máximo de archivo que se puede cargar."
alwaysMarkNsfw: "Siempre marcar archivos como NSFW"
canUpdateBioMedia: "Puede editar un icono o una imagen de fondo (banner)"
pinMax: "Máximo de notas fijadas"
antennaMax: "Máximo de antenas"
wordMuteMax: "Máximo de caracteres en palabras silenciadas"
@@ -1981,15 +1906,6 @@ _role:
canSearchNotes: "Uso de la búsqueda de notas"
canUseTranslator: "Uso de traductor"
avatarDecorationLimit: "Número máximo de decoraciones de avatar"
canImportAntennas: "Permitir la importación de antenas"
canImportBlocking: "Permitir la importación de bloqueos"
canImportFollowing: "Permitir la importación de seguidos"
canImportMuting: "Permitir la importación de silenciados"
canImportUserLists: "Permitir la importación de listas"
chatAvailability: "Permitir Chats"
uploadableFileTypes: "Tipos de archivos que se pueden cargar."
uploadableFileTypes_caption: "Especifica los tipos MIME/archivos permitidos. Se pueden especificar varios tipos MIME separándolos con una nueva línea, y se pueden especificar comodines con un asterisco (*). (por ejemplo, image/*)"
uploadableFileTypes_caption2: "Es posible que no se detecten algunos tipos de archivos. Para permitir estos archivos, añade {x} a la especificación."
_condition:
roleAssignedTo: "Asignado a roles manuales"
isLocal: "Usuario local"
@@ -1998,7 +1914,6 @@ _role:
isBot: "Usuarios Bot"
isSuspended: "Usuario suspendido"
isLocked: "Cuentas privadas"
isExplorable: "Hacer que la cuenta sea visible en las búsquedas"
createdLessThan: "Menos de X han pasado desde la creación de la cuenta"
createdMoreThan: "Más de X han pasado desde la creación de la cuenta"
followersLessThanOrEq: "Tiene X o menos seguidores"
@@ -2153,7 +2068,6 @@ _theme:
installed: "{name} ha sido instalado"
installedThemes: "Temas instalados"
builtinThemes: "Temas integrados"
instanceTheme: "Tema del servidor (o también denominado: tema de la instancia)"
alreadyInstalled: "Este tema ya está instalado"
invalid: "El formato del tema no es válido"
make: "Crear tema"
@@ -2215,7 +2129,6 @@ _sfx:
noteMy: "Nota (a mí mismo)"
notification: "Notificaciones"
reaction: "Al seleccionar una reacción"
chatMessage: "Mensajes del Chat"
_soundSettings:
driveFile: "Usar un archivo de audio en Drive"
driveFileWarn: "Selecciona un archivo de audio en Drive."
@@ -2223,7 +2136,6 @@ _soundSettings:
driveFileTypeWarnDescription: "Selecciona un archivo de audio"
driveFileDurationWarn: "La duración del audio es demasiado larga."
driveFileDurationWarnDescription: "Usar un audio de larga duración puede llegar a molestar mientras usas Misskey. ¿Quieres continuar?"
driveFileError: "No puedo cargar el sonido. Por favor cambia la configuración."
_ago:
future: "Futuro"
justNow: "Justo ahora"
@@ -2363,7 +2275,6 @@ _permissions:
"read:federation": "Ver instancias federadas"
"write:report-abuse": "Crear reportes de usuario"
"write:chat": "Administrar chat"
"read:chat": "Explorar Chats"
_auth:
shareAccessTitle: "Permisos de la aplicación"
shareAccess: "¿Desea permitir el acceso a la cuenta \"{name}\"?"
@@ -2372,11 +2283,8 @@ _auth:
permissionAsk: "Esta aplicación requiere los siguientes permisos"
pleaseGoBack: "Por favor, vuelve a la aplicación"
callback: "Volviendo a la aplicación"
accepted: "Acceso concedido."
denied: "Acceso denegado"
scopeUser: "Operar como el siguiente usuario"
pleaseLogin: "Se requiere un inicio de sesión para darle permisos a la aplicación"
byClickingYouWillBeRedirectedToThisUrl: "Cuando el acceso es concedido, serás automáticamente redireccionado a la siguiente URL"
_antennaSources:
all: "Todas las notas"
homeTimeline: "Notas de los usuarios que sigues"
@@ -2486,9 +2394,6 @@ _profile:
changeBanner: "Cambiar banner"
verifiedLinkDescription: "Introduciendo una URL que contiene un enlace a tu perfil, se puede mostrar un icono de verificación de propiedad al lado del campo."
avatarDecorationMax: "Puedes añadir un máximo de {max} decoraciones de avatar."
followedMessage: "Mensaje cuando te han seguido"
followedMessageDescription: "Puedes establecer un mensaje de bienvenida para nuevos seguidores."
followedMessageDescriptionForLockedAccount: "Si apruebas manualmente seguidores, el mensaje se mostrará al seguidor en el momento de la aprobación."
_exportOrImport:
allNotes: "Todas las notas"
favoritedNotes: "Notas favoritas"
@@ -2578,7 +2483,6 @@ _pages:
eyeCatchingImageSet: "Elegir imagen llamativa"
eyeCatchingImageRemove: "Borrar imagen llamativa"
chooseBlock: "Agregar bloque"
enterSectionTitle: "Escribe el título de la sección"
selectType: "Elegir tipo"
contentBlocks: "Contenido"
inputBlocks: "Entrada"
@@ -2613,7 +2517,6 @@ _notification:
newNote: "Nueva nota"
unreadAntennaNote: "Antena {name}"
roleAssigned: "Rol asignado"
chatRoomInvitationReceived: "Invitado a la sala de chat."
emptyPushNotificationMessage: "Se han actualizado las notificaciones push"
achievementEarned: "Logro desbloqueado"
testNotification: "Notificación de prueba"
@@ -2621,13 +2524,8 @@ _notification:
sendTestNotification: "Enviar notificación de prueba"
notificationWillBeDisplayedLikeThis: "Las notificaciones tendrán este aspecto"
reactedBySomeUsers: "{n} usuarios han reaccionado"
likedBySomeUsers: "{n} usuarios les gustó tu nota"
renotedBySomeUsers: "{n} usuarios han renotado"
followedBySomeUsers: "Seguido por {n} usuarios"
flushNotification: "Limpiar notificaciones"
exportOfXCompleted: "La exportación de {x} ha sido completada."
login: "Alguien ha iniciado sesión"
createTokenDescription: "Si no tienes ni idea, elimina el token de acceso a través de \"{text}\"."
_types:
all: "Todo"
note: "Nuevas notas"
@@ -2641,11 +2539,8 @@ _notification:
receiveFollowRequest: "Recibió una solicitud de seguimiento"
followRequestAccepted: "El seguimiento fue aceptado"
roleAssigned: "Rol asignado"
chatRoomInvitationReceived: "Invitado a la sala de chat."
achievementEarned: "Logro desbloqueado"
exportCompleted: "La exportación se ha completado"
login: "Iniciar sesión"
createToken: "Crear tokens de acceso"
test: "Pruebas de nofiticaciones"
app: "Notificaciones desde aplicaciones"
_actions:
@@ -2655,11 +2550,7 @@ _notification:
_deck:
alwaysShowMainColumn: "Siempre mostrar la columna principal"
columnAlign: "Alinear columnas"
columnGap: "Margen entre columnas"
deckMenuPosition: "Posición del menú Deck"
navbarPosition: "Posición de la barra de navegación"
addColumn: "Agregar columna"
newNoteNotificationSettings: "Configuración de las notificaciones para notas nuevas"
configureColumn: "Ajustes de columna"
swapLeft: "Mover a la izquierda"
swapRight: "Mover a la derecha"
@@ -2676,7 +2567,6 @@ _deck:
useSimpleUiForNonRootPages: "Mostrar páginas no pertenecientes a la raíz con la interfaz simple"
usedAsMinWidthWhenFlexible: "Se usará el ancho mínimo cuando la opción \"Autoajustar ancho\" esté habilitada"
flexible: "Autoajustar ancho"
enableSyncBetweenDevicesForProfiles: "Activar la sincronización de la información de perfiles entre dispositivos."
_columns:
main: "Principal"
widgets: "Widgets"
@@ -2700,10 +2590,8 @@ _drivecleaner:
orderByCreatedAtAsc: "Fecha ascendente"
_webhookSettings:
createWebhook: "Crear Webhook"
modifyWebhook: "Editar webhook"
name: "Nombre"
secret: "Secreto"
trigger: "Disparador"
active: "Activado"
_events:
follow: "Cuando se sigue a alguien"
@@ -2714,16 +2602,9 @@ _webhookSettings:
reaction: "Cuando se recibe una reacción"
mention: "Cuando hay una mención"
_systemEvents:
abuseReport: "Cuando se recibe un nuevo informe de moderación"
abuseReportResolved: "Cuando se resuelve un informe de moderación"
userCreated: "Cuando se crea el usuario."
inactiveModeratorsWarning: "Cuando un moderador ha estado inactivo por un tiempo"
inactiveModeratorsInvitationOnlyChanged: "Cuando un moderador ha estado inactivo durante un tiempo, y el servidor se cambia a sólo por invitación"
deleteConfirm: "¿Estás seguro de querer eliminar el Webhook?"
testRemarks: "Haz clic en el botón de la derecha del switch para mandar una prueba Webhook con datos ficticios"
_abuseReport:
_notificationRecipient:
createRecipient: "Añadir destinatario a los informes"
_recipientType:
mail: "Correo"
webhook: "Webhook"
@@ -2829,107 +2710,21 @@ _reversi:
rules: "Reglas"
won: "{name} ha ganado"
total: "Total"
allGames: "Todos los juegos"
ended: "Finalizado"
playing: "Jugando actualmente"
isLlotheo: "El que tenga menos fichas gana (LLoTheO)"
loopedMap: "Mapa en bucle"
canPutEverywhere: "Las fichas se pueden poner a cualquier lugar\n"
timeLimitForEachTurn: "Tiempo límite por jugada."
freeMatch: "Partida libre."
lookingForPlayer: "Buscando oponente"
gameCanceled: "La partida ha sido cancelada."
shareToTlTheGameWhenStart: "Compartir la partida en la línea de tiempo cuando comience "
iStartedAGame: "¡La partida ha comenzado!"
opponentHasSettingsChanged: "El oponente ha cambiado su configuración"
allowIrregularRules: "Reglas irregulares (completamente libre)"
disallowIrregularRules: "Sin reglas irregulares "
showBoardLabels: "Mostrar el número de línea y de columna en el tablero de juego."
useAvatarAsStone: "Usar los avatares de los usuarios como fichas\n"
_offlineScreen:
title: "Fuera de línea. No se puede conectar con el servidor"
header: "Incapaz de conectar con el servidor"
_urlPreviewSetting:
title: "Configuración para la previsualización de la URL"
enable: "Activar la vista previa de URL"
allowRedirect: "Permitir la redirección de la visualización previa"
allowRedirectDescription: "Si una URL tiene una redirección establecida, puede activar esta función para seguir la redirección y mostrar una vista previa del contenido redirigido. Si se desactiva, se ahorrarán recursos del servidor, pero no se mostrará el contenido redirigido."
timeout: "Timeout de la carga de vista previa de las URLs (ms)"
timeoutDescription: "Si se tarda más de este valor en obtener la vista previa, ésta no se generará."
maximumContentLength: "Content-Length Máximo (bytes)"
maximumContentLengthDescription: "Si Content-Length es superior a este valor, no se generará la vista previa."
requireContentLength: "Genere la vista previa sólo si puede obtener Content-Length"
requireContentLengthDescription: "Si el otro servidor no devuelve Content-Length, no se generará la vista previa."
userAgent: "User-Agent"
userAgentDescription: "Establece el User-Agent que se utilizará al recuperar vistas previas. Si se deja en blanco, se utilizará el User-Agent por defecto."
summaryProxy: "Proxy endpoints para generar vistas previas"
summaryProxyDescription: "La vista previa se genera usando Summaly proxy, no la genera el mismo Misskey."
summaryProxyDescription2: "Los siguientes parámetros se vinculan al proxy como cadena de consulta (query string). Si el proxy no los admite, los valores se ignoran."
_mediaControls:
pip: "Picture in Picture"
playbackRate: "Velocidad de reproducción"
loop: "Reproducción en bucle"
_contextMenu:
title: "Menú contextual"
app: "Aplicación"
appWithShift: "Aplicación con la tecla shift"
native: "Interfaz nativa (del navegador web)"
_gridComponent:
_error:
requiredValue: "Este valor es obligatorio"
columnTypeNotSupport: "La validación con expresión regular sólo se admite para columnas de tipo:texto."
patternNotMatch: "Este valor no coincide con el patrón en {pattern}"
notUnique: "Este valor debe ser único"
_roleSelectDialog:
notSelected: "No seleccionado"
_customEmojisManager:
_gridCommon:
copySelectionRows: "Copiar filas seleccionadas"
copySelectionRanges: "Copiar selección"
deleteSelectionRows: "Borrar las líneas seleccionadas"
deleteSelectionRanges: "Borrar las filas de la selección"
searchSettings: "Ajustes de búsqueda"
searchSettingCaption: "Establecer criterios de búsqueda detallados."
searchLimit: "Límite de resultados"
sortOrder: "Ordenar"
registrationLogs: "Log de registros "
registrationLogsCaption: "Los registros se mostrarán al actualizar o borrar Emojis. Desaparecerán después de actualizarlos o eliminarlos, pasar a una nueva página o recargar."
_followRequest:
recieved: "Petición de seguimiento recibida"
sent: "Petición de seguimiento enviada"
_remoteLookupErrors:
_federationNotAllowed:
description: "Es posible que se haya desactivado la comunicación con este servidor o que haya sido bloqueado.\nPonte en contacto con el administrador del servidor.."
_uriInvalid:
title: "La URI es inválida"
description: "Ha habido un problema con la dirección introducida. Comprueba que no hayas escrito caracteres que no pueden ser usados en la URI"
_requestFailed:
title: "Solicitud fallida."
description: "Ha fallado la comunicación con este servidor. Es posible que el servidor no funcione. Asegúrese también de que no ha introducido un URI no válido o inexistente."
_responseInvalid:
title: "La respuesta no es válida"
description: "Has podido comunicarte con este servidor, pero los datos obtenidos eran incorrectos. Si estás consultando contenidos remotos a través de un servidor de terceros, vuelve a realizar la consulta utilizando un URI que pueda obtenerse del servidor de origen."
_noSuchObject:
title: "No se encuentra"
description: "No se ha encontrado el recurso solicitado, por favor, vuelve a comprobar el URI."
_captcha:
verify: "Por favor verifica el CAPTCHA"
testSiteKeyMessage: "Puedes comprobar la vista previa introduciendo los valores de prueba para el sitio y las claves secretas.\nPara más detalles, consulta la página siguiente.\n"
_error:
_requestFailed:
title: "Ha fallado la solicitud del CAPTCHA"
text: "Por favor, ejecútalo después de un rato o comprueba los ajustes de nuevo."
_verificationFailed:
title: "Ha fallado la validación del CAPTCHA"
text: "Comprueba que los ajustes son los correctos."
_unknown:
title: "Error en el CAPTCHA."
text: "Se ha producido un error inesperado."
_bootErrors:
title: "Fallo al cargar"
_search:
searchScopeAll: "Todo"
searchScopeLocal: "Local"
searchScopeUser: "Especificar usuario"
_uploader:
allowedTypes: "Tipos de archivos que se pueden cargar."
+109 -7
View File
@@ -5335,19 +5335,15 @@ export interface Locale extends ILocale {
*/
"preferenceSyncConflictTitle": string;
/**
*
*
*/
"preferenceSyncConflictText": string;
/**
*
*/
"preferenceSyncConflictChoiceMerge": string;
/**
*
*
*/
"preferenceSyncConflictChoiceServer": string;
/**
*
*
*/
"preferenceSyncConflictChoiceDevice": string;
/**
@@ -5481,6 +5477,10 @@ export interface Locale extends ILocale {
*
*/
"hideAllTips": string;
/**
*
*/
"defaultImageCompressionLevel": string;
"_chat": {
/**
*
@@ -12020,6 +12020,108 @@ export interface Locale extends ILocale {
*/
"tip": string;
};
/**
*
*/
"watermark": string;
/**
*
*/
"defaultPreset": string;
"_watermarkEditor": {
/**
*
*/
"tip": string;
/**
*
*/
"title": string;
/**
*
*/
"cover": string;
/**
*
*/
"repeat": string;
/**
*
*/
"opacity": string;
/**
*
*/
"scale": string;
/**
*
*/
"text": string;
/**
*
*/
"position": string;
/**
*
*/
"type": string;
/**
*
*/
"image": string;
};
"_imageEffector": {
/**
*
*/
"title": string;
/**
*
*/
"addEffect": string;
"_fxs": {
/**
*
*/
"chromaticAberration": string;
/**
*
*/
"glitch": string;
/**
*
*/
"mirror": string;
/**
*
*/
"invert": string;
/**
*
*/
"grayscale": string;
/**
*
*/
"colorClamp": string;
/**
* ()
*/
"colorClampAdvanced": string;
/**
*
*/
"distort": string;
/**
*
*/
"threshold": string;
/**
*
*/
"zoomLines": string;
};
};
}
declare const locales: {
[lang: string]: Locale;
+34 -4
View File
@@ -1329,10 +1329,9 @@ skip: "スキップ"
restore: "復元"
syncBetweenDevices: "デバイス間で同期"
preferenceSyncConflictTitle: "サーバーに設定値が存在します"
preferenceSyncConflictText: "同期が有効にされた設定項目は設定値をサーバーに保存しますが、この設定項目のサーバーに保存された設定値が見つかりました。どしますか?"
preferenceSyncConflictChoiceMerge: "統合する"
preferenceSyncConflictChoiceServer: "サーバーの設定値で上書き"
preferenceSyncConflictChoiceDevice: "デバイスの設定値で上書き"
preferenceSyncConflictText: "同期が有効にされた設定項目は設定値をサーバーに保存しますが、この設定項目のサーバーに保存された設定値が見つかりました。どちらの設定値で上書きしますか?"
preferenceSyncConflictChoiceServer: "サーバーの設定値"
preferenceSyncConflictChoiceDevice: "デバイスの設定値"
preferenceSyncConflictChoiceCancel: "同期の有効化をキャンセル"
paste: "ペースト"
emojiPalette: "絵文字パレット"
@@ -1365,6 +1364,7 @@ abort: "中止"
tip: "ヒントとコツ"
redisplayAllTips: "全ての「ヒントとコツ」を再表示"
hideAllTips: "全ての「ヒントとコツ」を非表示"
defaultImageCompressionLevel: "デフォルトの画像圧縮度"
_chat:
noMessagesYet: "まだメッセージはありません"
@@ -3218,3 +3218,33 @@ _clip:
_userLists:
tip: "任意のユーザーが含まれるリストを作成できます。作成したリストはタイムラインとして表示可能です。"
watermark: "ウォーターマーク"
defaultPreset: "デフォルトのプリセット"
_watermarkEditor:
tip: "画像にクレジット情報などのウォーターマークを追加することができます。"
title: "ウォーターマークの編集"
cover: "全体に被せる"
repeat: "敷き詰める"
opacity: "不透明度"
scale: "サイズ"
text: "テキスト"
position: "位置"
type: "タイプ"
image: "画像"
_imageEffector:
title: "エフェクト"
addEffect: "エフェクトを追加"
_fxs:
chromaticAberration: "色収差"
glitch: "グリッチ"
mirror: "ミラー"
invert: "色の反転"
grayscale: "白黒"
colorClamp: "色の圧縮"
colorClampAdvanced: "色の圧縮(高度)"
distort: "歪み"
threshold: "二値化"
zoomLines: "集中線"
-116
View File
@@ -298,7 +298,6 @@ uploadFromUrl: "URL 업로드"
uploadFromUrlDescription: "업로드하려는 파일의 URL"
uploadFromUrlRequested: "업로드를 요청했습니다"
uploadFromUrlMayTakeTime: "업로드가 완료될 때까지 시간이 소요될 수 있습니다."
uploadNFiles: "{n}개의 파일을 업로"
explore: "둘러보기"
messageRead: "읽음"
noMoreHistory: "이것보다 과거의 기록이 없습니다"
@@ -327,7 +326,6 @@ dark: "다크"
lightThemes: "밝은 테마"
darkThemes: "어두운 테마"
syncDeviceDarkMode: "디바이스의 다크 모드 설정과 동기화"
switchDarkModeManuallyWhenSyncEnabledConfirm: "'{x}'가 켜져 있습니다. 동기화를 끄고 수동으로 모드를 변경하겠습니까?"
drive: "드라이브"
fileName: "파일명"
selectFile: "파일 선택"
@@ -577,10 +575,8 @@ showFixedPostForm: "타임라인 상단에 글 입력란을 표시"
showFixedPostFormInChannel: "채널 타임라인 상단에 글 입력란을 표시"
withRepliesByDefaultForNewlyFollowed: "팔로우 할 때 기본적으로 답글을 타임라인에 나오게 하기"
newNoteRecived: "새 노트가 있습니다"
newNote: "새로운 노트"
sounds: "소리"
sound: "소리"
notificationSoundSettings: "알림 설정"
listen: "듣기"
none: "없음"
showInPage: "페이지로 보기"
@@ -795,7 +791,6 @@ wide: "넓게"
narrow: "좁게"
reloadToApplySetting: "이 설정을 적용하려면 페이지를 새로고침해야 합니다. 바로 새로고침하시겠습니까?"
needReloadToApply: "변경 사항은 새로고침하면 적용됩니다."
needToRestartServerToApply: "변경 사항은 새로고침이 필요합니다."
showTitlebar: "타이틀 바를 표시하기"
clearCache: "캐시 비우기"
onlineUsersCount: "{n}명이 접속 중"
@@ -1002,7 +997,6 @@ failedToUpload: "업로드 실패"
cannotUploadBecauseInappropriate: "이 파일은 부적절한 내용을 포함한다고 판단되어 업로드할 수 없습니다."
cannotUploadBecauseNoFreeSpace: "드라이브 용량이 부족하여 업로드할 수 없습니다."
cannotUploadBecauseExceedsFileSizeLimit: "파일 크기가 너무 크기 때문에 업로드할 수 없습니다."
cannotUploadBecauseUnallowedFileType: "허가되지 않은 유형의 파일이기에 업로드할 수 없습니다."
beta: "베타"
enableAutoSensitive: "자동 NSFW 탐지"
enableAutoSensitiveDescription: "이용 가능할 경우 기계학습을 통해 자동으로 미디어 NSFW를 설정합니다. 이 기능을 해제하더라도, 서버 정책에 따라 자동으로 설정될 수 있습니다."
@@ -1330,7 +1324,6 @@ restore: "복원"
syncBetweenDevices: "장치간 동기화"
preferenceSyncConflictTitle: "서버에 설정값이 존재합니다."
preferenceSyncConflictText: "동기화를 활성화 한 항목의 설정 값은 서버에 저장되지만, 해당 항목은 이미 서버에 설정 값이 저장되어져 있습니다. 어느 쪽의 설정 값을 덮어씌울까요?"
preferenceSyncConflictChoiceMerge: "병합"
preferenceSyncConflictChoiceServer: "서버 설정값"
preferenceSyncConflictChoiceDevice: "장치 설정값"
preferenceSyncConflictChoiceCancel: "동기화 취소"
@@ -1353,18 +1346,6 @@ goToDeck: "덱으로 돌아가기"
federationJobs: "연합 작업"
driveAboutTip: "드라이브는 이전에 업로드한 파일 목록을 표시해요. <br>\n노트에 첨부할 때 다시 사용하거나 나중에 게시할 파일을 미리 업로드할 수 있어요. <br>\n<b>파일을 삭제하면, 지금까지 그 파일을 사용한 모든 장소(노트, 페이지, 아바타, 배너 등)에서도 보이지 않게 되므로 주의해 주세요. 폴더를 만들고 정리할 수도 있어요.</b><br>"
scrollToClose: "스크롤하여 닫기"
advice: "참고"
realtimeMode: "실시간 모드"
turnItOn: "켜기"
turnItOff: "끄기"
emojiMute: "이모티콘 뮤트"
emojiUnmute: "이모티콘 뮤트 해제"
muteX: "{x}를 뮤트"
unmuteX: "{x}의 뮤트를 해제"
abort: "중지"
tip: "팁과 유용한 정보"
redisplayAllTips: "모든 '팁과 유용한 정보'를 재표시"
hideAllTips: "모든 '팁과 유용한 정보'를 비표시"
_chat:
noMessagesYet: "아직 메시지가 없습니다"
newMessage: "새로운 메시지"
@@ -1398,8 +1379,6 @@ _chat:
chatNotAvailableInOtherAccount: "상대방 계정에서 채팅 기능을 사용할 수 없는 상태입니다."
cannotChatWithTheUser: "이 유저와 채팅을 시작할 수 없습니다"
cannotChatWithTheUser_description: "채팅을 사용할 수 없는 상태이거나 상대방이 채팅을 열지 않은 상태입니다."
youAreNotAMemberOfThisRoomButInvited: "당신은 이 룸의 참가자가 아닙니다만 초대 신청을 받으셨습니다. 참가하려면 초대를 수락해주십시오."
doYouAcceptInvitation: "초대를 수락하시겠습니까?"
chatWithThisUser: "채팅하기"
thisUserAllowsChatOnlyFromFollowers: "이 유저는 팔로워만 채팅을 할 수 있습니다."
thisUserAllowsChatOnlyFromFollowing: "이 유저는 이 유저가 팔로우하는 유저만 채팅을 허용합니다."
@@ -1439,19 +1418,12 @@ _settings:
makeEveryTextElementsSelectable: "모든 텍스트 요소를 선택할 수 있도록 함"
makeEveryTextElementsSelectable_description: "활성화 시, 일부 동작에서 유저의 접근성이 나빠질 수도 있습니다."
useStickyIcons: "아이콘이 스크롤을 따라가도록 하기"
enableHighQualityImagePlaceholders: "고화질 이미지의 플레이스홀더를 표시"
uiAnimations: "UI 애니메이션"
showNavbarSubButtons: "내비게이션 바에 보조 버튼 표시"
ifOn: "켜져 있을 때"
ifOff: "꺼져 있을 때"
enableSyncThemesBetweenDevices: "기기 간 설치한 테마 동기화"
enablePullToRefresh: "계속해서 갱신"
enablePullToRefresh_description: "마우스에서 휠을 누르면서 드래그해요."
realtimeMode_description: "서버에 접속하고 실시간으로 콘텐츠를 업데이트합니다. 데이터 사용량과 배터리의 소비가 증가할 수 있습니다."
contentsUpdateFrequency: "콘텐츠의 업데이트 빈도"
contentsUpdateFrequency_description: "높을수록 실시간으로 콘텐츠가 업데이트됩니다만, 성능이 저하되고 데이터 사용량과 배터리의 소비가 증가합니다."
contentsUpdateFrequency_description2: "실시간 모드가 켜져 있을 때는 이 설정과 상관없이 실시간으로 콘텐츠가 업데이트됩니다."
showUrlPreview: "URL 미리보기 표시"
_chat:
showSenderName: "발신자 이름 표시"
sendOnEnter: "엔터로 보내기"
@@ -1632,21 +1604,6 @@ _serverSettings:
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다."
deliverSuspendedSoftware: "전달 정지 중인 소프트웨어"
deliverSuspendedSoftwareDescription: "취약성 등의 이유로 서버의 소프트웨어 이름 및 버전 범위를 지정하여 전달을 정지할 수 있어요. 이 버전 정보는 서버가 제공한 것이며 신뢰성은 보장되지 않아요. 버전 지정에는 semver의 범위 지정을 사용할 수 있지만, >= 2024.3.1로 지정하면 2024.3.1-custom.0과 같은 custom.0과 같은 custom 버전이 포함되지 않기 때문에 >= 2024.3.1-0과 같이 prerelease를 지정하는 것이 좋아요."
singleUserMode: "1인 모드"
singleUserMode_description: "이 서버의 이용자가 자신 뿐인 경우, 이 모드를 활성화하면 동작이 최적화됩니다."
signToActivityPubGet: "GET 요청에 사인"
signToActivityPubGet_description: "보통의 경우 활성화해 주십시오. 연합의 통신에 관한 문제가 있는 경우, 비활성화하면 개선되는 경우도 있습니다만, 서버에 따라서는 통신이 불가능해지는 경우도 있습니다."
proxyRemoteFiles: "리모트 파일 프록시"
proxyRemoteFiles_description: "활성화하면 리모트 파일을 프록시로 제공합니다. 이미지의 섬네일 생성이나 유저의 개인정보 보호에 도움을 줍니다."
allowExternalApRedirect: "ActivityPub 경유 조회에 리디렉션 허가"
allowExternalApRedirect_description: "활성화하면 다른 서버가 이 서버를 통해 제3자의 콘텐츠를 조회할 수 있습니다만, 콘텐츠의 사칭 문제가 생길 수 있습니다."
userGeneratedContentsVisibilityForVisitor: "비이용자에 대한 유저 작성 콘텐츠의 공개 범위"
userGeneratedContentsVisibilityForVisitor_description: "조정을 하기 힘든 부적절한 리모트 콘텐츠 등이 자신의 서버 경유로 의도치 않게 인터넷에 공개되는 문제의 방지 등에 도움을 줍니다."
userGeneratedContentsVisibilityForVisitor_description2: "서버에서 받은 리모트 콘텐츠를 포함해 서버 내의 모든 콘텐츠를 무조건 인터넷에 공개하는 것에는 위험이 따릅니다. 특히, 분산형 특성에 대해 모르는 열람자에게는 리모트 콘텐츠여도 서버 내에서 작성된 콘텐츠라고 잘못 인식할 수 있기에 주의가 필요합니다."
_userGeneratedContentsVisibilityForVisitor:
all: "모두 공개"
localOnly: "로컬 콘텐츠만 공개하고 리모트 콘텐츠는 비공개"
none: "모두 비공개"
_accountMigration:
moveFrom: "다른 계정에서 이 계정으로 이사"
moveFromSub: "다른 계정에 대한 별칭을 생성"
@@ -1987,9 +1944,6 @@ _role:
canImportMuting: "뮤트 목록 가져오기 허용"
canImportUserLists: "리스트 목록 가져오기 허용"
chatAvailability: "채팅을 허락"
uploadableFileTypes: "업로드 가능한 파일 유형"
uploadableFileTypes_caption: "MIME 유형을 "
uploadableFileTypes_caption2: "파일에 따라서는 유형을 검사하지 못하는 경우가 있습니다. 그러한 파일을 허가하는 경우에는 {x}를 지정으로 추가해주십시오."
_condition:
roleAssignedTo: "수동 역할에 이미 할당됨"
isLocal: "로컬 유저"
@@ -2842,12 +2796,6 @@ _dataSaver:
_avatar:
title: "아이콘 이미지"
description: "아이콘 이미지의 애니메이션을 멈춥니다. 애니메이션 이미지는 일반 이미지보다 파일 크기가 클 수 있으므로 데이터 사용량을 더 줄일 수 있습니다."
_urlPreviewThumbnail:
title: "URL 미리보기의 섬네일을 비표시"
description: "URL 미리보기의 섬네일 이미지를 불러올 수 없게 됩니다."
_disableUrlPreview:
title: "URL 미리보기 비활성화"
description: "URL 미리보기 기능을 비활성화합니다. 섬네일 이미지와 달리 링크 정보 불러오기 자체를 줄일 수 있습니다."
_code:
title: "문자열 강조"
description: "MFM 등으로 문자열 강조 기법을 사용할 때 누르기 전에는 불러오지 않습니다. 문자열 강조에서는 강조할 언어마다 그 정의 파일을 불러와야 하지만 이를 자동으로 불러오지 않으므로 데이터 사용량을 줄일 수 있습니다."
@@ -2905,8 +2853,6 @@ _offlineScreen:
_urlPreviewSetting:
title: "URL 미리보기 설정"
enable: "URL 미리보기 활성화"
allowRedirect: "미리보기 위치의 리디렉션 허가"
allowRedirectDescription: "입력된 URL이 리디렉션될 경우, 그 리디렉션 위치를 따라 미리보기를 표시할 것인지 설정합니다. 비활성화하면 서버 리소스를 절약할 수 있습니다만, 리디렉션 위치의 내용은 표시되지 않습니다."
timeout: "미리보기를 불러올 때의 타임아웃 (ms)"
timeoutDescription: "미리보기를 로딩하는데 걸리는 시간이 정한 시간보다 오래 걸리는 경우, 미리보기를 생성하지 않습니다."
maximumContentLength: "Content-Length의 최대치 (byte)"
@@ -3055,65 +3001,3 @@ _search:
pleaseEnterServerHost: "서버의 호스트를 입력해 주세요."
pleaseSelectUser: "유저를 선택해주세요"
serverHostPlaceholder: "예: misskey.example.com"
_serverSetupWizard:
installCompleted: "Misskey의 설치가 완료됐습니다!"
firstCreateAccount: "먼저 관리자 계정을 만듭시다."
accountCreated: "관리자 계정이 만들어졌습니다!"
serverSetting: "서버 설정"
youCanEasilyConfigureOptimalServerSettingsWithThisWizard: "이 위자드로 쉽게 최적화된 서버의 설정을 할 수 있습니다."
settingsYouMakeHereCanBeChangedLater: "이 설정은 나중에 변경 가능합니다."
howWillYouUseMisskey: "Misskey를 어떻게 사용하십니까?"
_use:
single: "1인 서버"
single_description: "자신 전용 서버로 혼자서 사용"
single_youCanCreateMultipleAccounts: "1인 서버로 운영하는 경우에도 계정은 필요에 따라 여러 개 만들 수 있습니다."
group: "그룹 서버"
group_description: "신뢰 가능한 다른 유저를 초대해 여러 명이 사용"
open: "오픈 서버"
open_description: "불특정 다수의 유저를 받아들이는 운영을 함"
openServerAdvice: "불특정 다수의 유저를 받아들이는 것에는 위험이 따릅니다. 문제에 대처할 수 있도록 확실한 조정 체제로 운영하는 것을 권장합니다."
openServerAntiSpamAdvice: "자신의 서버가 스팸으로 사용되지 않게끔 reCAPTCHA라는 안티 봇 기능을 활성화하는 등 보안에 대해서도 세심한 주의가 필요합니다."
howManyUsersDoYouExpect: "어느 정도의 인원으로 생각 중이십니까?"
_scale:
small: "100명 이하(소규모)"
medium: "100명 이상 1000명 이하(중간 규모)"
large: "1000명 이상(대규모)"
largeScaleServerAdvice: "대규모 서버에서는 부하분산이나 데이터베이스의 레플리케이션 등 높은 인프라스트럭처 지식이 필요할 수 있습니다."
doYouConnectToFediverse: "Fediverse에 접속하시겠습니까?"
doYouConnectToFediverse_description1: "분산형 서버로 구성된 네트워크(Fediverse)에 접속하면 다른 서버와 서로 콘텐츠의 주고받기를 할 수 있습니다."
doYouConnectToFediverse_description2: "Fediverse에 접속하는 것을 '연합'이라고도 부릅니다."
youCanConfigureMoreFederationSettingsLater: "나중에 연합 가능한 서버의 지정 등 고급 설정을 할 수 있습니다."
adminInfo: "관리자 정보"
adminInfo_description: "문의 접수를 위해 사용되는 관리자 정보를 설정합니다."
adminInfo_mustBeFilled: "오픈 서버 혹은 연합이 켜져 있는 경우 반드시 입력해야 합니다."
followingSettingsAreRecommended: "아래의 설정이 권장됩니다."
applyTheseSettings: "이 설정을 적용"
skipSettings: "설정 건너뛰기"
settingsCompleted: "설정이 완료됐습니다!"
settingsCompleted_description: "수고하셨습니다. 준비를 마쳤으므로 바로 서버의 이용을 시작하실 수 있습니다."
settingsCompleted_description2: "상세한 서버 설정은 '제어판'에서 하실 수 있습니다."
donationRequest: "기부 요청"
_donationRequest:
text1: "Misskey는 자원봉사자들에 의해 개발되는 무료 소프트웨어입니다."
text2: "앞으로도 계속해서 개발을 할 수 있도록 괜찮으시다면 부디 기부를 부탁드립니다."
text3: "지원자 대상 특전도 있습니다!"
_uploader:
compressedToX: "{x}로 압축"
savedXPercent: "{x}% 절약"
abortConfirm: "업로드되지 않은 파일이 있습니다만, 그만 두시겠습니까?"
doneConfirm: "업로드되지 않은 파일이 있습니다만, 완료하시겠습니까?"
maxFileSizeIsX: "업오드 가능한 최대 파일 크기는 {x}입니다."
allowedTypes: "업로드 가능한 파일 유형"
tip: "파일은 아직 업로드되지 않았습니다. 이 다이얼로그에서 업로드 전의 확인, 이름 바꾸기, 압축, 자르기 등을 하실 수 있습니다. 준비가 되셨다면 '업로드' 버튼을 클릭해 업로드를 시작하실 수 있습니다."
_clientPerformanceIssueTip:
title: "배터리 소비가 심하다고 생각되시면"
makeSureDisabledAdBlocker: "광고 차단을 비활성화해 주십시오."
makeSureDisabledAdBlocker_description: "광고 차단은 성능에 영향을 미칠 수 있습니다. OS의 기능이나 브라우저의 기능, 애드온 등으로 광고 차단이 활성화돼있지 않은지 확인해 주십시오."
makeSureDisabledCustomCss: "커스텀 CSS를 무효로 해주십시오."
makeSureDisabledCustomCss_description: "스타일을 덮어쓰기하면 성능에 영향을 미칠 수 있습니다. 커스텀 CSS나 스타일을 덮어쓰기하는 확장 기능이 유효로 돼있는지 확인해주십시오."
makeSureDisabledAddons: "확장 기능을 비활성화해 주십시오."
makeSureDisabledAddons_description: "일부 확장 기능은 클라이언트의 동작에 간섭해 성능에 영향을 미칠 수 있습니다. 브라우저의 확장 기능을 비활성화해 개선할지 확인해주십시오."
_clip:
tip: "클립은 노트를 정리할 수 있는 기능입니다."
_userLists:
tip: "임의의 유저가 포함된 리스트를 작성할 수 있습니다. 작성한 리스트는 타임라인으로 표시가 가능합니다."
-12
View File
@@ -327,7 +327,6 @@ dark: "深色"
lightThemes: "浅色主题"
darkThemes: "深色主题"
syncDeviceDarkMode: "将深色模式与设备设置同步"
switchDarkModeManuallyWhenSyncEnabledConfirm: "「{x}」已开启。要关闭同步并手动切换模式吗?"
drive: "网盘"
fileName: "文件名称"
selectFile: "选择文件"
@@ -1330,7 +1329,6 @@ restore: "恢复"
syncBetweenDevices: "设备间同步"
preferenceSyncConflictTitle: "服务器上已存在设定值"
preferenceSyncConflictText: "服务器上已有此设置的设定值。要覆盖哪个设定值?"
preferenceSyncConflictChoiceMerge: "合并"
preferenceSyncConflictChoiceServer: "服务器上的设定值"
preferenceSyncConflictChoiceDevice: "设备上的设定值"
preferenceSyncConflictChoiceCancel: "取消同步"
@@ -1362,9 +1360,6 @@ emojiUnmute: "解除隐藏表情符号"
muteX: "隐藏{x}"
unmuteX: "解除隐藏{x}"
abort: "中止"
tip: "提示和技巧"
redisplayAllTips: "重新显示所有的提示和技巧"
hideAllTips: "隐藏所有的提示和技巧"
_chat:
noMessagesYet: "还没有消息"
newMessage: "新消息"
@@ -2905,8 +2900,6 @@ _offlineScreen:
_urlPreviewSetting:
title: "设置 URL 预览"
enable: "启用 URL 预览"
allowRedirect: "允许预览目标的重定向"
allowRedirectDescription: "如果输入的 URL 被重定向,可设置是否跟随重定向目标并显示预览。禁用此选项将节省服务器资源,但重定向目标的内容将不会显示。"
timeout: "超时阈值(ms"
timeoutDescription: "如果获取预览所用时间超过这个值,则不生成预览。"
maximumContentLength: "Content-Length 的最大值(byte"
@@ -3104,7 +3097,6 @@ _uploader:
doneConfirm: "还有未上传的文件,要完成吗?"
maxFileSizeIsX: "可上传最大 {x} 的文件。"
allowedTypes: "可上传的文件类型"
tip: "文件还没有被上传。可在此对话框中进行上传前确认、重命名、压缩、裁剪等操作。准备完成后,点击「上传」即可开始上传。"
_clientPerformanceIssueTip:
title: "如果觉得电池耗电过高"
makeSureDisabledAdBlocker: "请关闭广告拦截器"
@@ -3113,7 +3105,3 @@ _clientPerformanceIssueTip:
makeSureDisabledCustomCss_description: "覆盖样式可能会影响性能。请确保没有启用任何自定义 CSS 或覆盖样式的扩展。"
makeSureDisabledAddons: "请关闭扩展"
makeSureDisabledAddons_description: "某些扩展可能会干扰客户端的运行并影响性能。尝试禁用浏览器扩展并查看是否有改善。"
_clip:
tip: "便签功能可以将帖子合并在一起。"
_userLists:
tip: "可创建包含任意用户的列表。已创建的列表可作为时间线查看。"
-2
View File
@@ -327,7 +327,6 @@ dark: "深色"
lightThemes: "淺色佈景主題"
darkThemes: "深色佈景主題"
syncDeviceDarkMode: "與裝置的深色模式同步"
switchDarkModeManuallyWhenSyncEnabledConfirm: "「{x}」已開啟。要關閉同步並手動切換模式嗎?\n"
drive: "雲端硬碟"
fileName: "檔案名稱"
selectFile: "選擇檔案"
@@ -1330,7 +1329,6 @@ restore: "還原"
syncBetweenDevices: "裝置之間的同步化"
preferenceSyncConflictTitle: "伺服器上存在設定值"
preferenceSyncConflictText: "已啟用同步的設定項目會將設定值儲存至伺服器,並已找到該設定項目在伺服器上儲存的設定值。請選擇要使用哪個設定值進行覆寫。"
preferenceSyncConflictChoiceMerge: "合併至"
preferenceSyncConflictChoiceServer: "伺服器設定值"
preferenceSyncConflictChoiceDevice: "裝置的設定值"
preferenceSyncConflictChoiceCancel: "取消啟用同步"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2025.6.0-beta.1",
"version": "2025.5.1-beta.4",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -28,7 +28,7 @@ export const paramDef = {
type: 'object',
properties: {
queue: { type: 'string', enum: QUEUE_TYPES },
state: { type: 'array', items: { type: 'string', enum: ['active', 'wait', 'delayed', 'completed', 'failed', 'paused'] } },
state: { type: 'array', items: { type: 'string', enum: ['active', 'wait', 'delayed', 'completed', 'failed'] } },
search: { type: 'string' },
},
required: ['queue', 'state'],
@@ -116,10 +116,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private apiLoggerService: ApiLoggerService,
) {
super(meta, paramDef, async (ps, me, _1, _2, _3, ip) => {
// ログイン時にusers/showできなくなってしまう
//if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) {
// throw new ApiError(meta.errors.noSuchUser);
//}
if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) {
throw new ApiError(meta.errors.noSuchUser);
}
let user;
Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

@@ -282,8 +282,8 @@ function onContextmenu(ev: MouseEvent) {
menu = [{
text: i18n.ts.openInWindow,
icon: 'ti ti-app-window',
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkDriveWindow.vue').then(x => x.default), {
action: () => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), {
initialFolder: props.folder,
}, {
closed: () => dispose(),
+1 -1
View File
@@ -113,7 +113,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="filesPaginator.items.value.length == 0 && foldersPaginator.items.value.length == 0 && !fetching" :class="$style.empty">
<div v-if="draghover">{{ i18n.ts['empty-draghover'] }}</div>
<div v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong></div>
<div v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.ts['empty-drive-description'] }}</div>
<div v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</div>
</div>
</div>
@@ -41,7 +41,7 @@ const emit = defineEmits<{
(_: 'closed'): void
}>();
const zIndex = claimZIndex('low');
const zIndex = claimZIndex('middle');
const showing = ref(true);
function closePage() {
@@ -0,0 +1,70 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkFolder :defaultOpen="true" :canPage="false">
<template #label>{{ fx.name }}</template>
<template #footer>
<MkButton @click="emit('del')">{{ i18n.ts.remove }}</MkButton>
</template>
<div :class="$style.root" class="_gaps">
<div v-for="[k, v] in Object.entries(fx.params)" :key="k">
<MkSwitch v-if="v.type === 'boolean'" v-model="layer.params[k]">
<template #label>{{ k }}</template>
</MkSwitch>
<MkRange v-else-if="v.type === 'number'" v-model="layer.params[k]" continuousUpdate :min="v.min" :max="v.max" :step="v.step">
<template #label>{{ k }}</template>
</MkRange>
<MkRadios v-else-if="v.type === 'number:enum'" v-model="layer.params[k]">
<template #label>{{ k }}</template>
<option v-for="item in v.enum" :value="item.value">{{ item.label }}</option>
</MkRadios>
<div v-else-if="v.type === 'seed'">
<MkRange v-model="layer.params[k]" continuousUpdate type="number" :min="0" :max="10000" :step="1">
<template #label>{{ k }}</template>
</MkRange>
</div>
</div>
</div>
</MkFolder>
</template>
<script setup lang="ts">
import { ref, useTemplateRef, watch, onMounted, onUnmounted } from 'vue';
import { v4 as uuid } from 'uuid';
import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
import { i18n } from '@/i18n.js';
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
import MkFolder from '@/components/MkFolder.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRange from '@/components/MkRange.vue';
import FormSlot from '@/components/form/slot.vue';
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';
import { FXS } from '@/utility/image-effector/fxs.js';
const layer = defineModel<ImageEffectorLayer>('layer', { required: true });
const fx = FXS.find((fx) => fx.id === layer.value.fxId);
if (fx == null) {
throw new Error(`Unrecognized effect: ${layer.value.fxId}`);
}
const emit = defineEmits<{
(e: 'del'): void;
}>();
</script>
<style module>
.root {
}
</style>
@@ -0,0 +1,247 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="1000"
:height="600"
:scroll="false"
:withOkButton="true"
@close="cancel()"
@ok="save()"
@closed="emit('closed')"
>
<template #header><i class="ti ti-sparkles"></i> {{ i18n.ts._imageEffector.title }}</template>
<div :class="$style.root">
<div :class="$style.container">
<div :class="$style.preview">
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
<div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
<div class="_acrylic" :class="$style.previewControls">
<button class="_button" :class="[$style.previewControlsButton, !enabled ? $style.active : null]" @click="enabled = false">Before</button>
<button class="_button" :class="[$style.previewControlsButton, enabled ? $style.active : null]" @click="enabled = true">After</button>
</div>
</div>
</div>
<div :class="$style.controls">
<div class="_spacer _gaps">
<XLayer
v-for="(layer, i) in layers"
:key="layer.id"
v-model:layer="layers[i]"
@del="onLayerDelete(layer)"
></XLayer>
<MkButton rounded primary style="margin: 0 auto;" @click="addEffect"><i class="ti ti-plus"></i> {{ i18n.ts._imageEffector.addEffect }}</MkButton>
</div>
</div>
</div>
</div>
</MkModalWindow>
</template>
<script setup lang="ts">
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive } from 'vue';
import { v4 as uuid } from 'uuid';
import type { WatermarkPreset } from '@/utility/watermark.js';
import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
import { i18n } from '@/i18n.js';
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import XLayer from '@/components/MkImageEffectorDialog.Layer.vue';
import * as os from '@/os.js';
import { deepClone } from '@/utility/clone.js';
import { FXS } from '@/utility/image-effector/fxs.js';
const props = defineProps<{
image: HTMLImageElement;
}>();
const emit = defineEmits<{
(ev: 'ok', image: File): void;
(ev: 'cancel'): void;
(ev: 'closed'): void;
}>();
const dialog = useTemplateRef('dialog');
function cancel() {
emit('cancel');
dialog.value?.close();
}
const layers = reactive<ImageEffectorLayer[]>([]);
watch(layers, async () => {
if (renderer != null) {
renderer.updateLayers(layers);
}
}, { deep: true });
function addEffect(ev: MouseEvent) {
os.popupMenu(FXS.filter(fx => fx.id !== 'watermarkPlacement').map((fx) => ({
text: fx.name,
action: () => {
layers.push({
id: uuid(),
fxId: fx.id,
params: Object.fromEntries(Object.entries(fx.params).map(([k, v]) => [k, v.default])),
});
},
})), ev.currentTarget ?? ev.target);
}
function onLayerDelete(layer: ImageEffectorLayer) {
const index = layers.indexOf(layer);
if (index !== -1) {
layers.splice(index, 1);
}
}
const canvasEl = useTemplateRef('canvasEl');
let renderer: ImageEffector | null = null;
onMounted(async () => {
renderer = new ImageEffector({
canvas: canvasEl.value,
width: props.image.width,
height: props.image.height,
layers: layers,
originalImage: props.image,
fxs: FXS,
});
await renderer!.bakeTextures();
renderer!.render();
});
onUnmounted(() => {
if (renderer != null) {
renderer.destroy();
renderer = null;
}
});
function save() {
if (layers.length === 0) {
cancel();
return;
}
renderer!.render(); // toBlob
canvasEl.value!.toBlob((blob) => {
emit('ok', new File([blob!], `image-${Date.now()}.png`, { type: 'image/png' }));
dialog.value?.close();
}, 'image/png');
}
const enabled = ref(true);
watch(enabled, () => {
if (renderer != null) {
if (enabled.value) {
renderer.updateLayers(layers);
} else {
renderer.updateLayers([]);
}
renderer.render();
}
});
</script>
<style module>
.root {
container-type: inline-size;
height: 100%;
}
.container {
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.preview {
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);
}
.previewContainer {
display: flex;
flex-direction: column;
height: 100%;
user-select: none;
-webkit-user-drag: none;
}
.previewTitle {
position: absolute;
z-index: 100;
top: 8px;
left: 8px;
padding: 6px 10px;
border-radius: 6px;
font-size: 85%;
}
.previewControls {
position: absolute;
z-index: 100;
bottom: 8px;
right: 8px;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
}
.previewControlsButton {
&.active {
color: var(--MI_THEME-accent);
}
}
.previewSpinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.previewCanvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 20px;
box-sizing: border-box;
object-fit: contain;
}
.controls {
overflow-y: scroll;
}
@container (max-width: 800px) {
.container {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>
@@ -0,0 +1,53 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[$style.root]">
<div :class="$style.items">
<button class="_button" :class="[$style.item, x === 'left' && y === 'top' ? $style.active : null]" @click="() => { x = 'left'; y = 'top'; }"><i class="ti ti-align-box-left-top"></i></button>
<button class="_button" :class="[$style.item, x === 'center' && y === 'top' ? $style.active : null]" @click="() => { x = 'center'; y = 'top'; }"><i class="ti ti-align-box-center-top"></i></button>
<button class="_button" :class="[$style.item, x === 'right' && y === 'top' ? $style.active : null]" @click="() => { x = 'right'; y = 'top'; }"><i class="ti ti-align-box-right-top"></i></button>
<button class="_button" :class="[$style.item, x === 'left' && y === 'center' ? $style.active : null]" @click="() => { x = 'left'; y = 'center'; }"><i class="ti ti-align-box-left-middle"></i></button>
<button class="_button" :class="[$style.item, x === 'center' && y === 'center' ? $style.active : null]" @click="() => { x = 'center'; y = 'center'; }"><i class="ti ti-align-box-center-middle"></i></button>
<button class="_button" :class="[$style.item, x === 'right' && y === 'center' ? $style.active : null]" @click="() => { x = 'right'; y = 'center'; }"><i class="ti ti-align-box-right-middle"></i></button>
<button class="_button" :class="[$style.item, x === 'left' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'left'; y = 'bottom'; }"><i class="ti ti-align-box-left-bottom"></i></button>
<button class="_button" :class="[$style.item, x === 'center' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'center'; y = 'bottom'; }"><i class="ti ti-align-box-center-bottom"></i></button>
<button class="_button" :class="[$style.item, x === 'right' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'right'; y = 'bottom'; }"><i class="ti ti-align-box-right-bottom"></i></button>
</div>
</div>
</template>
<script lang="ts" setup>
import { } from 'vue';
const x = defineModel<string>('x', { default: 'center' });
const y = defineModel<string>('y', { default: 'center' });
</script>
<style lang="scss" module>
.root {
position: relative;
}
.items {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 4px;
border-radius: 8px;
overflow: clip;
}
.item {
height: 32px;
background: var(--MI_THEME-panel);
border-radius: 4px;
&.active {
background: var(--MI_THEME-accentedBg);
color: var(--MI_THEME-accent);
}
}
</style>
@@ -126,7 +126,7 @@ async function rename(file) {
async function describe(file: Misskey.entities.DriveFile) {
if (mock) return;
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkFileCaptionEditWindow.vue').then(x => x.default), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
default: file.comment !== null ? file.comment : '',
file: file,
}, {
@@ -168,11 +168,9 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
menuItems.push({
text: i18n.ts.preview,
icon: 'ti ti-photo-search',
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImgPreviewDialog.vue').then(x => x.default), {
action: () => {
os.popup(defineAsyncComponent(() => import('@/components/MkImgPreviewDialog.vue')), {
file: file,
}, {
closed: () => dispose(),
});
},
});
+57 -11
View File
@@ -12,7 +12,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<slot name="prefix"></slot>
<div ref="containerEl" class="container">
<div class="track">
<div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div>
<div class="highlight right" :style="{ width: ((steppedRawValue - minRatio) * 100) + '%', left: (Math.abs(Math.min(0, min)) / (max + Math.abs(Math.min(0, min)))) * 100 + '%' }">
<div class="shine right"></div>
</div>
<div class="highlight left" :style="{ width: ((minRatio - steppedRawValue) * 100) + '%', left: (steppedRawValue) * 100 + '%' }">
<div class="shine left"></div>
</div>
</div>
<div v-if="steps && showTicks" class="ticks">
<div v-for="i in (steps + 1)" class="tick" :style="{ left: (((i - 1) / steps) * 100) + '%' }"></div>
@@ -24,7 +29,9 @@ SPDX-License-Identifier: AGPL-3.0-only
@mouseenter.passive="onMouseenter"
@mousedown="onMousedown"
@touchstart="onMousedown"
></div>
>
<div class="thumbInner"></div>
</div>
</div>
<slot name="suffix"></slot>
</div>
@@ -63,6 +70,9 @@ const emit = defineEmits<{
const containerEl = useTemplateRef('containerEl');
const thumbEl = useTemplateRef('thumbEl');
const maxRatio = computed(() => Math.abs(props.max) / (props.max + Math.abs(Math.min(0, props.min))));
const minRatio = computed(() => Math.abs(Math.min(0, props.min)) / (props.max + Math.abs(Math.min(0, props.min))));
const rawValue = ref((props.modelValue - props.min) / (props.max - props.min));
const steppedRawValue = computed(() => {
if (props.step) {
@@ -222,15 +232,17 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
}
}
$thumbHeight: 20px;
$thumbWidth: 20px;
$thumbHeight: 32px;
$thumbWidth: 32px;
$thumbInnerHeight: 19px;
$thumbInnerWidth: 19px;
> .body {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 7px 12px;
padding: 0px 4px;
background: var(--MI_THEME-panel);
border: solid 1px var(--MI_THEME-panel);
border-radius: 6px;
@@ -256,10 +268,30 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
> .highlight {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: var(--MI_THEME-accent);
opacity: 0.5;
background: color(from var(--MI_THEME-buttonGradateA) srgb r g b / 0.5);
overflow: clip;
> .shine {
position: absolute;
top: 0;
width: 64px;
height: 100%;
}
}
> .highlight.right {
> .shine.right {
right: calc(#{$thumbInnerWidth} / 2);
background: linear-gradient(-90deg, var(--MI_THEME-buttonGradateB), color(from var(--MI_THEME-buttonGradateA) srgb r g b / 0));
}
}
> .highlight.left {
> .shine.left {
left: calc(#{$thumbInnerWidth} / 2);
background: linear-gradient(90deg, var(--MI_THEME-buttonGradateB), color(from var(--MI_THEME-buttonGradateA) srgb r g b / 0));
}
}
}
@@ -290,11 +322,25 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
width: $thumbWidth;
height: $thumbHeight;
cursor: grab;
background: var(--MI_THEME-accent);
border-radius: 999px;
&:hover {
background: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
> .thumbInner {
background: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
}
}
> .thumbInner {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
width: $thumbInnerWidth;
height: $thumbInnerHeight;
background: var(--MI_THEME-accent);
border-radius: 999px;
pointer-events: none;
}
}
}
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="ctx in items"
:key="ctx.id"
v-panel
:class="[$style.item, ctx.waiting ? $style.itemWaiting : null, ctx.uploaded ? $style.itemCompleted : null, ctx.uploadFailed ? $style.itemFailed : null]"
:class="[$style.item, ctx.preprocessing ? $style.itemWaiting : null, ctx.uploaded ? $style.itemCompleted : null, ctx.uploadFailed ? $style.itemFailed : null]"
:style="{ '--p': ctx.progress != null ? `${ctx.progress.value / ctx.progress.max * 100}%` : '0%' }"
>
<div :class="$style.itemInner">
@@ -40,8 +40,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div><MkCondensedLine :minScale="2 / 3">{{ ctx.name }}</MkCondensedLine></div>
<div :class="$style.itemInfo">
<span>{{ ctx.file.type }}</span>
<span>{{ bytes(ctx.file.size) }}</span>
<span v-if="ctx.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(ctx.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - ctx.compressedSize / ctx.file.size) * 100) }) }})</span>
<span v-else>{{ bytes(ctx.file.size) }}</span>
</div>
<div>
</div>
@@ -59,19 +59,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton style="margin: auto;" :iconOnly="true" rounded @click="chooseFile($event)"><i class="ti ti-plus"></i></MkButton>
</div>
<MkSelect
v-if="items.length > 0"
v-model="compressionLevel"
:items="[
{ value: 0, label: i18n.ts.none },
{ value: 1, label: i18n.ts.low },
{ value: 2, label: i18n.ts.middle },
{ value: 3, label: i18n.ts.high },
]"
>
<template #label>{{ i18n.ts.compress }}</template>
</MkSelect>
<div>{{ i18n.tsx._uploader.maxFileSizeIsX({ x: $i.policies.maxFileSizeMb + 'MB' }) }}</div>
<!-- クライアントで検出するMIME typeとサーバーで検出するMIME typeが異なる場合があり混乱の元になるのでとりあえず隠しとく -->
@@ -93,7 +80,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, markRaw, onMounted, ref, useTemplateRef, watch } from 'vue';
import { computed, defineAsyncComponent, markRaw, onMounted, onUnmounted, ref, triggerRef, useTemplateRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { v4 as uuid } from 'uuid';
import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
@@ -109,6 +96,9 @@ import { isWebpSupported } from '@/utility/isWebpSupported.js';
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
import * as os from '@/os.js';
import { ensureSignin } from '@/i.js';
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
import { makeImageEffectorLayers } from '@/utility/watermark.js';
import { FX_watermarkPlacement } from '@/utility/image-effector/fxs/watermarkPlacement.js';
const $i = ensureSignin();
@@ -125,6 +115,14 @@ const CROPPING_SUPPORTED_TYPES = [
'image/webp',
];
const IMAGE_EDITING_SUPPORTED_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
];
const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES;
const mimeTypeMap = {
'image/webp': 'webp',
'image/jpeg': 'jpg',
@@ -148,16 +146,19 @@ const emit = defineEmits<{
const items = ref<{
id: string;
name: string;
uploadName?: string;
progress: { max: number; value: number } | null;
thumbnail: string;
waiting: boolean;
preprocessing: boolean;
uploading: boolean;
uploaded: Misskey.entities.DriveFile | null;
uploadFailed: boolean;
aborted: boolean;
compressionLevel: number;
compressedSize?: number | null;
compressedImage?: Blob | null;
preprocessedFile?: Blob | null;
file: File;
watermarkPresetId: string | null;
abort?: (() => void) | null;
}[]>([]);
@@ -165,7 +166,7 @@ const dialog = useTemplateRef('dialog');
const firstUploadAttempted = ref(false);
const isUploading = computed(() => items.value.some(item => item.uploading));
const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.waiting) && items.value.some(item => item.uploaded == null));
const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.preprocessing) && items.value.some(item => item.uploaded == null));
const canDone = computed(() => items.value.some(item => item.uploaded != null));
const overallProgress = computed(() => {
const max = items.value.length;
@@ -178,19 +179,18 @@ const overallProgress = computed(() => {
return Math.round((v / max) * 100);
});
const compressionLevel = ref<0 | 1 | 2 | 3>(2);
const compressionSettings = computed(() => {
if (compressionLevel.value === 1) {
function getCompressionSettings(level: 0 | 1 | 2 | 3) {
if (level === 1) {
return {
maxWidth: 2000,
maxHeight: 2000,
};
} else if (compressionLevel.value === 2) {
} else if (level === 2) {
return {
maxWidth: 2000 * 0.75, // =1500
maxHeight: 2000 * 0.75, // =1500
};
} else if (compressionLevel.value === 3) {
} else if (level === 3) {
return {
maxWidth: 2000 * 0.75 * 0.75, // =1125
maxHeight: 2000 * 0.75 * 0.75, // =1125
@@ -198,7 +198,7 @@ const compressionSettings = computed(() => {
} else {
return null;
}
});
}
watch(items, () => {
if (items.value.length === 0) {
@@ -274,31 +274,138 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
},
});
if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.waiting && !item.uploading && !item.uploaded) {
if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !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, {
URL.revokeObjectURL(item.thumbnail);
const newItem = {
...item,
file: markRaw(cropped),
thumbnail: window.URL.createObjectURL(cropped),
};
items.value.splice(items.value.indexOf(item), 1, newItem);
preprocess(newItem).then(() => {
triggerRef(items);
});
},
});
}
if (!item.waiting && !item.uploading && !item.uploaded) {
if (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,
action: async () => {
const img = await getImageElement(item.file);
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkImageEffectorDialog.vue')), {
image: img,
}, {
ok: (file) => {
URL.revokeObjectURL(item.thumbnail);
const newItem = {
...item,
file: markRaw(file),
thumbnail: window.URL.createObjectURL(file),
};
items.value.splice(items.value.indexOf(item), 1, newItem);
preprocess(newItem).then(() => {
triggerRef(items);
});
},
closed: () => {
URL.revokeObjectURL(img.src);
dispose();
},
});
},
});
}
if (WATERMARK_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
function changeWatermarkPreset(presetId: string | null) {
item.watermarkPresetId = presetId;
preprocess(item).then(() => {
triggerRef(items);
});
}
menu.push({
icon: 'ti ti-copyright',
text: i18n.ts.watermark,
type: 'parent',
children: [{
type: 'radioOption',
text: i18n.ts.none,
active: computed(() => item.watermarkPresetId == null),
action: () => changeWatermarkPreset(null),
}, {
type: 'divider',
}, ...prefer.s.watermarkPresets.map(preset => ({
type: 'radioOption',
text: preset.name,
active: computed(() => item.watermarkPresetId === preset.id),
action: () => changeWatermarkPreset(preset.id),
}))],
});
}
if (COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
function changeCompressionLevel(level: 0 | 1 | 2 | 3) {
item.compressionLevel = level;
preprocess(item).then(() => {
triggerRef(items);
});
}
menu.push({
icon: 'ti ti-leaf',
text: i18n.ts.compress,
type: 'parent',
children: [{
type: 'radioOption',
text: i18n.ts.none,
active: computed(() => item.compressionLevel === 0 || item.compressionLevel == null),
action: () => changeCompressionLevel(0),
}, {
type: 'divider',
}, {
type: 'radioOption',
text: i18n.ts.low,
active: computed(() => item.compressionLevel === 1),
action: () => changeCompressionLevel(1),
}, {
type: 'radioOption',
text: i18n.ts.medium,
active: computed(() => item.compressionLevel === 2),
action: () => changeCompressionLevel(2),
}, {
type: 'radioOption',
text: i18n.ts.high,
active: computed(() => item.compressionLevel === 3),
action: () => changeCompressionLevel(3),
},
],
});
}
if (!item.preprocessing && !item.uploading && !item.uploaded) {
menu.push({
type: 'divider',
}, {
icon: 'ti ti-x',
text: i18n.ts.remove,
action: () => {
URL.revokeObjectURL(item.thumbnail);
items.value.splice(items.value.indexOf(item), 1);
},
});
} else if (item.uploading) {
menu.push({
type: 'divider',
}, {
icon: 'ti ti-cloud-pause',
text: i18n.ts.abort,
danger: true,
@@ -320,7 +427,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ
...item,
aborted: false,
uploadFailed: false,
waiting: false,
uploading: false,
}));
@@ -330,40 +436,13 @@ async function upload() { // エラーハンドリングなどを考慮してシ
continue;
}
item.waiting = true;
item.uploadFailed = false;
const shouldCompress = item.compressedImage == null && compressionLevel.value !== 0 && compressionSettings.value && COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !(await isAnimated(item.file));
if (shouldCompress) {
const config = {
mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg',
maxWidth: compressionSettings.value.maxWidth,
maxHeight: compressionSettings.value.maxHeight,
quality: isWebpSupported() ? 0.85 : 0.8,
};
try {
const result = await readAndCompressImage(item.file, config);
if (result.size < item.file.size || item.file.type === 'image/webp') {
// The compression may not always reduce the file size
// (and WebP is not browser safe yet)
item.compressedImage = markRaw(result);
item.compressedSize = result.size;
item.name = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name;
}
} catch (err) {
console.error('Failed to resize image', err);
}
}
item.uploading = true;
const { filePromise, abort } = uploadFile(item.compressedImage ?? item.file, {
name: item.name,
const { filePromise, abort } = uploadFile(item.preprocessedFile ?? item.file, {
name: item.uploadName ?? item.name,
folderId: props.folderId,
onProgress: (progress) => {
item.waiting = false;
if (item.progress == null) {
item.progress = { max: progress.total, value: progress.loaded };
} else {
@@ -377,7 +456,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ
item.abort = null;
abort();
item.uploading = false;
item.waiting = false;
item.uploadFailed = true;
};
@@ -392,7 +470,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ
}
}).finally(() => {
item.uploading = false;
item.waiting = false;
});
}
}
@@ -419,21 +496,112 @@ async function chooseFile(ev: MouseEvent) {
}
}
function getImageElement(file: Blob | File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve(img);
};
img.onerror = (error) => {
reject(error);
};
img.src = URL.createObjectURL(file);
});
}
async function preprocess(item: (typeof items)['value'][number]): Promise<void> {
item.preprocessing = true;
let file: Blob | File = item.file;
const img = await getImageElement(file);
const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(file.type);
const preset = prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId);
if (needsWatermark && preset != null) {
const canvas = window.document.createElement('canvas');
const renderer = new ImageEffector({
canvas: canvas,
width: img.width,
height: img.height,
layers: makeImageEffectorLayers(preset.layers),
originalImage: img,
fxs: [FX_watermarkPlacement],
});
await renderer.bakeTextures();
renderer.render();
file = await new Promise<Blob>((resolve) => {
canvas.toBlob((blob) => {
if (blob == null) {
throw new Error('Failed to convert canvas to blob');
}
resolve(blob);
}, 'image/png');
});
}
const compressionSettings = getCompressionSettings(item.compressionLevel);
const needsCompress = item.compressionLevel !== 0 && compressionSettings && COMPRESSION_SUPPORTED_TYPES.includes(file.type) && !(await isAnimated(file));
if (needsCompress) {
const config = {
mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg',
maxWidth: compressionSettings.maxWidth,
maxHeight: compressionSettings.maxHeight,
quality: isWebpSupported() ? 0.85 : 0.8,
};
try {
const result = await readAndCompressImage(file, config);
if (result.size < file.size || file.type === 'image/webp') {
// The compression may not always reduce the file size
// (and WebP is not browser safe yet)
file = result;
item.compressedSize = result.size;
item.uploadName = file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name;
}
} catch (err) {
console.error('Failed to resize image', err);
}
} else {
item.compressedSize = null;
item.uploadName = item.name;
}
URL.revokeObjectURL(item.thumbnail);
item.thumbnail = window.URL.createObjectURL(file);
item.preprocessedFile = markRaw(file);
item.preprocessing = false;
URL.revokeObjectURL(img.src);
}
function initializeFile(file: File) {
const id = uuid();
const filename = file.name ?? 'untitled';
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
items.value.push({
const item = {
id,
name: prefer.s.keepOriginalFilename ? filename : id + extension,
progress: null,
thumbnail: window.URL.createObjectURL(file),
waiting: false,
preprocessing: false,
uploading: false,
aborted: false,
uploaded: null,
uploadFailed: false,
compressionLevel: prefer.s.defaultImageCompressionLevel,
watermarkPresetId: prefer.s.defaultWatermarkPresetId,
file: markRaw(file),
};
items.value.push(item);
preprocess(item).then(() => {
triggerRef(items);
});
}
@@ -442,6 +610,12 @@ onMounted(() => {
initializeFile(file);
}
});
onUnmounted(() => {
for (const item of items.value) {
URL.revokeObjectURL(item.thumbnail);
}
});
</script>
<style lang="scss" module>
@@ -174,8 +174,8 @@ function setupComplete() {
function launchTutorial() {
setupComplete();
nextTick(async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkTutorialDialog.vue').then(x => x.default), {
nextTick(() => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {
initialPage: 1,
}, {
closed: () => dispose(),
@@ -0,0 +1,147 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root" class="_gaps">
<template v-if="layer.type === 'text'">
<MkInput v-model="layer.text">
<template #label>{{ i18n.ts._watermarkEditor.text }}</template>
</MkInput>
<FormSlot>
<template #label>{{ i18n.ts._watermarkEditor.position }}</template>
<MkPositionSelector
v-model:x="layer.align.x"
v-model:y="layer.align.y"
></MkPositionSelector>
</FormSlot>
<MkRange
v-model="layer.scale"
:min="0"
:max="1"
:step="0.01"
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
continuousUpdate
>
<template #label>{{ i18n.ts._watermarkEditor.scale }}</template>
</MkRange>
<MkRange
v-model="layer.opacity"
:min="0"
:max="1"
:step="0.01"
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
continuousUpdate
>
<template #label>{{ i18n.ts._watermarkEditor.opacity }}</template>
</MkRange>
<MkSwitch v-model="layer.repeat">
<template #label>{{ i18n.ts._watermarkEditor.repeat }}</template>
</MkSwitch>
</template>
<template v-else-if="layer.type === 'image'">
<MkButton inline rounded primary @click="chooseFile">{{ i18n.ts.selectFile }}</MkButton>
<FormSlot>
<template #label>{{ i18n.ts._watermarkEditor.position }}</template>
<MkPositionSelector
v-model:x="layer.align.x"
v-model:y="layer.align.y"
></MkPositionSelector>
</FormSlot>
<MkRange
v-model="layer.scale"
:min="0"
:max="1"
:step="0.01"
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
continuousUpdate
>
<template #label>{{ i18n.ts._watermarkEditor.scale }}</template>
</MkRange>
<MkRange
v-model="layer.opacity"
:min="0"
:max="1"
:step="0.01"
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
continuousUpdate
>
<template #label>{{ i18n.ts._watermarkEditor.opacity }}</template>
</MkRange>
<MkSwitch v-model="layer.repeat">
<template #label>{{ i18n.ts._watermarkEditor.repeat }}</template>
</MkSwitch>
<MkSwitch v-model="layer.cover">
<template #label>{{ i18n.ts._watermarkEditor.cover }}</template>
</MkSwitch>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, useTemplateRef, watch, onMounted, onUnmounted } from 'vue';
import { v4 as uuid } from 'uuid';
import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.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';
import MkRange from '@/components/MkRange.vue';
import FormSlot from '@/components/form/slot.vue';
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 driveFileError = ref(false);
onMounted(async () => {
if (layer.value.type === 'image' && layer.value.imageId != null) {
await misskeyApi('drive/files/show', {
fileId: layer.value.imageId,
}).then((res) => {
driveFile.value = res;
}).catch((err) => {
driveFileError.value = true;
});
}
});
function chooseFile(ev: MouseEvent) {
selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then((file) => {
if (!file.type.startsWith('image')) {
os.alert({
type: 'warning',
title: i18n.ts._watermarkEditor.driveFileTypeWarn,
text: i18n.ts._watermarkEditor.driveFileTypeWarnDescription,
});
return;
}
layer.value.imageId = file.id;
layer.value.imageUrl = file.url;
driveFileError.value = false;
});
}
</script>
<style module>
.root {
}
</style>
@@ -0,0 +1,301 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="1000"
:height="600"
:scroll="false"
:withOkButton="true"
@close="cancel()"
@ok="save()"
@closed="emit('closed')"
>
<template #header><i class="ti ti-copyright"></i> {{ i18n.ts._watermarkEditor.title }}</template>
<div :class="$style.root">
<div :class="$style.container">
<div :class="$style.preview">
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
<div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
<div class="_acrylic" :class="$style.previewControls">
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
</div>
</div>
</div>
<div :class="$style.controls">
<div class="_spacer _gaps">
<MkSelect v-model="type" :items="[{ label: i18n.ts._watermarkEditor.text, value: 'text' }, { label: i18n.ts._watermarkEditor.image, value: 'image' }]"></MkSelect>
<XLayer
v-for="(layer, i) in preset.layers"
:key="layer.id"
v-model:layer="preset.layers[i]"
></XLayer>
</div>
</div>
</div>
</div>
</MkModalWindow>
</template>
<script setup lang="ts">
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive } from 'vue';
import { v4 as uuid } from 'uuid';
import type { WatermarkPreset } from '@/utility/watermark.js';
import { makeImageEffectorLayers } from '@/utility/watermark.js';
import { i18n } from '@/i18n.js';
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import XLayer from '@/components/MkWatermarkEditorDialog.Layer.vue';
import * as os from '@/os.js';
import { deepClone } from '@/utility/clone.js';
import { ensureSignin } from '@/i.js';
import { FX_watermarkPlacement } from '@/utility/image-effector/fxs/watermarkPlacement.js';
const $i = ensureSignin();
const props = defineProps<{
preset?: WatermarkPreset | null;
}>();
const preset = reactive(deepClone(props.preset) ?? {
id: uuid(),
name: '',
layers: [{
id: uuid(),
type: 'text',
text: `(c) @${$i.username}`,
align: { x: 'right', y: 'bottom' },
scale: 0.3,
opacity: 0.75,
repeat: false,
}],
} satisfies WatermarkPreset);
const emit = defineEmits<{
(ev: 'ok', preset: WatermarkPreset): void;
(ev: 'cancel'): void;
(ev: 'closed'): void;
}>();
const dialog = useTemplateRef('dialog');
function cancel() {
emit('cancel');
dialog.value?.close();
}
const type = ref(preset.layers[0].type);
watch(type, () => {
if (type.value === 'text') {
preset.layers = [{
id: uuid(),
type: 'text',
text: `(c) @${$i.username}`,
align: { x: 'right', y: 'bottom' },
scale: 0.3,
opacity: 0.75,
repeat: false,
}];
} else if (type.value === 'image') {
preset.layers = [{
id: uuid(),
type: 'image',
imageId: null,
imageUrl: null,
align: { x: 'right', y: 'bottom' },
scale: 0.3,
opacity: 0.75,
repeat: false,
}];
}
});
watch(preset, async (newValue, oldValue) => {
if (renderer != null) {
renderer.updateLayers(makeImageEffectorLayers(preset.layers));
}
}, { deep: true });
const canvasEl = useTemplateRef('canvasEl');
const sampleImage_3_2 = new Image();
sampleImage_3_2.src = '/client-assets/sample/3-2.jpg';
const sampleImage_3_2_loading = new Promise<void>(resolve => {
sampleImage_3_2.onload = () => resolve();
});
const sampleImage_2_3 = new Image();
sampleImage_2_3.src = '/client-assets/sample/2-3.jpg';
const sampleImage_2_3_loading = new Promise<void>(resolve => {
sampleImage_2_3.onload = () => resolve();
});
const sampleImageType = ref('3_2');
watch(sampleImageType, async () => {
if (renderer != null) {
renderer.destroy();
renderer = null;
initRenderer();
}
});
let renderer: ImageEffector | null = null;
async function initRenderer() {
if (canvasEl.value == null) return;
if (sampleImageType.value === '3_2') {
renderer = new ImageEffector({
canvas: canvasEl.value,
width: 1500,
height: 1000,
layers: makeImageEffectorLayers(preset.layers),
originalImage: sampleImage_3_2,
fxs: [FX_watermarkPlacement],
});
} else if (sampleImageType.value === '2_3') {
renderer = new ImageEffector({
canvas: canvasEl.value,
width: 1000,
height: 1500,
layers: makeImageEffectorLayers(preset.layers),
originalImage: sampleImage_2_3,
fxs: [FX_watermarkPlacement],
});
}
await renderer!.bakeTextures();
renderer!.render();
}
onMounted(async () => {
await sampleImage_3_2_loading;
await sampleImage_2_3_loading;
initRenderer();
});
onUnmounted(() => {
if (renderer != null) {
renderer.destroy();
renderer = null;
}
});
async function save() {
const { canceled, result: name } = await os.inputText({
title: i18n.ts.name,
default: preset.name,
});
if (canceled) return;
preset.name = name || '';
dialog.value?.close();
if (renderer != null) {
renderer.destroy();
renderer = null;
}
emit('ok', preset);
}
</script>
<style module>
.root {
container-type: inline-size;
height: 100%;
}
.container {
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.preview {
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);
}
.previewContainer {
display: flex;
flex-direction: column;
height: 100%;
user-select: none;
-webkit-user-drag: none;
}
.previewTitle {
position: absolute;
z-index: 100;
top: 8px;
left: 8px;
padding: 6px 10px;
border-radius: 6px;
font-size: 85%;
}
.previewControls {
position: absolute;
z-index: 100;
bottom: 8px;
right: 8px;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
}
.previewControlsButton {
&.active {
color: var(--MI_THEME-accent);
}
}
.previewSpinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.previewCanvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 20px;
box-sizing: border-box;
object-fit: contain;
}
.controls {
overflow-y: scroll;
}
@container (max-width: 800px) {
.container {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>
@@ -185,7 +185,7 @@ async function edit(name: string) {
const emoji = await misskeyApi('emoji', {
name: name,
});
const { dispose } = await os.popupAsyncWithDialog(import('@/pages/emoji-edit-dialog.vue').then(x => x.default), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/pages/emoji-edit-dialog.vue')), {
emoji: emoji,
}, {
closed: () => dispose(),
@@ -194,9 +194,9 @@ export function useNoteCapture(props: {
parentNote: Misskey.entities.Note | null;
mock?: boolean;
}): {
$note: Reactive<ReactiveNoteData>;
subscribe: () => void;
} {
$note: Reactive<ReactiveNoteData>;
subscribe: () => void;
} {
const { note, parentNote, mock } = props;
const $note = reactive<ReactiveNoteData>({
@@ -225,8 +225,8 @@ export function useNoteCapture(props: {
let latestPollVotedKey: string | null = null;
function onReacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void {
let normalizedName = ctx.reaction.replace(/^:(\w+):$/, ':$1@.:');
normalizedName = normalizedName.match('\u200d') ? normalizedName : normalizedName.replace(/\ufe0f/g, '');
const normalizedName = ctx.reaction.replace(/^:(\w+):$/, ':$1@.:');
if (reactionUserMap.has(ctx.userId) && reactionUserMap.get(ctx.userId) === normalizedName) return;
reactionUserMap.set(ctx.userId, normalizedName);
@@ -245,8 +245,7 @@ export function useNoteCapture(props: {
}
function onUnreacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void {
let normalizedName = ctx.reaction.replace(/^:(\w+):$/, ':$1@.:');
normalizedName = normalizedName.match('\u200d') ? normalizedName : normalizedName.replace(/\ufe0f/g, '');
const normalizedName = ctx.reaction.replace(/^:(\w+):$/, ':$1@.:');
// 確実に一度リアクションされて取り消されている場合のみ処理をとめる(APIで初回読み込み→Streamでアップデート等の場合、reactionUserMapに情報がないため)
if (reactionUserMap.has(ctx.userId) && reactionUserMap.get(ctx.userId) === noReaction) return;
+8 -71
View File
@@ -206,57 +206,6 @@ export function popup<T extends Component>(
};
}
export async function popupAsyncWithDialog<T extends Component>(
componentFetching: Promise<T>,
props: ComponentProps<T>,
events: Partial<ComponentEmit<T>> = {},
): Promise<{ dispose: () => void }> {
let component: T;
let closeWaiting = () => {};
const timer = window.setTimeout(() => {
closeWaiting = waiting();
}, 100); // コンポーネントがキャッシュされている場合にもwaitingが表示されて画面がちらつくのを防止するためにラグを追加
try {
component = await componentFetching;
} catch (err) {
window.clearTimeout(timer);
closeWaiting();
alert({
type: 'error',
title: i18n.ts.somethingHappened,
text: 'CODE: ASYNC_COMP_LOAD_FAIL',
});
throw err;
}
window.clearTimeout(timer);
closeWaiting();
markRaw(component);
const id = ++popupIdCount;
const dispose = () => {
// このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ?
window.setTimeout(() => {
popups.value = popups.value.filter(p => p.id !== id);
}, 0);
};
const state = {
component,
props,
events,
id,
};
popups.value.push(state);
return {
dispose,
};
}
export function pageWindow(path: string) {
const { dispose } = popup(MkPageWindow, {
initialPath: path,
@@ -598,28 +547,14 @@ export function success(): Promise<void> {
});
}
export function waiting(options: { text?: string } = {}) {
export function waiting(text?: string | null): () => void {
window.document.body.setAttribute('inert', 'true');
const showing = ref(true);
const isSuccess = ref(false);
function done(doneOptions: { success?: boolean } = {}) {
if (doneOptions.success) {
isSuccess.value = true;
window.setTimeout(() => {
showing.value = false;
}, 1000);
} else {
showing.value = false;
}
}
// NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない)
const { dispose } = popup(MkWaitingDialog, {
success: isSuccess,
success: false,
showing: showing,
text: options.text,
text,
}, {
closed: () => {
window.document.body.removeAttribute('inert');
@@ -627,7 +562,9 @@ export function waiting(options: { text?: string } = {}) {
},
});
return done;
return () => {
showing.value = false;
};
}
export function form<F extends Form>(title: string, f: F): Promise<{ canceled: true, result?: undefined } | { canceled?: false, result: GetFormResultType<F> }> {
@@ -838,9 +775,9 @@ export function launchUploader(
multiple?: boolean;
},
): Promise<Misskey.entities.DriveFile[]> {
return new Promise(async (res, rej) => {
return new Promise((res, rej) => {
if (files.length === 0) return rej();
const { dispose } = await popupAsyncWithDialog(import('@/components/MkUploaderDialog.vue').then(x => x.default), {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUploaderDialog.vue')), {
files: markRaw(files),
folderId: options?.folderId,
multiple: options?.multiple,
@@ -391,7 +391,6 @@ const patrons = [
'asata',
'ruru',
'みりめい',
'東雲 琥珀',
];
const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure'));
+4 -4
View File
@@ -477,16 +477,16 @@ function toggleRoleItem(role) {
}
}
async function createAnnouncement() {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkUserAnnouncementEditDialog.vue').then(x => x.default), {
function createAnnouncement() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
user: user.value,
}, {
closed: () => dispose(),
});
}
async function editAnnouncement(announcement) {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkUserAnnouncementEditDialog.vue').then(x => x.default), {
function editAnnouncement(announcement) {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
user: user.value,
announcement,
}, {
@@ -525,10 +525,10 @@ const headerPageMetadata = computed(() => ({
const headerActions = computed(() => [{
icon: 'ti ti-search',
text: i18n.ts.search,
handler: async () => {
handler: () => {
if (searchWindowOpening) return;
searchWindowOpening = true;
const { dispose } = await os.popupAsyncWithDialog(import('./custom-emojis-manager.local.list.search.vue').then(x => x.default), {
const { dispose } = os.popup(defineAsyncComponent(() => import('./custom-emojis-manager.local.list.search.vue')), {
query: searchQuery.value,
}, {
queryUpdated: (query: EmojiSearchQuery) => {
@@ -584,8 +584,8 @@ const headerActions = computed(() => [{
}, {
icon: 'ti ti-notes',
text: i18n.ts._customEmojisManager._gridCommon.registrationLogs,
handler: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('./custom-emojis-manager.local.list.logs.vue').then(x => x.default), {
handler: () => {
const { dispose } = os.popup(defineAsyncComponent(() => import('./custom-emojis-manager.local.list.logs.vue')), {
logs: requestLogs.value,
}, {
closed: () => {
@@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkFolder>
<div class="_buttonsCenter">
<MkButton primary rounded @click="onFileSelectClicked">{{ i18n.ts.upload }}</MkButton>
<MkButton primary rounded @click="onFileSelectClicked">{{ i18n.ts.uplaod }}</MkButton>
<MkButton primary rounded @click="onDriveSelectClicked">{{ i18n.ts.fromDrive }}</MkButton>
</div>
+1 -1
View File
@@ -149,7 +149,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFolder v-if="matchQuery([i18n.ts._role._options.uploadableFileTypes, 'uploadableFileTypes'])">
<template #label>{{ i18n.ts._role._options.uploadableFileTypes }}</template>
<template #suffix>...</template>
<MkTextarea :modelValue="policies.uploadableFileTypes.join('\n')" @update:modelValue="v => policies.uploadableFileTypes = v.split('\n')">
<MkTextarea :modelValue="policies.uploadableFileTypes.join('\n')">
<template #caption>
<div>{{ i18n.ts._role._options.uploadableFileTypes_caption }}</div>
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.tsx._role._options.uploadableFileTypes_caption2({ x: 'application/octet-stream' }) }}</div>
@@ -46,7 +46,7 @@ function load() {
load();
async function add(ev: MouseEvent) {
const { dispose } = await os.popupAsyncWithDialog(import('./avatar-decoration-edit-dialog.vue').then(x => x.default), {
const { dispose } = os.popup(defineAsyncComponent(() => import('./avatar-decoration-edit-dialog.vue')), {
}, {
done: result => {
if (result.created) {
@@ -57,8 +57,8 @@ async function add(ev: MouseEvent) {
});
}
async function edit(avatarDecoration) {
const { dispose } = await os.popupAsyncWithDialog(import('./avatar-decoration-edit-dialog.vue').then(x => x.default), {
function edit(avatarDecoration) {
const { dispose } = os.popup(defineAsyncComponent(() => import('./avatar-decoration-edit-dialog.vue')), {
avatarDecoration: avatarDecoration,
}, {
done: result => {
@@ -181,9 +181,9 @@ function showMenu(ev: MouseEvent, contextmenu = false) {
menu.push({
text: i18n.ts.reportAbuse,
icon: 'ti ti-exclamation-circle',
action: async () => {
action: () => {
const localUrl = `${url}/chat/messages/${props.message.id}`;
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAbuseReportWindow.vue').then(x => x.default), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
user: props.message.fromUser!,
initialComment: `${localUrl}\n-----\n`,
}, {
@@ -128,7 +128,7 @@ const toggleSelect = (emoji) => {
};
const add = async (ev: MouseEvent) => {
const { dispose } = await os.popupAsyncWithDialog(import('./emoji-edit-dialog.vue').then(x => x.default), {
const { dispose } = os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
}, {
done: result => {
if (result.created) {
@@ -139,8 +139,8 @@ const add = async (ev: MouseEvent) => {
});
};
const edit = async (emoji) => {
const { dispose } = await os.popupAsyncWithDialog(import('./emoji-edit-dialog.vue').then(x => x.default), {
const edit = (emoji) => {
const { dispose } = os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
emoji: emoji,
}, {
done: result => {
@@ -174,10 +174,10 @@ function rename() {
});
}
async function describe() {
function describe() {
if (!file.value) return;
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkFileCaptionEditWindow.vue').then(x => x.default), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
default: file.value.comment ?? '',
file: file.value,
}, {
+1 -1
View File
@@ -67,7 +67,7 @@ function menu(ev) {
}
const edit = async (emoji) => {
const { dispose } = await os.popupAsyncWithDialog(import('@/pages/emoji-edit-dialog.vue').then(x => x.default), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/pages/emoji-edit-dialog.vue')), {
emoji: emoji,
}, {
closed: () => dispose(),
+2 -2
View File
@@ -238,12 +238,12 @@ async function run() {
}
}
async function reportAbuse() {
function reportAbuse() {
if (!flash.value) return;
const pageUrl = `${url}/play/${flash.value.id}`;
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAbuseReportWindow.vue').then(x => x.default), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
user: flash.value.user,
initialComment: `Play: ${pageUrl}\n-----\n`,
}, {
+2 -2
View File
@@ -153,12 +153,12 @@ function edit() {
router.push(`/gallery/${post.value.id}/edit`);
}
async function reportAbuse() {
function reportAbuse() {
if (!post.value) return;
const pageUrl = `${url}/gallery/${post.value.id}`;
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAbuseReportWindow.vue').then(x => x.default), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
user: post.value.user,
initialComment: `Post: ${pageUrl}\n-----\n`,
}, {
+2 -2
View File
@@ -245,12 +245,12 @@ function pin(pin) {
});
}
async function reportAbuse() {
function reportAbuse() {
if (!page.value) return;
const pageUrl = `${url}/@${props.username}/pages/${props.pageName}`;
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAbuseReportWindow.vue').then(x => x.default), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
user: page.value.user,
initialComment: `Page: ${pageUrl}\n-----\n`,
}, {
@@ -41,9 +41,9 @@ async function save() {
mainRouter.push('/');
}
onMounted(async () => {
onMounted(() => {
if (props.token == null) {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkForgotPassword.vue').then(x => x.default), {}, {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
closed: () => dispose(),
});
mainRouter.push('/');
+1 -1
View File
@@ -117,7 +117,7 @@ async function registerTOTP(): Promise<void> {
token: auth.result.token,
});
const { dispose } = await os.popupAsyncWithDialog(import('./2fa.qrdialog.vue').then(x => x.default), {
const { dispose } = os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), {
twoFactorData,
}, {
closed: () => dispose(),
@@ -68,8 +68,8 @@ misskeyApi('get-avatar-decorations').then(_avatarDecorations => {
loading.value = false;
});
async function openDecoration(avatarDecoration, index?: number) {
const { dispose } = await os.popupAsyncWithDialog(import('./avatar-decoration.dialog.vue').then(x => x.default), {
function openDecoration(avatarDecoration, index?: number) {
const { dispose } = os.popup(defineAsyncComponent(() => import('./avatar-decoration.dialog.vue')), {
decoration: avatarDecoration,
usingIndex: index,
}, {
@@ -81,8 +81,8 @@ const pagination = {
noPaging: true,
};
async function generateToken() {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkTokenGenerateWindow.vue').then(x => x.default), {}, {
function generateToken() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, {
done: async result => {
const { name, permissions } = result;
const { token } = await misskeyApi('miauth/gen-token', {
@@ -0,0 +1,117 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkFolder :defaultOpen="false">
<template #icon><i class="ti ti-pencil"></i></template>
<template #label>{{ i18n.ts.preset }}: {{ preset.name === '' ? '(' + i18n.ts.noName + ')' : preset.name }}</template>
<template #footer>
<div class="_buttons">
<MkButton @click="edit"><i class="ti ti-pencil"></i> {{ i18n.ts.edit }}</MkButton>
<MkButton danger iconOnly style="margin-left: auto;" @click="del"><i class="ti ti-trash"></i></MkButton>
</div>
</template>
<div>
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
</div>
</MkFolder>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
import type { WatermarkPreset } from '@/utility/watermark.js';
import { makeImageEffectorLayers } from '@/utility/watermark.js';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { deepClone } from '@/utility/clone.js';
import MkFolder from '@/components/MkFolder.vue';
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
import { FX_watermarkPlacement } from '@/utility/image-effector/fxs/watermarkPlacement.js';
const props = defineProps<{
preset: WatermarkPreset;
}>();
const emit = defineEmits<{
(ev: 'updatePreset', preset: WatermarkPreset): void,
(ev: 'del'): void,
}>();
async function edit() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWatermarkEditorDialog.vue')), {
preset: deepClone(props.preset),
}, {
ok: (preset: WatermarkPreset) => {
emit('updatePreset', preset);
},
closed: () => dispose(),
});
}
function del(ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts.delete,
action: () => {
emit('del');
},
}], ev.currentTarget ?? ev.target);
}
const canvasEl = useTemplateRef('canvasEl');
const sampleImage = new Image();
sampleImage.src = '/client-assets/sample/3-2.jpg';
let renderer: ImageEffector | null = null;
onMounted(() => {
sampleImage.onload = async () => {
watch(canvasEl, async () => {
if (canvasEl.value == null) return;
renderer = new ImageEffector({
canvas: canvasEl.value,
width: 1500,
height: 1000,
layers: makeImageEffectorLayers(props.preset.layers),
originalImage: sampleImage,
fxs: [FX_watermarkPlacement],
});
await renderer.bakeTextures();
renderer.render();
}, { immediate: true });
};
});
onUnmounted(() => {
if (renderer != null) {
renderer.destroy();
renderer = null;
}
});
watch(() => props.preset, async () => {
if (renderer != null) {
renderer.updateLayers(makeImageEffectorLayers(props.preset.layers));
await renderer.bakeTextures();
renderer.render();
}
}, { deep: true });
</script>
<style lang="scss" module>
.previewCanvas {
display: block;
width: 100%;
height: 100%;
max-height: 200px;
box-sizing: border-box;
object-fit: contain;
}
</style>
+152 -32
View File
@@ -39,53 +39,120 @@ SPDX-License-Identifier: AGPL-3.0-only
</FormSection>
</SearchMarker>
<FormSection>
<div class="_gaps_m">
<SearchMarker :keywords="['default', 'upload', 'folder']">
<FormLink @click="chooseUploadFolder()">
<SearchLabel>{{ i18n.ts.uploadFolder }}</SearchLabel>
<template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template>
<template #suffixIcon><i class="ti ti-folder"></i></template>
<SearchMarker :keywords="['general']">
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.general }}</SearchLabel></template>
<div class="_gaps_m">
<SearchMarker :keywords="['default', 'upload', 'folder']">
<FormLink @click="chooseUploadFolder()">
<SearchLabel>{{ i18n.ts.uploadFolder }}</SearchLabel>
<template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template>
<template #suffixIcon><i class="ti ti-folder"></i></template>
</FormLink>
</SearchMarker>
<FormLink to="/settings/drive/cleaner">
{{ i18n.ts.drivecleaner }}
</FormLink>
</SearchMarker>
<FormLink to="/settings/drive/cleaner">
{{ i18n.ts.drivecleaner }}
</FormLink>
<SearchMarker :keywords="['keep', 'original', 'filename']">
<MkPreferenceContainer k="keepOriginalFilename">
<MkSwitch v-model="keepOriginalFilename">
<template #label><SearchLabel>{{ i18n.ts.keepOriginalFilename }}</SearchLabel></template>
<template #caption><SearchKeyword>{{ i18n.ts.keepOriginalFilenameDescription }}</SearchKeyword></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['keep', 'original', 'filename']">
<MkPreferenceContainer k="keepOriginalFilename">
<MkSwitch v-model="keepOriginalFilename">
<template #label><SearchLabel>{{ i18n.ts.keepOriginalFilename }}</SearchLabel></template>
<template #caption><SearchKeyword>{{ i18n.ts.keepOriginalFilenameDescription }}</SearchKeyword></template>
<SearchMarker :keywords="['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file']">
<MkSwitch v-model="alwaysMarkNsfw" @update:modelValue="saveProfile()">
<template #label><SearchLabel>{{ i18n.ts.alwaysMarkSensitive }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
</SearchMarker>
<SearchMarker :keywords="['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file']">
<MkSwitch v-model="alwaysMarkNsfw" @update:modelValue="saveProfile()">
<template #label><SearchLabel>{{ i18n.ts.alwaysMarkSensitive }}</SearchLabel></template>
</MkSwitch>
</SearchMarker>
<SearchMarker :keywords="['auto', 'nsfw', 'sensitive', 'media', 'file']">
<MkSwitch v-model="autoSensitive" @update:modelValue="saveProfile()">
<template #label><SearchLabel>{{ i18n.ts.enableAutoSensitive }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template>
<template #caption><SearchKeyword>{{ i18n.ts.enableAutoSensitiveDescription }}</SearchKeyword></template>
</MkSwitch>
</SearchMarker>
</div>
</FormSection>
</SearchMarker>
<SearchMarker :keywords="['auto', 'nsfw', 'sensitive', 'media', 'file']">
<MkSwitch v-model="autoSensitive" @update:modelValue="saveProfile()">
<template #label><SearchLabel>{{ i18n.ts.enableAutoSensitive }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template>
<template #caption><SearchKeyword>{{ i18n.ts.enableAutoSensitiveDescription }}</SearchKeyword></template>
</MkSwitch>
</SearchMarker>
</div>
</FormSection>
<SearchMarker :keywords="['image']">
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.image }}</SearchLabel></template>
<div class="_gaps_m">
<SearchMarker :keywords="['watermark', 'credit']">
<MkFolder>
<template #icon><i class="ti ti-copyright"></i></template>
<template #label><SearchLabel>{{ i18n.ts.watermark }}</SearchLabel></template>
<div class="_gaps">
<div class="_gaps_s">
<XWatermarkItem
v-for="(preset, i) in prefer.r.watermarkPresets.value"
:key="preset.id"
:preset="preset"
@updatePreset="onUpdateWatermarkPreset(preset.id, $event)"
@del="onDeleteWatermarkPreset(preset.id)"
/>
<MkButton iconOnly rounded style="margin: 0 auto;" @click="addWatermarkPreset"><i class="ti ti-plus"></i></MkButton>
<SearchMarker :keywords="['sync', 'watermark', 'preset', 'devices']">
<MkSwitch :modelValue="watermarkPresetsSyncEnabled" @update:modelValue="changeWatermarkPresetsSyncEnabled">
<template #label><i class="ti ti-cloud-cog"></i> <SearchLabel>{{ i18n.ts.syncBetweenDevices }}</SearchLabel></template>
</MkSwitch>
</SearchMarker>
</div>
<hr>
<SearchMarker :keywords="['default', 'watermark', 'preset']">
<MkPreferenceContainer k="defaultWatermarkPresetId">
<MkSelect v-model="defaultWatermarkPresetId" :items="[{ label: i18n.ts.none, value: null }, ...prefer.r.watermarkPresets.value.map(p => ({ label: p.name || i18n.ts.noName, value: p.id }))]">
<template #label><SearchLabel>{{ i18n.ts.defaultPreset }}</SearchLabel></template>
</MkSelect>
</MkPreferenceContainer>
</SearchMarker>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['default', 'image', 'compression']">
<MkPreferenceContainer k="defaultImageCompressionLevel">
<MkSelect
v-model="defaultImageCompressionLevel" :items="[
{ label: i18n.ts.none, value: 0 },
{ label: i18n.ts.low, value: 1 },
{ label: i18n.ts.medium, value: 2 },
{ label: i18n.ts.high, value: 3 },
]"
>
<template #label><SearchLabel>{{ i18n.ts.defaultImageCompressionLevel }}</SearchLabel></template>
</MkSelect>
</MkPreferenceContainer>
</SearchMarker>
</div>
</FormSection>
</SearchMarker>
</div>
</SearchMarker>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { computed, defineAsyncComponent, ref } from 'vue';
import * as Misskey from 'misskey-js';
import tinycolor from 'tinycolor2';
import XWatermarkItem from './drive.WatermarkItem.vue';
import type { WatermarkPreset } from '@/utility/watermark.js';
import FormLink from '@/components/form/link.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue';
import FormSection from '@/components/form/section.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import FormSplit from '@/components/form/split.vue';
@@ -100,6 +167,8 @@ import { prefer } from '@/preferences.js';
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
import { selectDriveFolder } from '@/utility/drive.js';
import MkFolder from '@/components/MkFolder.vue';
import MkButton from '@/components/MkButton.vue';
const $i = ensureSignin();
@@ -123,6 +192,22 @@ const meterStyle = computed(() => {
});
const keepOriginalFilename = prefer.model('keepOriginalFilename');
const defaultWatermarkPresetId = prefer.model('defaultWatermarkPresetId');
const defaultImageCompressionLevel = prefer.model('defaultImageCompressionLevel');
const watermarkPresetsSyncEnabled = ref(prefer.isSyncEnabled('watermarkPresets'));
function changeWatermarkPresetsSyncEnabled(value: boolean) {
if (value) {
prefer.enableSync('watermarkPresets').then((res) => {
if (res == null) return;
if (res.enabled) watermarkPresetsSyncEnabled.value = true;
});
} else {
prefer.disableSync('watermarkPresets');
watermarkPresetsSyncEnabled.value = false;
}
}
misskeyApi('drive').then(info => {
capacity.value = info.capacity;
@@ -152,6 +237,41 @@ function chooseUploadFolder() {
});
}
function addWatermarkPreset() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWatermarkEditorDialog.vue')), {
}, {
ok: (preset: WatermarkPreset) => {
prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]);
},
closed: () => dispose(),
});
}
function onUpdateWatermarkPreset(id: string, preset: WatermarkPreset) {
const index = prefer.s.watermarkPresets.findIndex(p => p.id === id);
if (index !== -1) {
prefer.commit('watermarkPresets', [
...prefer.s.watermarkPresets.slice(0, index),
preset,
...prefer.s.watermarkPresets.slice(index + 1),
]);
}
}
function onDeleteWatermarkPreset(id: string) {
const index = prefer.s.watermarkPresets.findIndex(p => p.id === id);
if (index !== -1) {
prefer.commit('watermarkPresets', [
...prefer.s.watermarkPresets.slice(0, index),
...prefer.s.watermarkPresets.slice(index + 1),
]);
if (prefer.s.defaultWatermarkPresetId === id) {
prefer.commit('defaultWatermarkPresetId', null);
}
}
}
function saveProfile() {
misskeyApi('i/update', {
alwaysMarkNsfw: !!alwaysMarkNsfw.value,
@@ -69,7 +69,6 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { prefer } from '@/preferences.js';
import { PREF_DEF } from '@/preferences/def.js';
import { getInitialPrefValue } from '@/preferences/manager.js';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
@@ -107,7 +106,7 @@ async function save() {
}
function reset() {
items.value = getInitialPrefValue('menu').map(x => ({
items.value = PREF_DEF.menu.default.map(x => ({
id: Math.random().toString(),
type: x,
}));
@@ -604,7 +604,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo>
<div class="_gaps_s">
<div>{{ i18n.ts._clientPerformanceIssueTip.title }}:</div>
<div>{{ i18n.ts._clientPerformanceIssueTip.title }}</div>
<div>
<div><b>{{ i18n.ts._clientPerformanceIssueTip.makeSureDisabledAdBlocker }}</b></div>
<div>{{ i18n.ts._clientPerformanceIssueTip.makeSureDisabledAdBlocker_description }}</div>
@@ -75,7 +75,6 @@ import MkSwitch from '@/components/MkSwitch.vue';
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
import { PREF_DEF } from '@/preferences/def.js';
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
import { getInitialPrefValue } from '@/preferences/manager.js';
const notUseSound = prefer.model('sound.notUseSound');
const useSoundOnlyWhenActive = prefer.model('sound.useSoundOnlyWhenActive');
@@ -114,7 +113,7 @@ async function updated(type: keyof typeof sounds.value, sound) {
function reset() {
for (const sound of Object.keys(sounds.value) as Array<keyof typeof sounds.value>) {
const v = getInitialPrefValue(`sound.on.${sound}`);
const v = PREF_DEF[`sound.on.${sound}`].default;
prefer.commit(`sound.on.${sound}`, v);
sounds.value[sound] = v;
}
+2 -2
View File
@@ -95,8 +95,8 @@ export async function authorizePlugin(plugin: Plugin) {
if (plugin.permissions == null || plugin.permissions.length === 0) return;
if (Object.hasOwn(store.s.pluginTokens, plugin.installId)) return;
const token = await new Promise<string>(async (res, rej) => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkTokenGenerateWindow.vue').then(x => x.default), {
const token = await new Promise<string>((res, rej) => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {
title: i18n.ts.tokenRequested,
information: i18n.ts.pluginTokenRequestedDescription,
initialName: plugin.name,
+1 -1
View File
@@ -15,7 +15,7 @@ import { i18n } from '@/i18n.js';
// TODO: そのうち消す
export function migrateOldSettings() {
os.waiting({ text: i18n.ts.settingsMigrating });
os.waiting(i18n.ts.settingsMigrating);
store.loaded.then(async () => {
misskeyApi('i/registry/get', { scope: ['client'], key: 'themes' }).catch(() => []).then((themes: any) => {
+21 -53
View File
@@ -5,15 +5,14 @@
import * as Misskey from 'misskey-js';
import { hemisphere } from '@@/js/intl-const.js';
import { v4 as uuid } from 'uuid';
import { definePreferences } from './manager.js';
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 { DeckProfile } from '@/deck.js';
import type { PreferencesDefinition } from './manager.js';
import type { WatermarkPreset } from '@/utility/watermark.js';
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
import { deepEqual } from '@/utility/deep-equal.js';
/** サウンド設定 */
export type SoundStore = {
@@ -33,7 +32,7 @@ export type SoundStore = {
// NOTE: デフォルト値は他の設定の状態に依存してはならない(依存していた場合、ユーザーがその設定項目単体で「初期値にリセット」した場合不具合の原因になる)
export const PREF_DEF = definePreferences({
export const PREF_DEF = {
accounts: {
default: [] as [host: string, user: {
id: string;
@@ -51,15 +50,15 @@ export const PREF_DEF = definePreferences({
},
widgets: {
accountDependent: true,
default: () => [{
default: [{
name: 'calendar',
id: uuid(), place: 'right', data: {},
id: 'a', place: 'right', data: {},
}, {
name: 'notifications',
id: uuid(), place: 'right', data: {},
id: 'b', place: 'right', data: {},
}, {
name: 'trends',
id: uuid(), place: 'right', data: {},
id: 'c', place: 'right', data: {},
}] as {
name: string;
id: string;
@@ -78,8 +77,8 @@ export const PREF_DEF = definePreferences({
emojiPalettes: {
serverDependent: true,
default: () => [{
id: uuid(),
default: [{
id: 'a',
name: '',
emojis: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
}] as {
@@ -87,22 +86,6 @@ export const PREF_DEF = definePreferences({
name: string;
emojis: string[];
}[],
mergeStrategy: (a, b) => {
const mergedItems = [] as typeof a;
for (const x of a.concat(b)) {
const sameIdItem = mergedItems.find(y => y.id === x.id);
if (sameIdItem != null) {
if (deepEqual(x, sameIdItem)) { // 完全な重複は無視
continue;
} else { // IDは同じなのに内容が違う場合はマージ不可とする
throw new Error();
}
} else {
mergedItems.push(x);
}
}
return mergedItems;
},
},
emojiPaletteForReaction: {
serverDependent: true,
@@ -118,22 +101,6 @@ export const PREF_DEF = definePreferences({
},
themes: {
default: [] as Theme[],
mergeStrategy: (a, b) => {
const mergedItems = [] as typeof a;
for (const x of a.concat(b)) {
const sameIdItem = mergedItems.find(y => y.id === x.id);
if (sameIdItem != null) {
if (deepEqual(x, sameIdItem)) { // 完全な重複は無視
continue;
} else { // IDは同じなのに内容が違う場合はマージ不可とする
throw new Error();
}
} else {
mergedItems.push(x);
}
}
return mergedItems;
},
},
lightTheme: {
default: null as Theme | null,
@@ -379,19 +346,20 @@ export const PREF_DEF = definePreferences({
},
plugins: {
default: [] as Plugin[],
mergeStrategy: (a, b) => {
const sameIdExists = a.some(x => b.some(y => x.installId === y.installId));
if (sameIdExists) throw new Error();
const sameNameExists = a.some(x => b.some(y => x.name === y.name));
if (sameNameExists) throw new Error();
return a.concat(b);
},
},
mutingEmojis: {
default: [] as string[],
mergeStrategy: (a, b) => {
return [...new Set(a.concat(b))];
},
},
watermarkPresets: {
accountDependent: true,
default: [] as WatermarkPreset[],
},
defaultWatermarkPresetId: {
accountDependent: true,
default: null as WatermarkPreset['id'] | null,
},
defaultImageCompressionLevel: {
default: 2,
},
'sound.masterVolume': {
@@ -464,4 +432,4 @@ export const PREF_DEF = definePreferences({
'experimental.enableFolderPageView': {
default: false,
},
});
} satisfies PreferencesDefinition;
+36 -128
View File
@@ -22,10 +22,7 @@ import { deepEqual } from '@/utility/deep-equal.js';
//};
type PREF = typeof PREF_DEF;
type DefaultValues = {
[K in keyof PREF]: PREF[K]['default'] extends (...args: any) => infer R ? R : PREF[K]['default'];
};
type ValueOf<K extends keyof PREF> = DefaultValues[K];
type ValueOf<K extends keyof PREF> = PREF[K]['default'];
type Scope = Partial<{
server: string | null; // host
@@ -87,33 +84,12 @@ export type StorageProvider = {
cloudSet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; value: ValueOf<K>; }) => Promise<void>;
};
type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) => infer R ? R : Default> = {
default: Default;
export type PreferencesDefinition = Record<string, {
default: any;
accountDependent?: boolean;
serverDependent?: boolean;
mergeStrategy?: (a: T, b: T) => T;
};
}>;
export type PreferencesDefinition = Record<string, PreferencesDefinitionRecord<any>>;
export function definePreferences<T extends Record<string, unknown>>(x: {
[K in keyof T]: PreferencesDefinitionRecord<T[K]>
}): {
[K in keyof T]: PreferencesDefinitionRecord<T[K]>
} {
return x;
}
export function getInitialPrefValue<K extends keyof PREF>(k: K): ValueOf<K> {
if (typeof PREF_DEF[k].default === 'function') { // factory
return PREF_DEF[k].default();
} else {
return PREF_DEF[k].default;
}
}
// TODO: PreferencesManagerForGuest のような非ログイン専用のクラスを分離すれば$iのnullチェックやaccountがnullであるスコープのレコード挿入などが不要になり綺麗になるかもしれない
// NOTE: accountDependentな設定は初期状態であってもアカウントごとのスコープでレコードを作成しておかないと、サーバー同期する際に正しく動作しなくなる
export class PreferencesManager {
private storageProvider: StorageProvider;
public profile: PreferencesProfile;
@@ -149,11 +125,11 @@ export class PreferencesManager {
// TODO: 定期的にクラウドの値をフェッチ
}
private static isAccountDependentKey<K extends keyof PREF>(key: K): boolean {
private isAccountDependentKey<K extends keyof PREF>(key: K): boolean {
return (PREF_DEF as PreferencesDefinition)[key].accountDependent === true;
}
private static isServerDependentKey<K extends keyof PREF>(key: K): boolean {
private isServerDependentKey<K extends keyof PREF>(key: K): boolean {
return (PREF_DEF as PreferencesDefinition)[key].serverDependent === true;
}
@@ -176,7 +152,7 @@ export class PreferencesManager {
const record = this.getMatchedRecordOf(key);
if (parseScope(record[0]).account == null && PreferencesManager.isAccountDependentKey(key)) {
if (parseScope(record[0]).account == null && this.isAccountDependentKey(key)) {
this.profile.preferences[key].push([makeScope({
server: host,
account: $i!.id,
@@ -185,7 +161,7 @@ export class PreferencesManager {
return;
}
if (parseScope(record[0]).server == null && PreferencesManager.isServerDependentKey(key)) {
if (parseScope(record[0]).server == null && this.isServerDependentKey(key)) {
this.profile.preferences[key].push([makeScope({
server: host,
}), v, {}]);
@@ -286,19 +262,7 @@ export class PreferencesManager {
public static newProfile(): PreferencesProfile {
const data = {} as PreferencesProfile['preferences'];
for (const key in PREF_DEF) {
const v = getInitialPrefValue(key as keyof typeof PREF_DEF);
if (PreferencesManager.isAccountDependentKey(key as keyof typeof PREF_DEF)) {
data[key] = $i ? [[makeScope({}), v, {}], [makeScope({
server: host,
account: $i.id,
}), v, {}]] : [[makeScope({}), v, {}]];
} else if (PreferencesManager.isServerDependentKey(key as keyof typeof PREF_DEF)) {
data[key] = [[makeScope({
server: host,
}), v, {}]];
} else {
data[key] = [[makeScope({}), v, {}]];
}
data[key] = [[makeScope({}), PREF_DEF[key].default, {}]];
}
return {
id: uuid(),
@@ -315,36 +279,18 @@ export class PreferencesManager {
for (const key in PREF_DEF) {
const records = profileLike.preferences[key];
if (records == null || records.length === 0) {
const v = getInitialPrefValue(key as keyof typeof PREF_DEF);
if (PreferencesManager.isAccountDependentKey(key as keyof typeof PREF_DEF)) {
data[key] = $i ? [[makeScope({}), v, {}], [makeScope({
server: host,
account: $i.id,
}), v, {}]] : [[makeScope({}), v, {}]];
} else if (PreferencesManager.isServerDependentKey(key as keyof typeof PREF_DEF)) {
data[key] = [[makeScope({
server: host,
}), v, {}]];
} else {
data[key] = [[makeScope({}), v, {}]];
}
data[key] = [[makeScope({}), PREF_DEF[key].default, {}]];
continue;
} else {
if ($i && PreferencesManager.isAccountDependentKey(key as keyof typeof PREF_DEF) && !records.some(([scope]) => parseScope(scope).server === host && parseScope(scope).account === $i!.id)) {
data[key] = records.concat([[makeScope({
server: host,
account: $i.id,
}), getInitialPrefValue(key as keyof typeof PREF_DEF), {}]]);
continue;
}
if ($i && PreferencesManager.isServerDependentKey(key as keyof typeof PREF_DEF) && !records.some(([scope]) => parseScope(scope).server === host)) {
data[key] = records.concat([[makeScope({
server: host,
}), getInitialPrefValue(key as keyof typeof PREF_DEF), {}]]);
continue;
}
data[key] = records;
// alpha段階ではmetaが無かったのでマイグレート
// TODO: そのうち消す
for (const record of data[key] as any[][]) {
if (record.length === 2) {
record.push({});
}
}
}
}
@@ -382,7 +328,7 @@ export class PreferencesManager {
public setAccountOverride<K extends keyof PREF>(key: K) {
if ($i == null) return;
if (PreferencesManager.isAccountDependentKey(key)) throw new Error('already account-dependent');
if (this.isAccountDependentKey(key)) throw new Error('already account-dependent');
if (this.isAccountOverrided(key)) return;
const records = this.profile.preferences[key];
@@ -396,7 +342,7 @@ export class PreferencesManager {
public clearAccountOverride<K extends keyof PREF>(key: K) {
if ($i == null) return;
if (PreferencesManager.isAccountDependentKey(key)) throw new Error('cannot clear override for this account-dependent property');
if (this.isAccountDependentKey(key)) throw new Error('cannot clear override for this account-dependent property');
const records = this.profile.preferences[key];
@@ -417,22 +363,14 @@ export class PreferencesManager {
public async enableSync<K extends keyof PREF>(key: K): Promise<{ enabled: boolean; } | null> {
if (this.isSyncEnabled(key)) return Promise.resolve(null);
// undefined ... cancel
async function resolveConflict(local: ValueOf<K>, remote: ValueOf<K>): Promise<ValueOf<K> | undefined> {
const merge = (PREF_DEF as PreferencesDefinition)[key].mergeStrategy;
let mergedValue: ValueOf<K> | undefined = undefined; // null と区別したいため
try {
if (merge != null) mergedValue = merge(local, remote);
} catch (err) {
// nop
}
const { canceled, result: choice } = await os.select({
const record = this.getMatchedRecordOf(key);
const existing = await this.storageProvider.cloudGet({ key, scope: record[0] });
if (existing != null && !deepEqual(existing.value, record[1])) {
const { canceled, result } = await os.select({
title: i18n.ts.preferenceSyncConflictTitle,
text: i18n.ts.preferenceSyncConflictText,
items: [...(mergedValue !== undefined ? [{
text: i18n.ts.preferenceSyncConflictChoiceMerge,
value: 'merge',
}] : []), {
items: [{
text: i18n.ts.preferenceSyncConflictChoiceServer,
value: 'remote',
}, {
@@ -442,53 +380,23 @@ export class PreferencesManager {
text: i18n.ts.preferenceSyncConflictChoiceCancel,
value: null,
}],
default: mergedValue !== undefined ? 'merge' : 'remote',
default: 'remote',
});
if (canceled || choice == null) return undefined;
if (canceled || result == null) return { enabled: false };
if (choice === 'remote') {
return remote;
} else if (choice === 'local') {
return local;
} else if (choice === 'merge') {
return mergedValue!;
if (result === 'remote') {
this.commit(key, existing.value);
} else if (result === 'local') {
// nop
}
}
const record = this.getMatchedRecordOf(key);
let newValue = record[1];
const existing = await this.storageProvider.cloudGet({ key, scope: record[0] });
if (existing != null && !deepEqual(record[1], existing.value)) {
const resolvedValue = await resolveConflict(record[1], existing.value);
if (resolvedValue === undefined) return { enabled: false }; // canceled
newValue = resolvedValue;
}
this.commit(key, newValue);
const done = os.waiting();
try {
await this.storageProvider.cloudSet({ key, scope: record[0], value: newValue });
} catch (err) {
done();
os.alert({
type: 'error',
title: i18n.ts.somethingHappened,
text: err,
});
return { enabled: false };
}
done({ success: true });
record[2].sync = true;
this.save();
// awaitの必要性は無い
this.storageProvider.cloudSet({ key, scope: record[0], value: this.s[key] });
return { enabled: true };
}
@@ -549,7 +457,7 @@ export class PreferencesManager {
text: i18n.ts.resetToDefaultValue,
danger: true,
action: () => {
this.commit(key, getInitialPrefValue(key));
this.commit(key, PREF_DEF[key].default);
},
}, {
type: 'divider',
+3 -3
View File
@@ -4,10 +4,10 @@
*/
import { defineAsyncComponent } from 'vue';
import { host } from '@@/js/config.js';
import type { MenuItem } from '@/types/menu.js';
import * as os from '@/os.js';
import { instance } from '@/instance.js';
import { host } from '@@/js/config.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/i.js';
@@ -146,8 +146,8 @@ export function openInstanceMenu(ev: MouseEvent) {
menuItems.push({
text: i18n.ts._initialTutorial.launchTutorial,
icon: 'ti ti-presentation',
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkTutorialDialog.vue').then(x => x.default), {}, {
action: () => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {}, {
closed: () => dispose(),
});
},
@@ -71,8 +71,8 @@ const otherNavItemIndicated = computed<boolean>(() => {
return false;
});
async function more(ev: MouseEvent) {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLaunchPad.vue').then(x => x.default), {
function more(ev: MouseEvent) {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
anchorElement: ev.currentTarget ?? ev.target,
anchor: { x: 'center', y: 'bottom' },
}, {
+2 -2
View File
@@ -176,10 +176,10 @@ function openAccountMenu(ev: MouseEvent) {
}, ev);
}
async function more(ev: MouseEvent) {
function more(ev: MouseEvent) {
const target = getHTMLElementOrNull(ev.currentTarget ?? ev.target);
if (!target) return;
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLaunchPad.vue').then(x => x.default), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
anchorElement: target,
}, {
closed: () => dispose(),
@@ -72,7 +72,7 @@ async function setAntenna() {
if (canceled || antenna == null) return;
if (antenna === '_CREATE_') {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAntennaEditorDialog.vue').then(x => x.default), {}, {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAntennaEditorDialog.vue')), {}, {
created: (newAntenna: MisskeyEntities.Antenna) => {
antennasCache.delete();
updateColumn(props.column.id, {
@@ -27,8 +27,8 @@ const props = defineProps<{
const notificationsComponent = useTemplateRef('notificationsComponent');
async function func() {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkNotificationSelectWindow.vue').then(x => x.default), {
function func() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSelectWindow.vue')), {
excludeTypes: props.column.excludeTypes,
}, {
done: async (res) => {
+4 -4
View File
@@ -172,8 +172,8 @@ export function chooseFileFromPcAndUpload(
export function chooseDriveFile(options: {
multiple?: boolean;
} = {}): Promise<Misskey.entities.DriveFile[]> {
return new Promise(async resolve => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkDriveFileSelectDialog.vue').then(x => x.default), {
return new Promise(resolve => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveFileSelectDialog.vue')), {
multiple: options.multiple ?? false,
}, {
done: files => {
@@ -286,8 +286,8 @@ export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFi
}
export async function selectDriveFolder(initialFolder: Misskey.entities.DriveFolder['id'] | null): Promise<Misskey.entities.DriveFolder[]> {
return new Promise(async resolve => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkDriveFolderSelectDialog.vue').then(x => x.default), {
return new Promise(resolve => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveFolderSelectDialog.vue')), {
initialFolder,
}, {
done: folders => {
@@ -28,8 +28,8 @@ function rename(file: Misskey.entities.DriveFile) {
});
}
async function describe(file: Misskey.entities.DriveFile) {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkFileCaptionEditWindow.vue').then(x => x.default), {
function describe(file: Misskey.entities.DriveFile) {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
default: file.comment ?? '',
file: file,
}, {
@@ -64,7 +64,7 @@ export function getEmbedCode(path: string, params?: EmbedParams): string {
*
* getEmbedCode 使
*/
export async function genEmbedCode(entity: EmbeddableEntity, id: string, params?: EmbedParams) {
export function genEmbedCode(entity: EmbeddableEntity, id: string, params?: EmbedParams) {
const _params = { ...params };
if (embedRouteWithScrollbar.includes(entity) && _params.maxHeight == null) {
@@ -75,7 +75,7 @@ export async function genEmbedCode(entity: EmbeddableEntity, id: string, params?
if (window.innerWidth < MOBILE_THRESHOLD) {
copyToClipboard(getEmbedCode(`/embed/${entity}/${id}`, _params));
} else {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkEmbedCodeGenDialog.vue').then(x => x.default), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkEmbedCodeGenDialog.vue')), {
entity,
id,
params: _params,
@@ -135,12 +135,12 @@ export function getAbuseNoteMenu(note: Misskey.entities.Note, text: string): Men
return {
icon: 'ti ti-exclamation-circle',
text,
action: async (): Promise<void> => {
action: (): void => {
const localUrl = `${url}/notes/${note.id}`;
let noteInfo = '';
if (note.url ?? note.uri != null) noteInfo = `Note: ${note.url ?? note.uri}\n`;
noteInfo += `Local Note: ${localUrl}\n`;
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAbuseReportWindow.vue').then(x => x.default), {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
user: note.user,
initialComment: `${noteInfo}-----\n`,
}, {
@@ -94,8 +94,8 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
});
}
async function reportAbuse() {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAbuseReportWindow.vue').then(x => x.default), {
function reportAbuse() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
user: user,
}, {
closed: () => dispose(),
@@ -0,0 +1,455 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getProxiedImageUrl } from '../media-proxy.js';
type ParamTypeToPrimitive = {
'number': number;
'number:enum': number;
'boolean': boolean;
'align': { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; };
'seed': number;
};
type ImageEffectorFxParamDefs = Record<string, {
type: keyof ParamTypeToPrimitive;
default: any;
}>;
export function defineImageEffectorFx<ID extends string, P extends ImageEffectorFxParamDefs>(fx: ImageEffectorFx<ID, P>) {
return fx;
}
export type ImageEffectorFx<ID extends string, P extends ImageEffectorFxParamDefs> = {
id: ID;
name: string;
shader: string;
params: P,
main: (ctx: {
gl: WebGL2RenderingContext;
program: WebGLProgram;
params: {
[key in keyof P]: ParamTypeToPrimitive[P[key]['type']];
};
preTexture: WebGLTexture;
width: number;
height: number;
watermark?: {
texture: WebGLTexture;
width: number;
height: number;
};
}) => void;
};
export type ImageEffectorLayer = {
id: string;
fxId: string;
params: Record<string, any>;
// for watermarkPlacement fx
imageUrl?: string | null;
text?: string | null;
};
export class ImageEffector {
private canvas: HTMLCanvasElement | null = null;
private gl: WebGL2RenderingContext | null = null;
private renderTextureProgram!: WebGLProgram;
private renderInvertedTextureProgram!: WebGLProgram;
private renderWidth!: number;
private renderHeight!: number;
private originalImage: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
private layers: ImageEffectorLayer[];
private originalImageTexture: WebGLTexture;
private bakedTexturesForWatermarkFx: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
private texturesKey: string;
private shaderCache: Map<string, WebGLProgram> = new Map();
private perLayerResultTextures: Map<string, WebGLTexture> = new Map();
private perLayerResultFrameBuffers: Map<string, WebGLFramebuffer> = new Map();
private fxs: ImageEffectorFx<string, any>[];
constructor(options: {
canvas: HTMLCanvasElement;
width: number;
height: number;
originalImage: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
layers: ImageEffectorLayer[];
fxs: ImageEffectorFx<string, any>[];
}) {
this.canvas = options.canvas;
this.canvas.width = options.width;
this.canvas.height = options.height;
this.renderWidth = options.width;
this.renderHeight = options.height;
this.originalImage = options.originalImage;
this.layers = options.layers;
this.fxs = options.fxs;
this.texturesKey = this.calcTexturesKey();
this.gl = this.canvas.getContext('webgl2', {
preserveDrawingBuffer: false,
alpha: true,
premultipliedAlpha: false,
})!;
const gl = this.gl;
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
const VERTICES = new Float32Array([-1, -1, -1, 1, 1, 1, -1, -1, 1, 1, 1, -1]);
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
this.originalImageTexture = this.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, options.width, options.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, this.originalImage);
gl.bindTexture(gl.TEXTURE_2D, null);
this.renderTextureProgram = this.initShaderProgram(`#version 300 es
in vec2 position;
out vec2 in_uv;
void main() {
in_uv = (position + 1.0) / 2.0;
gl_Position = vec4(position, 0.0, 1.0);
}
`, `#version 300 es
precision mediump float;
in vec2 in_uv;
uniform sampler2D u_texture;
out vec4 out_color;
void main() {
out_color = texture(u_texture, in_uv);
}
`)!;
this.renderInvertedTextureProgram = this.initShaderProgram(`#version 300 es
in vec2 position;
out vec2 in_uv;
void main() {
in_uv = (position + 1.0) / 2.0;
in_uv.y = 1.0 - in_uv.y;
gl_Position = vec4(position, 0.0, 1.0);
}
`, `#version 300 es
precision mediump float;
in vec2 in_uv;
uniform sampler2D u_texture;
out vec4 out_color;
void main() {
out_color = texture(u_texture, in_uv);
}
`)!;
}
private calcTexturesKey() {
return this.layers.map(layer => {
if (layer.fxId === 'watermarkPlacement' && layer.imageUrl != null) {
return layer.imageUrl;
} else if (layer.fxId === 'watermarkPlacement' && layer.text != null) {
return layer.text;
}
return '';
}).join(';');
}
private createTexture(): WebGLTexture {
const gl = this.gl!;
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.bindTexture(gl.TEXTURE_2D, null);
return texture!;
}
public disposeBakedTextures() {
const gl = this.gl;
if (gl == null) {
throw new Error('gl is not initialized');
}
for (const bakedTexture of this.bakedTexturesForWatermarkFx.values()) {
gl.deleteTexture(bakedTexture.texture);
}
this.bakedTexturesForWatermarkFx.clear();
}
public async bakeTextures() {
const gl = this.gl;
if (gl == null) {
throw new Error('gl is not initialized');
}
console.log('baking textures', this.texturesKey);
this.disposeBakedTextures();
for (const layer of this.layers) {
if (layer.fxId === 'watermarkPlacement' && layer.imageUrl != null) {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = getProxiedImageUrl(layer.imageUrl); // CORS対策
});
const texture = this.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, image.width, image.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.bindTexture(gl.TEXTURE_2D, null);
this.bakedTexturesForWatermarkFx.set(layer.id, {
texture: texture,
width: image.width,
height: image.height,
});
} else if (layer.fxId === 'watermarkPlacement' && layer.text != null) {
const measureCtx = window.document.createElement('canvas').getContext('2d')!;
measureCtx.canvas.width = this.renderWidth;
measureCtx.canvas.height = this.renderHeight;
const fontSize = Math.min(this.renderWidth, this.renderHeight) / 20;
const margin = Math.min(this.renderWidth, this.renderHeight) / 50;
measureCtx.font = `bold ${fontSize}px sans-serif`;
const textMetrics = measureCtx.measureText(layer.text);
measureCtx.canvas.remove();
const RESOLUTION_FACTOR = 4;
const textCtx = window.document.createElement('canvas').getContext('2d')!;
textCtx.canvas.width = (Math.ceil(textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft) + margin + margin) * RESOLUTION_FACTOR;
textCtx.canvas.height = (Math.ceil(textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) + margin + margin) * RESOLUTION_FACTOR;
//textCtx.fillStyle = '#00ff00';
//textCtx.fillRect(0, 0, textCtx.canvas.width, textCtx.canvas.height);
textCtx.shadowColor = '#000000';
textCtx.shadowBlur = 10 * RESOLUTION_FACTOR;
textCtx.fillStyle = '#ffffff';
textCtx.font = `bold ${fontSize * RESOLUTION_FACTOR}px sans-serif`;
textCtx.textBaseline = 'middle';
textCtx.textAlign = 'center';
textCtx.fillText(layer.text, textCtx.canvas.width / 2, textCtx.canvas.height / 2);
const texture = this.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, textCtx.canvas.width, textCtx.canvas.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, textCtx.canvas);
gl.bindTexture(gl.TEXTURE_2D, null);
this.bakedTexturesForWatermarkFx.set(layer.id, {
texture: texture,
width: textCtx.canvas.width,
height: textCtx.canvas.height,
});
textCtx.canvas.remove();
}
}
}
public loadShader(type, source) {
const gl = this.gl!;
const shader = gl.createShader(type)!;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
alert(
`falied to compile shader: ${gl.getShaderInfoLog(shader)}`,
);
gl.deleteShader(shader);
return null;
}
return shader;
}
public initShaderProgram(vsSource, fsSource): WebGLProgram {
const gl = this.gl!;
const vertexShader = this.loadShader(gl.VERTEX_SHADER, vsSource)!;
const fragmentShader = this.loadShader(gl.FRAGMENT_SHADER, fsSource)!;
const shaderProgram = gl.createProgram()!;
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
alert(
`failed to init shader: ${gl.getProgramInfoLog(
shaderProgram,
)}`,
);
throw new Error('failed to init shader');
}
return shaderProgram;
}
private renderLayer(layer: ImageEffectorLayer, preTexture: WebGLTexture) {
const gl = this.gl;
if (gl == null) {
throw new Error('gl is not initialized');
}
const fx = this.fxs.find(fx => fx.id === layer.fxId);
if (fx == null) return;
const watermark = layer.fxId === 'watermarkPlacement' ? this.bakedTexturesForWatermarkFx.get(layer.id) : undefined;
const cachedShader = this.shaderCache.get(fx.id);
const shaderProgram = cachedShader ?? this.initShaderProgram(`#version 300 es
in vec2 position;
out vec2 in_uv;
void main() {
in_uv = (position + 1.0) / 2.0;
gl_Position = vec4(position, 0.0, 1.0);
}
`, fx.shader);
if (cachedShader == null) {
this.shaderCache.set(fx.id, shaderProgram);
}
gl.useProgram(shaderProgram);
const u_resolution = gl.getUniformLocation(shaderProgram, 'u_resolution');
gl.uniform2fv(u_resolution, [this.renderWidth, this.renderHeight]);
fx.main({
gl: gl,
program: shaderProgram,
params: Object.fromEntries(
Object.entries(fx.params).map(([key, param]) => {
return [key, layer.params[key] ?? param.default];
}),
) as any,
preTexture: preTexture,
width: this.renderWidth,
height: this.renderHeight,
watermark: watermark,
});
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
public render() {
const gl = this.gl;
if (gl == null) {
throw new Error('gl is not initialized');
}
{
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
gl.useProgram(this.renderTextureProgram);
const u_texture = gl.getUniformLocation(this.renderTextureProgram, 'u_texture');
gl.uniform1i(u_texture, 0);
const u_resolution = gl.getUniformLocation(this.renderTextureProgram, 'u_resolution');
gl.uniform2fv(u_resolution, [this.renderWidth, this.renderHeight]);
const positionLocation = gl.getAttribLocation(this.renderTextureProgram, 'position');
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(positionLocation);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
// --------------------
let preTexture = this.originalImageTexture;
for (const layer of this.layers) {
const cachedResultTexture = this.perLayerResultTextures.get(layer.id);
const resultTexture = cachedResultTexture ?? this.createTexture();
if (cachedResultTexture == null) {
this.perLayerResultTextures.set(layer.id, resultTexture);
}
gl.bindTexture(gl.TEXTURE_2D, resultTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.renderWidth, this.renderHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.bindTexture(gl.TEXTURE_2D, null);
const cachedResultFrameBuffer = this.perLayerResultFrameBuffers.get(layer.id);
const resultFrameBuffer = cachedResultFrameBuffer ?? gl.createFramebuffer()!;
if (cachedResultFrameBuffer == null) {
this.perLayerResultFrameBuffers.set(layer.id, resultFrameBuffer);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, resultFrameBuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, resultTexture, 0);
this.renderLayer(layer, preTexture);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
preTexture = resultTexture;
}
// --------------------
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.useProgram(this.renderInvertedTextureProgram);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, preTexture);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
public async updateLayers(layers: ImageEffectorLayer[]) {
this.layers = layers;
const newTexturesKey = this.calcTexturesKey();
if (newTexturesKey !== this.texturesKey) {
this.texturesKey = newTexturesKey;
await this.bakeTextures();
}
this.render();
}
public destroy() {
const gl = this.gl;
if (gl == null) {
throw new Error('gl is not initialized');
}
for (const shader of this.shaderCache.values()) {
gl.deleteProgram(shader);
}
this.shaderCache.clear();
for (const texture of this.perLayerResultTextures.values()) {
gl.deleteTexture(texture);
}
this.perLayerResultTextures.clear();
for (const framebuffer of this.perLayerResultFrameBuffers.values()) {
gl.deleteFramebuffer(framebuffer);
}
this.perLayerResultFrameBuffers.clear();
this.disposeBakedTextures();
gl.deleteProgram(this.renderTextureProgram);
gl.deleteProgram(this.renderInvertedTextureProgram);
gl.deleteTexture(this.originalImageTexture);
}
}
@@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { FX_chromaticAberration } from './fxs/chromaticAberration.js';
import { FX_colorClamp } from './fxs/colorClamp.js';
import { FX_colorClampAdvanced } from './fxs/colorClampAdvanced.js';
import { FX_distort } from './fxs/distort.js';
import { FX_glitch } from './fxs/glitch.js';
import { FX_grayscale } from './fxs/grayscale.js';
import { FX_invert } from './fxs/invert.js';
import { FX_mirror } from './fxs/mirror.js';
import { FX_threshold } from './fxs/threshold.js';
import { FX_watermarkPlacement } from './fxs/watermarkPlacement.js';
import { FX_zoomLines } from './fxs/zoomLines.js';
import type { ImageEffectorFx } from './ImageEffector.js';
export const FXS = [
FX_watermarkPlacement,
FX_chromaticAberration,
FX_glitch,
FX_mirror,
FX_invert,
FX_grayscale,
FX_colorClamp,
FX_colorClampAdvanced,
FX_distort,
FX_threshold,
FX_zoomLines,
] as const satisfies ImageEffectorFx<string, any>[];
@@ -0,0 +1,83 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import { i18n } from '@/i18n.js';
const shader = `#version 300 es
precision mediump float;
in vec2 in_uv;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
out vec4 out_color;
uniform float u_amount;
uniform float u_start;
uniform bool u_normalize;
void main() {
int samples = 64;
float r_strength = 1.0;
float g_strength = 1.5;
float b_strength = 2.0;
vec2 size = vec2(u_resolution.x, u_resolution.y);
vec4 accumulator = vec4(0.0);
float normalisedValue = length((in_uv - 0.5) * 2.0);
float strength = clamp((normalisedValue - u_start) * (1.0 / (1.0 - u_start)), 0.0, 1.0);
vec2 vector = (u_normalize ? normalize(in_uv - vec2(0.5)) : in_uv - vec2(0.5));
vec2 velocity = vector * strength * u_amount;
vec2 rOffset = -vector * strength * (u_amount * r_strength);
vec2 gOffset = -vector * strength * (u_amount * g_strength);
vec2 bOffset = -vector * strength * (u_amount * b_strength);
for (int i = 0; i < samples; i++) {
accumulator.r += texture(u_texture, in_uv + rOffset).r;
rOffset -= velocity / float(samples);
accumulator.g += texture(u_texture, in_uv + gOffset).g;
gOffset -= velocity / float(samples);
accumulator.b += texture(u_texture, in_uv + bOffset).b;
bOffset -= velocity / float(samples);
}
out_color = vec4(vec3(accumulator / float(samples)), 1.0);
}
`;
export const FX_chromaticAberration = defineImageEffectorFx({
id: 'chromaticAberration' as const,
name: i18n.ts._imageEffector._fxs.chromaticAberration,
shader,
params: {
normalize: {
type: 'boolean' as const,
default: false,
},
amount: {
type: 'number' as const,
default: 0.1,
min: 0.0,
max: 1.0,
step: 0.01,
},
},
main: ({ gl, program, params, preTexture }) => {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, preTexture);
const u_texture = gl.getUniformLocation(program, 'u_texture');
gl.uniform1i(u_texture, 0);
const u_amount = gl.getUniformLocation(program, 'u_amount');
gl.uniform1f(u_amount, params.amount);
const u_normalize = gl.getUniformLocation(program, 'u_normalize');
gl.uniform1i(u_normalize, params.normalize ? 1 : 0);
},
});
@@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import { i18n } from '@/i18n.js';
const shader = `#version 300 es
precision mediump float;
in vec2 in_uv;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_max;
uniform float u_min;
out vec4 out_color;
void main() {
vec4 in_color = texture(u_texture, in_uv);
float r = min(max(in_color.r, u_min), u_max);
float g = min(max(in_color.g, u_min), u_max);
float b = min(max(in_color.b, u_min), u_max);
out_color = vec4(r, g, b, in_color.a);
}
`;
export const FX_colorClamp = defineImageEffectorFx({
id: 'colorClamp' as const,
name: i18n.ts._imageEffector._fxs.colorClamp,
shader,
params: {
max: {
type: 'number' as const,
default: 1.0,
min: 0.0,
max: 1.0,
step: 0.01,
},
min: {
type: 'number' as const,
default: -1.0,
min: -1.0,
max: 0.0,
step: 0.01,
},
},
main: ({ gl, program, params, preTexture }) => {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, preTexture);
const u_texture = gl.getUniformLocation(program, 'u_texture');
gl.uniform1i(u_texture, 0);
const u_max = gl.getUniformLocation(program, 'u_max');
gl.uniform1f(u_max, params.max);
const u_min = gl.getUniformLocation(program, 'u_min');
gl.uniform1f(u_min, 1.0 + params.min);
},
});
@@ -0,0 +1,104 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import { i18n } from '@/i18n.js';
const shader = `#version 300 es
precision mediump float;
in vec2 in_uv;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_rMax;
uniform float u_rMin;
uniform float u_gMax;
uniform float u_gMin;
uniform float u_bMax;
uniform float u_bMin;
out vec4 out_color;
void main() {
vec4 in_color = texture(u_texture, in_uv);
float r = min(max(in_color.r, u_rMin), u_rMax);
float g = min(max(in_color.g, u_gMin), u_gMax);
float b = min(max(in_color.b, u_bMin), u_bMax);
out_color = vec4(r, g, b, in_color.a);
}
`;
export const FX_colorClampAdvanced = defineImageEffectorFx({
id: 'colorClampAdvanced' as const,
name: i18n.ts._imageEffector._fxs.colorClampAdvanced,
shader,
params: {
rMax: {
type: 'number' as const,
default: 1.0,
min: 0.0,
max: 1.0,
step: 0.01,
},
rMin: {
type: 'number' as const,
default: -1.0,
min: -1.0,
max: 0.0,
step: 0.01,
},
gMax: {
type: 'number' as const,
default: 1.0,
min: 0.0,
max: 1.0,
step: 0.01,
},
gMin: {
type: 'number' as const,
default: -1.0,
min: -1.0,
max: 0.0,
step: 0.01,
},
bMax: {
type: 'number' as const,
default: 1.0,
min: 0.0,
max: 1.0,
step: 0.01,
},
bMin: {
type: 'number' as const,
default: -1.0,
min: -1.0,
max: 0.0,
step: 0.01,
},
},
main: ({ gl, program, params, preTexture }) => {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, preTexture);
const u_texture = gl.getUniformLocation(program, 'u_texture');
gl.uniform1i(u_texture, 0);
const u_rMax = gl.getUniformLocation(program, 'u_rMax');
gl.uniform1f(u_rMax, params.rMax);
const u_rMin = gl.getUniformLocation(program, 'u_rMin');
gl.uniform1f(u_rMin, 1.0 + params.rMin);
const u_gMax = gl.getUniformLocation(program, 'u_gMax');
gl.uniform1f(u_gMax, params.gMax);
const u_gMin = gl.getUniformLocation(program, 'u_gMin');
gl.uniform1f(u_gMin, 1.0 + params.gMin);
const u_bMax = gl.getUniformLocation(program, 'u_bMax');
gl.uniform1f(u_bMax, params.bMax);
const u_bMin = gl.getUniformLocation(program, 'u_bMin');
gl.uniform1f(u_bMin, 1.0 + params.bMin);
},
});
@@ -0,0 +1,82 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import { i18n } from '@/i18n.js';
const shader = `#version 300 es
precision mediump float;
in vec2 in_uv;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_phase;
uniform float u_frequency;
uniform float u_strength;
uniform int u_direction; // 0: vertical, 1: horizontal
out vec4 out_color;
void main() {
float v = u_direction == 0 ?
sin(u_phase + in_uv.y * u_frequency) * u_strength :
sin(u_phase + in_uv.x * u_frequency) * u_strength;
vec4 in_color = u_direction == 0 ?
texture(u_texture, vec2(in_uv.x + v, in_uv.y)) :
texture(u_texture, vec2(in_uv.x, in_uv.y + v));
out_color = in_color;
}
`;
export const FX_distort = defineImageEffectorFx({
id: 'distort' as const,
name: i18n.ts._imageEffector._fxs.distort,
shader,
params: {
direction: {
type: 'number:enum' as const,
enum: [{ value: 0, label: 'v' }, { value: 1, label: 'h' }],
default: 0,
},
phase: {
type: 'number' as const,
default: 50.0,
min: 0.0,
max: 100,
step: 0.01,
},
frequency: {
type: 'number' as const,
default: 50,
min: 0,
max: 100,
step: 0.1,
},
strength: {
type: 'number' as const,
default: 0.1,
min: 0,
max: 1,
step: 0.01,
},
},
main: ({ gl, program, params, preTexture }) => {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, preTexture);
const u_texture = gl.getUniformLocation(program, 'u_texture');
gl.uniform1i(u_texture, 0);
const u_phase = gl.getUniformLocation(program, 'u_phase');
gl.uniform1f(u_phase, params.phase / 10);
const u_frequency = gl.getUniformLocation(program, 'u_frequency');
gl.uniform1f(u_frequency, params.frequency);
const u_strength = gl.getUniformLocation(program, 'u_strength');
gl.uniform1f(u_strength, params.strength);
const u_direction = gl.getUniformLocation(program, 'u_direction');
gl.uniform1i(u_direction, params.direction);
},
});
@@ -0,0 +1,103 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import seedrandom from 'seedrandom';
import { defineImageEffectorFx } from '../ImageEffector.js';
import { i18n } from '@/i18n.js';
const shader = `#version 300 es
precision mediump float;
in vec2 in_uv;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform int u_amount;
uniform float u_shiftStrengths[128];
uniform float u_shiftOrigins[128];
uniform float u_shiftHeights[128];
uniform float u_channelShift;
out vec4 out_color;
void main() {
float v = 0.0;
for (int i = 0; i < u_amount; i++) {
if (in_uv.y > (u_shiftOrigins[i] - u_shiftHeights[i]) && in_uv.y < (u_shiftOrigins[i] + u_shiftHeights[i])) {
v += u_shiftStrengths[i];
}
}
float r = texture(u_texture, vec2(in_uv.x + (v * (1.0 + u_channelShift)), in_uv.y)).r;
float g = texture(u_texture, vec2(in_uv.x + v, in_uv.y)).g;
float b = texture(u_texture, vec2(in_uv.x + (v * (1.0 + (u_channelShift / 2.0))), in_uv.y)).b;
float a = texture(u_texture, vec2(in_uv.x + v, in_uv.y)).a;
out_color = vec4(r, g, b, a);
}
`;
export const FX_glitch = defineImageEffectorFx({
id: 'glitch' as const,
name: i18n.ts._imageEffector._fxs.glitch,
shader,
params: {
amount: {
type: 'number' as const,
default: 3,
min: 1,
max: 100,
step: 1,
},
strength: {
type: 'number' as const,
default: 5,
min: -100,
max: 100,
step: 0.01,
},
size: {
type: 'number' as const,
default: 20,
min: 0,
max: 100,
step: 0.01,
},
channelShift: {
type: 'number' as const,
default: 0.5,
min: 0,
max: 10,
step: 0.01,
},
seed: {
type: 'seed' as const,
default: 100,
},
},
main: ({ gl, program, params, preTexture }) => {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, preTexture);
const u_texture = gl.getUniformLocation(program, 'u_texture');
gl.uniform1i(u_texture, 0);
const u_amount = gl.getUniformLocation(program, 'u_amount');
gl.uniform1i(u_amount, params.amount);
const u_channelShift = gl.getUniformLocation(program, 'u_channelShift');
gl.uniform1f(u_channelShift, params.channelShift);
const rnd = seedrandom(params.seed.toString());
for (let i = 0; i < params.amount; i++) {
const o = gl.getUniformLocation(program, `u_shiftOrigins[${i.toString()}]`);
gl.uniform1f(o, rnd());
const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`);
gl.uniform1f(s, (1 - (rnd() * 2)) * (params.strength / 100));
const h = gl.getUniformLocation(program, `u_shiftHeights[${i.toString()}]`);
gl.uniform1f(h, rnd() * (params.size / 100));
}
},
});
@@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import { i18n } from '@/i18n.js';
const shader = `#version 300 es
precision mediump float;
in vec2 in_uv;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
out vec4 out_color;
float getBrightness(vec4 color) {
return (color.r + color.g + color.b) / 3.0;
}
void main() {
vec4 in_color = texture(u_texture, in_uv);
float brightness = getBrightness(in_color);
out_color = vec4(brightness, brightness, brightness, in_color.a);
}
`;
export const FX_grayscale = defineImageEffectorFx({
id: 'grayscale' as const,
name: i18n.ts._imageEffector._fxs.grayscale,
shader,
params: {
},
main: ({ gl, program, params, preTexture }) => {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, preTexture);
const u_texture = gl.getUniformLocation(program, 'u_texture');
gl.uniform1i(u_texture, 0);
},
});
@@ -0,0 +1,70 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import { i18n } from '@/i18n.js';
const shader = `#version 300 es
precision mediump float;
in vec2 in_uv;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform bool u_r;
uniform bool u_g;
uniform bool u_b;
uniform bool u_a;
out vec4 out_color;
void main() {
vec4 in_color = texture(u_texture, in_uv);
out_color.r = u_r ? 1.0 - in_color.r : in_color.r;
out_color.g = u_g ? 1.0 - in_color.g : in_color.g;
out_color.b = u_b ? 1.0 - in_color.b : in_color.b;
out_color.a = u_a ? 1.0 - in_color.a : in_color.a;
}
`;
export const FX_invert = defineImageEffectorFx({
id: 'invert' as const,
name: i18n.ts._imageEffector._fxs.invert,
shader,
params: {
r: {
type: 'boolean' as const,
default: true,
},
g: {
type: 'boolean' as const,
default: true,
},
b: {
type: 'boolean' as const,
default: true,
},
a: {
type: 'boolean' as const,
default: false,
},
},
main: ({ gl, program, params, preTexture }) => {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, preTexture);
const u_texture = gl.getUniformLocation(program, 'u_texture');
gl.uniform1i(u_texture, 0);
const u_r = gl.getUniformLocation(program, 'u_r');
gl.uniform1i(u_r, params.r ? 1 : 0);
const u_g = gl.getUniformLocation(program, 'u_g');
gl.uniform1i(u_g, params.g ? 1 : 0);
const u_b = gl.getUniformLocation(program, 'u_b');
gl.uniform1i(u_b, params.b ? 1 : 0);
const u_a = gl.getUniformLocation(program, 'u_a');
gl.uniform1i(u_a, params.a ? 1 : 0);
},
});
@@ -0,0 +1,65 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import { i18n } from '@/i18n.js';
const shader = `#version 300 es
precision mediump float;
in vec2 in_uv;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform int u_h;
uniform int u_v;
out vec4 out_color;
void main() {
vec2 uv = in_uv;
if (u_h == -1 && in_uv.x > 0.5) {
uv.x = 1.0 - uv.x;
}
if (u_h == 1 && in_uv.x < 0.5) {
uv.x = 1.0 - uv.x;
}
if (u_v == -1 && in_uv.y > 0.5) {
uv.y = 1.0 - uv.y;
}
if (u_v == 1 && in_uv.y < 0.5) {
uv.y = 1.0 - uv.y;
}
out_color = texture(u_texture, uv);
}
`;
export const FX_mirror = defineImageEffectorFx({
id: 'mirror' as const,
name: i18n.ts._imageEffector._fxs.mirror,
shader,
params: {
h: {
type: 'number:enum' as const,
enum: [{ value: -1, label: '<-' }, { value: 0, label: '|' }, { value: 1, label: '->' }],
default: -1,
},
v: {
type: 'number:enum' as const,
enum: [{ value: -1, label: '^' }, { value: 0, label: '-' }, { value: 1, label: 'v' }],
default: 0,
},
},
main: ({ gl, program, params, preTexture }) => {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, preTexture);
const u_texture = gl.getUniformLocation(program, 'u_texture');
gl.uniform1i(u_texture, 0);
const u_h = gl.getUniformLocation(program, 'u_h');
gl.uniform1i(u_h, params.h);
const u_v = gl.getUniformLocation(program, 'u_v');
gl.uniform1i(u_v, params.v);
},
});
@@ -0,0 +1,71 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import { i18n } from '@/i18n.js';
const shader = `#version 300 es
precision mediump float;
in vec2 in_uv;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_r;
uniform float u_g;
uniform float u_b;
out vec4 out_color;
void main() {
vec4 in_color = texture(u_texture, in_uv);
float r = in_color.r < u_r ? 0.0 : 1.0;
float g = in_color.g < u_g ? 0.0 : 1.0;
float b = in_color.b < u_b ? 0.0 : 1.0;
out_color = vec4(r, g, b, in_color.a);
}
`;
export const FX_threshold = defineImageEffectorFx({
id: 'threshold' as const,
name: i18n.ts._imageEffector._fxs.threshold,
shader,
params: {
r: {
type: 'number' as const,
default: 0.5,
min: 0.0,
max: 1.0,
step: 0.01,
},
g: {
type: 'number' as const,
default: 0.5,
min: 0.0,
max: 1.0,
step: 0.01,
},
b: {
type: 'number' as const,
default: 0.5,
min: 0.0,
max: 1.0,
step: 0.01,
},
},
main: ({ gl, program, params, preTexture }) => {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, preTexture);
const u_texture = gl.getUniformLocation(program, 'u_texture');
gl.uniform1i(u_texture, 0);
const u_r = gl.getUniformLocation(program, 'u_r');
gl.uniform1f(u_r, params.r);
const u_g = gl.getUniformLocation(program, 'u_g');
gl.uniform1f(u_g, params.g);
const u_b = gl.getUniformLocation(program, 'u_b');
gl.uniform1f(u_b, params.b);
},
});
@@ -0,0 +1,143 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
const shader = `#version 300 es
precision mediump float;
in vec2 in_uv;
uniform sampler2D u_texture_src;
uniform sampler2D u_texture_watermark;
uniform vec2 u_resolution_src;
uniform vec2 u_resolution_watermark;
uniform float u_scale;
uniform float u_angle;
uniform float u_opacity;
uniform bool u_repeat;
uniform int u_alignX; // 0: left, 1: center, 2: right
uniform int u_alignY; // 0: top, 1: center, 2: bottom
uniform int u_fitMode; // 0: contain, 1: cover
out vec4 out_color;
void main() {
vec4 in_color = texture(u_texture_src, in_uv);
bool contain = u_fitMode == 0;
float x_ratio = u_resolution_watermark.x / u_resolution_src.x;
float y_ratio = u_resolution_watermark.y / u_resolution_src.y;
float aspect_ratio = contain ?
(min(x_ratio, y_ratio) / max(x_ratio, y_ratio)) :
(max(x_ratio, y_ratio) / min(x_ratio, y_ratio));
float x_scale = contain ?
(x_ratio > y_ratio ? 1.0 * u_scale : aspect_ratio * u_scale) :
(x_ratio > y_ratio ? aspect_ratio * u_scale : 1.0 * u_scale);
float y_scale = contain ?
(y_ratio > x_ratio ? 1.0 * u_scale : aspect_ratio * u_scale) :
(y_ratio > x_ratio ? aspect_ratio * u_scale : 1.0 * u_scale);
float x_offset = u_alignX == 0 ? x_scale / 2.0 : u_alignX == 2 ? 1.0 - (x_scale / 2.0) : 0.5;
float y_offset = u_alignY == 0 ? y_scale / 2.0 : u_alignY == 2 ? 1.0 - (y_scale / 2.0) : 0.5;
if (!u_repeat) {
bool isInside = in_uv.x > x_offset - (x_scale / 2.0) && in_uv.x < x_offset + (x_scale / 2.0) &&
in_uv.y > y_offset - (y_scale / 2.0) && in_uv.y < y_offset + (y_scale / 2.0);
if (!isInside) {
out_color = in_color;
return;
}
}
vec4 watermark_color = texture(u_texture_watermark, vec2(
(in_uv.x - (x_offset - (x_scale / 2.0))) / x_scale,
(in_uv.y - (y_offset - (y_scale / 2.0))) / y_scale
));
out_color.r = mix(in_color.r, watermark_color.r, u_opacity * watermark_color.a);
out_color.g = mix(in_color.g, watermark_color.g, u_opacity * watermark_color.a);
out_color.b = mix(in_color.b, watermark_color.b, u_opacity * watermark_color.a);
out_color.a = in_color.a * (1.0 - u_opacity * watermark_color.a) + watermark_color.a * u_opacity;
}
`;
export const FX_watermarkPlacement = defineImageEffectorFx({
id: 'watermarkPlacement' as const,
name: '(internal)',
shader,
params: {
cover: {
type: 'boolean' as const,
default: false,
},
repeat: {
type: 'boolean' as const,
default: false,
},
scale: {
type: 'number' as const,
default: 0.3,
min: 0.0,
max: 1.0,
step: 0.01,
},
align: {
type: 'align' as const,
default: { x: 'right', y: 'bottom' },
},
opacity: {
type: 'number' as const,
default: 0.75,
min: 0.0,
max: 1.0,
step: 0.01,
},
},
main: ({ gl, program, params, preTexture, width, height, watermark }) => {
if (watermark == null) {
return;
}
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, preTexture);
const u_texture_src = gl.getUniformLocation(program, 'u_texture_src');
gl.uniform1i(u_texture_src, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, watermark.texture);
const u_texture_watermark = gl.getUniformLocation(program, 'u_texture_watermark');
gl.uniform1i(u_texture_watermark, 1);
const u_resolution_src = gl.getUniformLocation(program, 'u_resolution_src');
gl.uniform2fv(u_resolution_src, [width, height]);
const u_resolution_watermark = gl.getUniformLocation(program, 'u_resolution_watermark');
gl.uniform2fv(u_resolution_watermark, [watermark.width, watermark.height]);
const u_scale = gl.getUniformLocation(program, 'u_scale');
gl.uniform1f(u_scale, params.scale);
const u_opacity = gl.getUniformLocation(program, 'u_opacity');
gl.uniform1f(u_opacity, params.opacity);
const u_angle = gl.getUniformLocation(program, 'u_angle');
gl.uniform1f(u_angle, 0.0);
const u_repeat = gl.getUniformLocation(program, 'u_repeat');
gl.uniform1i(u_repeat, params.repeat ? 1 : 0);
const u_alignX = gl.getUniformLocation(program, 'u_alignX');
gl.uniform1i(u_alignX, params.align.x === 'left' ? 0 : params.align.x === 'right' ? 2 : 1);
const u_alignY = gl.getUniformLocation(program, 'u_alignY');
gl.uniform1i(u_alignY, params.align.y === 'top' ? 0 : params.align.y === 'bottom' ? 2 : 1);
const u_fitMode = gl.getUniformLocation(program, 'u_fitMode');
gl.uniform1i(u_fitMode, params.cover ? 1 : 0);
},
});
@@ -0,0 +1,112 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineImageEffectorFx } from '../ImageEffector.js';
import { i18n } from '@/i18n.js';
const shader = `#version 300 es
precision mediump float;
in vec2 in_uv;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform vec2 u_pos;
uniform float u_frequency;
uniform bool u_thresholdEnabled;
uniform float u_threshold;
uniform float u_maskSize;
uniform bool u_black;
out vec4 out_color;
void main() {
vec4 in_color = texture(u_texture, in_uv);
float angle = atan(-u_pos.y + (in_uv.y), -u_pos.x + (in_uv.x));
float t = (1.0 + sin(angle * u_frequency)) / 2.0;
if (u_thresholdEnabled) t = t > u_threshold ? 1.0 : 0.0;
float d = distance(in_uv * vec2(2.0, 2.0), u_pos * vec2(2.0, 2.0));
float mask = d < u_maskSize ? 0.0 : ((d - u_maskSize) * (1.0 + (u_maskSize * 2.0)));
out_color = vec4(
mix(in_color.r, u_black ? 0.0 : 1.0, t * mask),
mix(in_color.g, u_black ? 0.0 : 1.0, t * mask),
mix(in_color.b, u_black ? 0.0 : 1.0, t * mask),
in_color.a
);
}
`;
export const FX_zoomLines = defineImageEffectorFx({
id: 'zoomLines' as const,
name: i18n.ts._imageEffector._fxs.zoomLines,
shader,
params: {
x: {
type: 'number' as const,
default: 0.0,
min: -1.0,
max: 1.0,
step: 0.01,
},
y: {
type: 'number' as const,
default: 0.0,
min: -1.0,
max: 1.0,
step: 0.01,
},
frequency: {
type: 'number' as const,
default: 30.0,
min: 1.0,
max: 200.0,
step: 0.1,
},
thresholdEnabled: {
type: 'boolean' as const,
default: true,
},
threshold: {
type: 'number' as const,
default: 0.5,
min: 0.0,
max: 1.0,
step: 0.01,
},
maskSize: {
type: 'number' as const,
default: 0.5,
min: 0.0,
max: 1.0,
step: 0.01,
},
black: {
type: 'boolean' as const,
default: false,
},
},
main: ({ gl, program, params, preTexture }) => {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, preTexture);
const u_texture = gl.getUniformLocation(program, 'u_texture');
gl.uniform1i(u_texture, 0);
const u_pos = gl.getUniformLocation(program, 'u_pos');
gl.uniform2f(u_pos, (1.0 + params.x) / 2.0, (1.0 + params.y) / 2.0);
const u_frequency = gl.getUniformLocation(program, 'u_frequency');
gl.uniform1f(u_frequency, params.frequency);
const u_thresholdEnabled = gl.getUniformLocation(program, 'u_thresholdEnabled');
gl.uniform1i(u_thresholdEnabled, params.thresholdEnabled ? 1 : 0);
const u_threshold = gl.getUniformLocation(program, 'u_threshold');
gl.uniform1f(u_threshold, params.threshold);
const u_maskSize = gl.getUniformLocation(program, 'u_maskSize');
gl.uniform1f(u_maskSize, params.maskSize);
const u_black = gl.getUniformLocation(program, 'u_black');
gl.uniform1i(u_black, params.black ? 1 : 0);
},
});
@@ -3,10 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAsyncComponent } from 'vue';
import { $i } from '@/i.js';
import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { popupAsyncWithDialog } from '@/os.js';
import { popup } from '@/os.js';
export type OpenOnRemoteOptions = {
/**
@@ -44,7 +45,7 @@ export type OpenOnRemoteOptions = {
params: Record<string, string>;
};
export async function pleaseLogin(opts: {
export function pleaseLogin(opts: {
path?: string;
message?: string;
openOnRemote?: OpenOnRemoteOptions;
@@ -58,7 +59,7 @@ export async function pleaseLogin(opts: {
_openOnRemote = opts.openOnRemote;
}
const { dispose } = await popupAsyncWithDialog(import('@/components/MkSigninDialog.vue').then(x => x.default), {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {
autoSet: true,
message: opts.message ?? (_openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired),
openOnRemote: _openOnRemote,
@@ -38,7 +38,7 @@ export class SnowfallEffect {
`;
private FRAGMENT_SOURCE = `#version 300 es
precision highp float;
precision mediump float;
in vec4 v_color;
in float v_rotation;
+1 -3
View File
@@ -6,7 +6,6 @@
import type { SoundStore } from '@/preferences/def.js';
import { prefer } from '@/preferences.js';
import { PREF_DEF } from '@/preferences/def.js';
import { getInitialPrefValue } from '@/preferences/manager.js';
let ctx: AudioContext;
const cache = new Map<string, AudioBuffer>();
@@ -134,8 +133,7 @@ export function playMisskeySfx(operationType: OperationType) {
playMisskeySfxFile(sound).then((succeed) => {
if (!succeed && sound.type === '_driveFile_') {
// ドライブファイルが存在しない場合はデフォルトのサウンドを再生する
const default_ = getInitialPrefValue(`sound.on.${operationType}`);
const soundName = default_.type as Exclude<SoundType, '_driveFile_'>;
const soundName = PREF_DEF[`sound.on.${operationType}`].default.type as Exclude<SoundType, '_driveFile_'>;
if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`);
playMisskeySfxFileInternal({
type: soundName,
@@ -0,0 +1,64 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
export type WatermarkPreset = {
id: string;
name: string;
layers: ({
id: string;
type: 'text';
text: string;
repeat: boolean;
scale: number;
align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom' };
opacity: number;
} | {
id: string;
type: 'image';
imageUrl: string | null;
imageId: string | null;
cover: boolean;
repeat: boolean;
scale: number;
align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom' };
opacity: number;
})[];
};
export function makeImageEffectorLayers(layers: WatermarkPreset['layers']): ImageEffectorLayer[] {
return layers.map(layer => {
if (layer.type === 'text') {
return {
fxId: 'watermarkPlacement',
id: layer.id,
params: {
repeat: layer.repeat,
scale: layer.scale,
align: layer.align,
opacity: layer.opacity,
cover: false,
},
text: layer.text,
imageUrl: null,
};
} else {
return {
fxId: 'watermarkPlacement',
id: layer.id,
params: {
repeat: layer.repeat,
scale: layer.scale,
align: layer.align,
opacity: layer.opacity,
cover: layer.cover,
},
text: null,
imageUrl: layer.imageUrl ?? null,
};
}
});
}
@@ -54,8 +54,8 @@ const { widgetProps, configure, save } = useWidgetPropsManager(name,
emit,
);
const configureNotification = async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkNotificationSelectWindow.vue').then(x => x.default), {
const configureNotification = () => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSelectWindow.vue')), {
excludeTypes: widgetProps.excludeTypes,
}, {
done: async (res) => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2025.6.0-beta.1",
"version": "2025.5.1-beta.4",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",
+1 -1
View File
@@ -9074,7 +9074,7 @@ export type operations = {
'application/json': {
/** @enum {string} */
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
state: ('active' | 'wait' | 'delayed' | 'completed' | 'failed' | 'paused')[];
state: ('active' | 'wait' | 'delayed' | 'completed' | 'failed')[];
search?: string;
};
};