feat(frontend): 自分のプロフィールページの二次元コード(QRコード)を表示し、他の人のコードを読み取りするページを追加 (#16456)
* wip (qr.show.vue) * added to navbar * qr.show.vue * fix * added to navbar * fix size * 🎨 * 🎨 * fix div warn * fix * use * 0.25 * fix?? * fix lint * clean up * ??? * ? * fix * 🎨 * 🎨 * refactor * 🎨 * 🎨 * :ar:t * 🎨 * iphone flip * no lazy import * 🎨 * 🎨 * 🎨 * ユーザー全部flipでいいや * ✌️ * fix * fix * fix lint * 🎨 * 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 * 🎨 * 🎨 * fix imports * fix * fix * fix * 🎨 * fix... * set spacer-w * ✌️ * 全画面でQRを読むように * fix * 🎨 * modify navbar.ts * start/stop on vue activation * display raw content read from qr * 端末のQRをスキャンするボタンを追加 * chore * やっぱりmfmを先に表示する * 🎨 * 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>
This commit is contained in:
parent
97adf6f2cc
commit
42b2aea533
|
@ -4,8 +4,10 @@
|
||||||
- Enhance: 広告ごとにセンシティブフラグを設定できるようになりました
|
- Enhance: 広告ごとにセンシティブフラグを設定できるようになりました
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
|
- Feat: アカウントのQRコードを表示・読み取りできるようになりました
|
||||||
- Enhance: チャットの日本語名称がダイレクトメッセージに戻るとともに、ベータ版機能ではなくなりました
|
- Enhance: チャットの日本語名称がダイレクトメッセージに戻るとともに、ベータ版機能ではなくなりました
|
||||||
- Enhance: 画像編集にマスクエフェクトを追加
|
- Enhance: 画像編集にマスクエフェクトを追加
|
||||||
|
- Enhance: ウォーターマークにアカウントのQRコードを追加できるように
|
||||||
- Enhance: 時刻計算のための基準値を一か所で管理するようにし、パフォーマンスを向上
|
- Enhance: 時刻計算のための基準値を一か所で管理するようにし、パフォーマンスを向上
|
||||||
- Fix: iOSで、デバイスがダークモードだと初回読み込み時にエラーになる問題を修正
|
- Fix: iOSで、デバイスがダークモードだと初回読み込み時にエラーになる問題を修正
|
||||||
|
|
||||||
|
|
|
@ -12235,10 +12235,18 @@ export interface Locale extends ILocale {
|
||||||
* テキスト
|
* テキスト
|
||||||
*/
|
*/
|
||||||
"text": string;
|
"text": string;
|
||||||
|
/**
|
||||||
|
* 二次元コード
|
||||||
|
*/
|
||||||
|
"qr": string;
|
||||||
/**
|
/**
|
||||||
* 位置
|
* 位置
|
||||||
*/
|
*/
|
||||||
"position": string;
|
"position": string;
|
||||||
|
/**
|
||||||
|
* マージン
|
||||||
|
*/
|
||||||
|
"margin": string;
|
||||||
/**
|
/**
|
||||||
* タイプ
|
* タイプ
|
||||||
*/
|
*/
|
||||||
|
@ -12295,6 +12303,10 @@ export interface Locale extends ILocale {
|
||||||
* サブドットの数
|
* サブドットの数
|
||||||
*/
|
*/
|
||||||
"polkadotSubDotDivisions": string;
|
"polkadotSubDotDivisions": string;
|
||||||
|
/**
|
||||||
|
* 空欄にするとアカウントのURLになります
|
||||||
|
*/
|
||||||
|
"leaveBlankToAccountUrl": string;
|
||||||
};
|
};
|
||||||
"_imageEffector": {
|
"_imageEffector": {
|
||||||
/**
|
/**
|
||||||
|
@ -12572,6 +12584,68 @@ export interface Locale extends ILocale {
|
||||||
*/
|
*/
|
||||||
"listDrafts": string;
|
"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: {
|
declare const locales: {
|
||||||
[lang: string]: Locale;
|
[lang: string]: Locale;
|
||||||
|
|
|
@ -3275,7 +3275,9 @@ _watermarkEditor:
|
||||||
opacity: "不透明度"
|
opacity: "不透明度"
|
||||||
scale: "サイズ"
|
scale: "サイズ"
|
||||||
text: "テキスト"
|
text: "テキスト"
|
||||||
|
qr: "二次元コード"
|
||||||
position: "位置"
|
position: "位置"
|
||||||
|
margin: "マージン"
|
||||||
type: "タイプ"
|
type: "タイプ"
|
||||||
image: "画像"
|
image: "画像"
|
||||||
advanced: "高度"
|
advanced: "高度"
|
||||||
|
@ -3290,6 +3292,7 @@ _watermarkEditor:
|
||||||
polkadotSubDotOpacity: "サブドットの不透明度"
|
polkadotSubDotOpacity: "サブドットの不透明度"
|
||||||
polkadotSubDotRadius: "サブドットの大きさ"
|
polkadotSubDotRadius: "サブドットの大きさ"
|
||||||
polkadotSubDotDivisions: "サブドットの数"
|
polkadotSubDotDivisions: "サブドットの数"
|
||||||
|
leaveBlankToAccountUrl: "空欄にするとアカウントのURLになります"
|
||||||
|
|
||||||
_imageEffector:
|
_imageEffector:
|
||||||
title: "エフェクト"
|
title: "エフェクト"
|
||||||
|
@ -3365,3 +3368,20 @@ _drafts:
|
||||||
restoreFromDraft: "下書きから復元"
|
restoreFromDraft: "下書きから復元"
|
||||||
restore: "復元"
|
restore: "復元"
|
||||||
listDrafts: "下書き一覧"
|
listDrafts: "下書き一覧"
|
||||||
|
|
||||||
|
qr: "二次元コード"
|
||||||
|
_qr:
|
||||||
|
showTabTitle: "表示"
|
||||||
|
readTabTitle: "読み取る"
|
||||||
|
shareTitle: "{name} {acct}"
|
||||||
|
shareText: "Fediverseで私をフォローしてください!"
|
||||||
|
chooseCamera: "カメラを選択"
|
||||||
|
cannotToggleFlash: "ライト選択不可"
|
||||||
|
turnOnFlash: "ライトをオンにする"
|
||||||
|
turnOffFlash: "ライトをオフにする"
|
||||||
|
startQr: "コードリーダーを再開"
|
||||||
|
stopQr: "コードリーダーを停止"
|
||||||
|
noQrCodeFound: "QRコードが見つかりません"
|
||||||
|
scanFile: "端末の画像をスキャン"
|
||||||
|
raw: "テキスト"
|
||||||
|
mfm: "MFM"
|
||||||
|
|
|
@ -18,9 +18,9 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { ApiError } from '../../error.js';
|
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
|
import { FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
|
||||||
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['federation'],
|
tags: ['federation'],
|
||||||
|
|
|
@ -63,6 +63,8 @@
|
||||||
"misskey-reversi": "workspace:*",
|
"misskey-reversi": "workspace:*",
|
||||||
"photoswipe": "5.4.4",
|
"photoswipe": "5.4.4",
|
||||||
"punycode.js": "2.3.1",
|
"punycode.js": "2.3.1",
|
||||||
|
"qr-code-styling": "1.9.2",
|
||||||
|
"qr-scanner": "1.4.2",
|
||||||
"rollup": "4.50.1",
|
"rollup": "4.50.1",
|
||||||
"sanitize-html": "2.17.0",
|
"sanitize-html": "2.17.0",
|
||||||
"sass": "1.92.1",
|
"sass": "1.92.1",
|
||||||
|
|
|
@ -4,14 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="[$style.root, accented ? $style.accented : null]"></div>
|
<div :class="[$style.root, accented ? $style.accented : null, revered ? $style.revered : null]"/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
accented?: boolean;
|
accented?: boolean;
|
||||||
|
revered?: boolean;
|
||||||
|
height?: number;
|
||||||
}>(), {
|
}>(), {
|
||||||
accented: false,
|
accented: false,
|
||||||
|
revered: false,
|
||||||
|
height: 200,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -27,14 +31,17 @@ const props = withDefaults(defineProps<{
|
||||||
--dot-size: 2px;
|
--dot-size: 2px;
|
||||||
--gap-size: 40px;
|
--gap-size: 40px;
|
||||||
--offset: calc(var(--gap-size) / 2);
|
--offset: calc(var(--gap-size) / 2);
|
||||||
|
--height: v-bind('props.height + "px"');
|
||||||
|
|
||||||
height: 200px;
|
height: var(--height);
|
||||||
margin-bottom: -200px;
|
|
||||||
|
|
||||||
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-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-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);
|
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%);
|
mask-image: linear-gradient(to bottom, black 0%, transparent 100%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.revered {
|
||||||
|
mask-image: linear-gradient(to top, black 0%, transparent 100%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -6,15 +6,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template>
|
<template>
|
||||||
<div :class="[$style.root]">
|
<div :class="[$style.root]">
|
||||||
<div :class="$style.items">
|
<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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 === 'right' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'right'; y = 'bottom'; }"><i class="ti ti-arrow-down-right"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -18,6 +18,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
></MkPositionSelector>
|
></MkPositionSelector>
|
||||||
</FormSlot>
|
</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
|
<MkRange
|
||||||
v-model="layer.scale"
|
v-model="layer.scale"
|
||||||
:min="0"
|
:min="0"
|
||||||
|
@ -66,6 +78,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
></MkPositionSelector>
|
></MkPositionSelector>
|
||||||
</FormSlot>
|
</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
|
<MkRange
|
||||||
v-model="layer.scale"
|
v-model="layer.scale"
|
||||||
:min="0"
|
:min="0"
|
||||||
|
@ -107,6 +131,55 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</MkSwitch>
|
</MkSwitch>
|
||||||
</template>
|
</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'">
|
<template v-else-if="layer.type === 'stripe'">
|
||||||
<MkRange
|
<MkRange
|
||||||
v-model="layer.frequency"
|
v-model="layer.frequency"
|
||||||
|
|
|
@ -30,22 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.controls">
|
<div :class="$style.controls">
|
||||||
<div class="_spacer _gaps">
|
<div class="_spacer _gaps">
|
||||||
<MkSelect v-model="type" :items="typeDef">
|
<div class="_gaps_s">
|
||||||
<template #label>{{ i18n.ts._watermarkEditor.type }}</template>
|
|
||||||
</MkSelect>
|
|
||||||
|
|
||||||
<div v-if="type === 'text' || type === 'image'">
|
|
||||||
<XLayer
|
|
||||||
v-for="(layer, i) in preset.layers"
|
|
||||||
:key="layer.id"
|
|
||||||
v-model:layer="preset.layers[i]"
|
|
||||||
></XLayer>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="type === 'advanced'" class="_gaps_s">
|
|
||||||
<MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false">
|
<MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false">
|
||||||
<template #label>
|
<template #label>
|
||||||
<div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div>
|
<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 === '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 === 'stripe'">{{ i18n.ts._watermarkEditor.stripe }}</div>
|
||||||
<div v-if="layer.type === 'polkadot'">{{ i18n.ts._watermarkEditor.polkadot }}</div>
|
<div v-if="layer.type === 'polkadot'">{{ i18n.ts._watermarkEditor.polkadot }}</div>
|
||||||
<div v-if="layer.type === 'checker'">{{ i18n.ts._watermarkEditor.checker }}</div>
|
<div v-if="layer.type === 'checker'">{{ i18n.ts._watermarkEditor.checker }}</div>
|
||||||
|
@ -95,7 +85,7 @@ function createTextLayer(): WatermarkPreset['layers'][number] {
|
||||||
id: genId(),
|
id: genId(),
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: `(c) @${$i.username}`,
|
text: `(c) @${$i.username}`,
|
||||||
align: { x: 'right', y: 'bottom' },
|
align: { x: 'right', y: 'bottom', margin: 0 },
|
||||||
scale: 0.3,
|
scale: 0.3,
|
||||||
angle: 0,
|
angle: 0,
|
||||||
opacity: 0.75,
|
opacity: 0.75,
|
||||||
|
@ -109,7 +99,7 @@ function createImageLayer(): WatermarkPreset['layers'][number] {
|
||||||
type: 'image',
|
type: 'image',
|
||||||
imageId: null,
|
imageId: null,
|
||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
align: { x: 'right', y: 'bottom' },
|
align: { x: 'right', y: 'bottom', margin: 0 },
|
||||||
scale: 0.3,
|
scale: 0.3,
|
||||||
angle: 0,
|
angle: 0,
|
||||||
opacity: 0.75,
|
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] {
|
function createStripeLayer(): WatermarkPreset['layers'][number] {
|
||||||
return {
|
return {
|
||||||
id: genId(),
|
id: genId(),
|
||||||
|
@ -165,7 +166,7 @@ const props = defineProps<{
|
||||||
const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? {
|
const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? {
|
||||||
id: genId(),
|
id: genId(),
|
||||||
name: '',
|
name: '',
|
||||||
layers: [createTextLayer()],
|
layers: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
@ -187,28 +188,6 @@ async function cancel() {
|
||||||
dialog.value?.close();
|
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) => {
|
watch(preset, async (newValue, oldValue) => {
|
||||||
if (renderer != null) {
|
if (renderer != null) {
|
||||||
renderer.setLayers(preset.layers);
|
renderer.setLayers(preset.layers);
|
||||||
|
@ -338,6 +317,11 @@ function addLayer(ev: MouseEvent) {
|
||||||
action: () => {
|
action: () => {
|
||||||
preset.layers.push(createImageLayer());
|
preset.layers.push(createImageLayer());
|
||||||
},
|
},
|
||||||
|
}, {
|
||||||
|
text: i18n.ts._watermarkEditor.qr,
|
||||||
|
action: () => {
|
||||||
|
preset.layers.push(createQrLayer());
|
||||||
|
},
|
||||||
}, {
|
}, {
|
||||||
text: i18n.ts._watermarkEditor.stripe,
|
text: i18n.ts._watermarkEditor.stripe,
|
||||||
action: () => {
|
action: () => {
|
||||||
|
|
|
@ -66,6 +66,12 @@ export const navbarItemDef = reactive({
|
||||||
lookup();
|
lookup();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
qr: {
|
||||||
|
title: i18n.ts.qr,
|
||||||
|
icon: 'ti ti-qrcode',
|
||||||
|
show: computed(() => $i != null),
|
||||||
|
to: '/qr',
|
||||||
|
},
|
||||||
lists: {
|
lists: {
|
||||||
title: i18n.ts.lists,
|
title: i18n.ts.lists,
|
||||||
icon: 'ti ti-list',
|
icon: 'ti ti-list',
|
||||||
|
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true">
|
<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;">
|
<div class="_spacer" style="--MI_SPACER-w: 700px;">
|
||||||
<XHome v-if="tab === 'home'"/>
|
<XHome v-if="tab === 'home'"/>
|
||||||
<XInvitations v-else-if="tab === 'invitations'"/>
|
<XInvitations v-else-if="tab === 'invitations'"/>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<FormLink @click="chooseUploadFolder()">
|
<FormLink @click="chooseUploadFolder()">
|
||||||
<SearchLabel>{{ i18n.ts.uploadFolder }}</SearchLabel>
|
<SearchLabel>{{ i18n.ts.uploadFolder }}</SearchLabel>
|
||||||
<template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template>
|
<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>
|
</FormLink>
|
||||||
</SearchMarker>
|
</SearchMarker>
|
||||||
|
|
||||||
|
|
|
@ -151,6 +151,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
</SearchMarker>
|
</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>
|
</div>
|
||||||
</SearchMarker>
|
</SearchMarker>
|
||||||
</template>
|
</template>
|
||||||
|
@ -164,6 +173,7 @@ import MkSelect from '@/components/MkSelect.vue';
|
||||||
import FormSplit from '@/components/form/split.vue';
|
import FormSplit from '@/components/form/split.vue';
|
||||||
import MkFolder from '@/components/MkFolder.vue';
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
import FormSlot from '@/components/form/slot.vue';
|
import FormSlot from '@/components/form/slot.vue';
|
||||||
|
import FormLink from '@/components/form/link.vue';
|
||||||
import { chooseDriveFile } from '@/utility/drive.js';
|
import { chooseDriveFile } from '@/utility/drive.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
|
|
|
@ -590,6 +590,10 @@ export const ROUTE_DEF = [{
|
||||||
path: '/reversi/g/:gameId',
|
path: '/reversi/g/:gameId',
|
||||||
component: page(() => import('@/pages/reversi/game.vue')),
|
component: page(() => import('@/pages/reversi/game.vue')),
|
||||||
loginRequired: false,
|
loginRequired: false,
|
||||||
|
}, {
|
||||||
|
path: '/qr',
|
||||||
|
component: page(() => import('@/pages/qr.vue')),
|
||||||
|
loginRequired: true,
|
||||||
}, {
|
}, {
|
||||||
path: '/debug',
|
path: '/debug',
|
||||||
component: page(() => import('@/pages/debug.vue')),
|
component: page(() => import('@/pages/debug.vue')),
|
||||||
|
|
|
@ -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)) {
|
if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: 'ti ti-search',
|
icon: 'ti ti-search',
|
||||||
|
|
|
@ -3,8 +3,11 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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 { getProxiedImageUrl } from '../media-proxy.js';
|
||||||
import { initShaderProgram } from '../webgl.js';
|
import { initShaderProgram } from '../webgl.js';
|
||||||
|
import { ensureSignin } from '@/i.js';
|
||||||
|
|
||||||
export type ImageEffectorRGB = [r: number, g: number, b: number];
|
export type ImageEffectorRGB = [r: number, g: number, b: number];
|
||||||
|
|
||||||
|
@ -48,6 +51,7 @@ interface AlignParamDef extends CommonParamDef {
|
||||||
default: {
|
default: {
|
||||||
x: 'left' | 'center' | 'right';
|
x: 'left' | 'center' | 'right';
|
||||||
y: 'top' | 'center' | 'bottom';
|
y: 'top' | 'center' | 'bottom';
|
||||||
|
margin?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -58,7 +62,13 @@ interface SeedParamDef extends CommonParamDef {
|
||||||
|
|
||||||
interface TextureParamDef extends CommonParamDef {
|
interface TextureParamDef extends CommonParamDef {
|
||||||
type: 'texture';
|
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 {
|
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}>...`);
|
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;
|
if (texture == null) continue;
|
||||||
|
|
||||||
this.paramTextures.set(textureKey, texture);
|
this.paramTextures.set(textureKey, texture);
|
||||||
|
@ -352,7 +366,12 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
|
||||||
|
|
||||||
private getTextureKeyForParam(v: ParamTypeToPrimitive['texture']) {
|
private getTextureKeyForParam(v: ParamTypeToPrimitive['texture']) {
|
||||||
if (v == null) return '';
|
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;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -23,6 +23,7 @@ uniform float u_opacity;
|
||||||
uniform bool u_repeat;
|
uniform bool u_repeat;
|
||||||
uniform int u_alignX; // 0: left, 1: center, 2: right
|
uniform int u_alignX; // 0: left, 1: center, 2: right
|
||||||
uniform int u_alignY; // 0: top, 1: center, 2: bottom
|
uniform int u_alignY; // 0: top, 1: center, 2: bottom
|
||||||
|
uniform float u_alignMargin;
|
||||||
uniform int u_fitMode; // 0: contain, 1: cover
|
uniform int u_fitMode; // 0: contain, 1: cover
|
||||||
out vec4 out_color;
|
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 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;
|
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);
|
float angle = -(u_angle * PI);
|
||||||
vec2 center = vec2(x_offset, y_offset);
|
vec2 center = vec2(x_offset, y_offset);
|
||||||
//vec2 centeredUv = (in_uv - center) * vec2(in_x_ratio, in_y_ratio);
|
//vec2 centeredUv = (in_uv - center) * vec2(in_x_ratio, in_y_ratio);
|
||||||
|
@ -86,7 +90,7 @@ export const FX_watermarkPlacement = defineImageEffectorFx({
|
||||||
id: 'watermarkPlacement',
|
id: 'watermarkPlacement',
|
||||||
name: '(internal)',
|
name: '(internal)',
|
||||||
shader,
|
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: {
|
params: {
|
||||||
cover: {
|
cover: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
@ -112,7 +116,7 @@ export const FX_watermarkPlacement = defineImageEffectorFx({
|
||||||
},
|
},
|
||||||
align: {
|
align: {
|
||||||
type: 'align',
|
type: 'align',
|
||||||
default: { x: 'right', y: 'bottom' },
|
default: { x: 'right', y: 'bottom', margin: 0 },
|
||||||
},
|
},
|
||||||
opacity: {
|
opacity: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
@ -143,6 +147,7 @@ export const FX_watermarkPlacement = defineImageEffectorFx({
|
||||||
gl.uniform1i(u.repeat, params.repeat ? 1 : 0);
|
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.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.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);
|
gl.uniform1i(u.fitMode, params.cover ? 1 : 0);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,11 +3,11 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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_watermarkPlacement } from '@/utility/image-effector/fxs/watermarkPlacement.js';
|
||||||
import { FX_stripe } from '@/utility/image-effector/fxs/stripe.js';
|
import { FX_stripe } from '@/utility/image-effector/fxs/stripe.js';
|
||||||
import { FX_polkadot } from '@/utility/image-effector/fxs/polkadot.js';
|
import { FX_polkadot } from '@/utility/image-effector/fxs/polkadot.js';
|
||||||
import { FX_checker } from '@/utility/image-effector/fxs/checker.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';
|
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
|
||||||
|
|
||||||
const WATERMARK_FXS = [
|
const WATERMARK_FXS = [
|
||||||
|
@ -17,6 +17,8 @@ const WATERMARK_FXS = [
|
||||||
FX_checker,
|
FX_checker,
|
||||||
] as const satisfies ImageEffectorFx<string, any>[];
|
] as const satisfies ImageEffectorFx<string, any>[];
|
||||||
|
|
||||||
|
type Align = { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; margin?: number; };
|
||||||
|
|
||||||
export type WatermarkPreset = {
|
export type WatermarkPreset = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -27,7 +29,7 @@ export type WatermarkPreset = {
|
||||||
repeat: boolean;
|
repeat: boolean;
|
||||||
scale: number;
|
scale: number;
|
||||||
angle: number;
|
angle: number;
|
||||||
align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom' };
|
align: Align;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
} | {
|
} | {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -38,7 +40,14 @@ export type WatermarkPreset = {
|
||||||
repeat: boolean;
|
repeat: boolean;
|
||||||
scale: number;
|
scale: number;
|
||||||
angle: 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;
|
opacity: number;
|
||||||
} | {
|
} | {
|
||||||
id: string;
|
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') {
|
} else if (layer.type === 'stripe') {
|
||||||
return {
|
return {
|
||||||
fxId: 'stripe',
|
fxId: 'stripe',
|
||||||
|
@ -164,7 +190,7 @@ export class WatermarkRenderer {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unknown layer type`);
|
throw new Error(`Unrecognized layer type: ${(layer as any).type}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -847,6 +847,12 @@ importers:
|
||||||
punycode.js:
|
punycode.js:
|
||||||
specifier: 2.3.1
|
specifier: 2.3.1
|
||||||
version: 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:
|
rollup:
|
||||||
specifier: 4.50.1
|
specifier: 4.50.1
|
||||||
version: 4.50.1
|
version: 4.50.1
|
||||||
|
@ -9374,6 +9380,16 @@ packages:
|
||||||
resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
|
resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
|
||||||
engines: {node: '>=6.0.0'}
|
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:
|
qrcode@1.5.4:
|
||||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
@ -20999,6 +21015,16 @@ snapshots:
|
||||||
|
|
||||||
pvutils@1.1.3: {}
|
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:
|
qrcode@1.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
dijkstrajs: 1.0.2
|
dijkstrajs: 1.0.2
|
||||||
|
|
Loading…
Reference in New Issue