Compare commits
29 Commits
5621c85d6b
...
a2fd50f866
Author | SHA1 | Date |
---|---|---|
|
a2fd50f866 | |
|
2f0c472014 | |
|
02cffa3d51 | |
|
58b14e3f9d | |
|
12b59b9ee4 | |
|
3db7be8b74 | |
|
3a4288fe24 | |
|
12c71de4c7 | |
|
b7aa013a41 | |
|
3ff2e6b299 | |
|
cd9322a824 | |
|
9fdc3c5def | |
|
4af8c7f8b0 | |
|
ed29a3613b | |
|
810a609df4 | |
|
5537558fa7 | |
|
26215b7466 | |
|
0a0247a678 | |
|
93d17aff6c | |
|
3bc81522c6 | |
|
2a077de148 | |
|
3eb64ccf7a | |
|
47fede5e5c | |
|
b48a25ea77 | |
|
51e7081c9d | |
|
982ae9238d | |
|
d40c083233 | |
|
8f025c447b | |
|
9dc423afd4 |
22
CHANGELOG.md
22
CHANGELOG.md
|
@ -1,16 +1,30 @@
|
||||||
## 2025.6.0
|
## 2025.6.1
|
||||||
|
|
||||||
### General
|
### General
|
||||||
-
|
-
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
- Enhance: 非同期的なコンポーネントの読み込み時のハンドリングを強化
|
- Feat: 画像にウォーターマークを付与できるようになりました
|
||||||
- Fix: リアクションの一部の絵文字が重複して表示されることがある問題を修正
|
- Enhance: ノートのリアクション一覧で、押せるリアクションを優先して表示できるようにするオプションを追加
|
||||||
|
- Enhance: 全てのチャットメッセージを既読にできるように(設定→その他)
|
||||||
|
- Fix: ドライブファイルの選択が不安定な問題を修正
|
||||||
|
- Fix: コントロールパネルのファイル欄などのデザインが崩れている問題を修正
|
||||||
|
- Fix: ユーザーの検索結果を追加で読み込むことができない問題を修正
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
-
|
- Feat: 全てのチャットメッセージを既読にするAPIを追加(chat/read-all)
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.6.0
|
||||||
|
|
||||||
|
### Client
|
||||||
|
- Enhance: 非同期的なコンポーネントの読み込み時のハンドリングを強化
|
||||||
|
- Fix: リアクションの一部の絵文字が重複して表示されることがある問題を修正
|
||||||
|
- Fix: 非利用者に対するユーザー作成コンテンツの公開範囲が全て非公開になっている場合にログインできない問題を修正
|
||||||
|
|
||||||
|
### Server
|
||||||
|
- Fix: 非利用者に対するユーザー作成コンテンツの公開範囲が全て非公開になっている場合でもusers/showを許可するように
|
||||||
|
|
||||||
## 2025.5.1
|
## 2025.5.1
|
||||||
|
|
||||||
### Note
|
### Note
|
||||||
|
|
|
@ -1589,3 +1589,11 @@ _search:
|
||||||
searchScopeAll: "الكل"
|
searchScopeAll: "الكل"
|
||||||
searchScopeLocal: "المحلي"
|
searchScopeLocal: "المحلي"
|
||||||
searchScopeUser: "مستخدم محدد"
|
searchScopeUser: "مستخدم محدد"
|
||||||
|
_watermarkEditor:
|
||||||
|
opacity: "الشفافية"
|
||||||
|
scale: "الحجم"
|
||||||
|
text: "نص"
|
||||||
|
position: "الموضع"
|
||||||
|
type: "نوع"
|
||||||
|
image: "صور"
|
||||||
|
advanced: "متقدم"
|
||||||
|
|
|
@ -1349,3 +1349,9 @@ _remoteLookupErrors:
|
||||||
_search:
|
_search:
|
||||||
searchScopeAll: "সবগুলো"
|
searchScopeAll: "সবগুলো"
|
||||||
searchScopeLocal: "স্থানীয়"
|
searchScopeLocal: "স্থানীয়"
|
||||||
|
_watermarkEditor:
|
||||||
|
opacity: "অস্বচ্ছতা"
|
||||||
|
scale: "আকার"
|
||||||
|
text: "লেখা"
|
||||||
|
image: "ছবি"
|
||||||
|
advanced: "উন্নত"
|
||||||
|
|
|
@ -1365,6 +1365,8 @@ abort: "Cancel·lar"
|
||||||
tip: "Trucs i consells"
|
tip: "Trucs i consells"
|
||||||
redisplayAllTips: "Torna ha mostrat tots els trucs i consells"
|
redisplayAllTips: "Torna ha mostrat tots els trucs i consells"
|
||||||
hideAllTips: "Amagar tots els trucs i consells"
|
hideAllTips: "Amagar tots els trucs i consells"
|
||||||
|
defaultImageCompressionLevel: "Nivell de comprensió de la imatge per defecte"
|
||||||
|
defaultImageCompressionLevel_description: "Baixa, conserva la qualitat de la imatge però la mida de l'arxiu és més gran. <br>Alta, redueix la mida de l'arxiu però també la qualitat de la imatge."
|
||||||
_chat:
|
_chat:
|
||||||
noMessagesYet: "Encara no tens missatges "
|
noMessagesYet: "Encara no tens missatges "
|
||||||
newMessage: "Missatge nou"
|
newMessage: "Missatge nou"
|
||||||
|
@ -1452,6 +1454,7 @@ _settings:
|
||||||
contentsUpdateFrequency_description: "Com més alt sigui l'adquisició de contingut en temps real, més baixa el rendiment i més consum de dades i bateria."
|
contentsUpdateFrequency_description: "Com més alt sigui l'adquisició de contingut en temps real, més baixa el rendiment i més consum de dades i bateria."
|
||||||
contentsUpdateFrequency_description2: "Quan s'activa el mode en temps real, el contingut s'actualitza en temps real, independentment d'aquesta configuració."
|
contentsUpdateFrequency_description2: "Quan s'activa el mode en temps real, el contingut s'actualitza en temps real, independentment d'aquesta configuració."
|
||||||
showUrlPreview: "Mostrar vista prèvia d'URL"
|
showUrlPreview: "Mostrar vista prèvia d'URL"
|
||||||
|
showAvailableReactionsFirstInNote: "Mostra les reacciones que pots fer servir al damunt"
|
||||||
_chat:
|
_chat:
|
||||||
showSenderName: "Mostrar el nom del remitent"
|
showSenderName: "Mostrar el nom del remitent"
|
||||||
sendOnEnter: "Introdueix per enviar"
|
sendOnEnter: "Introdueix per enviar"
|
||||||
|
@ -3117,3 +3120,47 @@ _clip:
|
||||||
tip: "Clip és una funció que permet organitzar les teves notes."
|
tip: "Clip és una funció que permet organitzar les teves notes."
|
||||||
_userLists:
|
_userLists:
|
||||||
tip: "Es poden crear llistes amb qualsevol usuari. La llista creada es pot mostrar com una línia de temps."
|
tip: "Es poden crear llistes amb qualsevol usuari. La llista creada es pot mostrar com una línia de temps."
|
||||||
|
watermark: "Marca d'aigua "
|
||||||
|
defaultPreset: "Per defecte"
|
||||||
|
_watermarkEditor:
|
||||||
|
tip: "A la imatge es pot afegir una marca d'aigua com informació sobre drets."
|
||||||
|
quitWithoutSaveConfirm: "Sortir sense desar?"
|
||||||
|
title: "Editar la marca d'aigua "
|
||||||
|
cover: "Cobrir-ho tot"
|
||||||
|
repeat: "Repetir"
|
||||||
|
opacity: "Opacitat"
|
||||||
|
scale: "Mida"
|
||||||
|
text: "Text"
|
||||||
|
position: "Posició "
|
||||||
|
type: "Tipus"
|
||||||
|
image: "Imatges"
|
||||||
|
advanced: "Avançat"
|
||||||
|
stripe: "Bandes"
|
||||||
|
stripeWidth: "Amplada de la banda"
|
||||||
|
stripeFrequency: "Freqüència de la banda"
|
||||||
|
angle: "Angle"
|
||||||
|
polkadot: "Lunars"
|
||||||
|
checker: "Escacs"
|
||||||
|
polkadotMainDotOpacity: "Opacitat del lunar principal"
|
||||||
|
polkadotMainDotRadius: "Mida del lunar principal"
|
||||||
|
polkadotSubDotOpacity: "Opacitat del lunar secundari"
|
||||||
|
polkadotSubDotRadius: "Mida del lunar secundari"
|
||||||
|
polkadotSubDotDivisions: "Nombre de punts secundaris"
|
||||||
|
_imageEffector:
|
||||||
|
title: "Efecte"
|
||||||
|
addEffect: "Afegeix un efecte"
|
||||||
|
discardChangesConfirm: "Vols descartar els canvis i sortir?"
|
||||||
|
_fxs:
|
||||||
|
chromaticAberration: "Aberració cromàtica"
|
||||||
|
glitch: "Glitch"
|
||||||
|
mirror: "Mirall"
|
||||||
|
invert: "Inversió cromàtica "
|
||||||
|
grayscale: "Monocrom "
|
||||||
|
colorClamp: "Compressió cromàtica "
|
||||||
|
colorClampAdvanced: "Compressió de cromàtica avançada "
|
||||||
|
distort: "Distorsió "
|
||||||
|
threshold: "Binarització"
|
||||||
|
zoomLines: "Saturació de línies "
|
||||||
|
stripe: "Bandes"
|
||||||
|
polkadot: "Lunars"
|
||||||
|
checker: "Escacs"
|
||||||
|
|
|
@ -2043,3 +2043,11 @@ _search:
|
||||||
searchScopeAll: "Vše"
|
searchScopeAll: "Vše"
|
||||||
searchScopeLocal: "Místní"
|
searchScopeLocal: "Místní"
|
||||||
searchScopeUser: "Upřesnit uživatele"
|
searchScopeUser: "Upřesnit uživatele"
|
||||||
|
_watermarkEditor:
|
||||||
|
opacity: "Průhlednost"
|
||||||
|
scale: "Velikost"
|
||||||
|
text: "Text"
|
||||||
|
position: "Pozice"
|
||||||
|
type: "Typ"
|
||||||
|
image: "Obrázky"
|
||||||
|
advanced: "Pokročilé"
|
||||||
|
|
|
@ -3001,3 +3001,12 @@ _search:
|
||||||
pleaseEnterServerHost: "Gib den Server-Host ein"
|
pleaseEnterServerHost: "Gib den Server-Host ein"
|
||||||
pleaseSelectUser: "Benutzer auswählen"
|
pleaseSelectUser: "Benutzer auswählen"
|
||||||
serverHostPlaceholder: "Beispiel: misskey.example.com"
|
serverHostPlaceholder: "Beispiel: misskey.example.com"
|
||||||
|
_watermarkEditor:
|
||||||
|
opacity: "Transparenz"
|
||||||
|
scale: "Größe"
|
||||||
|
text: "Text"
|
||||||
|
position: "Position"
|
||||||
|
type: "Art"
|
||||||
|
image: "Bilder"
|
||||||
|
advanced: "Fortgeschritten"
|
||||||
|
angle: "Winkel"
|
||||||
|
|
|
@ -403,3 +403,5 @@ _reversi:
|
||||||
total: "Σύνολο"
|
total: "Σύνολο"
|
||||||
_search:
|
_search:
|
||||||
searchScopeLocal: "Τοπικό"
|
searchScopeLocal: "Τοπικό"
|
||||||
|
_watermarkEditor:
|
||||||
|
image: "Εικόνες"
|
||||||
|
|
|
@ -3117,3 +3117,12 @@ _clip:
|
||||||
tip: "Clip is a feature that allows you to organize your notes."
|
tip: "Clip is a feature that allows you to organize your notes."
|
||||||
_userLists:
|
_userLists:
|
||||||
tip: "Lists can contain any user you specify when creating, the created list can then be displayed as a timeline showing only the specified users."
|
tip: "Lists can contain any user you specify when creating, the created list can then be displayed as a timeline showing only the specified users."
|
||||||
|
_watermarkEditor:
|
||||||
|
opacity: "Opacity"
|
||||||
|
scale: "Size"
|
||||||
|
text: "Text"
|
||||||
|
position: "Position"
|
||||||
|
type: "Type"
|
||||||
|
image: "Images"
|
||||||
|
advanced: "Advanced"
|
||||||
|
angle: "Angle"
|
||||||
|
|
|
@ -1330,6 +1330,7 @@ restore: "Restaurar"
|
||||||
syncBetweenDevices: "Sincronizar entre dispositivos"
|
syncBetweenDevices: "Sincronizar entre dispositivos"
|
||||||
preferenceSyncConflictTitle: "Los valores configurados existen en el servidor."
|
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?"
|
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"
|
preferenceSyncConflictChoiceServer: "Valores de configuración del servidor"
|
||||||
preferenceSyncConflictChoiceDevice: "Valor configurado en el dispositivo"
|
preferenceSyncConflictChoiceDevice: "Valor configurado en el dispositivo"
|
||||||
preferenceSyncConflictChoiceCancel: "Cancelar la activación de la sincronización"
|
preferenceSyncConflictChoiceCancel: "Cancelar la activación de la sincronización"
|
||||||
|
@ -1626,6 +1627,26 @@ _serverSettings:
|
||||||
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."
|
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 "
|
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."
|
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:
|
_accountMigration:
|
||||||
moveFrom: "Trasladar de otra cuenta a ésta"
|
moveFrom: "Trasladar de otra cuenta a ésta"
|
||||||
moveFromSub: "Crear un alias para otra cuenta."
|
moveFromSub: "Crear un alias para otra cuenta."
|
||||||
|
@ -1922,6 +1943,8 @@ _role:
|
||||||
descriptionOfIsExplorable: "La línea de tiempo de éste rol y la lista de usuarios serán públicos si se activa.."
|
descriptionOfIsExplorable: "La línea de tiempo de éste rol y la lista de usuarios serán públicos si se activa.."
|
||||||
displayOrder: "Posición"
|
displayOrder: "Posición"
|
||||||
descriptionOfDisplayOrder: "Entre más alto el número, mayor es la posición en la interfaz."
|
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"
|
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."
|
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"
|
priority: "Prioridad"
|
||||||
|
@ -1941,7 +1964,9 @@ _role:
|
||||||
canManageCustomEmojis: "Administrar emojis personalizados"
|
canManageCustomEmojis: "Administrar emojis personalizados"
|
||||||
canManageAvatarDecorations: "Administrar decoraciones de avatar"
|
canManageAvatarDecorations: "Administrar decoraciones de avatar"
|
||||||
driveCapacity: "Capacidad del drive"
|
driveCapacity: "Capacidad del drive"
|
||||||
|
maxFileSize: "Tamaño máximo de archivo que se puede cargar."
|
||||||
alwaysMarkNsfw: "Siempre marcar archivos como NSFW"
|
alwaysMarkNsfw: "Siempre marcar archivos como NSFW"
|
||||||
|
canUpdateBioMedia: "Puede editar un icono o una imagen de fondo (banner)"
|
||||||
pinMax: "Máximo de notas fijadas"
|
pinMax: "Máximo de notas fijadas"
|
||||||
antennaMax: "Máximo de antenas"
|
antennaMax: "Máximo de antenas"
|
||||||
wordMuteMax: "Máximo de caracteres en palabras silenciadas"
|
wordMuteMax: "Máximo de caracteres en palabras silenciadas"
|
||||||
|
@ -1956,6 +1981,15 @@ _role:
|
||||||
canSearchNotes: "Uso de la búsqueda de notas"
|
canSearchNotes: "Uso de la búsqueda de notas"
|
||||||
canUseTranslator: "Uso de traductor"
|
canUseTranslator: "Uso de traductor"
|
||||||
avatarDecorationLimit: "Número máximo de decoraciones de avatar"
|
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:
|
_condition:
|
||||||
roleAssignedTo: "Asignado a roles manuales"
|
roleAssignedTo: "Asignado a roles manuales"
|
||||||
isLocal: "Usuario local"
|
isLocal: "Usuario local"
|
||||||
|
@ -1964,6 +1998,7 @@ _role:
|
||||||
isBot: "Usuarios Bot"
|
isBot: "Usuarios Bot"
|
||||||
isSuspended: "Usuario suspendido"
|
isSuspended: "Usuario suspendido"
|
||||||
isLocked: "Cuentas privadas"
|
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"
|
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"
|
createdMoreThan: "Más de X han pasado desde la creación de la cuenta"
|
||||||
followersLessThanOrEq: "Tiene X o menos seguidores"
|
followersLessThanOrEq: "Tiene X o menos seguidores"
|
||||||
|
@ -2118,6 +2153,7 @@ _theme:
|
||||||
installed: "{name} ha sido instalado"
|
installed: "{name} ha sido instalado"
|
||||||
installedThemes: "Temas instalados"
|
installedThemes: "Temas instalados"
|
||||||
builtinThemes: "Temas integrados"
|
builtinThemes: "Temas integrados"
|
||||||
|
instanceTheme: "Tema del servidor (o también denominado: tema de la instancia)"
|
||||||
alreadyInstalled: "Este tema ya está instalado"
|
alreadyInstalled: "Este tema ya está instalado"
|
||||||
invalid: "El formato del tema no es válido"
|
invalid: "El formato del tema no es válido"
|
||||||
make: "Crear tema"
|
make: "Crear tema"
|
||||||
|
@ -2179,6 +2215,7 @@ _sfx:
|
||||||
noteMy: "Nota (a mí mismo)"
|
noteMy: "Nota (a mí mismo)"
|
||||||
notification: "Notificaciones"
|
notification: "Notificaciones"
|
||||||
reaction: "Al seleccionar una reacción"
|
reaction: "Al seleccionar una reacción"
|
||||||
|
chatMessage: "Mensajes del Chat"
|
||||||
_soundSettings:
|
_soundSettings:
|
||||||
driveFile: "Usar un archivo de audio en Drive"
|
driveFile: "Usar un archivo de audio en Drive"
|
||||||
driveFileWarn: "Selecciona un archivo de audio en Drive."
|
driveFileWarn: "Selecciona un archivo de audio en Drive."
|
||||||
|
@ -2186,6 +2223,7 @@ _soundSettings:
|
||||||
driveFileTypeWarnDescription: "Selecciona un archivo de audio"
|
driveFileTypeWarnDescription: "Selecciona un archivo de audio"
|
||||||
driveFileDurationWarn: "La duración del audio es demasiado larga."
|
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?"
|
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:
|
_ago:
|
||||||
future: "Futuro"
|
future: "Futuro"
|
||||||
justNow: "Justo ahora"
|
justNow: "Justo ahora"
|
||||||
|
@ -2325,6 +2363,7 @@ _permissions:
|
||||||
"read:federation": "Ver instancias federadas"
|
"read:federation": "Ver instancias federadas"
|
||||||
"write:report-abuse": "Crear reportes de usuario"
|
"write:report-abuse": "Crear reportes de usuario"
|
||||||
"write:chat": "Administrar chat"
|
"write:chat": "Administrar chat"
|
||||||
|
"read:chat": "Explorar Chats"
|
||||||
_auth:
|
_auth:
|
||||||
shareAccessTitle: "Permisos de la aplicación"
|
shareAccessTitle: "Permisos de la aplicación"
|
||||||
shareAccess: "¿Desea permitir el acceso a la cuenta \"{name}\"?"
|
shareAccess: "¿Desea permitir el acceso a la cuenta \"{name}\"?"
|
||||||
|
@ -2333,8 +2372,11 @@ _auth:
|
||||||
permissionAsk: "Esta aplicación requiere los siguientes permisos"
|
permissionAsk: "Esta aplicación requiere los siguientes permisos"
|
||||||
pleaseGoBack: "Por favor, vuelve a la aplicación"
|
pleaseGoBack: "Por favor, vuelve a la aplicación"
|
||||||
callback: "Volviendo a la aplicación"
|
callback: "Volviendo a la aplicación"
|
||||||
|
accepted: "Acceso concedido."
|
||||||
denied: "Acceso denegado"
|
denied: "Acceso denegado"
|
||||||
|
scopeUser: "Operar como el siguiente usuario"
|
||||||
pleaseLogin: "Se requiere un inicio de sesión para darle permisos a la aplicación"
|
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:
|
_antennaSources:
|
||||||
all: "Todas las notas"
|
all: "Todas las notas"
|
||||||
homeTimeline: "Notas de los usuarios que sigues"
|
homeTimeline: "Notas de los usuarios que sigues"
|
||||||
|
@ -2444,6 +2486,9 @@ _profile:
|
||||||
changeBanner: "Cambiar banner"
|
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."
|
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."
|
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:
|
_exportOrImport:
|
||||||
allNotes: "Todas las notas"
|
allNotes: "Todas las notas"
|
||||||
favoritedNotes: "Notas favoritas"
|
favoritedNotes: "Notas favoritas"
|
||||||
|
@ -2533,6 +2578,7 @@ _pages:
|
||||||
eyeCatchingImageSet: "Elegir imagen llamativa"
|
eyeCatchingImageSet: "Elegir imagen llamativa"
|
||||||
eyeCatchingImageRemove: "Borrar imagen llamativa"
|
eyeCatchingImageRemove: "Borrar imagen llamativa"
|
||||||
chooseBlock: "Agregar bloque"
|
chooseBlock: "Agregar bloque"
|
||||||
|
enterSectionTitle: "Escribe el título de la sección"
|
||||||
selectType: "Elegir tipo"
|
selectType: "Elegir tipo"
|
||||||
contentBlocks: "Contenido"
|
contentBlocks: "Contenido"
|
||||||
inputBlocks: "Entrada"
|
inputBlocks: "Entrada"
|
||||||
|
@ -2567,6 +2613,7 @@ _notification:
|
||||||
newNote: "Nueva nota"
|
newNote: "Nueva nota"
|
||||||
unreadAntennaNote: "Antena {name}"
|
unreadAntennaNote: "Antena {name}"
|
||||||
roleAssigned: "Rol asignado"
|
roleAssigned: "Rol asignado"
|
||||||
|
chatRoomInvitationReceived: "Invitado a la sala de chat."
|
||||||
emptyPushNotificationMessage: "Se han actualizado las notificaciones push"
|
emptyPushNotificationMessage: "Se han actualizado las notificaciones push"
|
||||||
achievementEarned: "Logro desbloqueado"
|
achievementEarned: "Logro desbloqueado"
|
||||||
testNotification: "Notificación de prueba"
|
testNotification: "Notificación de prueba"
|
||||||
|
@ -2574,8 +2621,12 @@ _notification:
|
||||||
sendTestNotification: "Enviar notificación de prueba"
|
sendTestNotification: "Enviar notificación de prueba"
|
||||||
notificationWillBeDisplayedLikeThis: "Las notificaciones tendrán este aspecto"
|
notificationWillBeDisplayedLikeThis: "Las notificaciones tendrán este aspecto"
|
||||||
reactedBySomeUsers: "{n} usuarios han reaccionado"
|
reactedBySomeUsers: "{n} usuarios han reaccionado"
|
||||||
|
likedBySomeUsers: "{n} usuarios les gustó tu nota"
|
||||||
renotedBySomeUsers: "{n} usuarios han renotado"
|
renotedBySomeUsers: "{n} usuarios han renotado"
|
||||||
followedBySomeUsers: "Seguido por {n} usuarios"
|
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}\"."
|
createTokenDescription: "Si no tienes ni idea, elimina el token de acceso a través de \"{text}\"."
|
||||||
_types:
|
_types:
|
||||||
all: "Todo"
|
all: "Todo"
|
||||||
|
@ -2778,21 +2829,116 @@ _reversi:
|
||||||
rules: "Reglas"
|
rules: "Reglas"
|
||||||
won: "{name} ha ganado"
|
won: "{name} ha ganado"
|
||||||
total: "Total"
|
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:
|
_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)"
|
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)"
|
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"
|
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:
|
_mediaControls:
|
||||||
pip: "Picture in Picture"
|
pip: "Picture in Picture"
|
||||||
playbackRate: "Velocidad de reproducción"
|
playbackRate: "Velocidad de reproducción"
|
||||||
loop: "Reproducción en bucle"
|
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:
|
_followRequest:
|
||||||
recieved: "Petición de seguimiento recibida"
|
recieved: "Petición de seguimiento recibida"
|
||||||
sent: "Petición de seguimiento enviada"
|
sent: "Petición de seguimiento enviada"
|
||||||
_remoteLookupErrors:
|
_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:
|
_noSuchObject:
|
||||||
title: "No se encuentra"
|
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:
|
_search:
|
||||||
searchScopeAll: "Todo"
|
searchScopeAll: "Todo"
|
||||||
searchScopeLocal: "Local"
|
searchScopeLocal: "Local"
|
||||||
searchScopeUser: "Especificar usuario"
|
searchScopeUser: "Especificar usuario"
|
||||||
|
_uploader:
|
||||||
|
allowedTypes: "Tipos de archivos que se pueden cargar."
|
||||||
|
_watermarkEditor:
|
||||||
|
opacity: "Opacidad"
|
||||||
|
scale: "Tamaño"
|
||||||
|
text: "Texto"
|
||||||
|
position: "Posición"
|
||||||
|
type: "Tipo"
|
||||||
|
image: "Imágenes"
|
||||||
|
advanced: "Avanzado"
|
||||||
|
angle: "Ángulo"
|
||||||
|
|
|
@ -2360,3 +2360,12 @@ _search:
|
||||||
searchScopeAll: "Tous"
|
searchScopeAll: "Tous"
|
||||||
searchScopeLocal: "Local"
|
searchScopeLocal: "Local"
|
||||||
searchScopeUser: "Spécifier l'utilisateur·rice"
|
searchScopeUser: "Spécifier l'utilisateur·rice"
|
||||||
|
_watermarkEditor:
|
||||||
|
opacity: "Transparence"
|
||||||
|
scale: "Taille"
|
||||||
|
text: "Texte"
|
||||||
|
position: "Position"
|
||||||
|
type: "Type"
|
||||||
|
image: "Images"
|
||||||
|
advanced: "Avancé"
|
||||||
|
angle: "Angle"
|
||||||
|
|
|
@ -2608,3 +2608,12 @@ _search:
|
||||||
searchScopeAll: "Semua"
|
searchScopeAll: "Semua"
|
||||||
searchScopeLocal: "Lokal"
|
searchScopeLocal: "Lokal"
|
||||||
searchScopeUser: "Pengguna spesifik"
|
searchScopeUser: "Pengguna spesifik"
|
||||||
|
_watermarkEditor:
|
||||||
|
opacity: "Opasitas"
|
||||||
|
scale: "Ukuran"
|
||||||
|
text: "Teks"
|
||||||
|
position: "Posisi"
|
||||||
|
type: "Tipe"
|
||||||
|
image: "Gambar"
|
||||||
|
advanced: "Tingkat lanjut"
|
||||||
|
angle: "Sudut"
|
||||||
|
|
|
@ -5481,6 +5481,14 @@ export interface Locale extends ILocale {
|
||||||
* 全ての「ヒントとコツ」を非表示
|
* 全ての「ヒントとコツ」を非表示
|
||||||
*/
|
*/
|
||||||
"hideAllTips": string;
|
"hideAllTips": string;
|
||||||
|
/**
|
||||||
|
* デフォルトの画像圧縮度
|
||||||
|
*/
|
||||||
|
"defaultImageCompressionLevel": string;
|
||||||
|
/**
|
||||||
|
* 低くすると画質を保てますが、ファイルサイズは増加します。<br>高くするとファイルサイズを減らせますが、画質は低下します。
|
||||||
|
*/
|
||||||
|
"defaultImageCompressionLevel_description": string;
|
||||||
"_chat": {
|
"_chat": {
|
||||||
/**
|
/**
|
||||||
* まだメッセージはありません
|
* まだメッセージはありません
|
||||||
|
@ -5821,6 +5829,10 @@ export interface Locale extends ILocale {
|
||||||
* URLプレビューを表示する
|
* URLプレビューを表示する
|
||||||
*/
|
*/
|
||||||
"showUrlPreview": string;
|
"showUrlPreview": string;
|
||||||
|
/**
|
||||||
|
* 利用できるリアクションを先頭に表示
|
||||||
|
*/
|
||||||
|
"showAvailableReactionsFirstInNote": string;
|
||||||
"_chat": {
|
"_chat": {
|
||||||
/**
|
/**
|
||||||
* 送信者の名前を表示
|
* 送信者の名前を表示
|
||||||
|
@ -12020,6 +12032,176 @@ export interface Locale extends ILocale {
|
||||||
*/
|
*/
|
||||||
"tip": string;
|
"tip": string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* ウォーターマーク
|
||||||
|
*/
|
||||||
|
"watermark": string;
|
||||||
|
/**
|
||||||
|
* デフォルトのプリセット
|
||||||
|
*/
|
||||||
|
"defaultPreset": string;
|
||||||
|
"_watermarkEditor": {
|
||||||
|
/**
|
||||||
|
* 画像にクレジット情報などのウォーターマークを追加することができます。
|
||||||
|
*/
|
||||||
|
"tip": string;
|
||||||
|
/**
|
||||||
|
* 保存せずに終了しますか?
|
||||||
|
*/
|
||||||
|
"quitWithoutSaveConfirm": string;
|
||||||
|
/**
|
||||||
|
* ウォーターマークの編集
|
||||||
|
*/
|
||||||
|
"title": string;
|
||||||
|
/**
|
||||||
|
* 全体に被せる
|
||||||
|
*/
|
||||||
|
"cover": string;
|
||||||
|
/**
|
||||||
|
* 敷き詰める
|
||||||
|
*/
|
||||||
|
"repeat": string;
|
||||||
|
/**
|
||||||
|
* 不透明度
|
||||||
|
*/
|
||||||
|
"opacity": string;
|
||||||
|
/**
|
||||||
|
* サイズ
|
||||||
|
*/
|
||||||
|
"scale": string;
|
||||||
|
/**
|
||||||
|
* テキスト
|
||||||
|
*/
|
||||||
|
"text": string;
|
||||||
|
/**
|
||||||
|
* 位置
|
||||||
|
*/
|
||||||
|
"position": string;
|
||||||
|
/**
|
||||||
|
* タイプ
|
||||||
|
*/
|
||||||
|
"type": string;
|
||||||
|
/**
|
||||||
|
* 画像
|
||||||
|
*/
|
||||||
|
"image": string;
|
||||||
|
/**
|
||||||
|
* 高度
|
||||||
|
*/
|
||||||
|
"advanced": string;
|
||||||
|
/**
|
||||||
|
* ストライプ
|
||||||
|
*/
|
||||||
|
"stripe": string;
|
||||||
|
/**
|
||||||
|
* ラインの幅
|
||||||
|
*/
|
||||||
|
"stripeWidth": string;
|
||||||
|
/**
|
||||||
|
* ラインの数
|
||||||
|
*/
|
||||||
|
"stripeFrequency": string;
|
||||||
|
/**
|
||||||
|
* 角度
|
||||||
|
*/
|
||||||
|
"angle": string;
|
||||||
|
/**
|
||||||
|
* ポルカドット
|
||||||
|
*/
|
||||||
|
"polkadot": string;
|
||||||
|
/**
|
||||||
|
* チェッカー
|
||||||
|
*/
|
||||||
|
"checker": string;
|
||||||
|
/**
|
||||||
|
* メインドットの不透明度
|
||||||
|
*/
|
||||||
|
"polkadotMainDotOpacity": string;
|
||||||
|
/**
|
||||||
|
* メインドットの大きさ
|
||||||
|
*/
|
||||||
|
"polkadotMainDotRadius": string;
|
||||||
|
/**
|
||||||
|
* サブドットの不透明度
|
||||||
|
*/
|
||||||
|
"polkadotSubDotOpacity": string;
|
||||||
|
/**
|
||||||
|
* サブドットの大きさ
|
||||||
|
*/
|
||||||
|
"polkadotSubDotRadius": string;
|
||||||
|
/**
|
||||||
|
* サブドットの数
|
||||||
|
*/
|
||||||
|
"polkadotSubDotDivisions": string;
|
||||||
|
};
|
||||||
|
"_imageEffector": {
|
||||||
|
/**
|
||||||
|
* エフェクト
|
||||||
|
*/
|
||||||
|
"title": string;
|
||||||
|
/**
|
||||||
|
* エフェクトを追加
|
||||||
|
*/
|
||||||
|
"addEffect": string;
|
||||||
|
/**
|
||||||
|
* 変更を破棄して終了しますか?
|
||||||
|
*/
|
||||||
|
"discardChangesConfirm": string;
|
||||||
|
"_fxs": {
|
||||||
|
/**
|
||||||
|
* 色収差
|
||||||
|
*/
|
||||||
|
"chromaticAberration": string;
|
||||||
|
/**
|
||||||
|
* グリッチ
|
||||||
|
*/
|
||||||
|
"glitch": string;
|
||||||
|
/**
|
||||||
|
* ミラー
|
||||||
|
*/
|
||||||
|
"mirror": string;
|
||||||
|
/**
|
||||||
|
* 色の反転
|
||||||
|
*/
|
||||||
|
"invert": string;
|
||||||
|
/**
|
||||||
|
* 白黒
|
||||||
|
*/
|
||||||
|
"grayscale": string;
|
||||||
|
/**
|
||||||
|
* 色の圧縮
|
||||||
|
*/
|
||||||
|
"colorClamp": string;
|
||||||
|
/**
|
||||||
|
* 色の圧縮(高度)
|
||||||
|
*/
|
||||||
|
"colorClampAdvanced": string;
|
||||||
|
/**
|
||||||
|
* 歪み
|
||||||
|
*/
|
||||||
|
"distort": string;
|
||||||
|
/**
|
||||||
|
* 二値化
|
||||||
|
*/
|
||||||
|
"threshold": string;
|
||||||
|
/**
|
||||||
|
* 集中線
|
||||||
|
*/
|
||||||
|
"zoomLines": string;
|
||||||
|
/**
|
||||||
|
* ストライプ
|
||||||
|
*/
|
||||||
|
"stripe": string;
|
||||||
|
/**
|
||||||
|
* ポルカドット
|
||||||
|
*/
|
||||||
|
"polkadot": string;
|
||||||
|
/**
|
||||||
|
* チェッカー
|
||||||
|
*/
|
||||||
|
"checker": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
declare const locales: {
|
declare const locales: {
|
||||||
[lang: string]: Locale;
|
[lang: string]: Locale;
|
||||||
|
|
|
@ -3112,3 +3112,12 @@ _clip:
|
||||||
tip: "Le clip sono una funzionalità che consente di raggruppare le Note."
|
tip: "Le clip sono una funzionalità che consente di raggruppare le Note."
|
||||||
_userLists:
|
_userLists:
|
||||||
tip: "Puoi creare un elenco di Note create da qualsiasi profilo. L'elenco è visualizzato come una sequenza temporale."
|
tip: "Puoi creare un elenco di Note create da qualsiasi profilo. L'elenco è visualizzato come una sequenza temporale."
|
||||||
|
_watermarkEditor:
|
||||||
|
opacity: "Opacità"
|
||||||
|
scale: "Dimensioni"
|
||||||
|
text: "Testo"
|
||||||
|
position: "Posizione"
|
||||||
|
type: "Tipo"
|
||||||
|
image: "Immagini"
|
||||||
|
advanced: "Avanzato"
|
||||||
|
angle: "Angolo"
|
||||||
|
|
|
@ -1365,6 +1365,8 @@ abort: "中止"
|
||||||
tip: "ヒントとコツ"
|
tip: "ヒントとコツ"
|
||||||
redisplayAllTips: "全ての「ヒントとコツ」を再表示"
|
redisplayAllTips: "全ての「ヒントとコツ」を再表示"
|
||||||
hideAllTips: "全ての「ヒントとコツ」を非表示"
|
hideAllTips: "全ての「ヒントとコツ」を非表示"
|
||||||
|
defaultImageCompressionLevel: "デフォルトの画像圧縮度"
|
||||||
|
defaultImageCompressionLevel_description: "低くすると画質を保てますが、ファイルサイズは増加します。<br>高くするとファイルサイズを減らせますが、画質は低下します。"
|
||||||
|
|
||||||
_chat:
|
_chat:
|
||||||
noMessagesYet: "まだメッセージはありません"
|
noMessagesYet: "まだメッセージはありません"
|
||||||
|
@ -1455,6 +1457,7 @@ _settings:
|
||||||
contentsUpdateFrequency_description: "高いほどリアルタイムにコンテンツが更新されますが、パフォーマンスが低下し、通信量とバッテリーの消費が多くなります。"
|
contentsUpdateFrequency_description: "高いほどリアルタイムにコンテンツが更新されますが、パフォーマンスが低下し、通信量とバッテリーの消費が多くなります。"
|
||||||
contentsUpdateFrequency_description2: "リアルタイムモードがオンのときは、この設定に関わらずリアルタイムでコンテンツが更新されます。"
|
contentsUpdateFrequency_description2: "リアルタイムモードがオンのときは、この設定に関わらずリアルタイムでコンテンツが更新されます。"
|
||||||
showUrlPreview: "URLプレビューを表示する"
|
showUrlPreview: "URLプレビューを表示する"
|
||||||
|
showAvailableReactionsFirstInNote: "利用できるリアクションを先頭に表示"
|
||||||
|
|
||||||
_chat:
|
_chat:
|
||||||
showSenderName: "送信者の名前を表示"
|
showSenderName: "送信者の名前を表示"
|
||||||
|
@ -3218,3 +3221,50 @@ _clip:
|
||||||
|
|
||||||
_userLists:
|
_userLists:
|
||||||
tip: "任意のユーザーが含まれるリストを作成できます。作成したリストはタイムラインとして表示可能です。"
|
tip: "任意のユーザーが含まれるリストを作成できます。作成したリストはタイムラインとして表示可能です。"
|
||||||
|
|
||||||
|
watermark: "ウォーターマーク"
|
||||||
|
defaultPreset: "デフォルトのプリセット"
|
||||||
|
_watermarkEditor:
|
||||||
|
tip: "画像にクレジット情報などのウォーターマークを追加することができます。"
|
||||||
|
quitWithoutSaveConfirm: "保存せずに終了しますか?"
|
||||||
|
title: "ウォーターマークの編集"
|
||||||
|
cover: "全体に被せる"
|
||||||
|
repeat: "敷き詰める"
|
||||||
|
opacity: "不透明度"
|
||||||
|
scale: "サイズ"
|
||||||
|
text: "テキスト"
|
||||||
|
position: "位置"
|
||||||
|
type: "タイプ"
|
||||||
|
image: "画像"
|
||||||
|
advanced: "高度"
|
||||||
|
stripe: "ストライプ"
|
||||||
|
stripeWidth: "ラインの幅"
|
||||||
|
stripeFrequency: "ラインの数"
|
||||||
|
angle: "角度"
|
||||||
|
polkadot: "ポルカドット"
|
||||||
|
checker: "チェッカー"
|
||||||
|
polkadotMainDotOpacity: "メインドットの不透明度"
|
||||||
|
polkadotMainDotRadius: "メインドットの大きさ"
|
||||||
|
polkadotSubDotOpacity: "サブドットの不透明度"
|
||||||
|
polkadotSubDotRadius: "サブドットの大きさ"
|
||||||
|
polkadotSubDotDivisions: "サブドットの数"
|
||||||
|
|
||||||
|
_imageEffector:
|
||||||
|
title: "エフェクト"
|
||||||
|
addEffect: "エフェクトを追加"
|
||||||
|
discardChangesConfirm: "変更を破棄して終了しますか?"
|
||||||
|
|
||||||
|
_fxs:
|
||||||
|
chromaticAberration: "色収差"
|
||||||
|
glitch: "グリッチ"
|
||||||
|
mirror: "ミラー"
|
||||||
|
invert: "色の反転"
|
||||||
|
grayscale: "白黒"
|
||||||
|
colorClamp: "色の圧縮"
|
||||||
|
colorClampAdvanced: "色の圧縮(高度)"
|
||||||
|
distort: "歪み"
|
||||||
|
threshold: "二値化"
|
||||||
|
zoomLines: "集中線"
|
||||||
|
stripe: "ストライプ"
|
||||||
|
polkadot: "ポルカドット"
|
||||||
|
checker: "チェッカー"
|
||||||
|
|
|
@ -2848,3 +2848,12 @@ _search:
|
||||||
searchScopeAll: "みんな"
|
searchScopeAll: "みんな"
|
||||||
searchScopeLocal: "ローカル"
|
searchScopeLocal: "ローカル"
|
||||||
searchScopeUser: "ユーザー指定"
|
searchScopeUser: "ユーザー指定"
|
||||||
|
_watermarkEditor:
|
||||||
|
opacity: "不透明度"
|
||||||
|
scale: "大きさ"
|
||||||
|
text: "テキスト"
|
||||||
|
position: "位置"
|
||||||
|
type: "タイプ"
|
||||||
|
image: "画像"
|
||||||
|
advanced: "高度"
|
||||||
|
angle: "角度"
|
||||||
|
|
|
@ -848,3 +848,5 @@ _remoteLookupErrors:
|
||||||
_search:
|
_search:
|
||||||
searchScopeAll: "말캉"
|
searchScopeAll: "말캉"
|
||||||
searchScopeUser: "사용자 지정"
|
searchScopeUser: "사용자 지정"
|
||||||
|
_watermarkEditor:
|
||||||
|
image: "이미지"
|
||||||
|
|
|
@ -298,6 +298,7 @@ uploadFromUrl: "URL 업로드"
|
||||||
uploadFromUrlDescription: "업로드하려는 파일의 URL"
|
uploadFromUrlDescription: "업로드하려는 파일의 URL"
|
||||||
uploadFromUrlRequested: "업로드를 요청했습니다"
|
uploadFromUrlRequested: "업로드를 요청했습니다"
|
||||||
uploadFromUrlMayTakeTime: "업로드가 완료될 때까지 시간이 소요될 수 있습니다."
|
uploadFromUrlMayTakeTime: "업로드가 완료될 때까지 시간이 소요될 수 있습니다."
|
||||||
|
uploadNFiles: "{n}개의 파일을 업로"
|
||||||
explore: "둘러보기"
|
explore: "둘러보기"
|
||||||
messageRead: "읽음"
|
messageRead: "읽음"
|
||||||
noMoreHistory: "이것보다 과거의 기록이 없습니다"
|
noMoreHistory: "이것보다 과거의 기록이 없습니다"
|
||||||
|
@ -326,6 +327,7 @@ dark: "다크"
|
||||||
lightThemes: "밝은 테마"
|
lightThemes: "밝은 테마"
|
||||||
darkThemes: "어두운 테마"
|
darkThemes: "어두운 테마"
|
||||||
syncDeviceDarkMode: "디바이스의 다크 모드 설정과 동기화"
|
syncDeviceDarkMode: "디바이스의 다크 모드 설정과 동기화"
|
||||||
|
switchDarkModeManuallyWhenSyncEnabledConfirm: "'{x}'가 켜져 있습니다. 동기화를 끄고 수동으로 모드를 변경하겠습니까?"
|
||||||
drive: "드라이브"
|
drive: "드라이브"
|
||||||
fileName: "파일명"
|
fileName: "파일명"
|
||||||
selectFile: "파일 선택"
|
selectFile: "파일 선택"
|
||||||
|
@ -575,8 +577,10 @@ showFixedPostForm: "타임라인 상단에 글 입력란을 표시"
|
||||||
showFixedPostFormInChannel: "채널 타임라인 상단에 글 입력란을 표시"
|
showFixedPostFormInChannel: "채널 타임라인 상단에 글 입력란을 표시"
|
||||||
withRepliesByDefaultForNewlyFollowed: "팔로우 할 때 기본적으로 답글을 타임라인에 나오게 하기"
|
withRepliesByDefaultForNewlyFollowed: "팔로우 할 때 기본적으로 답글을 타임라인에 나오게 하기"
|
||||||
newNoteRecived: "새 노트가 있습니다"
|
newNoteRecived: "새 노트가 있습니다"
|
||||||
|
newNote: "새로운 노트"
|
||||||
sounds: "소리"
|
sounds: "소리"
|
||||||
sound: "소리"
|
sound: "소리"
|
||||||
|
notificationSoundSettings: "알림 설정"
|
||||||
listen: "듣기"
|
listen: "듣기"
|
||||||
none: "없음"
|
none: "없음"
|
||||||
showInPage: "페이지로 보기"
|
showInPage: "페이지로 보기"
|
||||||
|
@ -791,6 +795,7 @@ wide: "넓게"
|
||||||
narrow: "좁게"
|
narrow: "좁게"
|
||||||
reloadToApplySetting: "이 설정을 적용하려면 페이지를 새로고침해야 합니다. 바로 새로고침하시겠습니까?"
|
reloadToApplySetting: "이 설정을 적용하려면 페이지를 새로고침해야 합니다. 바로 새로고침하시겠습니까?"
|
||||||
needReloadToApply: "변경 사항은 새로고침하면 적용됩니다."
|
needReloadToApply: "변경 사항은 새로고침하면 적용됩니다."
|
||||||
|
needToRestartServerToApply: "변경 사항은 새로고침이 필요합니다."
|
||||||
showTitlebar: "타이틀 바를 표시하기"
|
showTitlebar: "타이틀 바를 표시하기"
|
||||||
clearCache: "캐시 비우기"
|
clearCache: "캐시 비우기"
|
||||||
onlineUsersCount: "{n}명이 접속 중"
|
onlineUsersCount: "{n}명이 접속 중"
|
||||||
|
@ -997,6 +1002,7 @@ failedToUpload: "업로드 실패"
|
||||||
cannotUploadBecauseInappropriate: "이 파일은 부적절한 내용을 포함한다고 판단되어 업로드할 수 없습니다."
|
cannotUploadBecauseInappropriate: "이 파일은 부적절한 내용을 포함한다고 판단되어 업로드할 수 없습니다."
|
||||||
cannotUploadBecauseNoFreeSpace: "드라이브 용량이 부족하여 업로드할 수 없습니다."
|
cannotUploadBecauseNoFreeSpace: "드라이브 용량이 부족하여 업로드할 수 없습니다."
|
||||||
cannotUploadBecauseExceedsFileSizeLimit: "파일 크기가 너무 크기 때문에 업로드할 수 없습니다."
|
cannotUploadBecauseExceedsFileSizeLimit: "파일 크기가 너무 크기 때문에 업로드할 수 없습니다."
|
||||||
|
cannotUploadBecauseUnallowedFileType: "허가되지 않은 유형의 파일이기에 업로드할 수 없습니다."
|
||||||
beta: "베타"
|
beta: "베타"
|
||||||
enableAutoSensitive: "자동 NSFW 탐지"
|
enableAutoSensitive: "자동 NSFW 탐지"
|
||||||
enableAutoSensitiveDescription: "이용 가능할 경우 기계학습을 통해 자동으로 미디어 NSFW를 설정합니다. 이 기능을 해제하더라도, 서버 정책에 따라 자동으로 설정될 수 있습니다."
|
enableAutoSensitiveDescription: "이용 가능할 경우 기계학습을 통해 자동으로 미디어 NSFW를 설정합니다. 이 기능을 해제하더라도, 서버 정책에 따라 자동으로 설정될 수 있습니다."
|
||||||
|
@ -1324,6 +1330,7 @@ restore: "복원"
|
||||||
syncBetweenDevices: "장치간 동기화"
|
syncBetweenDevices: "장치간 동기화"
|
||||||
preferenceSyncConflictTitle: "서버에 설정값이 존재합니다."
|
preferenceSyncConflictTitle: "서버에 설정값이 존재합니다."
|
||||||
preferenceSyncConflictText: "동기화를 활성화 한 항목의 설정 값은 서버에 저장되지만, 해당 항목은 이미 서버에 설정 값이 저장되어져 있습니다. 어느 쪽의 설정 값을 덮어씌울까요?"
|
preferenceSyncConflictText: "동기화를 활성화 한 항목의 설정 값은 서버에 저장되지만, 해당 항목은 이미 서버에 설정 값이 저장되어져 있습니다. 어느 쪽의 설정 값을 덮어씌울까요?"
|
||||||
|
preferenceSyncConflictChoiceMerge: "병합"
|
||||||
preferenceSyncConflictChoiceServer: "서버 설정값"
|
preferenceSyncConflictChoiceServer: "서버 설정값"
|
||||||
preferenceSyncConflictChoiceDevice: "장치 설정값"
|
preferenceSyncConflictChoiceDevice: "장치 설정값"
|
||||||
preferenceSyncConflictChoiceCancel: "동기화 취소"
|
preferenceSyncConflictChoiceCancel: "동기화 취소"
|
||||||
|
@ -1346,6 +1353,20 @@ goToDeck: "덱으로 돌아가기"
|
||||||
federationJobs: "연합 작업"
|
federationJobs: "연합 작업"
|
||||||
driveAboutTip: "드라이브는 이전에 업로드한 파일 목록을 표시해요. <br>\n노트에 첨부할 때 다시 사용하거나 나중에 게시할 파일을 미리 업로드할 수 있어요. <br>\n<b>파일을 삭제하면, 지금까지 그 파일을 사용한 모든 장소(노트, 페이지, 아바타, 배너 등)에서도 보이지 않게 되므로 주의해 주세요. 폴더를 만들고 정리할 수도 있어요.</b><br>"
|
driveAboutTip: "드라이브는 이전에 업로드한 파일 목록을 표시해요. <br>\n노트에 첨부할 때 다시 사용하거나 나중에 게시할 파일을 미리 업로드할 수 있어요. <br>\n<b>파일을 삭제하면, 지금까지 그 파일을 사용한 모든 장소(노트, 페이지, 아바타, 배너 등)에서도 보이지 않게 되므로 주의해 주세요. 폴더를 만들고 정리할 수도 있어요.</b><br>"
|
||||||
scrollToClose: "스크롤하여 닫기"
|
scrollToClose: "스크롤하여 닫기"
|
||||||
|
advice: "참고"
|
||||||
|
realtimeMode: "실시간 모드"
|
||||||
|
turnItOn: "켜기"
|
||||||
|
turnItOff: "끄기"
|
||||||
|
emojiMute: "이모티콘 뮤트"
|
||||||
|
emojiUnmute: "이모티콘 뮤트 해제"
|
||||||
|
muteX: "{x}를 뮤트"
|
||||||
|
unmuteX: "{x}의 뮤트를 해제"
|
||||||
|
abort: "중지"
|
||||||
|
tip: "팁과 유용한 정보"
|
||||||
|
redisplayAllTips: "모든 '팁과 유용한 정보'를 재표시"
|
||||||
|
hideAllTips: "모든 '팁과 유용한 정보'를 비표시"
|
||||||
|
defaultImageCompressionLevel: "기본 이미지 압축 정도"
|
||||||
|
defaultImageCompressionLevel_description: "낮추면 화질을 유지합니다만 파일 크기는 증가합니다. <br>높이면 파일 크기를 줄일 수 있습니다만 화질은 저하됩니다."
|
||||||
_chat:
|
_chat:
|
||||||
noMessagesYet: "아직 메시지가 없습니다"
|
noMessagesYet: "아직 메시지가 없습니다"
|
||||||
newMessage: "새로운 메시지"
|
newMessage: "새로운 메시지"
|
||||||
|
@ -1379,6 +1400,8 @@ _chat:
|
||||||
chatNotAvailableInOtherAccount: "상대방 계정에서 채팅 기능을 사용할 수 없는 상태입니다."
|
chatNotAvailableInOtherAccount: "상대방 계정에서 채팅 기능을 사용할 수 없는 상태입니다."
|
||||||
cannotChatWithTheUser: "이 유저와 채팅을 시작할 수 없습니다"
|
cannotChatWithTheUser: "이 유저와 채팅을 시작할 수 없습니다"
|
||||||
cannotChatWithTheUser_description: "채팅을 사용할 수 없는 상태이거나 상대방이 채팅을 열지 않은 상태입니다."
|
cannotChatWithTheUser_description: "채팅을 사용할 수 없는 상태이거나 상대방이 채팅을 열지 않은 상태입니다."
|
||||||
|
youAreNotAMemberOfThisRoomButInvited: "당신은 이 룸의 참가자가 아닙니다만 초대 신청을 받으셨습니다. 참가하려면 초대를 수락해주십시오."
|
||||||
|
doYouAcceptInvitation: "초대를 수락하시겠습니까?"
|
||||||
chatWithThisUser: "채팅하기"
|
chatWithThisUser: "채팅하기"
|
||||||
thisUserAllowsChatOnlyFromFollowers: "이 유저는 팔로워만 채팅을 할 수 있습니다."
|
thisUserAllowsChatOnlyFromFollowers: "이 유저는 팔로워만 채팅을 할 수 있습니다."
|
||||||
thisUserAllowsChatOnlyFromFollowing: "이 유저는 이 유저가 팔로우하는 유저만 채팅을 허용합니다."
|
thisUserAllowsChatOnlyFromFollowing: "이 유저는 이 유저가 팔로우하는 유저만 채팅을 허용합니다."
|
||||||
|
@ -1418,12 +1441,20 @@ _settings:
|
||||||
makeEveryTextElementsSelectable: "모든 텍스트 요소를 선택할 수 있도록 함"
|
makeEveryTextElementsSelectable: "모든 텍스트 요소를 선택할 수 있도록 함"
|
||||||
makeEveryTextElementsSelectable_description: "활성화 시, 일부 동작에서 유저의 접근성이 나빠질 수도 있습니다."
|
makeEveryTextElementsSelectable_description: "활성화 시, 일부 동작에서 유저의 접근성이 나빠질 수도 있습니다."
|
||||||
useStickyIcons: "아이콘이 스크롤을 따라가도록 하기"
|
useStickyIcons: "아이콘이 스크롤을 따라가도록 하기"
|
||||||
|
enableHighQualityImagePlaceholders: "고화질 이미지의 플레이스홀더를 표시"
|
||||||
|
uiAnimations: "UI 애니메이션"
|
||||||
showNavbarSubButtons: "내비게이션 바에 보조 버튼 표시"
|
showNavbarSubButtons: "내비게이션 바에 보조 버튼 표시"
|
||||||
ifOn: "켜져 있을 때"
|
ifOn: "켜져 있을 때"
|
||||||
ifOff: "꺼져 있을 때"
|
ifOff: "꺼져 있을 때"
|
||||||
enableSyncThemesBetweenDevices: "기기 간 설치한 테마 동기화"
|
enableSyncThemesBetweenDevices: "기기 간 설치한 테마 동기화"
|
||||||
enablePullToRefresh: "계속해서 갱신"
|
enablePullToRefresh: "계속해서 갱신"
|
||||||
enablePullToRefresh_description: "마우스에서 휠을 누르면서 드래그해요."
|
enablePullToRefresh_description: "마우스에서 휠을 누르면서 드래그해요."
|
||||||
|
realtimeMode_description: "서버에 접속하고 실시간으로 콘텐츠를 업데이트합니다. 데이터 사용량과 배터리의 소비가 증가할 수 있습니다."
|
||||||
|
contentsUpdateFrequency: "콘텐츠의 업데이트 빈도"
|
||||||
|
contentsUpdateFrequency_description: "높을수록 실시간으로 콘텐츠가 업데이트됩니다만, 성능이 저하되고 데이터 사용량과 배터리의 소비가 증가합니다."
|
||||||
|
contentsUpdateFrequency_description2: "실시간 모드가 켜져 있을 때는 이 설정과 상관없이 실시간으로 콘텐츠가 업데이트됩니다."
|
||||||
|
showUrlPreview: "URL 미리보기 표시"
|
||||||
|
showAvailableReactionsFirstInNote: "이용 가능한 리액션을 선두로 표시"
|
||||||
_chat:
|
_chat:
|
||||||
showSenderName: "발신자 이름 표시"
|
showSenderName: "발신자 이름 표시"
|
||||||
sendOnEnter: "엔터로 보내기"
|
sendOnEnter: "엔터로 보내기"
|
||||||
|
@ -1604,6 +1635,21 @@ _serverSettings:
|
||||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다."
|
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다."
|
||||||
deliverSuspendedSoftware: "전달 정지 중인 소프트웨어"
|
deliverSuspendedSoftware: "전달 정지 중인 소프트웨어"
|
||||||
deliverSuspendedSoftwareDescription: "취약성 등의 이유로 서버의 소프트웨어 이름 및 버전 범위를 지정하여 전달을 정지할 수 있어요. 이 버전 정보는 서버가 제공한 것이며 신뢰성은 보장되지 않아요. 버전 지정에는 semver의 범위 지정을 사용할 수 있지만, >= 2024.3.1로 지정하면 2024.3.1-custom.0과 같은 custom.0과 같은 custom 버전이 포함되지 않기 때문에 >= 2024.3.1-0과 같이 prerelease를 지정하는 것이 좋아요."
|
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:
|
_accountMigration:
|
||||||
moveFrom: "다른 계정에서 이 계정으로 이사"
|
moveFrom: "다른 계정에서 이 계정으로 이사"
|
||||||
moveFromSub: "다른 계정에 대한 별칭을 생성"
|
moveFromSub: "다른 계정에 대한 별칭을 생성"
|
||||||
|
@ -1944,6 +1990,9 @@ _role:
|
||||||
canImportMuting: "뮤트 목록 가져오기 허용"
|
canImportMuting: "뮤트 목록 가져오기 허용"
|
||||||
canImportUserLists: "리스트 목록 가져오기 허용"
|
canImportUserLists: "리스트 목록 가져오기 허용"
|
||||||
chatAvailability: "채팅을 허락"
|
chatAvailability: "채팅을 허락"
|
||||||
|
uploadableFileTypes: "업로드 가능한 파일 유형"
|
||||||
|
uploadableFileTypes_caption: "MIME 유형을 "
|
||||||
|
uploadableFileTypes_caption2: "파일에 따라서는 유형을 검사하지 못하는 경우가 있습니다. 그러한 파일을 허가하는 경우에는 {x}를 지정으로 추가해주십시오."
|
||||||
_condition:
|
_condition:
|
||||||
roleAssignedTo: "수동 역할에 이미 할당됨"
|
roleAssignedTo: "수동 역할에 이미 할당됨"
|
||||||
isLocal: "로컬 유저"
|
isLocal: "로컬 유저"
|
||||||
|
@ -2796,6 +2845,12 @@ _dataSaver:
|
||||||
_avatar:
|
_avatar:
|
||||||
title: "아이콘 이미지"
|
title: "아이콘 이미지"
|
||||||
description: "아이콘 이미지의 애니메이션을 멈춥니다. 애니메이션 이미지는 일반 이미지보다 파일 크기가 클 수 있으므로 데이터 사용량을 더 줄일 수 있습니다."
|
description: "아이콘 이미지의 애니메이션을 멈춥니다. 애니메이션 이미지는 일반 이미지보다 파일 크기가 클 수 있으므로 데이터 사용량을 더 줄일 수 있습니다."
|
||||||
|
_urlPreviewThumbnail:
|
||||||
|
title: "URL 미리보기의 섬네일을 비표시"
|
||||||
|
description: "URL 미리보기의 섬네일 이미지를 불러올 수 없게 됩니다."
|
||||||
|
_disableUrlPreview:
|
||||||
|
title: "URL 미리보기 비활성화"
|
||||||
|
description: "URL 미리보기 기능을 비활성화합니다. 섬네일 이미지와 달리 링크 정보 불러오기 자체를 줄일 수 있습니다."
|
||||||
_code:
|
_code:
|
||||||
title: "문자열 강조"
|
title: "문자열 강조"
|
||||||
description: "MFM 등으로 문자열 강조 기법을 사용할 때 누르기 전에는 불러오지 않습니다. 문자열 강조에서는 강조할 언어마다 그 정의 파일을 불러와야 하지만 이를 자동으로 불러오지 않으므로 데이터 사용량을 줄일 수 있습니다."
|
description: "MFM 등으로 문자열 강조 기법을 사용할 때 누르기 전에는 불러오지 않습니다. 문자열 강조에서는 강조할 언어마다 그 정의 파일을 불러와야 하지만 이를 자동으로 불러오지 않으므로 데이터 사용량을 줄일 수 있습니다."
|
||||||
|
@ -2853,6 +2908,8 @@ _offlineScreen:
|
||||||
_urlPreviewSetting:
|
_urlPreviewSetting:
|
||||||
title: "URL 미리보기 설정"
|
title: "URL 미리보기 설정"
|
||||||
enable: "URL 미리보기 활성화"
|
enable: "URL 미리보기 활성화"
|
||||||
|
allowRedirect: "미리보기 위치의 리디렉션 허가"
|
||||||
|
allowRedirectDescription: "입력된 URL이 리디렉션될 경우, 그 리디렉션 위치를 따라 미리보기를 표시할 것인지 설정합니다. 비활성화하면 서버 리소스를 절약할 수 있습니다만, 리디렉션 위치의 내용은 표시되지 않습니다."
|
||||||
timeout: "미리보기를 불러올 때의 타임아웃 (ms)"
|
timeout: "미리보기를 불러올 때의 타임아웃 (ms)"
|
||||||
timeoutDescription: "미리보기를 로딩하는데 걸리는 시간이 정한 시간보다 오래 걸리는 경우, 미리보기를 생성하지 않습니다."
|
timeoutDescription: "미리보기를 로딩하는데 걸리는 시간이 정한 시간보다 오래 걸리는 경우, 미리보기를 생성하지 않습니다."
|
||||||
maximumContentLength: "Content-Length의 최대치 (byte)"
|
maximumContentLength: "Content-Length의 최대치 (byte)"
|
||||||
|
@ -3001,12 +3058,109 @@ _search:
|
||||||
pleaseEnterServerHost: "서버의 호스트를 입력해 주세요."
|
pleaseEnterServerHost: "서버의 호스트를 입력해 주세요."
|
||||||
pleaseSelectUser: "유저를 선택해주세요"
|
pleaseSelectUser: "유저를 선택해주세요"
|
||||||
serverHostPlaceholder: "예: misskey.example.com"
|
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:
|
_clientPerformanceIssueTip:
|
||||||
|
title: "배터리 소비가 심하다고 생각되시면"
|
||||||
|
makeSureDisabledAdBlocker: "광고 차단을 비활성화해 주십시오."
|
||||||
|
makeSureDisabledAdBlocker_description: "광고 차단은 성능에 영향을 미칠 수 있습니다. OS의 기능이나 브라우저의 기능, 애드온 등으로 광고 차단이 활성화돼있지 않은지 확인해 주십시오."
|
||||||
makeSureDisabledCustomCss: "커스텀 CSS를 무효로 해주십시오."
|
makeSureDisabledCustomCss: "커스텀 CSS를 무효로 해주십시오."
|
||||||
makeSureDisabledCustomCss_description: "스타일을 덮어쓰기하면 성능에 영향을 미칠 수 있습니다. 커스텀 CSS나 스타일을 덮어쓰기하는 확장 기능이 유효로 돼있는지 확인해주십시오."
|
makeSureDisabledCustomCss_description: "스타일을 덮어쓰기하면 성능에 영향을 미칠 수 있습니다. 커스텀 CSS나 스타일을 덮어쓰기하는 확장 기능이 유효로 돼있는지 확인해주십시오."
|
||||||
makeSureDisabledAddons: "확장 기능을 무효로 해주십시오."
|
makeSureDisabledAddons: "확장 기능을 비활성화해 주십시오."
|
||||||
makeSureDisabledAddons_description: "일부 확장 기능은 클라이언트의 동작에 간섭해 성능에 영향을 미칠 수 있습니다. 브라우저의 확장 기능을 무효로 해 개선할지 확인해주십시오."
|
makeSureDisabledAddons_description: "일부 확장 기능은 클라이언트의 동작에 간섭해 성능에 영향을 미칠 수 있습니다. 브라우저의 확장 기능을 비활성화해 개선할지 확인해주십시오."
|
||||||
_clip:
|
_clip:
|
||||||
tip: "클립은 노트를 정리할 수 있는 기능입니다."
|
tip: "클립은 노트를 정리할 수 있는 기능입니다."
|
||||||
_userLists:
|
_userLists:
|
||||||
tip: "임의의 유저가 포함된 리스트를 작성할 수 있습니다. 작성한 리스트는 타임라인으로 표시가 가능합니다."
|
tip: "임의의 유저가 포함된 리스트를 작성할 수 있습니다. 작성한 리스트는 타임라인으로 표시가 가능합니다."
|
||||||
|
watermark: "워터마크"
|
||||||
|
defaultPreset: "기본 프리셋"
|
||||||
|
_watermarkEditor:
|
||||||
|
tip: "이미지에 크레딧 정보 등의 워터마크를 추가할 수 있습니다."
|
||||||
|
quitWithoutSaveConfirm: "보존하지 않고 종료하시겠습니까?"
|
||||||
|
title: "워터마크 편집"
|
||||||
|
cover: "전체에 붙이기"
|
||||||
|
repeat: "전면에 깔기"
|
||||||
|
opacity: "불투명도"
|
||||||
|
scale: "크기"
|
||||||
|
text: "텍스트"
|
||||||
|
position: "위치"
|
||||||
|
type: "종류"
|
||||||
|
image: "이미지"
|
||||||
|
advanced: "고급"
|
||||||
|
stripe: "줄무늬"
|
||||||
|
stripeWidth: "라인의 폭"
|
||||||
|
stripeFrequency: "라인의 수"
|
||||||
|
angle: "각도"
|
||||||
|
polkadot: "물방울 무늬"
|
||||||
|
checker: "체크 무늬"
|
||||||
|
polkadotMainDotOpacity: "주요 물방울의 불투명도"
|
||||||
|
polkadotMainDotRadius: "주요 물방울의 크기"
|
||||||
|
polkadotSubDotOpacity: "서브 물방울의 불투명도"
|
||||||
|
polkadotSubDotRadius: "서브 물방울의 크기"
|
||||||
|
polkadotSubDotDivisions: "서브 물방울의 수"
|
||||||
|
_imageEffector:
|
||||||
|
title: "이펙트"
|
||||||
|
addEffect: "이펙트를 추가"
|
||||||
|
discardChangesConfirm: "변경을 취소하고 종료하시겠습니까?"
|
||||||
|
_fxs:
|
||||||
|
chromaticAberration: "색수차"
|
||||||
|
glitch: "글리치"
|
||||||
|
mirror: "미러"
|
||||||
|
invert: "색 반전"
|
||||||
|
grayscale: "흑백"
|
||||||
|
colorClamp: "색 압축"
|
||||||
|
colorClampAdvanced: "색 압축(고급)"
|
||||||
|
distort: "뒤틀림"
|
||||||
|
threshold: "이진화"
|
||||||
|
zoomLines: "집중선"
|
||||||
|
stripe: "줄무늬"
|
||||||
|
polkadot: "물방울 무늬"
|
||||||
|
checker: "체크 무늬"
|
||||||
|
|
|
@ -483,3 +483,5 @@ _remoteLookupErrors:
|
||||||
title: "ບໍ່ພົບ"
|
title: "ບໍ່ພົບ"
|
||||||
_search:
|
_search:
|
||||||
searchScopeAll: "ທັງໝົດ"
|
searchScopeAll: "ທັງໝົດ"
|
||||||
|
_watermarkEditor:
|
||||||
|
image: "ຮູບພາບ"
|
||||||
|
|
|
@ -1078,3 +1078,6 @@ _remoteLookupErrors:
|
||||||
title: "Niet gevonden"
|
title: "Niet gevonden"
|
||||||
_search:
|
_search:
|
||||||
searchScopeAll: "Alle"
|
searchScopeAll: "Alle"
|
||||||
|
_watermarkEditor:
|
||||||
|
image: "Afbeeldingen"
|
||||||
|
advanced: "Geavanceerd"
|
||||||
|
|
|
@ -735,3 +735,8 @@ _remoteLookupErrors:
|
||||||
title: "Ikke funnet"
|
title: "Ikke funnet"
|
||||||
_search:
|
_search:
|
||||||
searchScopeAll: "Alle"
|
searchScopeAll: "Alle"
|
||||||
|
_watermarkEditor:
|
||||||
|
scale: "Størrelse"
|
||||||
|
text: "Tekst"
|
||||||
|
type: "Type"
|
||||||
|
image: "Bilder"
|
||||||
|
|
|
@ -1584,3 +1584,10 @@ _remoteLookupErrors:
|
||||||
_search:
|
_search:
|
||||||
searchScopeAll: "Wszystkie"
|
searchScopeAll: "Wszystkie"
|
||||||
searchScopeLocal: "Lokalne"
|
searchScopeLocal: "Lokalne"
|
||||||
|
_watermarkEditor:
|
||||||
|
opacity: "Przezroczystość"
|
||||||
|
scale: "Rozmiar"
|
||||||
|
text: "Tekst"
|
||||||
|
type: "Typ"
|
||||||
|
image: "Zdjęcia"
|
||||||
|
advanced: "Zaawansowane"
|
||||||
|
|
|
@ -3084,3 +3084,12 @@ _clientPerformanceIssueTip:
|
||||||
makeSureDisabledCustomCss_description: "Substituir o estilo da página pode afetar o desempenho. Certifique-se que o CSS personalizado ou extensões que modifiquem o estilo da página estejam desabilitados."
|
makeSureDisabledCustomCss_description: "Substituir o estilo da página pode afetar o desempenho. Certifique-se que o CSS personalizado ou extensões que modifiquem o estilo da página estejam desabilitados."
|
||||||
makeSureDisabledAddons: "Desabilite extensões"
|
makeSureDisabledAddons: "Desabilite extensões"
|
||||||
makeSureDisabledAddons_description: "Algumas extensões podem afetar comportamentos do cliente e afetar o desempenho. Por favor, desative as extensões do seu navegador e veja se isso melhora a situação."
|
makeSureDisabledAddons_description: "Algumas extensões podem afetar comportamentos do cliente e afetar o desempenho. Por favor, desative as extensões do seu navegador e veja se isso melhora a situação."
|
||||||
|
_watermarkEditor:
|
||||||
|
opacity: "Opacidade"
|
||||||
|
scale: "Tamanho"
|
||||||
|
text: "Texto"
|
||||||
|
position: "Posição"
|
||||||
|
type: "Tipo"
|
||||||
|
image: "imagem"
|
||||||
|
advanced: "Avançado"
|
||||||
|
angle: "Ângulo"
|
||||||
|
|
|
@ -1391,3 +1391,10 @@ _search:
|
||||||
searchScopeLocal: "Local"
|
searchScopeLocal: "Local"
|
||||||
searchScopeUser: "Utilizator specific"
|
searchScopeUser: "Utilizator specific"
|
||||||
serverHostPlaceholder: "Exemplu: misskey.example.com"
|
serverHostPlaceholder: "Exemplu: misskey.example.com"
|
||||||
|
_watermarkEditor:
|
||||||
|
scale: "Dimensiune"
|
||||||
|
text: "Text"
|
||||||
|
position: "Poziție"
|
||||||
|
type: "Tip"
|
||||||
|
image: "Imagini"
|
||||||
|
advanced: "Avansat"
|
||||||
|
|
|
@ -2191,3 +2191,12 @@ _search:
|
||||||
searchScopeAll: "Все"
|
searchScopeAll: "Все"
|
||||||
searchScopeLocal: "Местная"
|
searchScopeLocal: "Местная"
|
||||||
searchScopeUser: "Указанный пользователь"
|
searchScopeUser: "Указанный пользователь"
|
||||||
|
_watermarkEditor:
|
||||||
|
opacity: "Непрозрачность"
|
||||||
|
scale: "Размер"
|
||||||
|
text: "Текст"
|
||||||
|
position: "Позиция"
|
||||||
|
type: "Тип"
|
||||||
|
image: "Изображения"
|
||||||
|
advanced: "Для продвинутых"
|
||||||
|
angle: "Угол"
|
||||||
|
|
|
@ -1450,3 +1450,10 @@ _remoteLookupErrors:
|
||||||
_search:
|
_search:
|
||||||
searchScopeAll: "Všetko"
|
searchScopeAll: "Všetko"
|
||||||
searchScopeLocal: "Lokálne"
|
searchScopeLocal: "Lokálne"
|
||||||
|
_watermarkEditor:
|
||||||
|
opacity: "Priehľadnosť"
|
||||||
|
scale: "Veľkosť"
|
||||||
|
text: "Text"
|
||||||
|
type: "Typ"
|
||||||
|
image: "Obrázky"
|
||||||
|
advanced: "Rozšírené"
|
||||||
|
|
|
@ -711,3 +711,6 @@ _selfXssPrevention:
|
||||||
warning: "VARNING"
|
warning: "VARNING"
|
||||||
_search:
|
_search:
|
||||||
searchScopeAll: "Allt"
|
searchScopeAll: "Allt"
|
||||||
|
_watermarkEditor:
|
||||||
|
scale: "Storlek"
|
||||||
|
image: "Bilder"
|
||||||
|
|
|
@ -2722,3 +2722,12 @@ _search:
|
||||||
searchScopeAll: "ทั้งหมด"
|
searchScopeAll: "ทั้งหมด"
|
||||||
searchScopeLocal: "ท้องถิ่น"
|
searchScopeLocal: "ท้องถิ่น"
|
||||||
searchScopeUser: "ผู้ใช้เฉพาะ"
|
searchScopeUser: "ผู้ใช้เฉพาะ"
|
||||||
|
_watermarkEditor:
|
||||||
|
opacity: "ความทึบแสง"
|
||||||
|
scale: "ขนาด"
|
||||||
|
text: "ข้อความ"
|
||||||
|
position: "ตำแหน่ง"
|
||||||
|
type: "รูปแบบ"
|
||||||
|
image: "รูปภาพ"
|
||||||
|
advanced: "ขั้นสูง"
|
||||||
|
angle: "แองเกิล"
|
||||||
|
|
|
@ -460,3 +460,5 @@ _moderationLogTypes:
|
||||||
resetPassword: "Şifre sıfırlama"
|
resetPassword: "Şifre sıfırlama"
|
||||||
_search:
|
_search:
|
||||||
searchScopeAll: "Tümü"
|
searchScopeAll: "Tümü"
|
||||||
|
_watermarkEditor:
|
||||||
|
image: "Görseller"
|
||||||
|
|
|
@ -1625,3 +1625,10 @@ _remoteLookupErrors:
|
||||||
_search:
|
_search:
|
||||||
searchScopeAll: "Всі"
|
searchScopeAll: "Всі"
|
||||||
searchScopeLocal: "Локальна"
|
searchScopeLocal: "Локальна"
|
||||||
|
_watermarkEditor:
|
||||||
|
opacity: "Непрозорість"
|
||||||
|
scale: "Розмір"
|
||||||
|
text: "Текст"
|
||||||
|
type: "Тип"
|
||||||
|
image: "Зображення"
|
||||||
|
advanced: "Розширені"
|
||||||
|
|
|
@ -1097,3 +1097,8 @@ _remoteLookupErrors:
|
||||||
_search:
|
_search:
|
||||||
searchScopeAll: "Barcha"
|
searchScopeAll: "Barcha"
|
||||||
searchScopeLocal: "Mahalliy"
|
searchScopeLocal: "Mahalliy"
|
||||||
|
_watermarkEditor:
|
||||||
|
text: "Matn"
|
||||||
|
type: "turi"
|
||||||
|
image: "Rasmlar"
|
||||||
|
advanced: "Murakkab"
|
||||||
|
|
|
@ -2074,3 +2074,12 @@ _search:
|
||||||
searchScopeAll: "Tất cả"
|
searchScopeAll: "Tất cả"
|
||||||
searchScopeLocal: "Máy chủ này"
|
searchScopeLocal: "Máy chủ này"
|
||||||
searchScopeUser: "Người dùng chỉ định"
|
searchScopeUser: "Người dùng chỉ định"
|
||||||
|
_watermarkEditor:
|
||||||
|
opacity: "Độ trong suốt"
|
||||||
|
scale: "Kích thước"
|
||||||
|
text: "Văn bản"
|
||||||
|
position: "Vị trí"
|
||||||
|
type: "Loại"
|
||||||
|
image: "Hình ảnh"
|
||||||
|
advanced: "Nâng cao"
|
||||||
|
angle: "Góc"
|
||||||
|
|
|
@ -1365,6 +1365,8 @@ abort: "中止"
|
||||||
tip: "提示和技巧"
|
tip: "提示和技巧"
|
||||||
redisplayAllTips: "重新显示所有的提示和技巧"
|
redisplayAllTips: "重新显示所有的提示和技巧"
|
||||||
hideAllTips: "隐藏所有的提示和技巧"
|
hideAllTips: "隐藏所有的提示和技巧"
|
||||||
|
defaultImageCompressionLevel: "默认图像压缩等级"
|
||||||
|
defaultImageCompressionLevel_description: "较低的等级可以保持画质,但会增加文件大小。<br>较高的等级可以减少文件大小,但相对应的画质将会降低。"
|
||||||
_chat:
|
_chat:
|
||||||
noMessagesYet: "还没有消息"
|
noMessagesYet: "还没有消息"
|
||||||
newMessage: "新消息"
|
newMessage: "新消息"
|
||||||
|
@ -1452,6 +1454,7 @@ _settings:
|
||||||
contentsUpdateFrequency_description: "设置越高,内容更新越实时,但性能会降低,并且会消耗更多的流量和电池。"
|
contentsUpdateFrequency_description: "设置越高,内容更新越实时,但性能会降低,并且会消耗更多的流量和电池。"
|
||||||
contentsUpdateFrequency_description2: "当实时模式开启时,无论此设置如何,内容都会实时更新。"
|
contentsUpdateFrequency_description2: "当实时模式开启时,无论此设置如何,内容都会实时更新。"
|
||||||
showUrlPreview: "显示 URL 预览"
|
showUrlPreview: "显示 URL 预览"
|
||||||
|
showAvailableReactionsFirstInNote: "在顶部显示可用的回应"
|
||||||
_chat:
|
_chat:
|
||||||
showSenderName: "显示发送者的名字"
|
showSenderName: "显示发送者的名字"
|
||||||
sendOnEnter: "回车键发送"
|
sendOnEnter: "回车键发送"
|
||||||
|
@ -3117,3 +3120,47 @@ _clip:
|
||||||
tip: "便签功能可以将帖子合并在一起。"
|
tip: "便签功能可以将帖子合并在一起。"
|
||||||
_userLists:
|
_userLists:
|
||||||
tip: "可创建包含任意用户的列表。已创建的列表可作为时间线查看。"
|
tip: "可创建包含任意用户的列表。已创建的列表可作为时间线查看。"
|
||||||
|
watermark: "水印"
|
||||||
|
defaultPreset: "默认预设"
|
||||||
|
_watermarkEditor:
|
||||||
|
tip: "可在图像内增加包含作者等信息的水印。"
|
||||||
|
quitWithoutSaveConfirm: "不保存就退出吗?"
|
||||||
|
title: "编辑水印"
|
||||||
|
cover: "覆盖全体"
|
||||||
|
repeat: "平铺"
|
||||||
|
opacity: "不透明度"
|
||||||
|
scale: "大小"
|
||||||
|
text: "文本"
|
||||||
|
position: "位置"
|
||||||
|
type: "类型"
|
||||||
|
image: "图片"
|
||||||
|
advanced: "高级"
|
||||||
|
stripe: "条纹"
|
||||||
|
stripeWidth: "线条宽度"
|
||||||
|
stripeFrequency: "线条数量"
|
||||||
|
angle: "角度"
|
||||||
|
polkadot: "波点"
|
||||||
|
checker: "检查"
|
||||||
|
polkadotMainDotOpacity: "主波点的不透明度"
|
||||||
|
polkadotMainDotRadius: "主波点的大小"
|
||||||
|
polkadotSubDotOpacity: "副波点的不透明度"
|
||||||
|
polkadotSubDotRadius: "副波点的大小"
|
||||||
|
polkadotSubDotDivisions: "副波点的数量"
|
||||||
|
_imageEffector:
|
||||||
|
title: "效果"
|
||||||
|
addEffect: "添加效果"
|
||||||
|
discardChangesConfirm: "丢弃当前设置并退出?"
|
||||||
|
_fxs:
|
||||||
|
chromaticAberration: "色差"
|
||||||
|
glitch: "故障"
|
||||||
|
mirror: "镜像"
|
||||||
|
invert: "反转颜色"
|
||||||
|
grayscale: "黑白"
|
||||||
|
colorClamp: "颜色限制"
|
||||||
|
colorClampAdvanced: "颜色限制(高级)"
|
||||||
|
distort: "失真"
|
||||||
|
threshold: "二值化"
|
||||||
|
zoomLines: "集中线"
|
||||||
|
stripe: "条纹"
|
||||||
|
polkadot: "波点"
|
||||||
|
checker: "检查"
|
||||||
|
|
|
@ -1365,6 +1365,8 @@ abort: "取消"
|
||||||
tip: "提示與技巧"
|
tip: "提示與技巧"
|
||||||
redisplayAllTips: "重新顯示所有「提示與技巧」"
|
redisplayAllTips: "重新顯示所有「提示與技巧」"
|
||||||
hideAllTips: "隱藏所有「提示與技巧」"
|
hideAllTips: "隱藏所有「提示與技巧」"
|
||||||
|
defaultImageCompressionLevel: "預設的影像壓縮程度"
|
||||||
|
defaultImageCompressionLevel_description: "低的話可以保留畫質,但是會增加檔案的大小。<br>高的話可以減少檔案大小,但是會降低畫質。"
|
||||||
_chat:
|
_chat:
|
||||||
noMessagesYet: "尚無訊息"
|
noMessagesYet: "尚無訊息"
|
||||||
newMessage: "新訊息"
|
newMessage: "新訊息"
|
||||||
|
@ -1452,6 +1454,7 @@ _settings:
|
||||||
contentsUpdateFrequency_description: "頻率越高,內容更新越即時,但可能會降低效能,並增加資料傳輸量與電池消耗。\n"
|
contentsUpdateFrequency_description: "頻率越高,內容更新越即時,但可能會降低效能,並增加資料傳輸量與電池消耗。\n"
|
||||||
contentsUpdateFrequency_description2: "當即時模式開啟時,不論此設定為何,內容都會即時更新。"
|
contentsUpdateFrequency_description2: "當即時模式開啟時,不論此設定為何,內容都會即時更新。"
|
||||||
showUrlPreview: "顯示網址預覽"
|
showUrlPreview: "顯示網址預覽"
|
||||||
|
showAvailableReactionsFirstInNote: "將可用的反應顯示在頂部"
|
||||||
_chat:
|
_chat:
|
||||||
showSenderName: "顯示發送者的名稱"
|
showSenderName: "顯示發送者的名稱"
|
||||||
sendOnEnter: "按下 Enter 發送訊息"
|
sendOnEnter: "按下 Enter 發送訊息"
|
||||||
|
@ -3117,3 +3120,40 @@ _clip:
|
||||||
tip: "摘錄是一項可以用來整理貼文的功能。"
|
tip: "摘錄是一項可以用來整理貼文的功能。"
|
||||||
_userLists:
|
_userLists:
|
||||||
tip: "您可以建立包含任意使用者的清單。建立後的清單可以作為時間軸顯示。\n"
|
tip: "您可以建立包含任意使用者的清單。建立後的清單可以作為時間軸顯示。\n"
|
||||||
|
watermark: "浮水印"
|
||||||
|
defaultPreset: "預設值"
|
||||||
|
_watermarkEditor:
|
||||||
|
tip: "可以在圖片中以浮水印加上出處等資訊。"
|
||||||
|
quitWithoutSaveConfirm: "不儲存就退出嗎?"
|
||||||
|
title: "編輯浮水印"
|
||||||
|
cover: "覆蓋整體"
|
||||||
|
repeat: "佈局"
|
||||||
|
opacity: "透明度"
|
||||||
|
scale: "大小"
|
||||||
|
text: "文字"
|
||||||
|
position: "位置"
|
||||||
|
type: "類型"
|
||||||
|
image: "圖片"
|
||||||
|
advanced: "進階"
|
||||||
|
stripe: "條紋"
|
||||||
|
stripeWidth: "線條寬度"
|
||||||
|
stripeFrequency: "線條數量"
|
||||||
|
angle: "角度"
|
||||||
|
polkadot: "波卡圓點"
|
||||||
|
polkadotMainDotOpacity: "主圓點的不透明度"
|
||||||
|
polkadotMainDotRadius: "主圓點的尺寸"
|
||||||
|
polkadotSubDotOpacity: "子圓點的不透明度"
|
||||||
|
polkadotSubDotRadius: "子圓點的尺寸"
|
||||||
|
polkadotSubDotDivisions: "子圓點的數量"
|
||||||
|
_imageEffector:
|
||||||
|
title: "特效"
|
||||||
|
addEffect: "新增特效"
|
||||||
|
discardChangesConfirm: "捨棄更改並退出嗎?"
|
||||||
|
_fxs:
|
||||||
|
chromaticAberration: "色差"
|
||||||
|
invert: "反轉色彩"
|
||||||
|
grayscale: "黑白"
|
||||||
|
colorClamp: "壓縮色彩"
|
||||||
|
colorClampAdvanced: "壓縮色彩(進階)"
|
||||||
|
stripe: "條紋"
|
||||||
|
polkadot: "波卡圓點"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "misskey",
|
"name": "misskey",
|
||||||
"version": "2025.6.0-beta.0",
|
"version": "2025.6.1-alpha.1",
|
||||||
"codename": "nasubi",
|
"codename": "nasubi",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
@ -331,6 +331,16 @@ export class ChatService {
|
||||||
await redisPipeline.exec();
|
await redisPipeline.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async readAllChatMessages(
|
||||||
|
readerId: MiUser['id'],
|
||||||
|
): Promise<void> {
|
||||||
|
const redisPipeline = this.redisClient.pipeline();
|
||||||
|
// TODO: newUserChatMessageExists とか newRoomChatMessageExists も消したい(けどキーの列挙が必要になって面倒)
|
||||||
|
redisPipeline.del(`newChatMessagesExists:${readerId}`);
|
||||||
|
await redisPipeline.exec();
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public findMessageById(messageId: MiChatMessage['id']) {
|
public findMessageById(messageId: MiChatMessage['id']) {
|
||||||
return this.chatMessagesRepository.findOneBy({ id: messageId });
|
return this.chatMessagesRepository.findOneBy({ id: messageId });
|
||||||
|
|
|
@ -428,4 +428,5 @@ export * as 'chat/rooms/invitations/ignore' from './endpoints/chat/rooms/invitat
|
||||||
export * as 'chat/rooms/invitations/inbox' from './endpoints/chat/rooms/invitations/inbox.js';
|
export * as 'chat/rooms/invitations/inbox' from './endpoints/chat/rooms/invitations/inbox.js';
|
||||||
export * as 'chat/rooms/invitations/outbox' from './endpoints/chat/rooms/invitations/outbox.js';
|
export * as 'chat/rooms/invitations/outbox' from './endpoints/chat/rooms/invitations/outbox.js';
|
||||||
export * as 'chat/history' from './endpoints/chat/history.js';
|
export * as 'chat/history' from './endpoints/chat/history.js';
|
||||||
|
export * as 'chat/read-all' from './endpoints/chat/read-all.js';
|
||||||
export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js';
|
export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js';
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { ChatService } from '@/core/ChatService.js';
|
||||||
|
import { ApiError } from '@/server/api/error.js';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
tags: ['chat'],
|
||||||
|
|
||||||
|
requireCredential: true,
|
||||||
|
|
||||||
|
kind: 'write:chat',
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const paramDef = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
|
constructor(
|
||||||
|
private chatService: ChatService,
|
||||||
|
) {
|
||||||
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
await this.chatService.readAllChatMessages(me.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -116,9 +116,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private apiLoggerService: ApiLoggerService,
|
private apiLoggerService: ApiLoggerService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me, _1, _2, _3, ip) => {
|
super(meta, paramDef, async (ps, me, _1, _2, _3, ip) => {
|
||||||
if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) {
|
// ログイン時にusers/showできなくなってしまう
|
||||||
throw new ApiError(meta.errors.noSuchUser);
|
//if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) {
|
||||||
}
|
// throw new ApiError(meta.errors.noSuchUser);
|
||||||
|
//}
|
||||||
|
|
||||||
let user;
|
let user;
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
"@rollup/pluginutils": "5.1.4",
|
"@rollup/pluginutils": "5.1.4",
|
||||||
"@twemoji/parser": "15.1.1",
|
"@twemoji/parser": "15.1.1",
|
||||||
"@vitejs/plugin-vue": "5.2.4",
|
"@vitejs/plugin-vue": "5.2.4",
|
||||||
"@vue/compiler-sfc": "3.5.14",
|
"@vue/compiler-sfc": "3.5.16",
|
||||||
"astring": "1.9.0",
|
"astring": "1.9.0",
|
||||||
"buraha": "0.0.1",
|
"buraha": "0.0.1",
|
||||||
"estree-walker": "3.0.3",
|
"estree-walker": "3.0.3",
|
||||||
|
@ -26,7 +26,7 @@
|
||||||
"mfm-js": "0.24.0",
|
"mfm-js": "0.24.0",
|
||||||
"misskey-js": "workspace:*",
|
"misskey-js": "workspace:*",
|
||||||
"punycode.js": "2.3.1",
|
"punycode.js": "2.3.1",
|
||||||
"rollup": "4.41.0",
|
"rollup": "4.41.1",
|
||||||
"sass": "1.89.0",
|
"sass": "1.89.0",
|
||||||
"shiki": "3.4.2",
|
"shiki": "3.4.2",
|
||||||
"tinycolor2": "1.6.0",
|
"tinycolor2": "1.6.0",
|
||||||
|
@ -35,7 +35,7 @@
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"uuid": "11.1.0",
|
"uuid": "11.1.0",
|
||||||
"vite": "6.3.5",
|
"vite": "6.3.5",
|
||||||
"vue": "3.5.14"
|
"vue": "3.5.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@misskey-dev/summaly": "5.2.1",
|
"@misskey-dev/summaly": "5.2.1",
|
||||||
|
@ -43,23 +43,23 @@
|
||||||
"@testing-library/vue": "8.1.0",
|
"@testing-library/vue": "8.1.0",
|
||||||
"@types/estree": "1.0.7",
|
"@types/estree": "1.0.7",
|
||||||
"@types/micromatch": "4.0.9",
|
"@types/micromatch": "4.0.9",
|
||||||
"@types/node": "22.15.21",
|
"@types/node": "22.15.28",
|
||||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||||
"@types/tinycolor2": "1.4.6",
|
"@types/tinycolor2": "1.4.6",
|
||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "8.32.1",
|
"@typescript-eslint/eslint-plugin": "8.33.0",
|
||||||
"@typescript-eslint/parser": "8.32.1",
|
"@typescript-eslint/parser": "8.33.0",
|
||||||
"@vitest/coverage-v8": "3.1.4",
|
"@vitest/coverage-v8": "3.1.4",
|
||||||
"@vue/runtime-core": "3.5.14",
|
"@vue/runtime-core": "3.5.16",
|
||||||
"acorn": "8.14.1",
|
"acorn": "8.14.1",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"eslint-plugin-import": "2.31.0",
|
"eslint-plugin-import": "2.31.0",
|
||||||
"eslint-plugin-vue": "10.1.0",
|
"eslint-plugin-vue": "10.1.0",
|
||||||
"fast-glob": "3.3.3",
|
"fast-glob": "3.3.3",
|
||||||
"happy-dom": "17.4.7",
|
"happy-dom": "17.5.6",
|
||||||
"intersection-observer": "0.12.2",
|
"intersection-observer": "0.12.2",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
"msw": "2.8.4",
|
"msw": "2.8.6",
|
||||||
"nodemon": "3.1.10",
|
"nodemon": "3.1.10",
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.5.3",
|
||||||
"start-server-and-test": "2.0.12",
|
"start-server-and-test": "2.0.12",
|
||||||
|
|
|
@ -48,6 +48,10 @@ export function getUnicodeEmoji(char: string): UnicodeEmojiDef | string {
|
||||||
?? char;
|
?? char;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isSupportedEmoji(char: string): boolean {
|
||||||
|
return unicodeEmojisMap.has(colorizeEmoji(char)) || unicodeEmojisMap.has(char);
|
||||||
|
}
|
||||||
|
|
||||||
export function getEmojiName(char: string): string {
|
export function getEmojiName(char: string): string {
|
||||||
// Colorize it because emojilist.json assumes that
|
// Colorize it because emojilist.json assumes that
|
||||||
const idx = _indexByChar.get(colorizeEmoji(char)) ?? _indexByChar.get(char);
|
const idx = _indexByChar.get(colorizeEmoji(char)) ?? _indexByChar.get(char);
|
||||||
|
|
|
@ -21,10 +21,10 @@
|
||||||
"lint": "pnpm typecheck && pnpm eslint"
|
"lint": "pnpm typecheck && pnpm eslint"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "22.15.21",
|
"@types/node": "22.15.28",
|
||||||
"@typescript-eslint/eslint-plugin": "8.32.1",
|
"@typescript-eslint/eslint-plugin": "8.33.0",
|
||||||
"@typescript-eslint/parser": "8.32.1",
|
"@typescript-eslint/parser": "8.33.0",
|
||||||
"esbuild": "0.25.4",
|
"esbuild": "0.25.5",
|
||||||
"eslint-plugin-vue": "10.1.0",
|
"eslint-plugin-vue": "10.1.0",
|
||||||
"nodemon": "3.1.10",
|
"nodemon": "3.1.10",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
|
@ -35,6 +35,6 @@
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"misskey-js": "workspace:*",
|
"misskey-js": "workspace:*",
|
||||||
"vue": "3.5.14"
|
"vue": "3.5.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 299 KiB |
Binary file not shown.
After Width: | Height: | Size: 410 KiB |
|
@ -24,11 +24,11 @@
|
||||||
"@rollup/plugin-json": "6.1.0",
|
"@rollup/plugin-json": "6.1.0",
|
||||||
"@rollup/plugin-replace": "6.0.2",
|
"@rollup/plugin-replace": "6.0.2",
|
||||||
"@rollup/pluginutils": "5.1.4",
|
"@rollup/pluginutils": "5.1.4",
|
||||||
"@sentry/vue": "9.22.0",
|
"@sentry/vue": "9.24.0",
|
||||||
"@syuilo/aiscript": "0.19.0",
|
"@syuilo/aiscript": "0.19.0",
|
||||||
"@twemoji/parser": "15.1.1",
|
"@twemoji/parser": "15.1.1",
|
||||||
"@vitejs/plugin-vue": "5.2.4",
|
"@vitejs/plugin-vue": "5.2.4",
|
||||||
"@vue/compiler-sfc": "3.5.14",
|
"@vue/compiler-sfc": "3.5.16",
|
||||||
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
|
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
|
||||||
"analytics": "0.8.16",
|
"analytics": "0.8.16",
|
||||||
"astring": "1.9.0",
|
"astring": "1.9.0",
|
||||||
|
@ -40,7 +40,7 @@
|
||||||
"chartjs-chart-matrix": "2.1.1",
|
"chartjs-chart-matrix": "2.1.1",
|
||||||
"chartjs-plugin-gradient": "0.6.1",
|
"chartjs-plugin-gradient": "0.6.1",
|
||||||
"chartjs-plugin-zoom": "2.2.0",
|
"chartjs-plugin-zoom": "2.2.0",
|
||||||
"chromatic": "11.28.2",
|
"chromatic": "11.29.0",
|
||||||
"compare-versions": "6.1.1",
|
"compare-versions": "6.1.1",
|
||||||
"cropperjs": "2.0.0",
|
"cropperjs": "2.0.0",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
|
@ -60,22 +60,21 @@
|
||||||
"misskey-reversi": "workspace:*",
|
"misskey-reversi": "workspace:*",
|
||||||
"photoswipe": "5.4.4",
|
"photoswipe": "5.4.4",
|
||||||
"punycode.js": "2.3.1",
|
"punycode.js": "2.3.1",
|
||||||
"rollup": "4.41.0",
|
"rollup": "4.41.1",
|
||||||
"sanitize-html": "2.17.0",
|
"sanitize-html": "2.17.0",
|
||||||
"sass": "1.89.0",
|
"sass": "1.89.0",
|
||||||
"shiki": "3.4.2",
|
"shiki": "3.4.2",
|
||||||
"strict-event-emitter-types": "2.0.0",
|
"strict-event-emitter-types": "2.0.0",
|
||||||
"textarea-caret": "3.1.0",
|
"textarea-caret": "3.1.0",
|
||||||
"three": "0.176.0",
|
"three": "0.177.0",
|
||||||
"throttle-debounce": "5.0.2",
|
"throttle-debounce": "5.0.2",
|
||||||
"tinycolor2": "1.6.0",
|
"tinycolor2": "1.6.0",
|
||||||
"tsc-alias": "1.8.16",
|
"tsc-alias": "1.8.16",
|
||||||
"tsconfig-paths": "4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"uuid": "11.1.0",
|
|
||||||
"v-code-diff": "1.13.1",
|
"v-code-diff": "1.13.1",
|
||||||
"vite": "6.3.5",
|
"vite": "6.3.5",
|
||||||
"vue": "3.5.14",
|
"vue": "3.5.16",
|
||||||
"vuedraggable": "next",
|
"vuedraggable": "next",
|
||||||
"wanakana": "5.3.1"
|
"wanakana": "5.3.1"
|
||||||
},
|
},
|
||||||
|
@ -105,29 +104,29 @@
|
||||||
"@types/estree": "1.0.7",
|
"@types/estree": "1.0.7",
|
||||||
"@types/matter-js": "0.19.8",
|
"@types/matter-js": "0.19.8",
|
||||||
"@types/micromatch": "4.0.9",
|
"@types/micromatch": "4.0.9",
|
||||||
"@types/node": "22.15.21",
|
"@types/node": "22.15.28",
|
||||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||||
"@types/sanitize-html": "2.16.0",
|
"@types/sanitize-html": "2.16.0",
|
||||||
"@types/seedrandom": "3.0.8",
|
"@types/seedrandom": "3.0.8",
|
||||||
"@types/throttle-debounce": "5.0.2",
|
"@types/throttle-debounce": "5.0.2",
|
||||||
"@types/tinycolor2": "1.4.6",
|
"@types/tinycolor2": "1.4.6",
|
||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "8.32.1",
|
"@typescript-eslint/eslint-plugin": "8.33.0",
|
||||||
"@typescript-eslint/parser": "8.32.1",
|
"@typescript-eslint/parser": "8.33.0",
|
||||||
"@vitest/coverage-v8": "3.1.4",
|
"@vitest/coverage-v8": "3.1.4",
|
||||||
"@vue/compiler-core": "3.5.14",
|
"@vue/compiler-core": "3.5.16",
|
||||||
"@vue/runtime-core": "3.5.14",
|
"@vue/runtime-core": "3.5.16",
|
||||||
"acorn": "8.14.1",
|
"acorn": "8.14.1",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"cypress": "14.4.0",
|
"cypress": "14.4.0",
|
||||||
"eslint-plugin-import": "2.31.0",
|
"eslint-plugin-import": "2.31.0",
|
||||||
"eslint-plugin-vue": "10.1.0",
|
"eslint-plugin-vue": "10.1.0",
|
||||||
"fast-glob": "3.3.3",
|
"fast-glob": "3.3.3",
|
||||||
"happy-dom": "17.4.7",
|
"happy-dom": "17.5.6",
|
||||||
"intersection-observer": "0.12.2",
|
"intersection-observer": "0.12.2",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
"minimatch": "10.0.1",
|
"minimatch": "10.0.1",
|
||||||
"msw": "2.8.4",
|
"msw": "2.8.6",
|
||||||
"msw-storybook-addon": "2.0.4",
|
"msw-storybook-addon": "2.0.4",
|
||||||
"nodemon": "3.1.10",
|
"nodemon": "3.1.10",
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.5.3",
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { utils, values } from '@syuilo/aiscript';
|
import { utils, values } from '@syuilo/aiscript';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { genId } from '@/utility/id.js';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import type { Ref } from 'vue';
|
import type { Ref } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
|
@ -543,7 +543,7 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R
|
||||||
call: C,
|
call: C,
|
||||||
) {
|
) {
|
||||||
if (id) utils.assertString(id);
|
if (id) utils.assertString(id);
|
||||||
const _id = id?.value ?? uuid();
|
const _id = id?.value ?? genId();
|
||||||
const component = ref({
|
const component = ref({
|
||||||
...getOptions(def, call),
|
...getOptions(def, call),
|
||||||
type,
|
type,
|
||||||
|
|
|
@ -185,7 +185,7 @@ const isRootSelected = ref(false);
|
||||||
|
|
||||||
watch(selectedFiles, () => {
|
watch(selectedFiles, () => {
|
||||||
emit('changeSelectedFiles', selectedFiles.value);
|
emit('changeSelectedFiles', selectedFiles.value);
|
||||||
});
|
}, { deep: true });
|
||||||
|
|
||||||
watch([selectedFolders, isRootSelected], () => {
|
watch([selectedFolders, isRootSelected], () => {
|
||||||
emit('changeSelectedFolders', isRootSelected.value ? [null, ...selectedFolders.value] : selectedFolders.value);
|
emit('changeSelectedFolders', isRootSelected.value ? [null, ...selectedFolders.value] : selectedFolders.value);
|
||||||
|
|
|
@ -64,6 +64,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/>
|
<MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/>
|
||||||
<MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/>
|
<MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/>
|
||||||
</button>
|
</button>
|
||||||
|
<button v-tooltip="i18n.ts.settings" class="_button config" @click="settings"><i class="ti ti-settings"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@ -139,6 +140,9 @@ import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-e
|
||||||
import { $i } from '@/i.js';
|
import { $i } from '@/i.js';
|
||||||
import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js';
|
import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js';
|
||||||
import { prefer } from '@/preferences.js';
|
import { prefer } from '@/preferences.js';
|
||||||
|
import { useRouter } from '@/router.js';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
showPinned?: boolean;
|
showPinned?: boolean;
|
||||||
|
@ -489,6 +493,11 @@ function done(query?: string): boolean | void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function settings() {
|
||||||
|
emit('esc');
|
||||||
|
router.push('settings/emoji-palette');
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
focus();
|
focus();
|
||||||
});
|
});
|
||||||
|
@ -720,6 +729,15 @@ defineExpose({
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: $pad;
|
padding: $pad;
|
||||||
|
|
||||||
|
> .config {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 3px;
|
||||||
|
width: var(--eachSize);
|
||||||
|
height: var(--eachSize);
|
||||||
|
contain: strict;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
> .item {
|
> .item {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0 3px;
|
padding: 0 3px;
|
||||||
|
|
|
@ -5,33 +5,35 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }">
|
<MkPagination v-slot="{ items }" :pagination="pagination">
|
||||||
<MkA
|
<div :class="[$style.fileList, { [$style.grid]: viewMode === 'grid', [$style.list]: viewMode === 'list', '_gaps_s': viewMode === 'list' }]">
|
||||||
v-for="file in (items as Misskey.entities.DriveFile[])"
|
<MkA
|
||||||
:key="file.id"
|
v-for="file in items"
|
||||||
v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${dateString(file.createdAt)}\nby ${file.user ? '@' + Misskey.acct.toString(file.user) : 'system'}`"
|
:key="file.id"
|
||||||
:to="`/admin/file/${file.id}`"
|
v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${dateString(file.createdAt)}\nby ${file.user ? '@' + Misskey.acct.toString(file.user) : 'system'}`"
|
||||||
class="file _button"
|
:to="`/admin/file/${file.id}`"
|
||||||
>
|
:class="[$style.file, '_button']"
|
||||||
<div v-if="file.isSensitive" class="sensitive-label">{{ i18n.ts.sensitive }}</div>
|
>
|
||||||
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain" :highlightWhenSensitive="true"/>
|
<div v-if="file.isSensitive" :class="$style.sensitiveLabel">{{ i18n.ts.sensitive }}</div>
|
||||||
<div v-if="viewMode === 'list'" class="body">
|
<MkDriveFileThumbnail :class="$style.thumbnail" :file="file" fit="contain" :highlightWhenSensitive="true"/>
|
||||||
<div>
|
<div v-if="viewMode === 'list'" :class="$style.body">
|
||||||
<small style="opacity: 0.7;">{{ file.name }}</small>
|
<div>
|
||||||
|
<small style="opacity: 0.7;">{{ file.name }}</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<MkAcct v-if="file.user" :user="file.user"/>
|
||||||
|
<div v-else>{{ i18n.ts.system }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="margin-right: 1em;">{{ file.type }}</span>
|
||||||
|
<span>{{ bytes(file.size) }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</MkA>
|
||||||
<MkAcct v-if="file.user" :user="file.user"/>
|
</div>
|
||||||
<div v-else>{{ i18n.ts.system }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span style="margin-right: 1em;">{{ file.type }}</span>
|
|
||||||
<span>{{ bytes(file.size) }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</MkA>
|
|
||||||
</MkPagination>
|
</MkPagination>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -43,76 +45,76 @@ import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
|
||||||
import bytes from '@/filters/bytes.js';
|
import bytes from '@/filters/bytes.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { dateString } from '@/filters/date.js';
|
import { dateString } from '@/filters/date.js';
|
||||||
|
import type { PagingCtx } from '@/composables/use-pagination.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
pagination: any;
|
pagination: PagingCtx<'admin/drive/files'>;
|
||||||
viewMode: 'grid' | 'list';
|
viewMode: 'grid' | 'list';
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" module>
|
||||||
@keyframes sensitive-blink {
|
@keyframes sensitive-blink {
|
||||||
0% { opacity: 1; }
|
0% { opacity: 1; }
|
||||||
50% { opacity: 0; }
|
50% { opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.urempief {
|
.list {
|
||||||
&.list {
|
> .file {
|
||||||
> .file {
|
display: flex;
|
||||||
display: flex;
|
width: 100%;
|
||||||
width: 100%;
|
height: auto;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--MI_THEME-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
> .thumbnail {
|
|
||||||
width: 128px;
|
|
||||||
height: 128px;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .body {
|
|
||||||
margin-left: 0.3em;
|
|
||||||
padding: 8px;
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.grid {
|
> .file:hover {
|
||||||
display: grid;
|
color: var(--MI_THEME-accent);
|
||||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
}
|
||||||
grid-gap: 12px;
|
|
||||||
|
|
||||||
> .file {
|
> .file > .thumbnail {
|
||||||
position: relative;
|
width: 128px;
|
||||||
aspect-ratio: 1;
|
height: 128px;
|
||||||
|
}
|
||||||
|
|
||||||
> .thumbnail {
|
> .file > .body {
|
||||||
width: 100%;
|
margin-left: 0.3em;
|
||||||
height: 100%;
|
padding: 8px;
|
||||||
}
|
flex: 1;
|
||||||
|
|
||||||
> .sensitive-label {
|
@media (max-width: 500px) {
|
||||||
position: absolute;
|
font-size: 14px;
|
||||||
z-index: 10;
|
|
||||||
top: 8px;
|
|
||||||
left: 8px;
|
|
||||||
padding: 2px 4px;
|
|
||||||
background: #ff0000bf;
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 85%;
|
|
||||||
animation: sensitive-blink 1s infinite;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
|
grid-gap: 12px;
|
||||||
|
|
||||||
|
> .file {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensitiveLabel {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
background: #ff0000bf;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 85%;
|
||||||
|
animation: sensitive-blink 1s infinite;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -41,7 +41,7 @@ const emit = defineEmits<{
|
||||||
(_: 'closed'): void
|
(_: 'closed'): void
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const zIndex = claimZIndex('middle');
|
const zIndex = claimZIndex('low');
|
||||||
const showing = ref(true);
|
const showing = ref(true);
|
||||||
|
|
||||||
function closePage() {
|
function closePage() {
|
||||||
|
|
|
@ -0,0 +1,78 @@
|
||||||
|
<!--
|
||||||
|
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>
|
||||||
|
<div class="_buttons">
|
||||||
|
<MkButton iconOnly @click="emit('del')"><i class="ti ti-trash"></i></MkButton>
|
||||||
|
<MkButton iconOnly @click="emit('swapUp')"><i class="ti ti-arrow-up"></i></MkButton>
|
||||||
|
<MkButton iconOnly @click="emit('swapDown')"><i class="ti ti-arrow-down"></i></MkButton>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<MkInput v-else-if="v.type === 'color'" :modelValue="`#${(layer.params[k][0] * 255).toString(16).padStart(2, '0')}${(layer.params[k][1] * 255).toString(16).padStart(2, '0')}${(layer.params[k][2] * 255).toString(16).padStart(2, '0')}`" type="color" @update:modelValue="v => { const c = v.slice(1).match(/.{2}/g)?.map(x => parseInt(x, 16) / 255); if (c) layer.params[k] = c; }">
|
||||||
|
<template #label>{{ k }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MkFolder>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, useTemplateRef, watch, onMounted, onUnmounted } from 'vue';
|
||||||
|
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;
|
||||||
|
(e: 'swapUp'): void;
|
||||||
|
(e: 'swapDown'): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style module>
|
||||||
|
.root {
|
||||||
|
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,302 @@
|
||||||
|
<!--
|
||||||
|
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)"
|
||||||
|
@swapUp="onLayerSwapUp(layer)"
|
||||||
|
@swapDown="onLayerSwapDown(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, nextTick } from 'vue';
|
||||||
|
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';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
image: File;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(ev: 'ok', image: File): void;
|
||||||
|
(ev: 'cancel'): void;
|
||||||
|
(ev: 'closed'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const dialog = useTemplateRef('dialog');
|
||||||
|
|
||||||
|
async function cancel() {
|
||||||
|
if (layers.length > 0) {
|
||||||
|
const { canceled } = await os.confirm({
|
||||||
|
text: i18n.ts._imageEffector.discardChangesConfirm,
|
||||||
|
});
|
||||||
|
if (canceled) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('cancel');
|
||||||
|
dialog.value?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const layers = reactive<ImageEffectorLayer[]>([]);
|
||||||
|
|
||||||
|
watch(layers, async () => {
|
||||||
|
if (renderer != null) {
|
||||||
|
renderer.setLayers(layers);
|
||||||
|
}
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
function addEffect(ev: MouseEvent) {
|
||||||
|
os.popupMenu(FXS.filter(fx => fx.id !== 'watermarkPlacement').map((fx) => ({
|
||||||
|
text: fx.name,
|
||||||
|
action: () => {
|
||||||
|
layers.push({
|
||||||
|
id: genId(),
|
||||||
|
fxId: fx.id,
|
||||||
|
params: Object.fromEntries(Object.entries(fx.params).map(([k, v]) => [k, v.default])),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})), ev.currentTarget ?? ev.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLayerSwapUp(layer: ImageEffectorLayer) {
|
||||||
|
const index = layers.indexOf(layer);
|
||||||
|
if (index > 0) {
|
||||||
|
layers.splice(index, 1);
|
||||||
|
layers.splice(index - 1, 0, layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLayerSwapDown(layer: ImageEffectorLayer) {
|
||||||
|
const index = layers.indexOf(layer);
|
||||||
|
if (index < layers.length - 1) {
|
||||||
|
layers.splice(index, 1);
|
||||||
|
layers.splice(index + 1, 0, layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
let imageBitmap: ImageBitmap | null = null;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (canvasEl.value == null) return;
|
||||||
|
|
||||||
|
const closeWaiting = os.waiting();
|
||||||
|
|
||||||
|
await nextTick(); // waitingがレンダリングされるまで待つ
|
||||||
|
|
||||||
|
imageBitmap = await window.createImageBitmap(props.image);
|
||||||
|
|
||||||
|
const MAX_W = 1000;
|
||||||
|
const MAX_H = 1000;
|
||||||
|
let w = imageBitmap.width;
|
||||||
|
let h = imageBitmap.height;
|
||||||
|
|
||||||
|
if (w > MAX_W || h > MAX_H) {
|
||||||
|
const scale = Math.min(MAX_W / w, MAX_H / h);
|
||||||
|
w *= scale;
|
||||||
|
h *= scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer = new ImageEffector({
|
||||||
|
canvas: canvasEl.value,
|
||||||
|
renderWidth: w,
|
||||||
|
renderHeight: h,
|
||||||
|
image: imageBitmap,
|
||||||
|
fxs: FXS,
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderer.setLayers(layers);
|
||||||
|
|
||||||
|
renderer.render();
|
||||||
|
|
||||||
|
closeWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (renderer != null) {
|
||||||
|
renderer.destroy();
|
||||||
|
renderer = null;
|
||||||
|
}
|
||||||
|
if (imageBitmap != null) {
|
||||||
|
imageBitmap.close();
|
||||||
|
imageBitmap = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (layers.length === 0 || renderer == null || imageBitmap == null || canvasEl.value == null) {
|
||||||
|
cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeWaiting = os.waiting();
|
||||||
|
|
||||||
|
await nextTick(); // waitingがレンダリングされるまで待つ
|
||||||
|
|
||||||
|
renderer.changeResolution(imageBitmap.width, imageBitmap.height); // 本番レンダリングのためオリジナル画質に戻す
|
||||||
|
renderer.render(); // toBlobの直前にレンダリングしないと何故か壊れる
|
||||||
|
canvasEl.value.toBlob((blob) => {
|
||||||
|
emit('ok', new File([blob!], `image-${Date.now()}.png`, { type: 'image/png' }));
|
||||||
|
dialog.value?.close();
|
||||||
|
closeWaiting();
|
||||||
|
}, 'image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = ref(true);
|
||||||
|
watch(enabled, () => {
|
||||||
|
if (renderer != null) {
|
||||||
|
if (enabled.value) {
|
||||||
|
renderer.setLayers(layers);
|
||||||
|
} else {
|
||||||
|
renderer.setLayers([]);
|
||||||
|
}
|
||||||
|
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>
|
|
@ -82,7 +82,7 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, nextTick, onMounted, onUnmounted, useTemplateRef, watch, ref } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, useTemplateRef, watch, ref } from 'vue';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { genId } from '@/utility/id.js';
|
||||||
import { render } from 'buraha';
|
import { render } from 'buraha';
|
||||||
import { prefer } from '@/preferences.js';
|
import { prefer } from '@/preferences.js';
|
||||||
|
|
||||||
|
@ -117,7 +117,7 @@ const props = withDefaults(defineProps<{
|
||||||
onlyAvgColor: false,
|
onlyAvgColor: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewId = uuid();
|
const viewId = genId();
|
||||||
const canvas = useTemplateRef('canvas');
|
const canvas = useTemplateRef('canvas');
|
||||||
const root = useTemplateRef('root');
|
const root = useTemplateRef('root');
|
||||||
const img = useTemplateRef('img');
|
const img = useTemplateRef('img');
|
||||||
|
|
|
@ -52,6 +52,7 @@ import type { SuggestionType } from '@/utility/autocomplete.js';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { Autocomplete } from '@/utility/autocomplete.js';
|
import { Autocomplete } from '@/utility/autocomplete.js';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: string | number | null;
|
modelValue: string | number | null;
|
||||||
|
@ -87,7 +88,7 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const { modelValue, type, autofocus } = toRefs(props);
|
const { modelValue, type, autofocus } = toRefs(props);
|
||||||
const v = ref(modelValue.value);
|
const v = ref(modelValue.value);
|
||||||
const id = Math.random().toString(); // TODO: uuid?
|
const id = genId();
|
||||||
const focused = ref(false);
|
const focused = ref(false);
|
||||||
const changed = ref(false);
|
const changed = ref(false);
|
||||||
const invalid = ref(false);
|
const invalid = ref(false);
|
||||||
|
|
|
@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { watch, ref } from 'vue';
|
import { watch, ref } from 'vue';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { genId } from '@/utility/id.js';
|
||||||
import tinycolor from 'tinycolor2';
|
import tinycolor from 'tinycolor2';
|
||||||
import { useInterval } from '@@/js/use-interval.js';
|
import { useInterval } from '@@/js/use-interval.js';
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ const props = defineProps<{
|
||||||
|
|
||||||
const viewBoxX = 50;
|
const viewBoxX = 50;
|
||||||
const viewBoxY = 50;
|
const viewBoxY = 50;
|
||||||
const gradientId = uuid();
|
const gradientId = genId();
|
||||||
const polylinePoints = ref('');
|
const polylinePoints = ref('');
|
||||||
const polygonPoints = ref('');
|
const polygonPoints = ref('');
|
||||||
const headX = ref<number | null>(null);
|
const headX = ref<number | null>(null);
|
||||||
|
|
|
@ -7,12 +7,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')">
|
<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')">
|
||||||
<div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }">
|
<div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }">
|
||||||
<div :class="$style.header">
|
<div :class="$style.header">
|
||||||
<button v-if="withOkButton && withCloseButton" :class="$style.headerButton" class="_button" @click="emit('close')"><i class="ti ti-x"></i></button>
|
<button v-if="withCloseButton" :class="$style.headerButton" class="_button" data-cy-modal-window-close @click="emit('close')"><i class="ti ti-x"></i></button>
|
||||||
<span :class="$style.title">
|
<span :class="$style.title">
|
||||||
<slot name="header"></slot>
|
<slot name="header"></slot>
|
||||||
</span>
|
</span>
|
||||||
<button v-if="!withOkButton && withCloseButton" :class="$style.headerButton" class="_button" data-cy-modal-window-close @click="emit('close')"><i class="ti ti-x"></i></button>
|
<div v-if="withOkButton" style="padding: 0 16px; place-content: center;">
|
||||||
<button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="emit('ok')"><i class="ti ti-check"></i></button>
|
<MkButton primary gradate small rounded :disabled="okButtonDisabled" @click="emit('ok')">{{ i18n.ts.done }} <i class="ti ti-check"></i></MkButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.body">
|
<div :class="$style.body">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
|
@ -26,7 +27,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, onUnmounted, useTemplateRef, ref } from 'vue';
|
import { onMounted, onUnmounted, useTemplateRef, ref } from 'vue';
|
||||||
import MkModal from './MkModal.vue';
|
import MkModal from '@/components/MkModal.vue';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
withOkButton?: boolean;
|
withOkButton?: boolean;
|
||||||
|
|
|
@ -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>
|
|
@ -12,7 +12,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<slot name="prefix"></slot>
|
<slot name="prefix"></slot>
|
||||||
<div ref="containerEl" class="container">
|
<div ref="containerEl" class="container">
|
||||||
<div class="track">
|
<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>
|
||||||
<div v-if="steps && showTicks" class="ticks">
|
<div v-if="steps && showTicks" class="ticks">
|
||||||
<div v-for="i in (steps + 1)" class="tick" :style="{ left: (((i - 1) / steps) * 100) + '%' }"></div>
|
<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"
|
@mouseenter.passive="onMouseenter"
|
||||||
@mousedown="onMousedown"
|
@mousedown="onMousedown"
|
||||||
@touchstart="onMousedown"
|
@touchstart="onMousedown"
|
||||||
></div>
|
>
|
||||||
|
<div class="thumbInner"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<slot name="suffix"></slot>
|
<slot name="suffix"></slot>
|
||||||
</div>
|
</div>
|
||||||
|
@ -63,6 +70,9 @@ const emit = defineEmits<{
|
||||||
const containerEl = useTemplateRef('containerEl');
|
const containerEl = useTemplateRef('containerEl');
|
||||||
const thumbEl = useTemplateRef('thumbEl');
|
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 rawValue = ref((props.modelValue - props.min) / (props.max - props.min));
|
||||||
const steppedRawValue = computed(() => {
|
const steppedRawValue = computed(() => {
|
||||||
if (props.step) {
|
if (props.step) {
|
||||||
|
@ -222,15 +232,17 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$thumbHeight: 20px;
|
$thumbHeight: 32px;
|
||||||
$thumbWidth: 20px;
|
$thumbWidth: 32px;
|
||||||
|
$thumbInnerHeight: 19px;
|
||||||
|
$thumbInnerWidth: 19px;
|
||||||
|
|
||||||
> .body {
|
> .body {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 7px 12px;
|
padding: 0px 4px;
|
||||||
background: var(--MI_THEME-panel);
|
background: var(--MI_THEME-panel);
|
||||||
border: solid 1px var(--MI_THEME-panel);
|
border: solid 1px var(--MI_THEME-panel);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
@ -256,10 +268,30 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
|
||||||
> .highlight {
|
> .highlight {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--MI_THEME-accent);
|
background: color(from var(--MI_THEME-buttonGradateA) srgb r g b / 0.5);
|
||||||
opacity: 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;
|
width: $thumbWidth;
|
||||||
height: $thumbHeight;
|
height: $thumbHeight;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
background: var(--MI_THEME-accent);
|
|
||||||
border-radius: 999px;
|
|
||||||
|
|
||||||
&:hover {
|
&: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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,7 +33,10 @@ import * as Misskey from 'misskey-js';
|
||||||
import { inject, watch, ref } from 'vue';
|
import { inject, watch, ref } from 'vue';
|
||||||
import { TransitionGroup } from 'vue';
|
import { TransitionGroup } from 'vue';
|
||||||
import XReaction from '@/components/MkReactionsViewer.reaction.vue';
|
import XReaction from '@/components/MkReactionsViewer.reaction.vue';
|
||||||
|
import { $i } from '@/i.js';
|
||||||
import { prefer } from '@/preferences.js';
|
import { prefer } from '@/preferences.js';
|
||||||
|
import { customEmojisMap } from '@/custom-emojis.js';
|
||||||
|
import { isSupportedEmoji } from '@@/js/emojilist.js';
|
||||||
import { DI } from '@/di.js';
|
import { DI } from '@/di.js';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
|
@ -70,6 +73,12 @@ function onMockToggleReaction(emoji: string, count: number) {
|
||||||
emit('mockUpdateMyReaction', emoji, (count - _reactions.value[i][1]));
|
emit('mockUpdateMyReaction', emoji, (count - _reactions.value[i][1]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canReact(reaction: string) {
|
||||||
|
if (!$i) return false;
|
||||||
|
// TODO: CheckPermissions
|
||||||
|
return !reaction.match(/@\w/) && (customEmojisMap.has(reaction) || isSupportedEmoji(reaction));
|
||||||
|
}
|
||||||
|
|
||||||
watch([() => props.reactions, () => props.maxNumber], ([newSource, maxNumber]) => {
|
watch([() => props.reactions, () => props.maxNumber], ([newSource, maxNumber]) => {
|
||||||
let newReactions: [string, number][] = [];
|
let newReactions: [string, number][] = [];
|
||||||
hasMoreReactions.value = Object.keys(newSource).length > maxNumber;
|
hasMoreReactions.value = Object.keys(newSource).length > maxNumber;
|
||||||
|
@ -86,7 +95,15 @@ watch([() => props.reactions, () => props.maxNumber], ([newSource, maxNumber]) =
|
||||||
newReactions = [
|
newReactions = [
|
||||||
...newReactions,
|
...newReactions,
|
||||||
...Object.entries(newSource)
|
...Object.entries(newSource)
|
||||||
.sort(([, a], [, b]) => b - a)
|
.sort(([emojiA, countA], [emojiB, countB]) => {
|
||||||
|
if (prefer.s.showAvailableReactionsFirstInNote) {
|
||||||
|
if (!canReact(emojiA) && canReact(emojiB)) return 1;
|
||||||
|
if (canReact(emojiA) && !canReact(emojiB)) return -1;
|
||||||
|
return countB - countA;
|
||||||
|
} else {
|
||||||
|
return countB - countA;
|
||||||
|
}
|
||||||
|
})
|
||||||
.filter(([y], i) => i < maxNumber && !newReactionsNames.includes(y)),
|
.filter(([y], i) => i < maxNumber && !newReactionsNames.includes(y)),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -57,6 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
|
import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
|
|
||||||
const particles = ref<{
|
const particles = ref<{
|
||||||
id: string,
|
id: string,
|
||||||
|
@ -86,7 +87,7 @@ onMounted(() => {
|
||||||
const y = (Math.random() * (height.value - 64));
|
const y = (Math.random() * (height.value - 64));
|
||||||
const sizeFactor = Math.random();
|
const sizeFactor = Math.random();
|
||||||
const particle = {
|
const particle = {
|
||||||
id: Math.random().toString(),
|
id: genId(),
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
size: 0.2 + ((sizeFactor / 10) * 3),
|
size: 0.2 + ((sizeFactor / 10) * 3),
|
||||||
|
|
|
@ -29,6 +29,7 @@ import { i18n } from '@/i18n.js';
|
||||||
import { globalEvents } from '@/events.js';
|
import { globalEvents } from '@/events.js';
|
||||||
import { $i } from '@/i.js';
|
import { $i } from '@/i.js';
|
||||||
import MkNote from '@/components/MkNote.vue';
|
import MkNote from '@/components/MkNote.vue';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
phase: 'aboutNote' | 'howToReact';
|
phase: 'aboutNote' | 'howToReact';
|
||||||
|
@ -83,7 +84,7 @@ function doNotification(emoji: string): void {
|
||||||
if (!$i || !emoji) return;
|
if (!$i || !emoji) return;
|
||||||
|
|
||||||
const notification: Misskey.entities.Notification = {
|
const notification: Misskey.entities.Notification = {
|
||||||
id: Math.random().toString(),
|
id: genId(),
|
||||||
createdAt: new Date().toUTCString(),
|
createdAt: new Date().toUTCString(),
|
||||||
type: 'reaction',
|
type: 'reaction',
|
||||||
reaction: emoji,
|
reaction: emoji,
|
||||||
|
|
|
@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
v-for="ctx in items"
|
v-for="ctx in items"
|
||||||
:key="ctx.id"
|
:key="ctx.id"
|
||||||
v-panel
|
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%' }"
|
:style="{ '--p': ctx.progress != null ? `${ctx.progress.value / ctx.progress.max * 100}%` : '0%' }"
|
||||||
>
|
>
|
||||||
<div :class="$style.itemInner">
|
<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><MkCondensedLine :minScale="2 / 3">{{ ctx.name }}</MkCondensedLine></div>
|
||||||
<div :class="$style.itemInfo">
|
<div :class="$style.itemInfo">
|
||||||
<span>{{ ctx.file.type }}</span>
|
<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-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>
|
<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>
|
<MkButton style="margin: auto;" :iconOnly="true" rounded @click="chooseFile($event)"><i class="ti ti-plus"></i></MkButton>
|
||||||
</div>
|
</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>
|
<div>{{ i18n.tsx._uploader.maxFileSizeIsX({ x: $i.policies.maxFileSizeMb + 'MB' }) }}</div>
|
||||||
|
|
||||||
<!-- クライアントで検出するMIME typeとサーバーで検出するMIME typeが異なる場合があり、混乱の元になるのでとりあえず隠しとく -->
|
<!-- クライアントで検出するMIME typeとサーバーで検出するMIME typeが異なる場合があり、混乱の元になるのでとりあえず隠しとく -->
|
||||||
|
@ -93,9 +80,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 * as Misskey from 'misskey-js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { genId } from '@/utility/id.js';
|
||||||
import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
|
import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
|
||||||
import isAnimated from 'is-file-animated';
|
import isAnimated from 'is-file-animated';
|
||||||
import type { MenuItem } from '@/types/menu.js';
|
import type { MenuItem } from '@/types/menu.js';
|
||||||
|
@ -109,6 +96,7 @@ import { isWebpSupported } from '@/utility/isWebpSupported.js';
|
||||||
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
|
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { ensureSignin } from '@/i.js';
|
import { ensureSignin } from '@/i.js';
|
||||||
|
import { WatermarkRenderer } from '@/utility/watermark.js';
|
||||||
|
|
||||||
const $i = ensureSignin();
|
const $i = ensureSignin();
|
||||||
|
|
||||||
|
@ -125,6 +113,14 @@ const CROPPING_SUPPORTED_TYPES = [
|
||||||
'image/webp',
|
'image/webp',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const IMAGE_EDITING_SUPPORTED_TYPES = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
];
|
||||||
|
|
||||||
|
const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES;
|
||||||
|
|
||||||
const mimeTypeMap = {
|
const mimeTypeMap = {
|
||||||
'image/webp': 'webp',
|
'image/webp': 'webp',
|
||||||
'image/jpeg': 'jpg',
|
'image/jpeg': 'jpg',
|
||||||
|
@ -148,16 +144,19 @@ const emit = defineEmits<{
|
||||||
const items = ref<{
|
const items = ref<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
uploadName?: string;
|
||||||
progress: { max: number; value: number } | null;
|
progress: { max: number; value: number } | null;
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
waiting: boolean;
|
preprocessing: boolean;
|
||||||
uploading: boolean;
|
uploading: boolean;
|
||||||
uploaded: Misskey.entities.DriveFile | null;
|
uploaded: Misskey.entities.DriveFile | null;
|
||||||
uploadFailed: boolean;
|
uploadFailed: boolean;
|
||||||
aborted: boolean;
|
aborted: boolean;
|
||||||
|
compressionLevel: number;
|
||||||
compressedSize?: number | null;
|
compressedSize?: number | null;
|
||||||
compressedImage?: Blob | null;
|
preprocessedFile?: Blob | null;
|
||||||
file: File;
|
file: File;
|
||||||
|
watermarkPresetId: string | null;
|
||||||
abort?: (() => void) | null;
|
abort?: (() => void) | null;
|
||||||
}[]>([]);
|
}[]>([]);
|
||||||
|
|
||||||
|
@ -165,7 +164,7 @@ const dialog = useTemplateRef('dialog');
|
||||||
|
|
||||||
const firstUploadAttempted = ref(false);
|
const firstUploadAttempted = ref(false);
|
||||||
const isUploading = computed(() => items.value.some(item => item.uploading));
|
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 canDone = computed(() => items.value.some(item => item.uploaded != null));
|
||||||
const overallProgress = computed(() => {
|
const overallProgress = computed(() => {
|
||||||
const max = items.value.length;
|
const max = items.value.length;
|
||||||
|
@ -178,19 +177,18 @@ const overallProgress = computed(() => {
|
||||||
return Math.round((v / max) * 100);
|
return Math.round((v / max) * 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
const compressionLevel = ref<0 | 1 | 2 | 3>(2);
|
function getCompressionSettings(level: 0 | 1 | 2 | 3) {
|
||||||
const compressionSettings = computed(() => {
|
if (level === 1) {
|
||||||
if (compressionLevel.value === 1) {
|
|
||||||
return {
|
return {
|
||||||
maxWidth: 2000,
|
maxWidth: 2000,
|
||||||
maxHeight: 2000,
|
maxHeight: 2000,
|
||||||
};
|
};
|
||||||
} else if (compressionLevel.value === 2) {
|
} else if (level === 2) {
|
||||||
return {
|
return {
|
||||||
maxWidth: 2000 * 0.75, // =1500
|
maxWidth: 2000 * 0.75, // =1500
|
||||||
maxHeight: 2000 * 0.75, // =1500
|
maxHeight: 2000 * 0.75, // =1500
|
||||||
};
|
};
|
||||||
} else if (compressionLevel.value === 3) {
|
} else if (level === 3) {
|
||||||
return {
|
return {
|
||||||
maxWidth: 2000 * 0.75 * 0.75, // =1125
|
maxWidth: 2000 * 0.75 * 0.75, // =1125
|
||||||
maxHeight: 2000 * 0.75 * 0.75, // =1125
|
maxHeight: 2000 * 0.75 * 0.75, // =1125
|
||||||
|
@ -198,7 +196,7 @@ const compressionSettings = computed(() => {
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
watch(items, () => {
|
watch(items, () => {
|
||||||
if (items.value.length === 0) {
|
if (items.value.length === 0) {
|
||||||
|
@ -274,31 +272,151 @@ 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({
|
menu.push({
|
||||||
icon: 'ti ti-crop',
|
icon: 'ti ti-crop',
|
||||||
text: i18n.ts.cropImage,
|
text: i18n.ts.cropImage,
|
||||||
action: async () => {
|
action: async () => {
|
||||||
const cropped = await os.cropImageFile(item.file, { aspectRatio: null });
|
const cropped = await os.cropImageFile(item.file, { aspectRatio: null });
|
||||||
items.value.splice(items.value.indexOf(item), 1, {
|
URL.revokeObjectURL(item.thumbnail);
|
||||||
|
const newItem = {
|
||||||
...item,
|
...item,
|
||||||
file: markRaw(cropped),
|
file: markRaw(cropped),
|
||||||
thumbnail: window.URL.createObjectURL(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({
|
menu.push({
|
||||||
|
icon: 'ti ti-sparkles',
|
||||||
|
text: i18n.ts._imageEffector.title + ' (BETA)',
|
||||||
|
action: async () => {
|
||||||
|
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageEffectorDialog.vue').then(x => x.default), {
|
||||||
|
image: item.file,
|
||||||
|
}, {
|
||||||
|
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: () => 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),
|
||||||
|
})), {
|
||||||
|
type: 'divider',
|
||||||
|
}, {
|
||||||
|
type: 'button',
|
||||||
|
icon: 'ti ti-plus',
|
||||||
|
text: i18n.ts.add,
|
||||||
|
action: async () => {
|
||||||
|
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), {
|
||||||
|
image: item.file,
|
||||||
|
}, {
|
||||||
|
ok: (preset) => {
|
||||||
|
prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]);
|
||||||
|
changeWatermarkPreset(preset.id);
|
||||||
|
},
|
||||||
|
closed: () => dispose(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
icon: 'ti ti-x',
|
||||||
text: i18n.ts.remove,
|
text: i18n.ts.remove,
|
||||||
action: () => {
|
action: () => {
|
||||||
|
URL.revokeObjectURL(item.thumbnail);
|
||||||
items.value.splice(items.value.indexOf(item), 1);
|
items.value.splice(items.value.indexOf(item), 1);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else if (item.uploading) {
|
} else if (item.uploading) {
|
||||||
menu.push({
|
menu.push({
|
||||||
|
type: 'divider',
|
||||||
|
}, {
|
||||||
icon: 'ti ti-cloud-pause',
|
icon: 'ti ti-cloud-pause',
|
||||||
text: i18n.ts.abort,
|
text: i18n.ts.abort,
|
||||||
danger: true,
|
danger: true,
|
||||||
|
@ -320,7 +438,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
||||||
...item,
|
...item,
|
||||||
aborted: false,
|
aborted: false,
|
||||||
uploadFailed: false,
|
uploadFailed: false,
|
||||||
waiting: false,
|
|
||||||
uploading: false,
|
uploading: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -330,40 +447,13 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
item.waiting = true;
|
|
||||||
item.uploadFailed = false;
|
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;
|
item.uploading = true;
|
||||||
|
|
||||||
const { filePromise, abort } = uploadFile(item.compressedImage ?? item.file, {
|
const { filePromise, abort } = uploadFile(item.preprocessedFile ?? item.file, {
|
||||||
name: item.name,
|
name: item.uploadName ?? item.name,
|
||||||
folderId: props.folderId,
|
folderId: props.folderId,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
item.waiting = false;
|
|
||||||
if (item.progress == null) {
|
if (item.progress == null) {
|
||||||
item.progress = { max: progress.total, value: progress.loaded };
|
item.progress = { max: progress.total, value: progress.loaded };
|
||||||
} else {
|
} else {
|
||||||
|
@ -377,7 +467,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
||||||
item.abort = null;
|
item.abort = null;
|
||||||
abort();
|
abort();
|
||||||
item.uploading = false;
|
item.uploading = false;
|
||||||
item.waiting = false;
|
|
||||||
item.uploadFailed = true;
|
item.uploadFailed = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -392,7 +481,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
||||||
}
|
}
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
item.uploading = false;
|
item.uploading = false;
|
||||||
item.waiting = false;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -419,21 +507,95 @@ async function chooseFile(ev: MouseEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function preprocess(item: (typeof items)['value'][number]): Promise<void> {
|
||||||
|
item.preprocessing = true;
|
||||||
|
|
||||||
|
let file: Blob | File = item.file;
|
||||||
|
const imageBitmap = await window.createImageBitmap(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 WatermarkRenderer({
|
||||||
|
canvas: canvas,
|
||||||
|
renderWidth: imageBitmap.width,
|
||||||
|
renderHeight: imageBitmap.height,
|
||||||
|
image: imageBitmap,
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderer.setLayers(preset.layers);
|
||||||
|
|
||||||
|
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);
|
||||||
|
renderer.destroy();
|
||||||
|
}, '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;
|
||||||
|
|
||||||
|
imageBitmap.close();
|
||||||
|
}
|
||||||
|
|
||||||
function initializeFile(file: File) {
|
function initializeFile(file: File) {
|
||||||
const id = uuid();
|
const id = genId();
|
||||||
const filename = file.name ?? 'untitled';
|
const filename = file.name ?? 'untitled';
|
||||||
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
|
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
|
||||||
items.value.push({
|
const item = {
|
||||||
id,
|
id,
|
||||||
name: prefer.s.keepOriginalFilename ? filename : id + extension,
|
name: prefer.s.keepOriginalFilename ? filename : id + extension,
|
||||||
progress: null,
|
progress: null,
|
||||||
thumbnail: window.URL.createObjectURL(file),
|
thumbnail: window.URL.createObjectURL(file),
|
||||||
waiting: false,
|
preprocessing: false,
|
||||||
uploading: false,
|
uploading: false,
|
||||||
aborted: false,
|
aborted: false,
|
||||||
uploaded: null,
|
uploaded: null,
|
||||||
uploadFailed: false,
|
uploadFailed: false,
|
||||||
|
compressionLevel: prefer.s.defaultImageCompressionLevel,
|
||||||
|
watermarkPresetId: prefer.s.defaultWatermarkPresetId,
|
||||||
file: markRaw(file),
|
file: markRaw(file),
|
||||||
|
};
|
||||||
|
items.value.push(item);
|
||||||
|
preprocess(item).then(() => {
|
||||||
|
triggerRef(items);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -442,6 +604,12 @@ onMounted(() => {
|
||||||
initializeFile(file);
|
initializeFile(file);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
for (const item of items.value) {
|
||||||
|
URL.revokeObjectURL(item.thumbnail);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
|
|
|
@ -0,0 +1,318 @@
|
||||||
|
<!--
|
||||||
|
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.angle"
|
||||||
|
:min="-1"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
continuousUpdate
|
||||||
|
>
|
||||||
|
<template #label>{{ i18n.ts._watermarkEditor.angle }}</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.angle"
|
||||||
|
:min="-1"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
continuousUpdate
|
||||||
|
>
|
||||||
|
<template #label>{{ i18n.ts._watermarkEditor.angle }}</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>
|
||||||
|
|
||||||
|
<template v-else-if="layer.type === 'stripe'">
|
||||||
|
<MkRange
|
||||||
|
v-model="layer.frequency"
|
||||||
|
:min="1"
|
||||||
|
:max="30"
|
||||||
|
:step="0.01"
|
||||||
|
continuousUpdate
|
||||||
|
>
|
||||||
|
<template #label>{{ i18n.ts._watermarkEditor.stripeFrequency }}</template>
|
||||||
|
</MkRange>
|
||||||
|
|
||||||
|
<MkRange
|
||||||
|
v-model="layer.threshold"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
continuousUpdate
|
||||||
|
>
|
||||||
|
<template #label>{{ i18n.ts._watermarkEditor.stripeWidth }}</template>
|
||||||
|
</MkRange>
|
||||||
|
|
||||||
|
<MkRange
|
||||||
|
v-model="layer.angle"
|
||||||
|
:min="-1"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
continuousUpdate
|
||||||
|
>
|
||||||
|
<template #label>{{ i18n.ts._watermarkEditor.angle }}</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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="layer.type === 'polkadot'">
|
||||||
|
<MkRange
|
||||||
|
v-model="layer.angle"
|
||||||
|
:min="-1"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
continuousUpdate
|
||||||
|
>
|
||||||
|
<template #label>{{ i18n.ts._watermarkEditor.angle }}</template>
|
||||||
|
</MkRange>
|
||||||
|
|
||||||
|
<MkRange
|
||||||
|
v-model="layer.scale"
|
||||||
|
:min="0"
|
||||||
|
:max="10"
|
||||||
|
:step="0.01"
|
||||||
|
continuousUpdate
|
||||||
|
>
|
||||||
|
<template #label>{{ i18n.ts._watermarkEditor.scale }}</template>
|
||||||
|
</MkRange>
|
||||||
|
|
||||||
|
<MkRange
|
||||||
|
v-model="layer.majorRadius"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
|
||||||
|
continuousUpdate
|
||||||
|
>
|
||||||
|
<template #label>{{ i18n.ts._watermarkEditor.polkadotMainDotRadius }}</template>
|
||||||
|
</MkRange>
|
||||||
|
|
||||||
|
<MkRange
|
||||||
|
v-model="layer.majorOpacity"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
|
||||||
|
continuousUpdate
|
||||||
|
>
|
||||||
|
<template #label>{{ i18n.ts._watermarkEditor.polkadotMainDotOpacity }}</template>
|
||||||
|
</MkRange>
|
||||||
|
|
||||||
|
<MkRange
|
||||||
|
v-model="layer.minorDivisions"
|
||||||
|
:min="0"
|
||||||
|
:max="16"
|
||||||
|
:step="1"
|
||||||
|
continuousUpdate
|
||||||
|
>
|
||||||
|
<template #label>{{ i18n.ts._watermarkEditor.polkadotSubDotDivisions }}</template>
|
||||||
|
</MkRange>
|
||||||
|
|
||||||
|
<MkRange
|
||||||
|
v-model="layer.minorRadius"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
|
||||||
|
continuousUpdate
|
||||||
|
>
|
||||||
|
<template #label>{{ i18n.ts._watermarkEditor.polkadotSubDotRadius }}</template>
|
||||||
|
</MkRange>
|
||||||
|
|
||||||
|
<MkRange
|
||||||
|
v-model="layer.minorOpacity"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
|
||||||
|
continuousUpdate
|
||||||
|
>
|
||||||
|
<template #label>{{ i18n.ts._watermarkEditor.polkadotSubDotOpacity }}</template>
|
||||||
|
</MkRange>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="layer.type === 'checker'">
|
||||||
|
<MkRange
|
||||||
|
v-model="layer.angle"
|
||||||
|
:min="-1"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
continuousUpdate
|
||||||
|
>
|
||||||
|
<template #label>{{ i18n.ts._watermarkEditor.angle }}</template>
|
||||||
|
</MkRange>
|
||||||
|
|
||||||
|
<MkRange
|
||||||
|
v-model="layer.scale"
|
||||||
|
:min="0"
|
||||||
|
:max="10"
|
||||||
|
:step="0.01"
|
||||||
|
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>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, useTemplateRef, watch, onMounted, onUnmounted } from 'vue';
|
||||||
|
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,455 @@
|
||||||
|
<!--
|
||||||
|
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 v-if="props.image == null" 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' }, { label: i18n.ts._watermarkEditor.advanced, value: 'advanced' }]">
|
||||||
|
<template #label>{{ i18n.ts._watermarkEditor.type }}</template>
|
||||||
|
</MkSelect>
|
||||||
|
|
||||||
|
<div v-if="type === 'text' || type === 'image'">
|
||||||
|
<XLayer
|
||||||
|
v-for="(layer, i) in preset.layers"
|
||||||
|
:key="layer.id"
|
||||||
|
v-model:layer="preset.layers[i]"
|
||||||
|
></XLayer>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="type === 'advanced'" class="_gaps_s">
|
||||||
|
<MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false">
|
||||||
|
<template #label>
|
||||||
|
<div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div>
|
||||||
|
<div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div>
|
||||||
|
<div v-if="layer.type === 'stripe'">{{ i18n.ts._watermarkEditor.stripe }}</div>
|
||||||
|
<div v-if="layer.type === 'polkadot'">{{ i18n.ts._watermarkEditor.polkadot }}</div>
|
||||||
|
<div v-if="layer.type === 'checker'">{{ i18n.ts._watermarkEditor.checker }}</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="_buttons">
|
||||||
|
<MkButton iconOnly @click="removeLayer(layer)"><i class="ti ti-trash"></i></MkButton>
|
||||||
|
<MkButton iconOnly @click="swapUpLayer(layer)"><i class="ti ti-arrow-up"></i></MkButton>
|
||||||
|
<MkButton iconOnly @click="swapDownLayer(layer)"><i class="ti ti-arrow-down"></i></MkButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<XLayer
|
||||||
|
v-model:layer="preset.layers[i]"
|
||||||
|
></XLayer>
|
||||||
|
</MkFolder>
|
||||||
|
|
||||||
|
<MkButton rounded primary style="margin: 0 auto;" @click="addLayer"><i class="ti ti-plus"></i></MkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MkModalWindow>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue';
|
||||||
|
import type { WatermarkPreset } from '@/utility/watermark.js';
|
||||||
|
import { WatermarkRenderer } from '@/utility/watermark.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||||
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import MkFolder from '@/components/MkFolder.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 { genId } from '@/utility/id.js';
|
||||||
|
|
||||||
|
const $i = ensureSignin();
|
||||||
|
|
||||||
|
function createTextLayer(): WatermarkPreset['layers'][number] {
|
||||||
|
return {
|
||||||
|
id: genId(),
|
||||||
|
type: 'text',
|
||||||
|
text: `(c) @${$i.username}`,
|
||||||
|
align: { x: 'right', y: 'bottom' },
|
||||||
|
scale: 0.3,
|
||||||
|
angle: 0,
|
||||||
|
opacity: 0.75,
|
||||||
|
repeat: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createImageLayer(): WatermarkPreset['layers'][number] {
|
||||||
|
return {
|
||||||
|
id: genId(),
|
||||||
|
type: 'image',
|
||||||
|
imageId: null,
|
||||||
|
imageUrl: null,
|
||||||
|
align: { x: 'right', y: 'bottom' },
|
||||||
|
scale: 0.3,
|
||||||
|
angle: 0,
|
||||||
|
opacity: 0.75,
|
||||||
|
repeat: false,
|
||||||
|
cover: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStripeLayer(): WatermarkPreset['layers'][number] {
|
||||||
|
return {
|
||||||
|
id: genId(),
|
||||||
|
type: 'stripe',
|
||||||
|
angle: 0.5,
|
||||||
|
frequency: 10,
|
||||||
|
threshold: 0.1,
|
||||||
|
black: false,
|
||||||
|
opacity: 0.75,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPolkadotLayer(): WatermarkPreset['layers'][number] {
|
||||||
|
return {
|
||||||
|
id: genId(),
|
||||||
|
type: 'polkadot',
|
||||||
|
angle: 0.5,
|
||||||
|
scale: 3,
|
||||||
|
majorRadius: 0.1,
|
||||||
|
minorRadius: 0.25,
|
||||||
|
majorOpacity: 0.75,
|
||||||
|
minorOpacity: 0.5,
|
||||||
|
minorDivisions: 4,
|
||||||
|
black: false,
|
||||||
|
opacity: 0.75,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCheckerLayer(): WatermarkPreset['layers'][number] {
|
||||||
|
return {
|
||||||
|
id: genId(),
|
||||||
|
type: 'checker',
|
||||||
|
angle: 0.5,
|
||||||
|
scale: 3,
|
||||||
|
black: false,
|
||||||
|
opacity: 0.75,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
preset?: WatermarkPreset | null;
|
||||||
|
image?: File | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? {
|
||||||
|
id: genId(),
|
||||||
|
name: '',
|
||||||
|
layers: [createTextLayer()],
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(ev: 'ok', preset: WatermarkPreset): void;
|
||||||
|
(ev: 'cancel'): void;
|
||||||
|
(ev: 'closed'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const dialog = useTemplateRef('dialog');
|
||||||
|
|
||||||
|
async function cancel() {
|
||||||
|
const { canceled } = await os.confirm({
|
||||||
|
text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm,
|
||||||
|
});
|
||||||
|
if (canceled) return;
|
||||||
|
|
||||||
|
emit('cancel');
|
||||||
|
dialog.value?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = ref(preset.layers.length > 1 ? 'advanced' : preset.layers[0].type);
|
||||||
|
watch(type, () => {
|
||||||
|
if (type.value === 'text') {
|
||||||
|
preset.layers = [createTextLayer()];
|
||||||
|
} else if (type.value === 'image') {
|
||||||
|
preset.layers = [createImageLayer()];
|
||||||
|
} else if (type.value === 'advanced') {
|
||||||
|
// nop
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(preset, async (newValue, oldValue) => {
|
||||||
|
if (renderer != null) {
|
||||||
|
renderer.setLayers(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(props.image != null ? 'provided' : '3_2');
|
||||||
|
watch(sampleImageType, async () => {
|
||||||
|
if (renderer != null) {
|
||||||
|
renderer.destroy(false);
|
||||||
|
renderer = null;
|
||||||
|
initRenderer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let renderer: WatermarkRenderer | null = null;
|
||||||
|
let imageBitmap: ImageBitmap | null = null;
|
||||||
|
|
||||||
|
async function initRenderer() {
|
||||||
|
if (canvasEl.value == null) return;
|
||||||
|
|
||||||
|
if (sampleImageType.value === '3_2') {
|
||||||
|
renderer = new WatermarkRenderer({
|
||||||
|
canvas: canvasEl.value,
|
||||||
|
renderWidth: 1500,
|
||||||
|
renderHeight: 1000,
|
||||||
|
image: sampleImage_3_2,
|
||||||
|
});
|
||||||
|
} else if (sampleImageType.value === '2_3') {
|
||||||
|
renderer = new WatermarkRenderer({
|
||||||
|
canvas: canvasEl.value,
|
||||||
|
renderWidth: 1000,
|
||||||
|
renderHeight: 1500,
|
||||||
|
image: sampleImage_2_3,
|
||||||
|
});
|
||||||
|
} else if (props.image != null) {
|
||||||
|
imageBitmap = await window.createImageBitmap(props.image);
|
||||||
|
|
||||||
|
const MAX_W = 1000;
|
||||||
|
const MAX_H = 1000;
|
||||||
|
let w = imageBitmap.width;
|
||||||
|
let h = imageBitmap.height;
|
||||||
|
|
||||||
|
if (w > MAX_W || h > MAX_H) {
|
||||||
|
const scale = Math.min(MAX_W / w, MAX_H / h);
|
||||||
|
w *= scale;
|
||||||
|
h *= scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer = new WatermarkRenderer({
|
||||||
|
canvas: canvasEl.value,
|
||||||
|
renderWidth: w,
|
||||||
|
renderHeight: h,
|
||||||
|
image: imageBitmap,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await renderer!.setLayers(preset.layers);
|
||||||
|
|
||||||
|
renderer!.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const closeWaiting = os.waiting();
|
||||||
|
|
||||||
|
await nextTick(); // waitingがレンダリングされるまで待つ
|
||||||
|
|
||||||
|
await sampleImage_3_2_loading;
|
||||||
|
await sampleImage_2_3_loading;
|
||||||
|
|
||||||
|
await initRenderer();
|
||||||
|
|
||||||
|
closeWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (renderer != null) {
|
||||||
|
renderer.destroy();
|
||||||
|
renderer = null;
|
||||||
|
}
|
||||||
|
if (imageBitmap != null) {
|
||||||
|
imageBitmap.close();
|
||||||
|
imageBitmap = 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLayer(ev: MouseEvent) {
|
||||||
|
os.popupMenu([{
|
||||||
|
text: i18n.ts._watermarkEditor.text,
|
||||||
|
action: () => {
|
||||||
|
preset.layers.push(createTextLayer());
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
text: i18n.ts._watermarkEditor.image,
|
||||||
|
action: () => {
|
||||||
|
preset.layers.push(createImageLayer());
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
text: i18n.ts._watermarkEditor.stripe,
|
||||||
|
action: () => {
|
||||||
|
preset.layers.push(createStripeLayer());
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
text: i18n.ts._watermarkEditor.polkadot,
|
||||||
|
action: () => {
|
||||||
|
preset.layers.push(createPolkadotLayer());
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
text: i18n.ts._watermarkEditor.checker,
|
||||||
|
action: () => {
|
||||||
|
preset.layers.push(createCheckerLayer());
|
||||||
|
},
|
||||||
|
}], ev.currentTarget ?? ev.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapUpLayer(layer: WatermarkPreset['layers'][number]) {
|
||||||
|
const index = preset.layers.findIndex(l => l.id === layer.id);
|
||||||
|
if (index > 0) {
|
||||||
|
const tmp = preset.layers[index - 1];
|
||||||
|
preset.layers[index - 1] = preset.layers[index];
|
||||||
|
preset.layers[index] = tmp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapDownLayer(layer: WatermarkPreset['layers'][number]) {
|
||||||
|
const index = preset.layers.findIndex(l => l.id === layer.id);
|
||||||
|
if (index < preset.layers.length - 1) {
|
||||||
|
const tmp = preset.layers[index + 1];
|
||||||
|
preset.layers[index + 1] = preset.layers[index];
|
||||||
|
preset.layers[index] = tmp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLayer(layer: WatermarkPreset['layers'][number]) {
|
||||||
|
preset.layers = preset.layers.filter(l => l.id !== layer.id);
|
||||||
|
}
|
||||||
|
</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>
|
|
@ -51,7 +51,7 @@ export type DefaultStoredWidget = {
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { defineAsyncComponent, ref, computed } from 'vue';
|
import { defineAsyncComponent, ref, computed } from 'vue';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { genId } from '@/utility/id.js';
|
||||||
import MkSelect from '@/components/MkSelect.vue';
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import { widgets as widgetDefs, federationWidgets } from '@/widgets/index.js';
|
import { widgets as widgetDefs, federationWidgets } from '@/widgets/index.js';
|
||||||
|
@ -95,7 +95,7 @@ const addWidget = () => {
|
||||||
|
|
||||||
emit('addWidget', {
|
emit('addWidget', {
|
||||||
name: widgetAdderSelected.value,
|
name: widgetAdderSelected.value,
|
||||||
id: uuid(),
|
id: genId(),
|
||||||
data: {},
|
data: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -5,11 +5,11 @@
|
||||||
|
|
||||||
import { notificationTypes } from 'misskey-js';
|
import { notificationTypes } from 'misskey-js';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
import { i18n } from './i18n.js';
|
import { i18n } from './i18n.js';
|
||||||
import type { BasicTimelineType } from '@/timelines.js';
|
import type { BasicTimelineType } from '@/timelines.js';
|
||||||
import type { SoundStore } from '@/preferences/def.js';
|
import type { SoundStore } from '@/preferences/def.js';
|
||||||
import type { MenuItem } from '@/types/menu.js';
|
import type { MenuItem } from '@/types/menu.js';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
import { deepClone } from '@/utility/clone.js';
|
import { deepClone } from '@/utility/clone.js';
|
||||||
import { prefer } from '@/preferences.js';
|
import { prefer } from '@/preferences.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
|
@ -103,7 +103,7 @@ function addProfile(name: string) {
|
||||||
if (prefer.s['deck.profiles'].find(p => p.name === name)) return;
|
if (prefer.s['deck.profiles'].find(p => p.name === name)) return;
|
||||||
|
|
||||||
const newProfile: DeckProfile = {
|
const newProfile: DeckProfile = {
|
||||||
id: uuid(),
|
id: genId(),
|
||||||
name,
|
name,
|
||||||
columns: [],
|
columns: [],
|
||||||
layout: [],
|
layout: [],
|
||||||
|
|
|
@ -211,13 +211,17 @@ export async function popupAsyncWithDialog<T extends Component>(
|
||||||
props: ComponentProps<T>,
|
props: ComponentProps<T>,
|
||||||
events: Partial<ComponentEmit<T>> = {},
|
events: Partial<ComponentEmit<T>> = {},
|
||||||
): Promise<{ dispose: () => void }> {
|
): Promise<{ dispose: () => void }> {
|
||||||
const closeWaiting = waiting();
|
|
||||||
|
|
||||||
let component: T;
|
let component: T;
|
||||||
|
let closeWaiting = () => {};
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
closeWaiting = waiting();
|
||||||
|
}, 100); // コンポーネントがキャッシュされている場合にもwaitingが表示されて画面がちらつくのを防止するためにラグを追加
|
||||||
|
|
||||||
try {
|
try {
|
||||||
component = await componentFetching;
|
component = await componentFetching;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
closeWaiting();
|
closeWaiting();
|
||||||
alert({
|
alert({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
@ -227,6 +231,7 @@ export async function popupAsyncWithDialog<T extends Component>(
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.clearTimeout(timer);
|
||||||
closeWaiting();
|
closeWaiting();
|
||||||
|
|
||||||
markRaw(component);
|
markRaw(component);
|
||||||
|
|
|
@ -66,7 +66,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { genId } from '@/utility/id.js';
|
||||||
import MkInput from '@/components/MkInput.vue';
|
import MkInput from '@/components/MkInput.vue';
|
||||||
import MkSelect from '@/components/MkSelect.vue';
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
@ -104,7 +104,7 @@ const type = computed({
|
||||||
set: (t) => {
|
set: (t) => {
|
||||||
if (t === 'and') v.value.values = [];
|
if (t === 'and') v.value.values = [];
|
||||||
if (t === 'or') v.value.values = [];
|
if (t === 'or') v.value.values = [];
|
||||||
if (t === 'not') v.value.value = { id: uuid(), type: 'isRemote' };
|
if (t === 'not') v.value.value = { id: genId(), type: 'isRemote' };
|
||||||
if (t === 'roleAssignedTo') v.value.roleId = '';
|
if (t === 'roleAssignedTo') v.value.roleId = '';
|
||||||
if (t === 'createdLessThan') v.value.sec = 86400;
|
if (t === 'createdLessThan') v.value.sec = 86400;
|
||||||
if (t === 'createdMoreThan') v.value.sec = 86400;
|
if (t === 'createdMoreThan') v.value.sec = 86400;
|
||||||
|
@ -119,7 +119,7 @@ const type = computed({
|
||||||
});
|
});
|
||||||
|
|
||||||
function addValue() {
|
function addValue() {
|
||||||
v.value.values.push({ id: uuid(), type: 'isRemote' });
|
v.value.values.push({ id: genId(), type: 'isRemote' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function valuesItemUpdated(item) {
|
function valuesItemUpdated(item) {
|
||||||
|
|
|
@ -97,6 +97,7 @@ import { i18n } from '@/i18n.js';
|
||||||
import { definePage } from '@/page.js';
|
import { definePage } from '@/page.js';
|
||||||
import MkFolder from '@/components/MkFolder.vue';
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
import MkTextarea from '@/components/MkTextarea.vue';
|
import MkTextarea from '@/components/MkTextarea.vue';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
|
|
||||||
const announcementsStatus = ref<'active' | 'archived'>('active');
|
const announcementsStatus = ref<'active' | 'archived'>('active');
|
||||||
|
|
||||||
|
@ -117,7 +118,7 @@ watch(announcementsStatus, (to) => {
|
||||||
|
|
||||||
function add() {
|
function add() {
|
||||||
announcements.value.unshift({
|
announcements.value.unshift({
|
||||||
_id: Math.random().toString(36),
|
_id: genId(),
|
||||||
id: null,
|
id: null,
|
||||||
title: 'New announcement',
|
title: 'New announcement',
|
||||||
text: '',
|
text: '',
|
||||||
|
|
|
@ -57,6 +57,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
import { useStream } from '@/stream.js';
|
import { useStream } from '@/stream.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import MkFolder from '@/components/MkFolder.vue';
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
|
|
||||||
const connection = markRaw(useStream().useChannel('queueStats'));
|
const connection = markRaw(useStream().useChannel('queueStats'));
|
||||||
|
|
||||||
|
@ -113,7 +114,7 @@ onMounted(() => {
|
||||||
connection.on('stats', onStats);
|
connection.on('stats', onStats);
|
||||||
connection.on('statsLog', onStatsLog);
|
connection.on('statsLog', onStatsLog);
|
||||||
connection.send('requestLog', {
|
connection.send('requestLog', {
|
||||||
id: Math.random().toString().substring(2, 10),
|
id: genId(),
|
||||||
length: 200,
|
length: 200,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -34,6 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
import * as Misskey from 'misskey-js';
|
||||||
import MkInput from '@/components/MkInput.vue';
|
import MkInput from '@/components/MkInput.vue';
|
||||||
import MkSelect from '@/components/MkSelect.vue';
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
|
import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
|
||||||
|
@ -41,12 +42,13 @@ import * as os from '@/os.js';
|
||||||
import { lookupFile } from '@/utility/admin-lookup.js';
|
import { lookupFile } from '@/utility/admin-lookup.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { definePage } from '@/page.js';
|
import { definePage } from '@/page.js';
|
||||||
|
import type { PagingCtx } from '@/composables/use-pagination.js';
|
||||||
|
|
||||||
const origin = ref('local');
|
const origin = ref<Misskey.entities.AdminDriveFilesRequest['origin']>('local');
|
||||||
const type = ref<string | null>(null);
|
const type = ref<string | null>(null);
|
||||||
const searchHost = ref('');
|
const searchHost = ref('');
|
||||||
const userId = ref('');
|
const userId = ref('');
|
||||||
const viewMode = ref('grid');
|
const viewMode = ref<'grid' | 'list'>('grid');
|
||||||
const pagination = {
|
const pagination = {
|
||||||
endpoint: 'admin/drive/files' as const,
|
endpoint: 'admin/drive/files' as const,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
|
@ -56,7 +58,7 @@ const pagination = {
|
||||||
origin: origin.value,
|
origin: origin.value,
|
||||||
hostname: (searchHost.value && searchHost.value !== '') ? searchHost.value : null,
|
hostname: (searchHost.value && searchHost.value !== '') ? searchHost.value : null,
|
||||||
})),
|
})),
|
||||||
};
|
} satisfies PagingCtx<'admin/drive/files'>;
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
os.confirm({
|
os.confirm({
|
||||||
|
|
|
@ -41,6 +41,7 @@ import XChart from './overview.queue.chart.vue';
|
||||||
import type { ApQueueDomain } from '@/pages/admin/queue.vue';
|
import type { ApQueueDomain } from '@/pages/admin/queue.vue';
|
||||||
import number from '@/filters/number.js';
|
import number from '@/filters/number.js';
|
||||||
import { useStream } from '@/stream.js';
|
import { useStream } from '@/stream.js';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
|
|
||||||
const connection = markRaw(useStream().useChannel('queueStats'));
|
const connection = markRaw(useStream().useChannel('queueStats'));
|
||||||
|
|
||||||
|
@ -92,7 +93,7 @@ onMounted(() => {
|
||||||
connection.on('stats', onStats);
|
connection.on('stats', onStats);
|
||||||
connection.on('statsLog', onStatsLog);
|
connection.on('statsLog', onStatsLog);
|
||||||
connection.send('requestLog', {
|
connection.send('requestLog', {
|
||||||
id: Math.random().toString().substring(2, 10),
|
id: genId(),
|
||||||
length: 100,
|
length: 100,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -84,6 +84,7 @@ import { useStream } from '@/stream.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { definePage } from '@/page.js';
|
import { definePage } from '@/page.js';
|
||||||
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
|
|
||||||
const rootEl = useTemplateRef('rootEl');
|
const rootEl = useTemplateRef('rootEl');
|
||||||
const serverInfo = ref<Misskey.entities.ServerInfoResponse | null>(null);
|
const serverInfo = ref<Misskey.entities.ServerInfoResponse | null>(null);
|
||||||
|
@ -170,7 +171,7 @@ onMounted(async () => {
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
queueStatsConnection.send('requestLog', {
|
queueStatsConnection.send('requestLog', {
|
||||||
id: Math.random().toString().substring(2, 10),
|
id: genId(),
|
||||||
length: 100,
|
length: 100,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { genId } from '@/utility/id.js';
|
||||||
import XEditor from './roles.editor.vue';
|
import XEditor from './roles.editor.vue';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
|
@ -55,7 +55,7 @@ if (props.id) {
|
||||||
color: null,
|
color: null,
|
||||||
iconUrl: null,
|
iconUrl: null,
|
||||||
target: 'manual',
|
target: 'manual',
|
||||||
condFormula: { id: uuid(), type: 'isRemote' },
|
condFormula: { id: genId(), type: 'isRemote' },
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
isExplorable: false,
|
isExplorable: false,
|
||||||
asBadge: false,
|
asBadge: false,
|
||||||
|
|
|
@ -7,9 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true">
|
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true">
|
||||||
<div v-if="instance" class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
|
<div v-if="instance" class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
|
||||||
<div v-if="tab === 'overview'" class="_gaps_m">
|
<div v-if="tab === 'overview'" class="_gaps_m">
|
||||||
<div class="fnfelxur">
|
<div :class="$style.faviconAndName">
|
||||||
<img :src="faviconUrl" alt="" class="icon"/>
|
<img :src="faviconUrl" alt="" :class="$style.icon"/>
|
||||||
<span class="name">{{ instance.name || `(${i18n.ts.unknown})` }}</span>
|
<span :class="$style.name">{{ instance.name || `(${i18n.ts.unknown})` }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; flex-direction: column; gap: 1em;">
|
<div style="display: flex; flex-direction: column; gap: 1em;">
|
||||||
<MkKeyValue :copy="host" oneline>
|
<MkKeyValue :copy="host" oneline>
|
||||||
|
@ -90,8 +90,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab === 'chart'" class="_gaps_m">
|
<div v-else-if="tab === 'chart'" class="_gaps_m">
|
||||||
<div class="cmhjzshl">
|
<div>
|
||||||
<div class="selects">
|
<div :class="$style.selects">
|
||||||
<MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;">
|
<MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;">
|
||||||
<option value="instance-requests">{{ i18n.ts._instanceCharts.requests }}</option>
|
<option value="instance-requests">{{ i18n.ts._instanceCharts.requests }}</option>
|
||||||
<option value="instance-users">{{ i18n.ts._instanceCharts.users }}</option>
|
<option value="instance-users">{{ i18n.ts._instanceCharts.users }}</option>
|
||||||
|
@ -106,19 +106,21 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<option value="instance-drive-files-total">{{ i18n.ts._instanceCharts.filesTotal }}</option>
|
<option value="instance-drive-files-total">{{ i18n.ts._instanceCharts.filesTotal }}</option>
|
||||||
</MkSelect>
|
</MkSelect>
|
||||||
</div>
|
</div>
|
||||||
<div class="charts">
|
<div>
|
||||||
<div class="label">{{ i18n.tsx.recentNHours({ n: 90 }) }}</div>
|
<div :class="$style.label">{{ i18n.tsx.recentNHours({ n: 90 }) }}</div>
|
||||||
<MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ host: host }" :detailed="true"></MkChart>
|
<MkChart :src="chartSrc" span="hour" :limit="90" :args="{ host: host }" :detailed="true"></MkChart>
|
||||||
<div class="label">{{ i18n.tsx.recentNDays({ n: 90 }) }}</div>
|
<div :class="$style.label">{{ i18n.tsx.recentNDays({ n: 90 }) }}</div>
|
||||||
<MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ host: host }" :detailed="true"></MkChart>
|
<MkChart :src="chartSrc" span="day" :limit="90" :args="{ host: host }" :detailed="true"></MkChart>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab === 'users'" class="_gaps_m">
|
<div v-else-if="tab === 'users'" class="_gaps_m">
|
||||||
<MkPagination v-slot="{items}" :pagination="usersPagination" style="display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); grid-gap: 12px;">
|
<MkPagination v-slot="{ items }" :pagination="usersPagination">
|
||||||
<MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${dateString(user.updatedAt)}`" class="user" :to="`/admin/user/${user.id}`">
|
<div :class="$style.users">
|
||||||
<MkUserCardMini :user="user"/>
|
<MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${user.updatedAt ? dateString(user.updatedAt) : 'unknown'}`" :to="`/admin/user/${user.id}`">
|
||||||
</MkA>
|
<MkUserCardMini :user="user"/>
|
||||||
|
</MkA>
|
||||||
|
</div>
|
||||||
</MkPagination>
|
</MkPagination>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab === 'raw'" class="_gaps_m">
|
<div v-else-if="tab === 'raw'" class="_gaps_m">
|
||||||
|
@ -180,7 +182,7 @@ const usersPagination = {
|
||||||
hostname: props.host,
|
hostname: props.host,
|
||||||
},
|
},
|
||||||
offsetMode: true,
|
offsetMode: true,
|
||||||
} satisfies PagingCtx;
|
} satisfies PagingCtx<'admin/show-users' | 'users'>;
|
||||||
|
|
||||||
if (iAmModerator) {
|
if (iAmModerator) {
|
||||||
watch(moderationNote, async () => {
|
watch(moderationNote, async () => {
|
||||||
|
@ -281,7 +283,7 @@ const headerTabs = computed(() => [{
|
||||||
key: 'overview',
|
key: 'overview',
|
||||||
title: i18n.ts.overview,
|
title: i18n.ts.overview,
|
||||||
icon: 'ti ti-info-circle',
|
icon: 'ti ti-info-circle',
|
||||||
}, {
|
}, ...(iAmModerator ? [{
|
||||||
key: 'chart',
|
key: 'chart',
|
||||||
title: i18n.ts.charts,
|
title: i18n.ts.charts,
|
||||||
icon: 'ti ti-chart-line',
|
icon: 'ti ti-chart-line',
|
||||||
|
@ -289,7 +291,7 @@ const headerTabs = computed(() => [{
|
||||||
key: 'users',
|
key: 'users',
|
||||||
title: i18n.ts.users,
|
title: i18n.ts.users,
|
||||||
icon: 'ti ti-users',
|
icon: 'ti ti-users',
|
||||||
}, {
|
}] : []), {
|
||||||
key: 'raw',
|
key: 'raw',
|
||||||
title: 'Raw',
|
title: 'Raw',
|
||||||
icon: 'ti ti-code',
|
icon: 'ti ti-code',
|
||||||
|
@ -301,34 +303,31 @@ definePage(() => ({
|
||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" module>
|
||||||
.fnfelxur {
|
.faviconAndName {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
> .icon {
|
|
||||||
display: block;
|
|
||||||
margin: 0 16px 0 0;
|
|
||||||
height: 64px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .name {
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.icon {
|
||||||
.cmhjzshl {
|
display: block;
|
||||||
> .selects {
|
margin: 0 16px 0 0;
|
||||||
display: flex;
|
height: 64px;
|
||||||
margin: 0 0 16px 0;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
.name {
|
||||||
> .charts {
|
word-break: break-all;
|
||||||
> .label {
|
}
|
||||||
margin-bottom: 12px;
|
.selects {
|
||||||
font-weight: bold;
|
display: flex;
|
||||||
}
|
margin: 0 0 16px 0;
|
||||||
}
|
}
|
||||||
|
.label {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.users {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill,minmax(270px,1fr));
|
||||||
|
grid-gap: 12px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { defineAsyncComponent, inject, onMounted, watch, ref } from 'vue';
|
import { defineAsyncComponent, inject, onMounted, watch, ref } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { genId } from '@/utility/id.js';
|
||||||
import XContainer from '../page-editor.container.vue';
|
import XContainer from '../page-editor.container.vue';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
|
@ -73,7 +73,7 @@ async function add() {
|
||||||
});
|
});
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
|
|
||||||
const id = uuid();
|
const id = genId();
|
||||||
children.value.push({ id, type });
|
children.value.push({ id, type });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -62,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, provide, watch, ref } from 'vue';
|
import { computed, provide, watch, ref } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { genId } from '@/utility/id.js';
|
||||||
import { url } from '@@/js/config.js';
|
import { url } from '@@/js/config.js';
|
||||||
import XBlocks from './page-editor.blocks.vue';
|
import XBlocks from './page-editor.blocks.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
@ -200,7 +200,7 @@ async function add() {
|
||||||
});
|
});
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
|
|
||||||
const id = uuid();
|
const id = genId();
|
||||||
content.value.push({ id, type });
|
content.value.push({ id, type });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -240,7 +240,7 @@ async function init() {
|
||||||
content.value = page.value.content;
|
content.value = page.value.content;
|
||||||
eyeCatchingImageId.value = page.value.eyeCatchingImageId;
|
eyeCatchingImageId.value = page.value.eyeCatchingImageId;
|
||||||
} else {
|
} else {
|
||||||
const id = uuid();
|
const id = genId();
|
||||||
content.value = [{
|
content.value = [{
|
||||||
id,
|
id,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
|
|
@ -158,6 +158,7 @@ import { userPage } from '@/filters/user.js';
|
||||||
import * as sound from '@/utility/sound.js';
|
import * as sound from '@/utility/sound.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { confetti } from '@/utility/confetti.js';
|
import { confetti } from '@/utility/confetti.js';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
|
|
||||||
const $i = ensureSignin();
|
const $i = ensureSignin();
|
||||||
|
|
||||||
|
@ -273,7 +274,7 @@ function putStone(pos: number) {
|
||||||
playbackRate: 1,
|
playbackRate: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const id = Math.random().toString(36).slice(2);
|
const id = genId();
|
||||||
props.connection!.send('putStone', {
|
props.connection!.send('putStone', {
|
||||||
pos: pos,
|
pos: pos,
|
||||||
id,
|
id,
|
||||||
|
|
|
@ -40,8 +40,8 @@ import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
import { useRouter } from '@/router.js';
|
import { useRouter } from '@/router.js';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
query?: string,
|
query?: string,
|
||||||
origin?: Endpoints['users/search']['req']['origin'],
|
origin?: Endpoints['users/search']['req']['origin'],
|
||||||
}>(), {
|
}>(), {
|
||||||
query: '',
|
query: '',
|
||||||
origin: 'combined',
|
origin: 'combined',
|
||||||
|
@ -115,6 +115,7 @@ async function search() {
|
||||||
userPagination.value = {
|
userPagination.value = {
|
||||||
endpoint: 'users/search',
|
endpoint: 'users/search',
|
||||||
limit: 10,
|
limit: 10,
|
||||||
|
offsetMode: true,
|
||||||
params: {
|
params: {
|
||||||
query: query,
|
query: query,
|
||||||
origin: instance.federation === 'none' ? 'local' : searchOrigin.value,
|
origin: instance.federation === 'none' ? 'local' : searchOrigin.value,
|
||||||
|
|
|
@ -0,0 +1,112 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MkFolder :defaultOpen="false" :canPage="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 { WatermarkRenderer } 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';
|
||||||
|
|
||||||
|
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: WatermarkRenderer | null = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
sampleImage.onload = async () => {
|
||||||
|
watch(canvasEl, async () => {
|
||||||
|
if (canvasEl.value == null) return;
|
||||||
|
|
||||||
|
renderer = new WatermarkRenderer({
|
||||||
|
canvas: canvasEl.value,
|
||||||
|
renderWidth: 1500,
|
||||||
|
renderHeight: 1000,
|
||||||
|
image: sampleImage,
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderer.setLayers(props.preset.layers);
|
||||||
|
|
||||||
|
renderer.render();
|
||||||
|
}, { immediate: true });
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (renderer != null) {
|
||||||
|
renderer.destroy();
|
||||||
|
renderer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.preset, async () => {
|
||||||
|
if (renderer != null) {
|
||||||
|
await renderer.setLayers(props.preset.layers);
|
||||||
|
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>
|
|
@ -39,53 +39,122 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</SearchMarker>
|
</SearchMarker>
|
||||||
|
|
||||||
<FormSection>
|
<SearchMarker :keywords="['general']">
|
||||||
<div class="_gaps_m">
|
<FormSection>
|
||||||
<SearchMarker :keywords="['default', 'upload', 'folder']">
|
<template #label><SearchLabel>{{ i18n.ts.general }}</SearchLabel></template>
|
||||||
<FormLink @click="chooseUploadFolder()">
|
|
||||||
<SearchLabel>{{ i18n.ts.uploadFolder }}</SearchLabel>
|
<div class="_gaps_m">
|
||||||
<template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template>
|
<SearchMarker :keywords="['default', 'upload', 'folder']">
|
||||||
<template #suffixIcon><i class="ti ti-folder"></i></template>
|
<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>
|
</FormLink>
|
||||||
</SearchMarker>
|
|
||||||
|
|
||||||
<FormLink to="/settings/drive/cleaner">
|
<SearchMarker :keywords="['keep', 'original', 'filename']">
|
||||||
{{ i18n.ts.drivecleaner }}
|
<MkPreferenceContainer k="keepOriginalFilename">
|
||||||
</FormLink>
|
<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']">
|
<SearchMarker :keywords="['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file']">
|
||||||
<MkPreferenceContainer k="keepOriginalFilename">
|
<MkSwitch v-model="alwaysMarkNsfw" @update:modelValue="saveProfile()">
|
||||||
<MkSwitch v-model="keepOriginalFilename">
|
<template #label><SearchLabel>{{ i18n.ts.alwaysMarkSensitive }}</SearchLabel></template>
|
||||||
<template #label><SearchLabel>{{ i18n.ts.keepOriginalFilename }}</SearchLabel></template>
|
|
||||||
<template #caption><SearchKeyword>{{ i18n.ts.keepOriginalFilenameDescription }}</SearchKeyword></template>
|
|
||||||
</MkSwitch>
|
</MkSwitch>
|
||||||
</MkPreferenceContainer>
|
</SearchMarker>
|
||||||
</SearchMarker>
|
|
||||||
|
|
||||||
<SearchMarker :keywords="['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file']">
|
<SearchMarker :keywords="['auto', 'nsfw', 'sensitive', 'media', 'file']">
|
||||||
<MkSwitch v-model="alwaysMarkNsfw" @update:modelValue="saveProfile()">
|
<MkSwitch v-model="autoSensitive" @update:modelValue="saveProfile()">
|
||||||
<template #label><SearchLabel>{{ i18n.ts.alwaysMarkSensitive }}</SearchLabel></template>
|
<template #label><SearchLabel>{{ i18n.ts.enableAutoSensitive }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template>
|
||||||
</MkSwitch>
|
<template #caption><SearchKeyword>{{ i18n.ts.enableAutoSensitiveDescription }}</SearchKeyword></template>
|
||||||
</SearchMarker>
|
</MkSwitch>
|
||||||
|
</SearchMarker>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
</SearchMarker>
|
||||||
|
|
||||||
<SearchMarker :keywords="['auto', 'nsfw', 'sensitive', 'media', 'file']">
|
<SearchMarker :keywords="['image']">
|
||||||
<MkSwitch v-model="autoSensitive" @update:modelValue="saveProfile()">
|
<FormSection>
|
||||||
<template #label><SearchLabel>{{ i18n.ts.enableAutoSensitive }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template>
|
<template #label><SearchLabel>{{ i18n.ts.image }}</SearchLabel></template>
|
||||||
<template #caption><SearchKeyword>{{ i18n.ts.enableAutoSensitiveDescription }}</SearchKeyword></template>
|
|
||||||
</MkSwitch>
|
<div class="_gaps_m">
|
||||||
</SearchMarker>
|
<SearchMarker :keywords="['watermark', 'credit']">
|
||||||
</div>
|
<MkFolder>
|
||||||
</FormSection>
|
<template #icon><i class="ti ti-copyright"></i></template>
|
||||||
|
<template #label><SearchLabel>{{ i18n.ts.watermark }}</SearchLabel></template>
|
||||||
|
<template #caption>{{ i18n.ts._watermarkEditor.tip }}</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>
|
||||||
|
<template #caption><div v-html="i18n.ts.defaultImageCompressionLevel_description"></div></template>
|
||||||
|
</MkSelect>
|
||||||
|
</MkPreferenceContainer>
|
||||||
|
</SearchMarker>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
</SearchMarker>
|
||||||
</div>
|
</div>
|
||||||
</SearchMarker>
|
</SearchMarker>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, defineAsyncComponent, ref } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import tinycolor from 'tinycolor2';
|
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 FormLink from '@/components/form/link.vue';
|
||||||
import MkSwitch from '@/components/MkSwitch.vue';
|
import MkSwitch from '@/components/MkSwitch.vue';
|
||||||
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
import FormSection from '@/components/form/section.vue';
|
import FormSection from '@/components/form/section.vue';
|
||||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||||
import FormSplit from '@/components/form/split.vue';
|
import FormSplit from '@/components/form/split.vue';
|
||||||
|
@ -100,6 +169,8 @@ import { prefer } from '@/preferences.js';
|
||||||
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
|
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
|
||||||
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
|
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
|
||||||
import { selectDriveFolder } from '@/utility/drive.js';
|
import { selectDriveFolder } from '@/utility/drive.js';
|
||||||
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
|
||||||
const $i = ensureSignin();
|
const $i = ensureSignin();
|
||||||
|
|
||||||
|
@ -123,6 +194,22 @@ const meterStyle = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const keepOriginalFilename = prefer.model('keepOriginalFilename');
|
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 => {
|
misskeyApi('drive').then(info => {
|
||||||
capacity.value = info.capacity;
|
capacity.value = info.capacity;
|
||||||
|
@ -152,6 +239,41 @@ function chooseUploadFolder() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function addWatermarkPreset() {
|
||||||
|
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), {
|
||||||
|
}, {
|
||||||
|
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() {
|
function saveProfile() {
|
||||||
misskeyApi('i/update', {
|
misskeyApi('i/update', {
|
||||||
alwaysMarkNsfw: !!alwaysMarkNsfw.value,
|
alwaysMarkNsfw: !!alwaysMarkNsfw.value,
|
||||||
|
|
|
@ -119,7 +119,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { genId } from '@/utility/id.js';
|
||||||
import XPalette from './emoji-palette.palette.vue';
|
import XPalette from './emoji-palette.palette.vue';
|
||||||
import MkRadios from '@/components/MkRadios.vue';
|
import MkRadios from '@/components/MkRadios.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
@ -159,7 +159,7 @@ function addPalette() {
|
||||||
prefer.commit('emojiPalettes', [
|
prefer.commit('emojiPalettes', [
|
||||||
...prefer.s.emojiPalettes,
|
...prefer.s.emojiPalettes,
|
||||||
{
|
{
|
||||||
id: uuid(),
|
id: genId(),
|
||||||
name: '',
|
name: '',
|
||||||
emojis: [],
|
emojis: [],
|
||||||
},
|
},
|
||||||
|
|
|
@ -70,11 +70,12 @@ import { definePage } from '@/page.js';
|
||||||
import { prefer } from '@/preferences.js';
|
import { prefer } from '@/preferences.js';
|
||||||
import { PREF_DEF } from '@/preferences/def.js';
|
import { PREF_DEF } from '@/preferences/def.js';
|
||||||
import { getInitialPrefValue } from '@/preferences/manager.js';
|
import { getInitialPrefValue } from '@/preferences/manager.js';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
|
|
||||||
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
|
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
|
||||||
|
|
||||||
const items = ref(prefer.s.menu.map(x => ({
|
const items = ref(prefer.s.menu.map(x => ({
|
||||||
id: Math.random().toString(),
|
id: genId(),
|
||||||
type: x,
|
type: x,
|
||||||
})));
|
})));
|
||||||
|
|
||||||
|
@ -93,7 +94,7 @@ async function addItem() {
|
||||||
});
|
});
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
items.value = [...items.value, {
|
items.value = [...items.value, {
|
||||||
id: Math.random().toString(),
|
id: genId(),
|
||||||
type: item,
|
type: item,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
@ -108,7 +109,7 @@ async function save() {
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
items.value = getInitialPrefValue('menu').map(x => ({
|
items.value = getInitialPrefValue('menu').map(x => ({
|
||||||
id: Math.random().toString(),
|
id: genId(),
|
||||||
type: x,
|
type: x,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
@ -128,6 +128,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
|
<MkButton @click="readAllChatMessages">Read all chat messages</MkButton>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
<FormSlot>
|
<FormSlot>
|
||||||
<MkButton danger @click="migrate"><i class="ti ti-refresh"></i> {{ i18n.ts.migrateOldSettings }}</MkButton>
|
<MkButton danger @click="migrate"><i class="ti ti-refresh"></i> {{ i18n.ts.migrateOldSettings }}</MkButton>
|
||||||
<template #caption>{{ i18n.ts.migrateOldSettings_description }}</template>
|
<template #caption>{{ i18n.ts.migrateOldSettings_description }}</template>
|
||||||
|
@ -214,6 +218,10 @@ function hideAllTips() {
|
||||||
os.success();
|
os.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readAllChatMessages() {
|
||||||
|
os.apiWithDialog('chat/read-all', {});
|
||||||
|
}
|
||||||
|
|
||||||
const headerActions = computed(() => []);
|
const headerActions = computed(() => []);
|
||||||
|
|
||||||
const headerTabs = computed(() => []);
|
const headerTabs = computed(() => []);
|
||||||
|
|
|
@ -229,6 +229,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</MkSwitch>
|
</MkSwitch>
|
||||||
</MkPreferenceContainer>
|
</MkPreferenceContainer>
|
||||||
</SearchMarker>
|
</SearchMarker>
|
||||||
|
|
||||||
|
<SearchMarker :keywords="['reaction', 'order']">
|
||||||
|
<MkPreferenceContainer k="showAvailableReactionsFirstInNote">
|
||||||
|
<MkSwitch v-model="showAvailableReactionsFirstInNote">
|
||||||
|
<template #label><SearchLabel>{{ i18n.ts._settings.showAvailableReactionsFirstInNote }}</SearchLabel></template>
|
||||||
|
</MkSwitch>
|
||||||
|
</MkPreferenceContainer>
|
||||||
|
</SearchMarker>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SearchMarker :keywords="['reaction', 'size', 'scale', 'display']">
|
<SearchMarker :keywords="['reaction', 'size', 'scale', 'display']">
|
||||||
|
@ -796,6 +804,7 @@ import { globalEvents } from '@/events.js';
|
||||||
import { claimAchievement } from '@/utility/achievements.js';
|
import { claimAchievement } from '@/utility/achievements.js';
|
||||||
import { instance } from '@/instance.js';
|
import { instance } from '@/instance.js';
|
||||||
import { ensureSignin } from '@/i.js';
|
import { ensureSignin } from '@/i.js';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
|
|
||||||
const $i = ensureSignin();
|
const $i = ensureSignin();
|
||||||
|
|
||||||
|
@ -823,6 +832,7 @@ const showFixedPostFormInChannel = prefer.model('showFixedPostFormInChannel');
|
||||||
const numberOfPageCache = prefer.model('numberOfPageCache');
|
const numberOfPageCache = prefer.model('numberOfPageCache');
|
||||||
const enableInfiniteScroll = prefer.model('enableInfiniteScroll');
|
const enableInfiniteScroll = prefer.model('enableInfiniteScroll');
|
||||||
const useReactionPickerForContextMenu = prefer.model('useReactionPickerForContextMenu');
|
const useReactionPickerForContextMenu = prefer.model('useReactionPickerForContextMenu');
|
||||||
|
const showAvailableReactionsFirstInNote = prefer.model('showAvailableReactionsFirstInNote');
|
||||||
const useGroupedNotifications = prefer.model('useGroupedNotifications');
|
const useGroupedNotifications = prefer.model('useGroupedNotifications');
|
||||||
const alwaysConfirmFollow = prefer.model('alwaysConfirmFollow');
|
const alwaysConfirmFollow = prefer.model('alwaysConfirmFollow');
|
||||||
const confirmWhenRevealingSensitiveMedia = prefer.model('confirmWhenRevealingSensitiveMedia');
|
const confirmWhenRevealingSensitiveMedia = prefer.model('confirmWhenRevealingSensitiveMedia');
|
||||||
|
@ -899,7 +909,6 @@ watch([
|
||||||
reactionsDisplaySize,
|
reactionsDisplaySize,
|
||||||
limitWidthOfReaction,
|
limitWidthOfReaction,
|
||||||
mediaListWithOneImageAppearance,
|
mediaListWithOneImageAppearance,
|
||||||
reactionsDisplaySize,
|
|
||||||
limitWidthOfReaction,
|
limitWidthOfReaction,
|
||||||
instanceTicker,
|
instanceTicker,
|
||||||
squareAvatars,
|
squareAvatars,
|
||||||
|
@ -916,6 +925,7 @@ watch([
|
||||||
enableHorizontalSwipe,
|
enableHorizontalSwipe,
|
||||||
enablePullToRefresh,
|
enablePullToRefresh,
|
||||||
reduceAnimation,
|
reduceAnimation,
|
||||||
|
showAvailableReactionsFirstInNote,
|
||||||
], async () => {
|
], async () => {
|
||||||
await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
|
await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
|
||||||
});
|
});
|
||||||
|
@ -1009,7 +1019,7 @@ let smashTimer: number | null = null;
|
||||||
|
|
||||||
function testNotification(): void {
|
function testNotification(): void {
|
||||||
const notification: Misskey.entities.Notification = {
|
const notification: Misskey.entities.Notification = {
|
||||||
id: Math.random().toString(),
|
id: genId(),
|
||||||
createdAt: new Date().toUTCString(),
|
createdAt: new Date().toUTCString(),
|
||||||
isRead: false,
|
isRead: false,
|
||||||
type: 'test',
|
type: 'test',
|
||||||
|
|
|
@ -171,6 +171,7 @@ import { claimAchievement } from '@/utility/achievements.js';
|
||||||
import { store } from '@/store.js';
|
import { store } from '@/store.js';
|
||||||
import MkInfo from '@/components/MkInfo.vue';
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
import MkTextarea from '@/components/MkTextarea.vue';
|
import MkTextarea from '@/components/MkTextarea.vue';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
|
|
||||||
const $i = ensureSignin();
|
const $i = ensureSignin();
|
||||||
|
|
||||||
|
@ -199,12 +200,12 @@ watch(() => profile, () => {
|
||||||
deep: true,
|
deep: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fields = ref($i.fields.map(field => ({ id: Math.random().toString(), name: field.name, value: field.value })) ?? []);
|
const fields = ref($i.fields.map(field => ({ id: genId(), name: field.name, value: field.value })) ?? []);
|
||||||
const fieldEditMode = ref(false);
|
const fieldEditMode = ref(false);
|
||||||
|
|
||||||
function addField() {
|
function addField() {
|
||||||
fields.value.push({
|
fields.value.push({
|
||||||
id: Math.random().toString(),
|
id: genId(),
|
||||||
name: '',
|
name: '',
|
||||||
value: '',
|
value: '',
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref, computed } from 'vue';
|
import { onMounted, ref, computed } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { genId } from '@/utility/id.js';
|
||||||
import XStatusbar from './statusbar.statusbar.vue';
|
import XStatusbar from './statusbar.statusbar.vue';
|
||||||
import MkFolder from '@/components/MkFolder.vue';
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
@ -38,7 +38,7 @@ onMounted(() => {
|
||||||
|
|
||||||
async function add() {
|
async function add() {
|
||||||
prefer.commit('statusbars', [...statusbars.value, {
|
prefer.commit('statusbars', [...statusbars.value, {
|
||||||
id: uuid(),
|
id: genId(),
|
||||||
type: null,
|
type: null,
|
||||||
black: false,
|
black: false,
|
||||||
size: 'medium',
|
size: 'medium',
|
||||||
|
|
|
@ -75,7 +75,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import { watch, ref, computed } from 'vue';
|
import { watch, ref, computed } from 'vue';
|
||||||
import { toUnicode } from 'punycode.js';
|
import { toUnicode } from 'punycode.js';
|
||||||
import tinycolor from 'tinycolor2';
|
import tinycolor from 'tinycolor2';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { genId } from '@/utility/id.js';
|
||||||
import JSON5 from 'json5';
|
import JSON5 from 'json5';
|
||||||
import lightTheme from '@@/themes/_light.json5';
|
import lightTheme from '@@/themes/_light.json5';
|
||||||
import darkTheme from '@@/themes/_dark.json5';
|
import darkTheme from '@@/themes/_dark.json5';
|
||||||
|
@ -192,7 +192,7 @@ async function saveAs() {
|
||||||
});
|
});
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
|
|
||||||
theme.value.id = uuid();
|
theme.value.id = genId();
|
||||||
theme.value.name = name;
|
theme.value.name = name;
|
||||||
theme.value.author = `@${$i.username}@${toUnicode(host)}`;
|
theme.value.author = `@${$i.username}@${toUnicode(host)}`;
|
||||||
if (description.value) theme.value.desc = description.value;
|
if (description.value) theme.value.desc = description.value;
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
import { ref, defineAsyncComponent } from 'vue';
|
import { ref, defineAsyncComponent } from 'vue';
|
||||||
import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
|
import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
|
||||||
import { compareVersions } from 'compare-versions';
|
import { compareVersions } from 'compare-versions';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { genId } from '@/utility/id.js';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
|
import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
|
||||||
import { store } from '@/store.js';
|
import { store } from '@/store.js';
|
||||||
|
@ -135,7 +135,7 @@ export async function installPlugin(code: string, meta?: AiScriptPluginMeta) {
|
||||||
throw new Error('Plugin already installed');
|
throw new Error('Plugin already installed');
|
||||||
}
|
}
|
||||||
|
|
||||||
const installId = uuid();
|
const installId = genId();
|
||||||
|
|
||||||
const plugin = {
|
const plugin = {
|
||||||
...realMeta,
|
...realMeta,
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
import type { DeckProfile } from '@/deck.js';
|
import type { DeckProfile } from '@/deck.js';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
import { ColdDeviceStorage, store } from '@/store.js';
|
import { ColdDeviceStorage, store } from '@/store.js';
|
||||||
import { prefer } from '@/preferences.js';
|
import { prefer } from '@/preferences.js';
|
||||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
|
@ -42,7 +42,7 @@ export function migrateOldSettings() {
|
||||||
key: key,
|
key: key,
|
||||||
});
|
});
|
||||||
profiles.push({
|
profiles.push({
|
||||||
id: uuid(),
|
id: genId(),
|
||||||
name: key,
|
name: key,
|
||||||
columns: deck.columns,
|
columns: deck.columns,
|
||||||
layout: deck.layout,
|
layout: deck.layout,
|
||||||
|
|
|
@ -5,13 +5,14 @@
|
||||||
|
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { hemisphere } from '@@/js/intl-const.js';
|
import { hemisphere } from '@@/js/intl-const.js';
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
import { definePreferences } from './manager.js';
|
import { definePreferences } from './manager.js';
|
||||||
import type { Theme } from '@/theme.js';
|
import type { Theme } from '@/theme.js';
|
||||||
import type { SoundType } from '@/utility/sound.js';
|
import type { SoundType } from '@/utility/sound.js';
|
||||||
import type { Plugin } from '@/plugin.js';
|
import type { Plugin } from '@/plugin.js';
|
||||||
import type { DeviceKind } from '@/utility/device-kind.js';
|
import type { DeviceKind } from '@/utility/device-kind.js';
|
||||||
import type { DeckProfile } from '@/deck.js';
|
import type { DeckProfile } from '@/deck.js';
|
||||||
|
import type { WatermarkPreset } from '@/utility/watermark.js';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
|
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
|
||||||
import { deepEqual } from '@/utility/deep-equal.js';
|
import { deepEqual } from '@/utility/deep-equal.js';
|
||||||
|
|
||||||
|
@ -53,13 +54,13 @@ export const PREF_DEF = definePreferences({
|
||||||
accountDependent: true,
|
accountDependent: true,
|
||||||
default: () => [{
|
default: () => [{
|
||||||
name: 'calendar',
|
name: 'calendar',
|
||||||
id: uuid(), place: 'right', data: {},
|
id: genId(), place: 'right', data: {},
|
||||||
}, {
|
}, {
|
||||||
name: 'notifications',
|
name: 'notifications',
|
||||||
id: uuid(), place: 'right', data: {},
|
id: genId(), place: 'right', data: {},
|
||||||
}, {
|
}, {
|
||||||
name: 'trends',
|
name: 'trends',
|
||||||
id: uuid(), place: 'right', data: {},
|
id: genId(), place: 'right', data: {},
|
||||||
}] as {
|
}] as {
|
||||||
name: string;
|
name: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -79,7 +80,7 @@ export const PREF_DEF = definePreferences({
|
||||||
emojiPalettes: {
|
emojiPalettes: {
|
||||||
serverDependent: true,
|
serverDependent: true,
|
||||||
default: () => [{
|
default: () => [{
|
||||||
id: uuid(),
|
id: genId(),
|
||||||
name: '',
|
name: '',
|
||||||
emojis: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
|
emojis: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
|
||||||
}] as {
|
}] as {
|
||||||
|
@ -377,6 +378,9 @@ export const PREF_DEF = definePreferences({
|
||||||
showTitlebar: {
|
showTitlebar: {
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
showAvailableReactionsFirstInNote: {
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
default: [] as Plugin[],
|
default: [] as Plugin[],
|
||||||
mergeStrategy: (a, b) => {
|
mergeStrategy: (a, b) => {
|
||||||
|
@ -393,6 +397,33 @@ export const PREF_DEF = definePreferences({
|
||||||
return [...new Set(a.concat(b))];
|
return [...new Set(a.concat(b))];
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
watermarkPresets: {
|
||||||
|
accountDependent: true,
|
||||||
|
default: [] as WatermarkPreset[],
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultWatermarkPresetId: {
|
||||||
|
accountDependent: true,
|
||||||
|
default: null as WatermarkPreset['id'] | null,
|
||||||
|
},
|
||||||
|
defaultImageCompressionLevel: {
|
||||||
|
default: 2,
|
||||||
|
},
|
||||||
|
|
||||||
'sound.masterVolume': {
|
'sound.masterVolume': {
|
||||||
default: 0.5,
|
default: 0.5,
|
||||||
|
|
|
@ -4,11 +4,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
import { host, version } from '@@/js/config.js';
|
import { host, version } from '@@/js/config.js';
|
||||||
import { PREF_DEF } from './def.js';
|
import { PREF_DEF } from './def.js';
|
||||||
import type { Ref, WritableComputedRef } from 'vue';
|
import type { Ref, WritableComputedRef } from 'vue';
|
||||||
import type { MenuItem } from '@/types/menu.js';
|
import type { MenuItem } from '@/types/menu.js';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
import { $i } from '@/i.js';
|
import { $i } from '@/i.js';
|
||||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
|
@ -301,7 +301,7 @@ export class PreferencesManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: uuid(),
|
id: genId(),
|
||||||
version: version,
|
version: version,
|
||||||
type: 'main',
|
type: 'main',
|
||||||
modifiedAt: Date.now(),
|
modifiedAt: Date.now(),
|
||||||
|
|
|
@ -3,9 +3,9 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { v4 as uuid } from 'uuid';
|
import { genId } from '@/utility/id.js';
|
||||||
|
|
||||||
// HMR有効時にバグか知らんけど複数回実行されるのでその対策
|
// HMR有効時にバグか知らんけど複数回実行されるのでその対策
|
||||||
export const TAB_ID = window.sessionStorage.getItem('TAB_ID') ?? uuid();
|
export const TAB_ID = window.sessionStorage.getItem('TAB_ID') ?? genId();
|
||||||
window.sessionStorage.setItem('TAB_ID', TAB_ID);
|
window.sessionStorage.setItem('TAB_ID', TAB_ID);
|
||||||
if (_DEV_) console.log('TAB_ID', TAB_ID);
|
if (_DEV_) console.log('TAB_ID', TAB_ID);
|
||||||
|
|
|
@ -81,7 +81,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { defineAsyncComponent, ref, useTemplateRef } from 'vue';
|
import { defineAsyncComponent, ref, useTemplateRef } from 'vue';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { genId } from '@/utility/id.js';
|
||||||
import XCommon from './_common_/common.vue';
|
import XCommon from './_common_/common.vue';
|
||||||
import XSidebar from '@/ui/_common_/navbar.vue';
|
import XSidebar from '@/ui/_common_/navbar.vue';
|
||||||
import XNavbarH from '@/ui/_common_/navbar-h.vue';
|
import XNavbarH from '@/ui/_common_/navbar-h.vue';
|
||||||
|
@ -169,7 +169,7 @@ const addColumn = async (ev) => {
|
||||||
|
|
||||||
addColumnToStore({
|
addColumnToStore({
|
||||||
type: column,
|
type: column,
|
||||||
id: uuid(),
|
id: genId(),
|
||||||
name: null,
|
name: null,
|
||||||
width: 330,
|
width: 330,
|
||||||
soundSetting: { type: null, volume: 1 },
|
soundSetting: { type: null, volume: 1 },
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { $i } from '@/i.js';
|
||||||
import { instance } from '@/instance.js';
|
import { instance } from '@/instance.js';
|
||||||
import { globalEvents } from '@/events.js';
|
import { globalEvents } from '@/events.js';
|
||||||
import { getProxiedImageUrl } from '@/utility/media-proxy.js';
|
import { getProxiedImageUrl } from '@/utility/media-proxy.js';
|
||||||
|
import { genId } from '@/utility/id.js';
|
||||||
|
|
||||||
type UploadReturnType = {
|
type UploadReturnType = {
|
||||||
filePromise: Promise<Misskey.entities.DriveFile>;
|
filePromise: Promise<Misskey.entities.DriveFile>;
|
||||||
|
@ -195,7 +196,7 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> {
|
||||||
}).then(({ canceled, result: url }) => {
|
}).then(({ canceled, result: url }) => {
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
|
|
||||||
const marker = Math.random().toString(); // TODO: UUIDとか使う
|
const marker = genId();
|
||||||
|
|
||||||
// TODO: no websocketモード対応
|
// TODO: no websocketモード対応
|
||||||
const connection = useStream().useChannel('main');
|
const connection = useStream().useChannel('main');
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { defineAsyncComponent } from 'vue';
|
import { defineAsyncComponent } from 'vue';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { genId } from '@/utility/id.js';
|
||||||
import { url } from '@@/js/config.js';
|
import { url } from '@@/js/config.js';
|
||||||
import { defaultEmbedParams, embedRouteWithScrollbar } from '@@/js/embed-page.js';
|
import { defaultEmbedParams, embedRouteWithScrollbar } from '@@/js/embed-page.js';
|
||||||
import type { EmbedParams, EmbeddableEntity } from '@@/js/embed-page.js';
|
import type { EmbedParams, EmbeddableEntity } from '@@/js/embed-page.js';
|
||||||
|
@ -44,7 +44,7 @@ export function normalizeEmbedParams(params: EmbedParams): Record<string, string
|
||||||
* 埋め込みコードを生成(iframe IDの発番もやる)
|
* 埋め込みコードを生成(iframe IDの発番もやる)
|
||||||
*/
|
*/
|
||||||
export function getEmbedCode(path: string, params?: EmbedParams): string {
|
export function getEmbedCode(path: string, params?: EmbedParams): string {
|
||||||
const iframeId = 'v1_' + uuid(); // 将来embed.jsのバージョンが上がったとき用にv1_を付けておく
|
const iframeId = 'v1_' + genId(); // 将来embed.jsのバージョンが上がったとき用にv1_を付けておく
|
||||||
|
|
||||||
let paramString = '';
|
let paramString = '';
|
||||||
if (params) {
|
if (params) {
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ランダムな文字列が生成できればなんでも良い(時系列でソートできるなら尚良)が、とりあえずaidの実装を拝借
|
||||||
|
|
||||||
|
const TIME2000 = 946684800000;
|
||||||
|
let counter = Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
function getTime(time: number): string {
|
||||||
|
time = time - TIME2000;
|
||||||
|
if (time < 0) time = 0;
|
||||||
|
|
||||||
|
return time.toString(36).padStart(8, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNoise(): string {
|
||||||
|
return counter.toString(36).padStart(2, '0').slice(-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function genId(): string {
|
||||||
|
counter++;
|
||||||
|
return getTime(Date.now()) + getNoise();
|
||||||
|
}
|
|
@ -0,0 +1,476 @@
|
||||||
|
/*
|
||||||
|
* 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;
|
||||||
|
'texture': { type: 'text'; text: string | null; } | { type: 'url'; url: string | null; } | null;
|
||||||
|
'color': [r: number, g: number, b: number];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImageEffectorFxParamDefs = Record<string, {
|
||||||
|
type: keyof ParamTypeToPrimitive;
|
||||||
|
default: any;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function defineImageEffectorFx<ID extends string, PS extends ImageEffectorFxParamDefs, US extends string[]>(fx: ImageEffectorFx<ID, PS, US>) {
|
||||||
|
return fx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImageEffectorFx<ID extends string = string, PS extends ImageEffectorFxParamDefs = ImageEffectorFxParamDefs, US extends string[] = string[]> = {
|
||||||
|
id: ID;
|
||||||
|
name: string;
|
||||||
|
shader: string;
|
||||||
|
uniforms: US;
|
||||||
|
params: PS,
|
||||||
|
main: (ctx: {
|
||||||
|
gl: WebGL2RenderingContext;
|
||||||
|
program: WebGLProgram;
|
||||||
|
params: {
|
||||||
|
[key in keyof PS]: ParamTypeToPrimitive[PS[key]['type']];
|
||||||
|
};
|
||||||
|
u: Record<US[number], WebGLUniformLocation>;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
textures: Record<string, {
|
||||||
|
texture: WebGLTexture;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null>;
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImageEffectorLayer = {
|
||||||
|
id: string;
|
||||||
|
fxId: string;
|
||||||
|
params: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getValue<T extends keyof ParamTypeToPrimitive>(params: Record<string, any>, k: string): ParamTypeToPrimitive[T] {
|
||||||
|
return params[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ImageEffector {
|
||||||
|
private gl: WebGL2RenderingContext;
|
||||||
|
private canvas: HTMLCanvasElement | 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 shaderCache: Map<string, WebGLProgram> = new Map();
|
||||||
|
private perLayerResultTextures: Map<string, WebGLTexture> = new Map();
|
||||||
|
private perLayerResultFrameBuffers: Map<string, WebGLFramebuffer> = new Map();
|
||||||
|
private fxs: ImageEffectorFx[];
|
||||||
|
private paramTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
|
||||||
|
|
||||||
|
constructor(options: {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
renderWidth: number;
|
||||||
|
renderHeight: number;
|
||||||
|
image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement;
|
||||||
|
fxs: ImageEffectorFx[];
|
||||||
|
}) {
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.renderWidth = options.renderWidth;
|
||||||
|
this.renderHeight = options.renderHeight;
|
||||||
|
this.originalImage = options.image;
|
||||||
|
this.fxs = options.fxs;
|
||||||
|
|
||||||
|
this.canvas.width = this.renderWidth;
|
||||||
|
this.canvas.height = this.renderHeight;
|
||||||
|
|
||||||
|
const gl = this.canvas.getContext('webgl2', {
|
||||||
|
preserveDrawingBuffer: false,
|
||||||
|
alpha: true,
|
||||||
|
premultipliedAlpha: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (gl == null) {
|
||||||
|
throw new Error('Failed to initialize WebGL2 context');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.gl = 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 = createTexture(gl);
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, this.originalImageTexture);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.originalImage.width, this.originalImage.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);
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public loadShader(type: GLenum, source: string): WebGLShader {
|
||||||
|
const gl = this.gl;
|
||||||
|
|
||||||
|
const shader = gl.createShader(type);
|
||||||
|
if (shader == null) {
|
||||||
|
throw new Error('falied to create shader');
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.shaderSource(shader, source);
|
||||||
|
gl.compileShader(shader);
|
||||||
|
|
||||||
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||||
|
console.error(`falied to compile shader: ${gl.getShaderInfoLog(shader)}`);
|
||||||
|
gl.deleteShader(shader);
|
||||||
|
throw new Error(`falied to compile shader: ${gl.getShaderInfoLog(shader)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
public initShaderProgram(vsSource: string, fsSource: string): 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)) {
|
||||||
|
console.error(`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;
|
||||||
|
|
||||||
|
const fx = this.fxs.find(fx => fx.id === layer.fxId);
|
||||||
|
if (fx == null) return;
|
||||||
|
|
||||||
|
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 in_resolution = gl.getUniformLocation(shaderProgram, 'in_resolution');
|
||||||
|
gl.uniform2fv(in_resolution, [this.renderWidth, this.renderHeight]);
|
||||||
|
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, preTexture);
|
||||||
|
const in_texture = gl.getUniformLocation(shaderProgram, 'in_texture');
|
||||||
|
gl.uniform1i(in_texture, 0);
|
||||||
|
|
||||||
|
fx.main({
|
||||||
|
gl: gl,
|
||||||
|
program: shaderProgram,
|
||||||
|
params: Object.fromEntries(
|
||||||
|
Object.entries(fx.params).map(([key, param]) => {
|
||||||
|
return [key, layer.params[key] ?? param.default];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
u: Object.fromEntries(fx.uniforms.map(u => [u, gl.getUniformLocation(shaderProgram, 'u_' + u)!])),
|
||||||
|
width: this.renderWidth,
|
||||||
|
height: this.renderHeight,
|
||||||
|
textures: Object.fromEntries(
|
||||||
|
Object.entries(fx.params).map(([k, v]) => {
|
||||||
|
if (v.type !== 'texture') return [k, null];
|
||||||
|
const param = getValue<typeof v.type>(layer.params, k);
|
||||||
|
if (param == null) return [k, null];
|
||||||
|
const texture = this.paramTextures.get(this.getTextureKeyForParam(param)) ?? null;
|
||||||
|
return [k, texture];
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
const gl = this.gl;
|
||||||
|
|
||||||
|
{
|
||||||
|
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 ?? createTexture(gl);
|
||||||
|
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 setLayers(layers: ImageEffectorLayer[]) {
|
||||||
|
this.layers = layers;
|
||||||
|
|
||||||
|
const unused = new Set(this.paramTextures.keys());
|
||||||
|
|
||||||
|
for (const layer of layers) {
|
||||||
|
const fx = this.fxs.find(fx => fx.id === layer.fxId);
|
||||||
|
if (fx == null) continue;
|
||||||
|
|
||||||
|
for (const k of Object.keys(layer.params)) {
|
||||||
|
const paramDef = fx.params[k];
|
||||||
|
if (paramDef == null) continue;
|
||||||
|
if (paramDef.type !== 'texture') continue;
|
||||||
|
const v = getValue<typeof paramDef.type>(layer.params, k);
|
||||||
|
if (v == null) continue;
|
||||||
|
|
||||||
|
const textureKey = this.getTextureKeyForParam(v);
|
||||||
|
unused.delete(textureKey);
|
||||||
|
if (this.paramTextures.has(textureKey)) continue;
|
||||||
|
|
||||||
|
console.log(`Baking texture of <${textureKey}>...`);
|
||||||
|
|
||||||
|
const texture = v.type === 'text' ? await createTextureFromText(this.gl, v.text) : v.type === 'url' ? await createTextureFromUrl(this.gl, v.url) : null;
|
||||||
|
if (texture == null) continue;
|
||||||
|
|
||||||
|
this.paramTextures.set(textureKey, texture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of unused) {
|
||||||
|
console.log(`Dispose unused texture <${k}>...`);
|
||||||
|
this.gl.deleteTexture(this.paramTextures.get(k)!.texture);
|
||||||
|
this.paramTextures.delete(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
public changeResolution(width: number, height: number) {
|
||||||
|
this.renderWidth = width;
|
||||||
|
this.renderHeight = height;
|
||||||
|
if (this.canvas) {
|
||||||
|
this.canvas.width = this.renderWidth;
|
||||||
|
this.canvas.height = this.renderHeight;
|
||||||
|
}
|
||||||
|
this.gl.viewport(0, 0, this.renderWidth, this.renderHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTextureKeyForParam(v: ParamTypeToPrimitive['texture']) {
|
||||||
|
if (v == null) return '';
|
||||||
|
return v.type === 'text' ? `text:${v.text}` : v.type === 'url' ? `url:${v.url}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
|
||||||
|
*/
|
||||||
|
public destroy(disposeCanvas = true) {
|
||||||
|
for (const shader of this.shaderCache.values()) {
|
||||||
|
this.gl.deleteProgram(shader);
|
||||||
|
}
|
||||||
|
this.shaderCache.clear();
|
||||||
|
|
||||||
|
for (const texture of this.perLayerResultTextures.values()) {
|
||||||
|
this.gl.deleteTexture(texture);
|
||||||
|
}
|
||||||
|
this.perLayerResultTextures.clear();
|
||||||
|
|
||||||
|
for (const framebuffer of this.perLayerResultFrameBuffers.values()) {
|
||||||
|
this.gl.deleteFramebuffer(framebuffer);
|
||||||
|
}
|
||||||
|
this.perLayerResultFrameBuffers.clear();
|
||||||
|
|
||||||
|
for (const texture of this.paramTextures.values()) {
|
||||||
|
this.gl.deleteTexture(texture.texture);
|
||||||
|
}
|
||||||
|
this.paramTextures.clear();
|
||||||
|
|
||||||
|
this.gl.deleteProgram(this.renderTextureProgram);
|
||||||
|
this.gl.deleteProgram(this.renderInvertedTextureProgram);
|
||||||
|
this.gl.deleteTexture(this.originalImageTexture);
|
||||||
|
|
||||||
|
if (disposeCanvas) {
|
||||||
|
const loseContextExt = this.gl.getExtension('WEBGL_lose_context');
|
||||||
|
if (loseContextExt) loseContextExt.loseContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTexture(gl: WebGL2RenderingContext): WebGLTexture {
|
||||||
|
const texture = gl.createTexture();
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTextureFromUrl(gl: WebGL2RenderingContext, imageUrl: string | null): Promise<{ texture: WebGLTexture, width: number, height: number } | null> {
|
||||||
|
if (imageUrl == null || imageUrl.trim() === '') return null;
|
||||||
|
|
||||||
|
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = getProxiedImageUrl(imageUrl); // CORS対策
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (image == null) return null;
|
||||||
|
|
||||||
|
const texture = createTexture(gl);
|
||||||
|
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);
|
||||||
|
|
||||||
|
return {
|
||||||
|
texture,
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTextureFromText(gl: WebGL2RenderingContext, text: string | null, resolution = 2048): Promise<{ texture: WebGLTexture, width: number, height: number } | null> {
|
||||||
|
if (text == null || text.trim() === '') return null;
|
||||||
|
|
||||||
|
const ctx = window.document.createElement('canvas').getContext('2d')!;
|
||||||
|
ctx.canvas.width = resolution;
|
||||||
|
ctx.canvas.height = resolution / 4;
|
||||||
|
const fontSize = resolution / 32;
|
||||||
|
const margin = fontSize / 2;
|
||||||
|
ctx.shadowColor = '#000000';
|
||||||
|
ctx.shadowBlur = fontSize / 4;
|
||||||
|
|
||||||
|
//ctx.fillStyle = '#00ff00';
|
||||||
|
//ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
ctx.fillText(text, margin, ctx.canvas.height / 2);
|
||||||
|
|
||||||
|
const textMetrics = ctx.measureText(text);
|
||||||
|
const cropWidth = (Math.ceil(textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft) + margin + margin);
|
||||||
|
const cropHeight = (Math.ceil(textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) + margin + margin);
|
||||||
|
const data = ctx.getImageData(0, (ctx.canvas.height / 2) - (cropHeight / 2), ctx.canvas.width, ctx.canvas.height);
|
||||||
|
|
||||||
|
const texture = createTexture(gl);
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, cropWidth, cropHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
texture: texture,
|
||||||
|
width: cropWidth,
|
||||||
|
height: cropHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.canvas.remove();
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FX_checker } from './fxs/checker.js';
|
||||||
|
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_polkadot } from './fxs/polkadot.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_stripe } from './fxs/stripe.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,
|
||||||
|
FX_stripe,
|
||||||
|
FX_polkadot,
|
||||||
|
FX_checker,
|
||||||
|
] as const satisfies ImageEffectorFx<string, any>[];
|
|
@ -0,0 +1,87 @@
|
||||||
|
/*
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
const float PI = 3.141592653589793;
|
||||||
|
const float TWO_PI = 6.283185307179586;
|
||||||
|
const float HALF_PI = 1.5707963267948966;
|
||||||
|
|
||||||
|
in vec2 in_uv;
|
||||||
|
uniform sampler2D in_texture;
|
||||||
|
uniform vec2 in_resolution;
|
||||||
|
uniform float u_angle;
|
||||||
|
uniform float u_scale;
|
||||||
|
uniform vec3 u_color;
|
||||||
|
uniform float u_opacity;
|
||||||
|
out vec4 out_color;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 in_color = texture(in_texture, in_uv);
|
||||||
|
float x_ratio = max(in_resolution.x / in_resolution.y, 1.0);
|
||||||
|
float y_ratio = max(in_resolution.y / in_resolution.x, 1.0);
|
||||||
|
|
||||||
|
float angle = -(u_angle * PI);
|
||||||
|
vec2 centeredUv = (in_uv - vec2(0.5, 0.5)) * vec2(x_ratio, y_ratio);
|
||||||
|
vec2 rotatedUV = vec2(
|
||||||
|
centeredUv.x * cos(angle) - centeredUv.y * sin(angle),
|
||||||
|
centeredUv.x * sin(angle) + centeredUv.y * cos(angle)
|
||||||
|
);
|
||||||
|
|
||||||
|
float fmodResult = mod(floor(u_scale * rotatedUV.x) + floor(u_scale * rotatedUV.y), 2.0);
|
||||||
|
float fin = max(sign(fmodResult), 0.0);
|
||||||
|
|
||||||
|
out_color = vec4(
|
||||||
|
mix(in_color.r, u_color.r, fin * u_opacity),
|
||||||
|
mix(in_color.g, u_color.g, fin * u_opacity),
|
||||||
|
mix(in_color.b, u_color.b, fin * u_opacity),
|
||||||
|
in_color.a
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FX_checker = defineImageEffectorFx({
|
||||||
|
id: 'checker' as const,
|
||||||
|
name: i18n.ts._imageEffector._fxs.checker,
|
||||||
|
shader,
|
||||||
|
uniforms: ['angle', 'scale', 'color', 'opacity'] as const,
|
||||||
|
params: {
|
||||||
|
angle: {
|
||||||
|
type: 'number' as const,
|
||||||
|
default: 0,
|
||||||
|
min: -1.0,
|
||||||
|
max: 1.0,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
scale: {
|
||||||
|
type: 'number' as const,
|
||||||
|
default: 3.0,
|
||||||
|
min: 1.0,
|
||||||
|
max: 10.0,
|
||||||
|
step: 0.1,
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: 'color' as const,
|
||||||
|
default: [1, 1, 1],
|
||||||
|
},
|
||||||
|
opacity: {
|
||||||
|
type: 'number' as const,
|
||||||
|
default: 0.5,
|
||||||
|
min: 0.0,
|
||||||
|
max: 1.0,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
main: ({ gl, u, params }) => {
|
||||||
|
gl.uniform1f(u.angle, params.angle / 2);
|
||||||
|
gl.uniform1f(u.scale, params.scale * params.scale);
|
||||||
|
gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
|
||||||
|
gl.uniform1f(u.opacity, params.opacity);
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,76 @@
|
||||||
|
/*
|
||||||
|
* 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 in_texture;
|
||||||
|
uniform vec2 in_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(in_resolution.x, in_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(in_texture, in_uv + rOffset).r;
|
||||||
|
rOffset -= velocity / float(samples);
|
||||||
|
|
||||||
|
accumulator.g += texture(in_texture, in_uv + gOffset).g;
|
||||||
|
gOffset -= velocity / float(samples);
|
||||||
|
|
||||||
|
accumulator.b += texture(in_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,
|
||||||
|
uniforms: ['amount', 'start', 'normalize'] as const,
|
||||||
|
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, u, params }) => {
|
||||||
|
gl.uniform1f(u.amount, params.amount);
|
||||||
|
gl.uniform1i(u.normalize, params.normalize ? 1 : 0);
|
||||||
|
},
|
||||||
|
});
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue