summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authortamaina <tamaina@hotmail.co.jp>2025-09-19 21:02:30 +0900
committerGitHub <noreply@github.com>2025-09-19 21:02:30 +0900
commit42b2aea53364c57c39ebb953359ece4b7b0017a5 (patch)
tree8abe7de01705ed3fb6324c6a174162c7bb9288f2 /packages/frontend/src
parent🎨 (diff)
downloadmisskey-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>
Diffstat (limited to 'packages/frontend/src')
-rw-r--r--packages/frontend/src/components/MkPolkadots.vue15
-rw-r--r--packages/frontend/src/components/MkPositionSelector.vue18
-rw-r--r--packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue73
-rw-r--r--packages/frontend/src/components/MkWatermarkEditorDialog.vue58
-rw-r--r--packages/frontend/src/navbar.ts6
-rw-r--r--packages/frontend/src/pages/chat/home.vue2
-rw-r--r--packages/frontend/src/pages/qr.read.raw-viewer.vue54
-rw-r--r--packages/frontend/src/pages/qr.read.vue397
-rw-r--r--packages/frontend/src/pages/qr.show.vue234
-rw-r--r--packages/frontend/src/pages/qr.vue57
-rw-r--r--packages/frontend/src/pages/settings/drive.vue2
-rw-r--r--packages/frontend/src/pages/settings/profile.vue10
-rw-r--r--packages/frontend/src/router.definition.ts4
-rw-r--r--packages/frontend/src/utility/get-user-menu.ts10
-rw-r--r--packages/frontend/src/utility/image-effector/ImageEffector.ts75
-rw-r--r--packages/frontend/src/utility/image-effector/fxs/watermarkPlacement.ts9
-rw-r--r--packages/frontend/src/utility/watermark.ts34
17 files changed, 997 insertions, 61 deletions
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}`);
}
});
}