diff options
| author | tamaina <tamaina@hotmail.co.jp> | 2025-09-19 21:02:30 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-09-19 21:02:30 +0900 |
| commit | 42b2aea53364c57c39ebb953359ece4b7b0017a5 (patch) | |
| tree | 8abe7de01705ed3fb6324c6a174162c7bb9288f2 | |
| parent | 🎨 (diff) | |
| download | misskey-42b2aea53364c57c39ebb953359ece4b7b0017a5.tar.gz misskey-42b2aea53364c57c39ebb953359ece4b7b0017a5.tar.bz2 misskey-42b2aea53364c57c39ebb953359ece4b7b0017a5.zip | |
feat(frontend): 自分のプロフィールページの二次元コード(QRコード)を表示し、他の人のコードを読み取りするページを追加 (#16456)
* wip (qr.show.vue)
* added to navbar
* qr.show.vue
* fix
* added to navbar
* fix size
* :art:
* :art:
* fix div warn
* fix
* use * 0.25
* fix??
* fix lint
* clean up
* ???
* ?
* fix
* :art:
* :art:
* refactor
* :art:
* :art:
* :ar:t
* :art:
* iphone flip
* no lazy import
* :art:
* :art:
* :art:
* ユーザー全部flipでいいや
* :v:
* fix
* fix
* fix lint
* :art:
* fix type
* fix: local user profile url cannot be resolved with ap/show
* fix: local user url with hostname could not be resolved
* chore: use common utility for checking self host
* wip
* :art:
* :art:
* fix imports
* fix
* fix
* fix
* :art:
* fix...
* set spacer-w
* :v:
* 全画面でQRを読むように
* fix
* :art:
* modify navbar.ts
* start/stop on vue activation
* display raw content read from qr
* 端末のQRをスキャンするボタンを追加
* chore
* やっぱりmfmを先に表示する
* :art:
* fix 18n
* QRの内容は/users/:userIdにする
* add spdx
* use cqh
* `defineProps` is a compiler macro and no longer needs to be imported.
* use MkUserName
* 🎨
* 🎨
* refactor
* clean up
* refactor
* 🎨
* Update qr.show.vue
* Misskeyロゴにdrop-shadowを追加
* clean up: do not use empty css
* fix os.select usage
* Update qr.vue
* Update qr.show.vue
* Update qr.show.vue
* Update get-user-menu.ts
* ✌️
* Update show.ts
* Update ja-JP.yml
* watermark
* Update CHANGELOG.md
* Update qr.read.vue
* Update qr.read.vue
* wip
* Update MkWatermarkEditorDialog.Layer.vue
---------
Co-authored-by: anatawa12 <anatawa12@icloud.com>
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
23 files changed, 1122 insertions, 62 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index adeb453795..e841ea2791 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,10 @@ - Enhance: 広告ごとにセンシティブフラグを設定できるようになりました ### Client +- Feat: アカウントのQRコードを表示・読み取りできるようになりました - Enhance: チャットの日本語名称がダイレクトメッセージに戻るとともに、ベータ版機能ではなくなりました - Enhance: 画像編集にマスクエフェクトを追加 +- Enhance: ウォーターマークにアカウントのQRコードを追加できるように - Enhance: 時刻計算のための基準値を一か所で管理するようにし、パフォーマンスを向上 - Fix: iOSで、デバイスがダークモードだと初回読み込み時にエラーになる問題を修正 diff --git a/locales/index.d.ts b/locales/index.d.ts index ee808bfa77..95886125ff 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -12236,10 +12236,18 @@ export interface Locale extends ILocale { */ "text": string; /** + * 二次元コード + */ + "qr": string; + /** * 位置 */ "position": string; /** + * マージン + */ + "margin": string; + /** * タイプ */ "type": string; @@ -12295,6 +12303,10 @@ export interface Locale extends ILocale { * サブドットの数 */ "polkadotSubDotDivisions": string; + /** + * 空欄にするとアカウントのURLになります + */ + "leaveBlankToAccountUrl": string; }; "_imageEffector": { /** @@ -12572,6 +12584,68 @@ export interface Locale extends ILocale { */ "listDrafts": string; }; + /** + * 二次元コード + */ + "qr": string; + "_qr": { + /** + * 表示 + */ + "showTabTitle": string; + /** + * 読み取る + */ + "readTabTitle": string; + /** + * {name} {acct} + */ + "shareTitle": ParameterizedString<"name" | "acct">; + /** + * Fediverseで私をフォローしてください! + */ + "shareText": string; + /** + * カメラを選択 + */ + "chooseCamera": string; + /** + * ライト選択不可 + */ + "cannotToggleFlash": string; + /** + * ライトをオンにする + */ + "turnOnFlash": string; + /** + * ライトをオフにする + */ + "turnOffFlash": string; + /** + * コードリーダーを再開 + */ + "startQr": string; + /** + * コードリーダーを停止 + */ + "stopQr": string; + /** + * QRコードが見つかりません + */ + "noQrCodeFound": string; + /** + * 端末の画像をスキャン + */ + "scanFile": string; + /** + * テキスト + */ + "raw": string; + /** + * MFM + */ + "mfm": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 322ff3ab2f..4ae52990e5 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -3275,7 +3275,9 @@ _watermarkEditor: opacity: "不透明度" scale: "サイズ" text: "テキスト" + qr: "二次元コード" position: "位置" + margin: "マージン" type: "タイプ" image: "画像" advanced: "高度" @@ -3290,6 +3292,7 @@ _watermarkEditor: polkadotSubDotOpacity: "サブドットの不透明度" polkadotSubDotRadius: "サブドットの大きさ" polkadotSubDotDivisions: "サブドットの数" + leaveBlankToAccountUrl: "空欄にするとアカウントのURLになります" _imageEffector: title: "エフェクト" @@ -3365,3 +3368,20 @@ _drafts: restoreFromDraft: "下書きから復元" restore: "復元" listDrafts: "下書き一覧" + +qr: "二次元コード" +_qr: + showTabTitle: "表示" + readTabTitle: "読み取る" + shareTitle: "{name} {acct}" + shareText: "Fediverseで私をフォローしてください!" + chooseCamera: "カメラを選択" + cannotToggleFlash: "ライト選択不可" + turnOnFlash: "ライトをオンにする" + turnOffFlash: "ライトをオフにする" + startQr: "コードリーダーを再開" + stopQr: "コードリーダーを停止" + noQrCodeFound: "QRコードが見つかりません" + scanFile: "端末の画像をスキャン" + raw: "テキスト" + mfm: "MFM" diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 4afed7dc5c..fe48e7497a 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -18,9 +18,9 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; -import { ApiError } from '../../error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['federation'], diff --git a/packages/frontend/package.json b/packages/frontend/package.json index f207d04b96..bacdc7b133 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -63,6 +63,8 @@ "misskey-reversi": "workspace:*", "photoswipe": "5.4.4", "punycode.js": "2.3.1", + "qr-code-styling": "1.9.2", + "qr-scanner": "1.4.2", "rollup": "4.50.1", "sanitize-html": "2.17.0", "sass": "1.92.1", diff --git a/packages/frontend/src/components/MkPolkadots.vue b/packages/frontend/src/components/MkPolkadots.vue index 285c4d0b79..4f1346b685 100644 --- a/packages/frontend/src/components/MkPolkadots.vue +++ b/packages/frontend/src/components/MkPolkadots.vue @@ -4,14 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[$style.root, accented ? $style.accented : null]"></div> +<div :class="[$style.root, accented ? $style.accented : null, revered ? $style.revered : null]"/> </template> <script lang="ts" setup> const props = withDefaults(defineProps<{ accented?: boolean; + revered?: boolean; + height?: number; }>(), { accented: false, + revered: false, + height: 200, }); </script> @@ -27,14 +31,17 @@ const props = withDefaults(defineProps<{ --dot-size: 2px; --gap-size: 40px; --offset: calc(var(--gap-size) / 2); + --height: v-bind('props.height + "px"'); - height: 200px; - margin-bottom: -200px; - + height: var(--height); background-image: linear-gradient(transparent 60%, transparent 100%), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)); background-position: 0 0, 0 0, var(--offset) var(--offset); background-size: 100% 100%, var(--gap-size) var(--gap-size), var(--gap-size) var(--gap-size); mask-image: linear-gradient(to bottom, black 0%, transparent 100%); pointer-events: none; + + &.revered { + mask-image: linear-gradient(to top, black 0%, transparent 100%); + } } </style> diff --git a/packages/frontend/src/components/MkPositionSelector.vue b/packages/frontend/src/components/MkPositionSelector.vue index 739f55125b..6f12aada30 100644 --- a/packages/frontend/src/components/MkPositionSelector.vue +++ b/packages/frontend/src/components/MkPositionSelector.vue @@ -6,15 +6,15 @@ 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> + <button v-panel class="_button" :class="[$style.item, x === 'left' && y === 'top' ? $style.active : null]" @click="() => { x = 'left'; y = 'top'; }"><i class="ti ti-arrow-up-left"></i></button> + <button v-panel class="_button" :class="[$style.item, x === 'center' && y === 'top' ? $style.active : null]" @click="() => { x = 'center'; y = 'top'; }"><i class="ti ti-arrow-up"></i></button> + <button v-panel class="_button" :class="[$style.item, x === 'right' && y === 'top' ? $style.active : null]" @click="() => { x = 'right'; y = 'top'; }"><i class="ti ti-arrow-up-right"></i></button> + <button v-panel class="_button" :class="[$style.item, x === 'left' && y === 'center' ? $style.active : null]" @click="() => { x = 'left'; y = 'center'; }"><i class="ti ti-arrow-left"></i></button> + <button v-panel class="_button" :class="[$style.item, x === 'center' && y === 'center' ? $style.active : null]" @click="() => { x = 'center'; y = 'center'; }"><i class="ti ti-focus-2"></i></button> + <button v-panel class="_button" :class="[$style.item, x === 'right' && y === 'center' ? $style.active : null]" @click="() => { x = 'right'; y = 'center'; }"><i class="ti ti-arrow-right"></i></button> + <button v-panel class="_button" :class="[$style.item, x === 'left' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'left'; y = 'bottom'; }"><i class="ti ti-arrow-down-left"></i></button> + <button v-panel class="_button" :class="[$style.item, x === 'center' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'center'; y = 'bottom'; }"><i class="ti ti-arrow-down"></i></button> + <button v-panel class="_button" :class="[$style.item, x === 'right' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'right'; y = 'bottom'; }"><i class="ti ti-arrow-down-right"></i></button> </div> </div> </template> diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue index 11ae091d90..288293db3f 100644 --- a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue @@ -19,6 +19,18 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSlot> <MkRange + :modelValue="layer.align.margin ?? 0" + :min="0" + :max="0.25" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + @update:modelValue="(v) => (layer as Extract<WatermarkPreset['layers'][number], { type: 'text' }>).align.margin = v" + > + <template #label>{{ i18n.ts._watermarkEditor.margin }}</template> + </MkRange> + + <MkRange v-model="layer.scale" :min="0" :max="1" @@ -67,6 +79,18 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSlot> <MkRange + :modelValue="layer.align.margin ?? 0" + :min="0" + :max="0.25" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + @update:modelValue="(v) => (layer as Extract<WatermarkPreset['layers'][number], { type: 'image' }>).align.margin = v" + > + <template #label>{{ i18n.ts._watermarkEditor.margin }}</template> + </MkRange> + + <MkRange v-model="layer.scale" :min="0" :max="1" @@ -107,6 +131,55 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </template> + <template v-else-if="layer.type === 'qr'"> + <MkInput v-model="layer.data" debounce> + <template #label>{{ i18n.ts._watermarkEditor.text }}</template> + <template #caption>{{ i18n.ts._watermarkEditor.leaveBlankToAccountUrl }}</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 + :modelValue="layer.align.margin ?? 0" + :min="0" + :max="0.25" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + @update:modelValue="(v) => (layer as Extract<WatermarkPreset['layers'][number], { type: 'qr' }>).align.margin = v" + > + <template #label>{{ i18n.ts._watermarkEditor.margin }}</template> + </MkRange> + + <MkRange + v-model="layer.scale" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.scale }}</template> + </MkRange> + + <MkRange + v-model="layer.opacity" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.opacity }}</template> + </MkRange> + </template> + <template v-else-if="layer.type === 'stripe'"> <MkRange v-model="layer.frequency" diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.vue index 75a45548fd..0d0488d9bc 100644 --- a/packages/frontend/src/components/MkWatermarkEditorDialog.vue +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.vue @@ -30,22 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.controls"> <div class="_spacer _gaps"> - <MkSelect v-model="type" :items="typeDef"> - <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"> + <div 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 === 'qr'">{{ i18n.ts._watermarkEditor.qr }}</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> @@ -95,7 +85,7 @@ function createTextLayer(): WatermarkPreset['layers'][number] { id: genId(), type: 'text', text: `(c) @${$i.username}`, - align: { x: 'right', y: 'bottom' }, + align: { x: 'right', y: 'bottom', margin: 0 }, scale: 0.3, angle: 0, opacity: 0.75, @@ -109,7 +99,7 @@ function createImageLayer(): WatermarkPreset['layers'][number] { type: 'image', imageId: null, imageUrl: null, - align: { x: 'right', y: 'bottom' }, + align: { x: 'right', y: 'bottom', margin: 0 }, scale: 0.3, angle: 0, opacity: 0.75, @@ -118,6 +108,17 @@ function createImageLayer(): WatermarkPreset['layers'][number] { }; } +function createQrLayer(): WatermarkPreset['layers'][number] { + return { + id: genId(), + type: 'qr', + data: '', + align: { x: 'right', y: 'bottom', margin: 0 }, + scale: 0.3, + opacity: 1, + }; +} + function createStripeLayer(): WatermarkPreset['layers'][number] { return { id: genId(), @@ -165,7 +166,7 @@ const props = defineProps<{ const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? { id: genId(), name: '', - layers: [createTextLayer()], + layers: [], }); const emit = defineEmits<{ @@ -187,28 +188,6 @@ async function cancel() { dialog.value?.close(); } -const { - model: type, - def: typeDef, -} = useMkSelect({ - items: [ - { label: i18n.ts._watermarkEditor.text, value: 'text' }, - { label: i18n.ts._watermarkEditor.image, value: 'image' }, - { label: i18n.ts._watermarkEditor.advanced, value: 'advanced' }, - ], - initialValue: 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); @@ -339,6 +318,11 @@ function addLayer(ev: MouseEvent) { preset.layers.push(createImageLayer()); }, }, { + text: i18n.ts._watermarkEditor.qr, + action: () => { + preset.layers.push(createQrLayer()); + }, + }, { text: i18n.ts._watermarkEditor.stripe, action: () => { preset.layers.push(createStripeLayer()); diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index aec1c7ae4c..a162b3aa9e 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -66,6 +66,12 @@ export const navbarItemDef = reactive({ lookup(); }, }, + qr: { + title: i18n.ts.qr, + icon: 'ti ti-qrcode', + show: computed(() => $i != null), + to: '/qr', + }, lists: { title: i18n.ts.lists, icon: 'ti ti-list', diff --git a/packages/frontend/src/pages/chat/home.vue b/packages/frontend/src/pages/chat/home.vue index 2af0e0b443..5c773a241b 100644 --- a/packages/frontend/src/pages/chat/home.vue +++ b/packages/frontend/src/pages/chat/home.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true"> - <MkPolkadots v-if="tab === 'home'" accented/> + <MkPolkadots v-if="tab === 'home'" accented :height="200" style="margin-bottom: -200px;"/> <div class="_spacer" style="--MI_SPACER-w: 700px;"> <XHome v-if="tab === 'home'"/> <XInvitations v-else-if="tab === 'invitations'"/> diff --git a/packages/frontend/src/pages/qr.read.raw-viewer.vue b/packages/frontend/src/pages/qr.read.raw-viewer.vue new file mode 100644 index 0000000000..5a23e2322d --- /dev/null +++ b/packages/frontend/src/pages/qr.read.raw-viewer.vue @@ -0,0 +1,54 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkFolder defaultOpen :withSpacer="false"> + <template #label>{{ data.split('\n')[0] }}</template> + <template #header> + <MkTabs + v-model:tab="tab" + :tabs="[ + { + key: 'mfm', + title: i18n.ts._qr.mfm, + icon: 'ti ti-align-left', + }, + { + key: 'raw', + title: i18n.ts._qr.raw, + icon: 'ti ti-code', + }, + ]" + /> + </template> + + <div v-show="tab === 'mfm'" class="_spacer _gaps"> + <Mfm :text="data" :nyaize="false"/> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false"/> + </div> + <div v-show="tab === 'raw'" class="_spacer" style="--MI_SPACER-min: 10px; --MI_SPACER-max: 16px;"> + <MkCode :code="data" lang="text"/> + </div> +</MkFolder> +</template> + +<script lang="ts" setup> +import { ref, computed } from 'vue'; +import * as mfm from 'mfm-js'; +import MkFolder from '@/components/MkFolder.vue'; +import MkTabs from '@/components/MkTabs.vue'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm'; +import MkCode from '@/components/MkCode.vue'; +import MkUrlPreview from '@/components/MkUrlPreview.vue'; +import { i18n } from '@/i18n.js'; + +const props = defineProps<{ + data: string; +}>(); + +const parsed = computed(() => mfm.parse(props.data)); +const urls = computed(() => extractUrlFromMfm(parsed.value)); +const tab = ref<'mfm' | 'raw'>('mfm'); +</script> diff --git a/packages/frontend/src/pages/qr.read.vue b/packages/frontend/src/pages/qr.read.vue new file mode 100644 index 0000000000..e4c475196a --- /dev/null +++ b/packages/frontend/src/pages/qr.read.vue @@ -0,0 +1,397 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + ref="rootEl" + :class="$style.root" + :style="{ + '--MI-QrReadViewHeight': 'calc(100cqh - var(--MI-stickyTop, 0px) - var(--MI-stickyBottom, 0px))', + '--MI-QrReadVideoHeight': 'min(calc(var(--MI-QrReadViewHeight) * 0.3), 512px)', + }" +> + <MkStickyContainer> + <template #header> + <div :class="$style.view"> + <video ref="videoEl" :class="$style.video" autoplay muted playsinline></video> + <div ref="overlayEl" :class="$style.overlay"></div> + <div :class="$style.controls"> + <MkButton v-tooltip="i18n.ts._qr.scanFile" iconOnly @click="upload"><i class="ti ti-photo-plus"></i></MkButton> + + <MkButton v-if="qrStarted" v-tooltip="i18n.ts._qr.stopQr" iconOnly @click="stopQr"><i class="ti ti-player-play"></i></MkButton> + <MkButton v-else v-tooltip="i18n.ts._qr.startQr" iconOnly danger @click="startQr"><i class="ti ti-player-pause"></i></MkButton> + + <MkButton v-tooltip="i18n.ts._qr.chooseCamera" iconOnly @click="chooseCamera"><i class="ti ti-camera-rotate"></i></MkButton> + + <MkButton v-if="!flashCanToggle" v-tooltip="i18n.ts._qr.cannotToggleFlash" iconOnly disabled><i class="ti ti-bolt"></i></MkButton> + <MkButton v-else-if="!flash" v-tooltip="i18n.ts._qr.turnOnFlash" iconOnly @click="toggleFlash(true)"><i class="ti ti-bolt-off"></i></MkButton> + <MkButton v-else v-tooltip="i18n.ts._qr.turnOffFlash" iconOnly @click="toggleFlash(false)"><i class="ti ti-bolt-filled"></i></MkButton> + </div> + </div> + </template> + <div + :class="['_spacer', $style.contents]" + :style="{ + '--MI_SPACER-w': '800px' + }" + > + <MkStickyContainer> + <template #header> + <MkTab v-model="tab" :class="$style.tab"> + <option value="users">{{ i18n.ts.users }}</option> + <option value="notes">{{ i18n.ts.notes }}</option> + <option value="all">{{ i18n.ts.all }}</option> + </MkTab> + </template> + <div v-if="tab === 'users'" :class="[$style.users, '_margin']" style="padding-bottom: var(--MI-margin);"> + <MkUserInfo v-for="user in users" :key="user.id" :user="user"/> + </div> + <div v-else-if="tab === 'notes'" class="_margin _gaps" style="padding-bottom: var(--MI-margin);"> + <MkNote v-for="note in notes" :key="note.id" :note="note" :class="$style.note"/> + </div> + <div v-else-if="tab === 'all'" class="_margin _gaps" style="padding-bottom: var(--MI-margin);"> + <MkQrReadRawViewer v-for="result in Array.from(results).reverse()" :key="result" :data="result"/> + </div> + </MkStickyContainer> + </div> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import QrScanner from 'qr-scanner'; +import { onActivated, onDeactivated, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import { getScrollContainer } from '@@/js/scroll.js'; +import type { ApShowResponse } from 'misskey-js/entities.js'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import MkUserInfo from '@/components/MkUserInfo.vue'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import MkNote from '@/components/MkNote.vue'; +import MkTab from '@/components/MkTab.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkQrReadRawViewer from '@/pages/qr.read.raw-viewer.vue'; + +const LIST_RERENDER_INTERVAL = 1500; + +const rootEl = useTemplateRef('rootEl'); +const videoEl = useTemplateRef('videoEl'); +const overlayEl = useTemplateRef('overlayEl'); + +const scannerInstance = shallowRef<QrScanner | null>(null); + +const tab = ref<'users' | 'notes' | 'all'>('users'); + +// higher is recent +const results = ref(new Set<string>()); +// lower is recent +const uris = ref<string[]>([]); +const sources = new Map<string, ApShowResponse | null>(); +const users = ref<(misskey.entities.UserDetailed)[]>([]); +const usersCount = ref(0); +const notes = ref<misskey.entities.Note[]>([]); +const notesCount = ref(0); + +const timer = ref<number | null>(null); + +function updateLists() { + const responses = uris.value.map(uri => sources.get(uri)).filter((r): r is ApShowResponse => !!r); + users.value = responses.filter(r => r.type === 'User').map(r => r.object).filter((u): u is misskey.entities.UserDetailed => !!u); + usersCount.value = users.value.length; + notes.value = responses.filter(r => r.type === 'Note').map(r => r.object).filter((n): n is misskey.entities.Note => !!n); + notesCount.value = notes.value.length; + updateRequired.value = false; +} + +const updateRequired = ref(false); + +watch(uris, () => { + if (timer.value) { + updateRequired.value = true; + return; + } + + updateLists(); + + timer.value = window.setTimeout(() => { + timer.value = null; + if (updateRequired.value) { + updateLists(); + } + }, LIST_RERENDER_INTERVAL) as number; +}); + +watch(tab, () => { + if (timer.value) { + window.clearTimeout(timer.value); + timer.value = null; + } + updateLists(); +}); + +async function processResult(result: QrScanner.ScanResult) { + if (!result) return; + const trimmed = result.data.trim(); + + if (!trimmed) return; + + const haveExisted = results.value.has(trimmed); + results.value.add(trimmed); + + try { + new URL(trimmed); + } catch { + if (!haveExisted) { + tab.value = 'all'; + } + return; + } + + if (uris.value[0] !== trimmed) { + // 並べ替え + uris.value = [trimmed, ...uris.value.slice(0, 29).filter(u => u !== trimmed)]; + } + + if (sources.has(trimmed)) return; + // Start fetching user info + sources.set(trimmed, null); + + await misskeyApi('ap/show', { uri: trimmed }) + .then(data => { + if (data.type === 'User') { + sources.set(trimmed, data); + tab.value = 'users'; + } else if (data.type === 'Note') { + sources.set(trimmed, data); + tab.value = 'notes'; + } + updateLists(); + }) + .catch(err => { + tab.value = 'all'; + throw err; + }); +} + +const qrStarted = ref(true); +const flashCanToggle = ref(false); +const flash = ref(false); + +async function upload() { + os.chooseFileFromPc({ multiple: true }).then(files => { + if (files.length === 0) return; + for (const file of files) { + QrScanner.scanImage(file, { returnDetailedScanResult: true }) + .then(result => { + processResult(result); + }) + .catch(err => { + if (err.toString().includes('No QR code found')) { + os.alert({ + type: 'info', + text: i18n.ts._qr.noQrCodeFound, + }); + } else { + os.alert({ + type: 'error', + text: err.toString(), + }); + console.error(err); + } + }); + } + }); +} + +async function chooseCamera() { + if (!scannerInstance.value) return; + const cameras = await QrScanner.listCameras(true); + if (cameras.length === 0) { + os.alert({ + type: 'error', + }); + return; + } + + const select = await os.select({ + title: i18n.ts._qr.chooseCamera, + items: cameras.map(camera => ({ + label: camera.label, + value: camera.id, + })), + }); + if (select.canceled) return; + if (select.result == null) return; + + await scannerInstance.value.setCamera(select.result); + flashCanToggle.value = await scannerInstance.value.hasFlash(); + flash.value = scannerInstance.value.isFlashOn(); +} + +async function toggleFlash(to = false) { + if (!scannerInstance.value) return; + + flash.value = to; + if (flash.value) { + await scannerInstance.value.turnFlashOn(); + } else { + await scannerInstance.value.turnFlashOff(); + } +} + +async function startQr() { + if (!scannerInstance.value) return; + await scannerInstance.value.start(); + qrStarted.value = true; +} + +function stopQr() { + if (!scannerInstance.value) return; + scannerInstance.value.stop(); + qrStarted.value = false; +} + +onActivated(() => { + startQr; +}); + +onDeactivated(() => { + stopQr; +}); + +const alertLock = ref(false); + +onMounted(() => { + if (!videoEl.value || !overlayEl.value) { + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + return; + } + + scannerInstance.value = new QrScanner( + videoEl.value, + processResult, + { + highlightScanRegion: true, + highlightCodeOutline: true, + overlay: overlayEl.value, + calculateScanRegion(video: HTMLVideoElement): QrScanner.ScanRegion { + const aspectRatio = video.videoWidth / video.videoHeight; + const SHORT_SIDE_SIZE_DOWNSCALED = 360; + return { + x: 0, + y: 0, + width: video.videoWidth, + height: video.videoHeight, + downScaledWidth: aspectRatio > 1 ? Math.round(SHORT_SIDE_SIZE_DOWNSCALED * aspectRatio) : SHORT_SIDE_SIZE_DOWNSCALED, + downScaledHeight: aspectRatio > 1 ? SHORT_SIDE_SIZE_DOWNSCALED : Math.round(SHORT_SIDE_SIZE_DOWNSCALED / aspectRatio), + }; + }, + onDecodeError(err) { + if (err.toString().includes('No QR code found')) return; + if (alertLock.value) return; + alertLock.value = true; + os.alert({ + type: 'error', + text: err.toString(), + }).finally(() => { + alertLock.value = false; + }); + }, + }, + ); + + scannerInstance.value.start() + .then(async () => { + qrStarted.value = true; + if (!scannerInstance.value) return; + flashCanToggle.value = await scannerInstance.value.hasFlash(); + flash.value = scannerInstance.value.isFlashOn(); + }) + .catch(err => { + qrStarted.value = false; + os.alert({ + type: 'error', + text: err.toString(), + }); + console.error(err); + }); +}); + +onUnmounted(() => { + if (timer.value) { + window.clearTimeout(timer.value); + timer.value = null; + } + + scannerInstance.value?.destroy(); +}); +</script> + +<style lang="scss" module> +.root { + position: relative; +} + +.view { + position: sticky; + top: var(--MI-stickyTop, 0); + z-index: 1; + background: var(--MI_THEME-bg); + background-size: 16px 16px; + width: 100%; + height: var(--MI-QrReadVideoHeight); +} + +.video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.controls { + width: 100%; + position: absolute; + right: 10px; + bottom: 10px; + display: flex; + justify-content: end; + align-items: center; + gap: 10px; +} + +html[data-color-scheme=dark] .view { + --c: rgb(255 255 255 / 2%); + background-image: linear-gradient(45deg, var(--c) 16.67%, var(--MI_THEME-bg) 16.67%, var(--MI_THEME-bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--MI_THEME-bg) 66.67%, var(--MI_THEME-bg) 100%); +} + +html[data-color-scheme=light] .view { + --c: rgb(0 0 0 / 2%); + background-image: linear-gradient(45deg, var(--c) 16.67%, var(--MI_THEME-bg) 16.67%, var(--MI_THEME-bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--MI_THEME-bg) 66.67%, var(--MI_THEME-bg) 100%); +} + +.contents { + padding-top: calc(var(--MI-margin) / 2); +} + +.tab { + padding: calc(var(--MI-margin) / 2) 0; + background: var(--MI_THEME-bg); +} + +.users { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: var(--MI-margin); +} + +.note { + background: var(--MI_THEME-panel); + border-radius: var(--MI-radius); +} +</style> diff --git a/packages/frontend/src/pages/qr.show.vue b/packages/frontend/src/pages/qr.show.vue new file mode 100644 index 0000000000..28f80e0963 --- /dev/null +++ b/packages/frontend/src/pages/qr.show.vue @@ -0,0 +1,234 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root"> + <div :class="[$style.content]"> + <div + ref="qrCodeEl" v-flip :style="{ + 'cursor': canShare ? 'pointer' : 'default', + }" + :class="$style.qr" @click="share" + ></div> + <div v-flip :class="$style.user"> + <MkAvatar :class="$style.avatar" :user="$i" :indicator="false"/> + <div> + <div :class="$style.name"><MkCondensedLine :minScale="2 / 3"><MkUserName :user="$i" :nowrap="true"/></MkCondensedLine></div> + <div><MkCondensedLine :minScale="2 / 3">{{ acct }}</MkCondensedLine></div> + </div> + </div> + <img v-if="deviceMotionPermissionNeeded" v-flip :class="$style.logo" :src="misskeysvg" alt="Misskey Logo" @click="requestDeviceMotion"/> + <img v-else v-flip :class="$style.logo" :src="misskeysvg" alt="Misskey Logo"/> + </div> +</div> +</template> + +<script lang="ts" setup> +import tinycolor from 'tinycolor2'; +import QRCodeStyling from 'qr-code-styling'; +import { computed, ref, shallowRef, watch, onMounted, onUnmounted, useTemplateRef } from 'vue'; +import { url, host } from '@@/js/config.js'; +import type { Directive } from 'vue'; +import { instance } from '@/instance.js'; +import { ensureSignin } from '@/i.js'; +import { userPage, userName } from '@/filters/user.js'; +import misskeysvg from '/client-assets/misskey.svg'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; +import { i18n } from '@/i18n.js'; + +const $i = ensureSignin(); + +const acct = computed(() => `@${$i.username}@${host}`); +const userProfileUrl = computed(() => userPage($i, undefined, true)); +const shareData = computed(() => ({ + title: i18n.tsx._qr.shareTitle({ name: userName($i), acct: acct.value }), + text: i18n.ts._qr.shareText, + url: userProfileUrl.value, +})); +const canShare = computed(() => navigator.canShare && navigator.canShare(shareData.value)); + +const qrCodeEl = useTemplateRef('qrCodeEl'); + +const qrColor = computed(() => tinycolor(instance.themeColor ?? '#86b300')); +const qrHsl = computed(() => qrColor.value.toHsl()); + +function share() { + if (!canShare.value) return; + return navigator.share(shareData.value); +} + +const qrCodeInstance = new QRCodeStyling({ + width: 600, + height: 600, + margin: 42, + type: 'canvas', + data: `${url}/users/${$i.id}`, + image: instance.iconUrl ? getStaticImageUrl(instance.iconUrl) : '/favicon.ico', + qrOptions: { + typeNumber: 0, + mode: 'Byte', + errorCorrectionLevel: 'H', + }, + imageOptions: { + hideBackgroundDots: true, + imageSize: 0.3, + margin: 16, + crossOrigin: 'anonymous', + }, + dotsOptions: { + type: 'dots', + color: tinycolor(`hsl(${qrHsl.value.h}, 100, 18)`).toRgbString(), + }, + cornersDotOptions: { + type: 'dot', + }, + cornersSquareOptions: { + type: 'extra-rounded', + }, + backgroundOptions: { + color: tinycolor(`hsl(${qrHsl.value.h}, 100, 97)`).toRgbString(), + }, +}); + +onMounted(() => { + if (qrCodeEl.value != null) { + qrCodeInstance.append(qrCodeEl.value); + } +}); + +//#region flip +const THRESHOLD = -3; +// @ts-expect-error TS(2339) +const deviceMotionPermissionNeeded = window.DeviceMotionEvent && typeof window.DeviceMotionEvent.requestPermission === 'function'; +const flipEls: Set<Element> = new Set(); +const flip = ref(false); + +function handleOrientationChange(event: DeviceOrientationEvent) { + const isUpsideDown = event.beta ? event.beta < THRESHOLD : false; + flip.value = isUpsideDown; +} + +watch(flip, (newState) => { + flipEls.forEach(el => { + el.classList.toggle('_qrShowFlipFliped', newState); + }); +}); + +function requestDeviceMotion() { + if (!deviceMotionPermissionNeeded) return; + // @ts-expect-error TS(2339) + window.DeviceMotionEvent.requestPermission() + .then((response: string) => { + if (response === 'granted') { + window.addEventListener('deviceorientation', handleOrientationChange); + } + }) + .catch(console.error); +} + +onMounted(() => { + window.addEventListener('deviceorientation', handleOrientationChange); +}); + +onUnmounted(() => { + window.removeEventListener('deviceorientation', handleOrientationChange); +}); + +const vFlip = { + mounted(el: Element) { + flipEls.add(el); + el.classList.add('_qrShowFlip'); + }, + unmounted(el: Element) { + el.classList.remove('_qrShowFlip'); + flipEls.delete(el); + }, +} as Directive; +//#endregion +</script> + +<style lang="scss" module> +$s1: 14px; +$s2: 21px; +$s3: 28px; +$avatarSize: 58px; + +.root { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; +} + +.content { + display: flex; + flex-direction: column; + align-items: center; +} + +.qr { + position: relative; + margin: 0 auto; + width: 100%; + max-width: 230px; + border-radius: 12px; + overflow: clip; + aspect-ratio: 1; + + > svg, + > canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } +} + +.user { + display: flex; + flex-direction: column; + margin: $s3 auto; + justify-content: center; + align-items: center; + text-align: center; + overflow: visible; + width: fit-content; + max-width: 100%; +} + +.avatar { + width: $avatarSize; + height: $avatarSize; + margin-bottom: 16px; +} + +.name { + font-weight: bold; + font-size: 110%; +} + +.logo { + width: 100px; + margin: $s3 auto 0; + filter: drop-shadow(0 0 6px #0007); +} +</style> + +<style lang="scss"> +/* + * useCssModuleで$styleを読み込みたかったが、rollupでのunwindが壊れてしまうらしく失敗。 + * グローバルにクラスを定義することでお茶を濁す。 + */ +._qrShowFlip { + transition: rotate .3s linear, scale .3s .15s step-start; +} + +._qrShowFlipFliped { + scale: -1 1; + rotate: x 180deg; +} +</style> diff --git a/packages/frontend/src/pages/qr.vue b/packages/frontend/src/pages/qr.vue new file mode 100644 index 0000000000..2e5629f232 --- /dev/null +++ b/packages/frontend/src/pages/qr.vue @@ -0,0 +1,57 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root" class="_pageScrollable"> + <div class="_spacer" :class="$style.main"> + <MkButton v-if="read" :class="$style.button" rounded @click="read = false"><i class="ti ti-qrcode"></i> {{ i18n.ts._qr.showTabTitle }}</MkButton> + <MkButton v-else :class="$style.button" rounded @click="read = true"><i class="ti ti-scan"></i> {{ i18n.ts._qr.readTabTitle }}</MkButton> + + <MkQrRead v-if="read"/> + <MkQrShow v-else/> + </div> + <MkPolkadots v-if="!read" accented revered :height="200" style="position: sticky; bottom: 0; margin-top: -200px;"/> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, ref, shallowRef } from 'vue'; +import MkQrShow from './qr.show.vue'; +import { definePage } from '@/page.js'; +import { i18n } from '@/i18n.js'; +import { ensureSignin } from '@/i'; +import MkButton from '@/components/MkButton.vue'; +import MkPolkadots from '@/components/MkPolkadots.vue'; + +// router definitionでloginRequiredが設定されているためエラーハンドリングしない +const $i = ensureSignin(); + +const read = ref(false); + +const MkQrRead = defineAsyncComponent(() => import('./qr.read.vue')); + +definePage(() => ({ + title: i18n.ts.qr, + icon: 'ti ti-qrcode', +})); +</script> + +<style lang="scss" module> +.root { + height: 100%; +} + +.main { + min-height: 100%; + display: flex; + flex-direction: column; + position: relative; + z-index: 1; +} + +.button { + margin: 0 auto 16px auto; +} +</style> diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index cfa4df18fc..2d794f2e30 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only <FormLink @click="chooseUploadFolder()"> <SearchLabel>{{ i18n.ts.uploadFolder }}</SearchLabel> <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> - <template #suffixIcon><i class="ti ti-folder"></i></template> + <template #icon><i class="ti ti-folder"></i></template> </FormLink> </SearchMarker> diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 17e8505474..89325dee63 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -151,6 +151,15 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> </SearchMarker> + + <hr> + + <SearchMarker :keywords="['qrcode']"> + <FormLink to="/qr"> + <template #icon><i class="ti ti-qrcode"></i></template> + <SearchLabel>{{ i18n.ts.qr }}</SearchLabel> + </FormLink> + </SearchMarker> </div> </SearchMarker> </template> @@ -164,6 +173,7 @@ import MkSelect from '@/components/MkSelect.vue'; import FormSplit from '@/components/form/split.vue'; import MkFolder from '@/components/MkFolder.vue'; import FormSlot from '@/components/form/slot.vue'; +import FormLink from '@/components/form/link.vue'; import { chooseDriveFile } from '@/utility/drive.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/router.definition.ts b/packages/frontend/src/router.definition.ts index e25e0fe161..d59c9d1c6f 100644 --- a/packages/frontend/src/router.definition.ts +++ b/packages/frontend/src/router.definition.ts @@ -591,6 +591,10 @@ export const ROUTE_DEF = [{ component: page(() => import('@/pages/reversi/game.vue')), loginRequired: false, }, { + path: '/qr', + component: page(() => import('@/pages/qr.vue')), + loginRequired: true, +}, { path: '/debug', component: page(() => import('@/pages/debug.vue')), loginRequired: false, diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts index 1ec322b5fd..fc1e9f209e 100644 --- a/packages/frontend/src/utility/get-user-menu.ts +++ b/packages/frontend/src/utility/get-user-menu.ts @@ -215,6 +215,16 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router }); } + if ($i && meId === user.id) { + menuItems.push({ + icon: 'ti ti-qrcode', + text: i18n.ts.qr, + action: () => { + router.push('/qr'); + }, + }); + } + if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) { menuItems.push({ icon: 'ti ti-search', diff --git a/packages/frontend/src/utility/image-effector/ImageEffector.ts b/packages/frontend/src/utility/image-effector/ImageEffector.ts index 66b4d1026c..26c74bfae5 100644 --- a/packages/frontend/src/utility/image-effector/ImageEffector.ts +++ b/packages/frontend/src/utility/image-effector/ImageEffector.ts @@ -3,8 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import QRCodeStyling from 'qr-code-styling'; +import { url, host } from '@@/js/config.js'; import { getProxiedImageUrl } from '../media-proxy.js'; import { initShaderProgram } from '../webgl.js'; +import { ensureSignin } from '@/i.js'; export type ImageEffectorRGB = [r: number, g: number, b: number]; @@ -48,6 +51,7 @@ interface AlignParamDef extends CommonParamDef { default: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; + margin?: number; }; }; @@ -58,7 +62,13 @@ interface SeedParamDef extends CommonParamDef { interface TextureParamDef extends CommonParamDef { type: 'texture'; - default: { type: 'text'; text: string | null; } | { type: 'url'; url: string | null; } | null; + default: { + type: 'text'; text: string | null; + } | { + type: 'url'; url: string | null; + } | { + type: 'qr'; data: string | null; + } | null; }; interface ColorParamDef extends CommonParamDef { @@ -324,7 +334,11 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a if (_DEV_) console.log(`Baking texture of <${textureKey}>...`); - const texture = v.type === 'text' ? await createTextureFromText(this.gl, v.text) : v.type === 'url' ? await createTextureFromUrl(this.gl, v.url) : null; + const texture = + v.type === 'text' ? await createTextureFromText(this.gl, v.text) : + v.type === 'url' ? await createTextureFromUrl(this.gl, v.url) : + v.type === 'qr' ? await createTextureFromQr(this.gl, { data: v.data }) : + null; if (texture == null) continue; this.paramTextures.set(textureKey, texture); @@ -352,7 +366,12 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a private getTextureKeyForParam(v: ParamTypeToPrimitive['texture']) { if (v == null) return ''; - return v.type === 'text' ? `text:${v.text}` : v.type === 'url' ? `url:${v.url}` : ''; + return ( + v.type === 'text' ? `text:${v.text}` : + v.type === 'url' ? `url:${v.url}` : + v.type === 'qr' ? `qr:${v.data}` : + '' + ); } /* @@ -467,3 +486,53 @@ async function createTextureFromText(gl: WebGL2RenderingContext, text: string | return info; } + +async function createTextureFromQr(gl: WebGL2RenderingContext, options: { data: string | null }, resolution = 512): Promise<{ texture: WebGLTexture, width: number, height: number } | null> { + const $i = ensureSignin(); + + const qrCodeInstance = new QRCodeStyling({ + width: resolution, + height: resolution, + margin: 42, + type: 'canvas', + data: options.data == null || options.data === '' ? `${url}/users/${$i.id}` : options.data, + image: $i.avatarUrl, + qrOptions: { + typeNumber: 0, + mode: 'Byte', + errorCorrectionLevel: 'H', + }, + imageOptions: { + hideBackgroundDots: true, + imageSize: 0.3, + margin: 16, + crossOrigin: 'anonymous', + }, + dotsOptions: { + type: 'dots', + }, + cornersDotOptions: { + type: 'dot', + }, + cornersSquareOptions: { + type: 'extra-rounded', + }, + }); + + const blob = await qrCodeInstance.getRawData('png') as Blob | null; + if (blob == null) return null; + + const image = await window.createImageBitmap(blob); + + const texture = createTexture(gl); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, resolution, resolution, 0, gl.RGBA, gl.UNSIGNED_BYTE, image); + gl.bindTexture(gl.TEXTURE_2D, null); + + return { + texture, + width: resolution, + height: resolution, + }; +} diff --git a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts b/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts index 9b79e2bf94..f79acb44b0 100644 --- a/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts +++ b/packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts @@ -23,6 +23,7 @@ uniform float u_opacity; uniform bool u_repeat; uniform int u_alignX; // 0: left, 1: center, 2: right uniform int u_alignY; // 0: top, 1: center, 2: bottom +uniform float u_alignMargin; uniform int u_fitMode; // 0: contain, 1: cover out vec4 out_color; @@ -51,6 +52,9 @@ void main() { float x_offset = u_alignX == 0 ? x_scale / 2.0 : u_alignX == 2 ? 1.0 - (x_scale / 2.0) : 0.5; float y_offset = u_alignY == 0 ? y_scale / 2.0 : u_alignY == 2 ? 1.0 - (y_scale / 2.0) : 0.5; + x_offset += (u_alignX == 0 ? 1.0 : u_alignX == 2 ? -1.0 : 0.0) * u_alignMargin; + y_offset += (u_alignY == 0 ? 1.0 : u_alignY == 2 ? -1.0 : 0.0) * u_alignMargin; + float angle = -(u_angle * PI); vec2 center = vec2(x_offset, y_offset); //vec2 centeredUv = (in_uv - center) * vec2(in_x_ratio, in_y_ratio); @@ -86,7 +90,7 @@ export const FX_watermarkPlacement = defineImageEffectorFx({ id: 'watermarkPlacement', name: '(internal)', shader, - uniforms: ['texture_watermark', 'resolution_watermark', 'scale', 'angle', 'opacity', 'repeat', 'alignX', 'alignY', 'fitMode'] as const, + uniforms: ['texture_watermark', 'resolution_watermark', 'scale', 'angle', 'opacity', 'repeat', 'alignX', 'alignY', 'alignMargin', 'fitMode'] as const, params: { cover: { type: 'boolean', @@ -112,7 +116,7 @@ export const FX_watermarkPlacement = defineImageEffectorFx({ }, align: { type: 'align', - default: { x: 'right', y: 'bottom' }, + default: { x: 'right', y: 'bottom', margin: 0 }, }, opacity: { type: 'number', @@ -143,6 +147,7 @@ export const FX_watermarkPlacement = defineImageEffectorFx({ gl.uniform1i(u.repeat, params.repeat ? 1 : 0); gl.uniform1i(u.alignX, params.align.x === 'left' ? 0 : params.align.x === 'right' ? 2 : 1); gl.uniform1i(u.alignY, params.align.y === 'top' ? 0 : params.align.y === 'bottom' ? 2 : 1); + gl.uniform1f(u.alignMargin, params.align.margin ?? 0); gl.uniform1i(u.fitMode, params.cover ? 1 : 0); }, }); diff --git a/packages/frontend/src/utility/watermark.ts b/packages/frontend/src/utility/watermark.ts index 75807b30c4..b3525f158f 100644 --- a/packages/frontend/src/utility/watermark.ts +++ b/packages/frontend/src/utility/watermark.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import type { ImageEffectorFx, ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js'; import { FX_watermarkPlacement } from '@/utility/image-effector/fxs/watermarkPlacement.js'; import { FX_stripe } from '@/utility/image-effector/fxs/stripe.js'; import { FX_polkadot } from '@/utility/image-effector/fxs/polkadot.js'; import { FX_checker } from '@/utility/image-effector/fxs/checker.js'; -import type { ImageEffectorFx, ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js'; import { ImageEffector } from '@/utility/image-effector/ImageEffector.js'; const WATERMARK_FXS = [ @@ -17,6 +17,8 @@ const WATERMARK_FXS = [ FX_checker, ] as const satisfies ImageEffectorFx<string, any>[]; +type Align = { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; margin?: number; }; + export type WatermarkPreset = { id: string; name: string; @@ -27,7 +29,7 @@ export type WatermarkPreset = { repeat: boolean; scale: number; angle: number; - align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom' }; + align: Align; opacity: number; } | { id: string; @@ -38,7 +40,14 @@ export type WatermarkPreset = { repeat: boolean; scale: number; angle: number; - align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom' }; + align: Align; + opacity: number; + } | { + id: string; + type: 'qr'; + data: string; + scale: number; + align: Align; opacity: number; } | { id: string; @@ -125,6 +134,23 @@ export class WatermarkRenderer { }, }, }; + } else if (layer.type === 'qr') { + return { + fxId: 'watermarkPlacement', + id: layer.id, + params: { + repeat: false, + scale: layer.scale, + align: layer.align, + angle: 0, + opacity: layer.opacity, + cover: false, + watermark: { + type: 'qr', + data: layer.data, + }, + }, + }; } else if (layer.type === 'stripe') { return { fxId: 'stripe', @@ -164,7 +190,7 @@ export class WatermarkRenderer { }, }; } else { - throw new Error(`Unknown layer type`); + throw new Error(`Unrecognized layer type: ${(layer as any).type}`); } }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f8a10c004..7b9782b0cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -847,6 +847,12 @@ importers: punycode.js: specifier: 2.3.1 version: 2.3.1 + qr-code-styling: + specifier: 1.9.2 + version: 1.9.2 + qr-scanner: + specifier: 1.4.2 + version: 1.4.2 rollup: specifier: 4.50.1 version: 4.50.1 @@ -9374,6 +9380,16 @@ packages: resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==} engines: {node: '>=6.0.0'} + qr-code-styling@1.9.2: + resolution: {integrity: sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==} + engines: {node: '>=18.18.0'} + + qr-scanner@1.4.2: + resolution: {integrity: sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==} + + qrcode-generator@1.5.2: + resolution: {integrity: sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==} + qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} engines: {node: '>=10.13.0'} @@ -20999,6 +21015,16 @@ snapshots: pvutils@1.1.3: {} + qr-code-styling@1.9.2: + dependencies: + qrcode-generator: 1.5.2 + + qr-scanner@1.4.2: + dependencies: + '@types/offscreencanvas': 2019.7.0 + + qrcode-generator@1.5.2: {} + qrcode@1.5.4: dependencies: dijkstrajs: 1.0.2 |