diff options
| author | misskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com> | 2025-06-16 02:33:18 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-06-16 02:33:18 +0000 |
| commit | 830e2f0a5b5bada00bfbe036ef6e7ee8d84b83fd (patch) | |
| tree | b9ac1c4efb202a62fe34608fb3f42fd73297774b /packages/frontend/src/components | |
| parent | Merge pull request #16134 from misskey-dev/develop (diff) | |
| parent | Release: 2025.6.1 (diff) | |
| download | misskey-830e2f0a5b5bada00bfbe036ef6e7ee8d84b83fd.tar.gz misskey-830e2f0a5b5bada00bfbe036ef6e7ee8d84b83fd.tar.bz2 misskey-830e2f0a5b5bada00bfbe036ef6e7ee8d84b83fd.zip | |
Merge pull request #16152 from misskey-dev/develop
Release: 2025.6.1
Diffstat (limited to 'packages/frontend/src/components')
26 files changed, 1921 insertions, 598 deletions
diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 4daba779d4..9d89c2f846 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -185,7 +185,7 @@ const isRootSelected = ref(false); watch(selectedFiles, () => { emit('changeSelectedFiles', selectedFiles.value); -}); +}, { deep: true }); watch([selectedFolders, isRootSelected], () => { emit('changeSelectedFolders', isRootSelected.value ? [null, ...selectedFolders.value] : selectedFolders.value); diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index d4367f6ee8..86f019c95c 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -64,6 +64,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/> <MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/> </button> + <button v-tooltip="i18n.ts.settings" class="_button config" @click="settings"><i class="ti ti-settings"></i></button> </div> </section> @@ -139,6 +140,9 @@ import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-e import { $i } from '@/i.js'; import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js'; import { prefer } from '@/preferences.js'; +import { useRouter } from '@/router.js'; + +const router = useRouter(); const props = withDefaults(defineProps<{ showPinned?: boolean; @@ -489,6 +493,11 @@ function done(query?: string): boolean | void { } } +function settings() { + emit('esc'); + router.push('settings/emoji-palette'); +} + onMounted(() => { focus(); }); @@ -720,6 +729,15 @@ defineExpose({ position: relative; padding: $pad; + > .config { + position: relative; + padding: 0 3px; + width: var(--eachSize); + height: var(--eachSize); + contain: strict; + opacity: 0.5; + } + > .item { position: relative; padding: 0 3px; diff --git a/packages/frontend/src/components/MkFileListForAdmin.vue b/packages/frontend/src/components/MkFileListForAdmin.vue index d5d32ebb28..3b495c2807 100644 --- a/packages/frontend/src/components/MkFileListForAdmin.vue +++ b/packages/frontend/src/components/MkFileListForAdmin.vue @@ -5,33 +5,35 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> - <MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }"> - <MkA - v-for="file in (items as Misskey.entities.DriveFile[])" - :key="file.id" - v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${dateString(file.createdAt)}\nby ${file.user ? '@' + Misskey.acct.toString(file.user) : 'system'}`" - :to="`/admin/file/${file.id}`" - class="file _button" - > - <div v-if="file.isSensitive" class="sensitive-label">{{ i18n.ts.sensitive }}</div> - <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain" :highlightWhenSensitive="true"/> - <div v-if="viewMode === 'list'" class="body"> - <div> - <small style="opacity: 0.7;">{{ file.name }}</small> + <MkPagination v-slot="{ items }" :pagination="pagination"> + <div :class="[$style.fileList, { [$style.grid]: viewMode === 'grid', [$style.list]: viewMode === 'list', '_gaps_s': viewMode === 'list' }]"> + <MkA + v-for="file in items" + :key="file.id" + v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${dateString(file.createdAt)}\nby ${file.user ? '@' + Misskey.acct.toString(file.user) : 'system'}`" + :to="`/admin/file/${file.id}`" + :class="[$style.file, '_button']" + > + <div v-if="file.isSensitive" :class="$style.sensitiveLabel">{{ i18n.ts.sensitive }}</div> + <MkDriveFileThumbnail :class="$style.thumbnail" :file="file" fit="contain" :highlightWhenSensitive="true"/> + <div v-if="viewMode === 'list'" :class="$style.body"> + <div> + <small style="opacity: 0.7;">{{ file.name }}</small> + </div> + <div> + <MkAcct v-if="file.user" :user="file.user"/> + <div v-else>{{ i18n.ts.system }}</div> + </div> + <div> + <span style="margin-right: 1em;">{{ file.type }}</span> + <span>{{ bytes(file.size) }}</span> + </div> + <div> + <span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span> + </div> </div> - <div> - <MkAcct v-if="file.user" :user="file.user"/> - <div v-else>{{ i18n.ts.system }}</div> - </div> - <div> - <span style="margin-right: 1em;">{{ file.type }}</span> - <span>{{ bytes(file.size) }}</span> - </div> - <div> - <span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span> - </div> - </div> - </MkA> + </MkA> + </div> </MkPagination> </div> </template> @@ -43,76 +45,76 @@ import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import bytes from '@/filters/bytes.js'; import { i18n } from '@/i18n.js'; import { dateString } from '@/filters/date.js'; +import type { PagingCtx } from '@/composables/use-pagination.js'; -const props = defineProps<{ - pagination: any; +defineProps<{ + pagination: PagingCtx<'admin/drive/files'>; viewMode: 'grid' | 'list'; }>(); </script> -<style lang="scss" scoped> +<style lang="scss" module> @keyframes sensitive-blink { 0% { opacity: 1; } 50% { opacity: 0; } } -.urempief { - &.list { - > .file { - display: flex; - width: 100%; - box-sizing: border-box; - text-align: left; - align-items: center; +.list { + > .file { + display: flex; + width: 100%; + height: auto; + box-sizing: border-box; + text-align: left; + align-items: center; + } - &:hover { - color: var(--MI_THEME-accent); - } + > .file:hover { + color: var(--MI_THEME-accent); + } - > .thumbnail { - width: 128px; - height: 128px; - } + > .file > .thumbnail { + width: 128px; + height: 128px; + } - > .body { - margin-left: 0.3em; - padding: 8px; - flex: 1; + > .file > .body { + margin-left: 0.3em; + padding: 8px; + flex: 1; - @media (max-width: 500px) { - font-size: 14px; - } - } + @media (max-width: 500px) { + font-size: 14px; } } +} - &.grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); - grid-gap: 12px; - - > .file { - position: relative; - aspect-ratio: 1; +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + grid-gap: 12px; - > .thumbnail { - width: 100%; - height: 100%; - } + > .file { + position: relative; + aspect-ratio: 1; + } - > .sensitive-label { - position: absolute; - z-index: 10; - top: 8px; - left: 8px; - padding: 2px 4px; - background: #ff0000bf; - color: #fff; - border-radius: 4px; - font-size: 85%; - animation: sensitive-blink 1s infinite; - } - } + .thumbnail { + width: 100%; + height: 100%; } } + +.sensitiveLabel { + position: absolute; + z-index: 10; + top: 8px; + left: 8px; + padding: 2px 4px; + background: #ff0000bf; + color: #fff; + border-radius: 4px; + font-size: 85%; + animation: sensitive-blink 1s infinite; +} </style> diff --git a/packages/frontend/src/components/MkFormDialog.file.vue b/packages/frontend/src/components/MkFormDialog.file.vue index a11075c342..182ff3ccf5 100644 --- a/packages/frontend/src/components/MkFormDialog.file.vue +++ b/packages/frontend/src/components/MkFormDialog.file.vue @@ -51,7 +51,10 @@ if (props.fileId) { } function selectButton(ev: MouseEvent) { - selectFile(ev.currentTarget ?? ev.target).then(async (file) => { + selectFile({ + anchorElement: ev.currentTarget ?? ev.target, + multiple: false, + }).then(async (file) => { if (!file) return; if (props.validate && !await props.validate(file)) return; diff --git a/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue b/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue new file mode 100644 index 0000000000..d8466fa7ca --- /dev/null +++ b/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue @@ -0,0 +1,121 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkFolder :defaultOpen="true" :canPage="false"> + <template #label>{{ fx.name }}</template> + <template #footer> + <div class="_buttons"> + <MkButton iconOnly @click="emit('del')"><i class="ti ti-trash"></i></MkButton> + <MkButton iconOnly @click="emit('swapUp')"><i class="ti ti-arrow-up"></i></MkButton> + <MkButton iconOnly @click="emit('swapDown')"><i class="ti ti-arrow-down"></i></MkButton> + </div> + </template> + + <div :class="$style.root" class="_gaps"> + <div v-for="[k, v] in Object.entries(fx.params)" :key="k"> + <MkSwitch + v-if="v.type === 'boolean'" + v-model="layer.params[k]" + > + <template #label>{{ fx.params[k].label ?? k }}</template> + </MkSwitch> + <MkRange + v-else-if="v.type === 'number'" + v-model="layer.params[k]" + continuousUpdate + :min="v.min" + :max="v.max" + :step="v.step" + :textConverter="fx.params[k].toViewValue" + @thumbDoubleClicked="() => { + if (fx.params[k].default != null) { + layer.params[k] = fx.params[k].default; + } else { + layer.params[k] = v.min; + } + }" + > + <template #label>{{ fx.params[k].label ?? k }}</template> + </MkRange> + <MkRadios + v-else-if="v.type === 'number:enum'" + v-model="layer.params[k]" + > + <template #label>{{ fx.params[k].label ?? k }}</template> + <option v-for="item in v.enum" :value="item.value">{{ item.label }}</option> + </MkRadios> + <div v-else-if="v.type === 'seed'"> + <MkRange + v-model="layer.params[k]" + continuousUpdate + type="number" + :min="0" + :max="10000" + :step="1" + > + <template #label>{{ fx.params[k].label ?? k }}</template> + </MkRange> + </div> + <MkInput + v-else-if="v.type === 'color'" + :modelValue="getHex(layer.params[k])" + type="color" + @update:modelValue="v => { const c = getRgb(v); if (c != null) layer.params[k] = c; }" + > + <template #label>{{ fx.params[k].label ?? k }}</template> + </MkInput> + </div> + </div> +</MkFolder> +</template> + +<script setup lang="ts"> +import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js'; +import { i18n } from '@/i18n.js'; +import MkFolder from '@/components/MkFolder.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkRadios from '@/components/MkRadios.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkRange from '@/components/MkRange.vue'; +import { FXS } from '@/utility/image-effector/fxs.js'; + +const layer = defineModel<ImageEffectorLayer>('layer', { required: true }); +const fx = FXS.find((fx) => fx.id === layer.value.fxId); +if (fx == null) { + throw new Error(`Unrecognized effect: ${layer.value.fxId}`); +} + +const emit = defineEmits<{ + (e: 'del'): void; + (e: 'swapUp'): void; + (e: 'swapDown'): void; +}>(); + +function getHex(c: [number, number, number]) { + return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`; +} + +function getRgb(hex: string | number): [number, number, number] | null { + if ( + typeof hex === 'number' || + typeof hex !== 'string' || + !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex) + ) { + return null; + } + + const m = hex.slice(1).match(/[0-9a-fA-F]{2}/g); + if (m == null) return [0, 0, 0]; + return m.map(x => parseInt(x, 16) / 255) as [number, number, number]; +} +</script> + +<style module> +.root { + +} +</style> diff --git a/packages/frontend/src/components/MkImageEffectorDialog.vue b/packages/frontend/src/components/MkImageEffectorDialog.vue new file mode 100644 index 0000000000..2c6185fd33 --- /dev/null +++ b/packages/frontend/src/components/MkImageEffectorDialog.vue @@ -0,0 +1,303 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialog" + :width="1000" + :height="600" + :scroll="false" + :withOkButton="true" + @close="cancel()" + @ok="save()" + @closed="emit('closed')" +> + <template #header><i class="ti ti-sparkles"></i> {{ i18n.ts._imageEffector.title }}</template> + + <div :class="$style.root"> + <div :class="$style.container"> + <div :class="$style.preview"> + <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> + <div :class="$style.previewContainer"> + <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> + <div class="_acrylic" :class="$style.previewControls"> + <button class="_button" :class="[$style.previewControlsButton, !enabled ? $style.active : null]" @click="enabled = false">Before</button> + <button class="_button" :class="[$style.previewControlsButton, enabled ? $style.active : null]" @click="enabled = true">After</button> + </div> + </div> + </div> + <div :class="$style.controls"> + <div class="_spacer _gaps"> + <XLayer + v-for="(layer, i) in layers" + :key="layer.id" + v-model:layer="layers[i]" + @del="onLayerDelete(layer)" + @swapUp="onLayerSwapUp(layer)" + @swapDown="onLayerSwapDown(layer)" + ></XLayer> + + <MkButton rounded primary style="margin: 0 auto;" @click="addEffect"><i class="ti ti-plus"></i> {{ i18n.ts._imageEffector.addEffect }}</MkButton> + </div> + </div> + </div> + </div> +</MkModalWindow> +</template> + +<script setup lang="ts"> +import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue'; +import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js'; +import { i18n } from '@/i18n.js'; +import { ImageEffector } from '@/utility/image-effector/ImageEffector.js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; +import XLayer from '@/components/MkImageEffectorDialog.Layer.vue'; +import * as os from '@/os.js'; +import { deepClone } from '@/utility/clone.js'; +import { FXS } from '@/utility/image-effector/fxs.js'; +import { genId } from '@/utility/id.js'; + +const props = defineProps<{ + image: File; +}>(); + +const emit = defineEmits<{ + (ev: 'ok', image: File): void; + (ev: 'cancel'): void; + (ev: 'closed'): void; +}>(); + +const dialog = useTemplateRef('dialog'); + +async function cancel() { + if (layers.length > 0) { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts._imageEffector.discardChangesConfirm, + }); + if (canceled) return; + } + + emit('cancel'); + dialog.value?.close(); +} + +const layers = reactive<ImageEffectorLayer[]>([]); + +watch(layers, async () => { + if (renderer != null) { + renderer.setLayers(layers); + } +}, { deep: true }); + +function addEffect(ev: MouseEvent) { + os.popupMenu(FXS.map((fx) => ({ + text: fx.name, + action: () => { + layers.push({ + id: genId(), + fxId: fx.id, + params: Object.fromEntries(Object.entries(fx.params).map(([k, v]) => [k, v.default])), + }); + }, + })), ev.currentTarget ?? ev.target); +} + +function onLayerSwapUp(layer: ImageEffectorLayer) { + const index = layers.indexOf(layer); + if (index > 0) { + layers.splice(index, 1); + layers.splice(index - 1, 0, layer); + } +} + +function onLayerSwapDown(layer: ImageEffectorLayer) { + const index = layers.indexOf(layer); + if (index < layers.length - 1) { + layers.splice(index, 1); + layers.splice(index + 1, 0, layer); + } +} + +function onLayerDelete(layer: ImageEffectorLayer) { + const index = layers.indexOf(layer); + if (index !== -1) { + layers.splice(index, 1); + } +} + +const canvasEl = useTemplateRef('canvasEl'); + +let renderer: ImageEffector<typeof FXS> | null = null; +let imageBitmap: ImageBitmap | null = null; + +onMounted(async () => { + if (canvasEl.value == null) return; + + const closeWaiting = os.waiting(); + + await nextTick(); // waitingがレンダリングされるまで待つ + + imageBitmap = await window.createImageBitmap(props.image); + + const MAX_W = 1000; + const MAX_H = 1000; + let w = imageBitmap.width; + let h = imageBitmap.height; + + if (w > MAX_W || h > MAX_H) { + const scale = Math.min(MAX_W / w, MAX_H / h); + w *= scale; + h *= scale; + } + + renderer = new ImageEffector({ + canvas: canvasEl.value, + renderWidth: w, + renderHeight: h, + image: imageBitmap, + fxs: FXS, + }); + + await renderer.setLayers(layers); + + renderer.render(); + + closeWaiting(); +}); + +onUnmounted(() => { + if (renderer != null) { + renderer.destroy(); + renderer = null; + } + if (imageBitmap != null) { + imageBitmap.close(); + imageBitmap = null; + } +}); + +async function save() { + if (layers.length === 0 || renderer == null || imageBitmap == null || canvasEl.value == null) { + cancel(); + return; + } + + const closeWaiting = os.waiting(); + + await nextTick(); // waitingがレンダリングされるまで待つ + + renderer.changeResolution(imageBitmap.width, imageBitmap.height); // 本番レンダリングのためオリジナル画質に戻す + renderer.render(); // toBlobの直前にレンダリングしないと何故か壊れる + canvasEl.value.toBlob((blob) => { + emit('ok', new File([blob!], `image-${Date.now()}.png`, { type: 'image/png' })); + dialog.value?.close(); + closeWaiting(); + }, 'image/png'); +} + +const enabled = ref(true); +watch(enabled, () => { + if (renderer != null) { + if (enabled.value) { + renderer.setLayers(layers); + } else { + renderer.setLayers([]); + } + renderer.render(); + } +}); +</script> + +<style module> +.root { + container-type: inline-size; + height: 100%; +} + +.container { + height: 100%; + display: grid; + grid-template-columns: 1fr 400px; +} + +.preview { + position: relative; + background-color: var(--MI_THEME-bg); + background-size: auto auto; + background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px); +} + +.previewContainer { + display: flex; + flex-direction: column; + height: 100%; + user-select: none; + -webkit-user-drag: none; +} + +.previewTitle { + position: absolute; + z-index: 100; + top: 8px; + left: 8px; + padding: 6px 10px; + border-radius: 6px; + font-size: 85%; +} + +.previewControls { + position: absolute; + z-index: 100; + bottom: 8px; + right: 8px; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 6px; +} + +.previewControlsButton { + &.active { + color: var(--MI_THEME-accent); + } +} + +.previewSpinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} + +.previewCanvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 20px; + box-sizing: border-box; + object-fit: contain; +} + +.controls { + overflow-y: scroll; +} + +@container (max-width: 800px) { + .container { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + } +} +</style> diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index e3a0a371b4..361aeff4d0 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -82,7 +82,7 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol <script lang="ts" setup> import { computed, nextTick, onMounted, onUnmounted, useTemplateRef, watch, ref } from 'vue'; -import { v4 as uuid } from 'uuid'; +import { genId } from '@/utility/id.js'; import { render } from 'buraha'; import { prefer } from '@/preferences.js'; @@ -117,7 +117,7 @@ const props = withDefaults(defineProps<{ onlyAvgColor: false, }); -const viewId = uuid(); +const viewId = genId(); const canvas = useTemplateRef('canvas'); const root = useTemplateRef('root'); const img = useTemplateRef('img'); diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index b34b7aaf60..cc7ad8bb78 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -52,6 +52,7 @@ import type { SuggestionType } from '@/utility/autocomplete.js'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import { Autocomplete } from '@/utility/autocomplete.js'; +import { genId } from '@/utility/id.js'; const props = defineProps<{ modelValue: string | number | null; @@ -87,7 +88,7 @@ const emit = defineEmits<{ const { modelValue, type, autofocus } = toRefs(props); const v = ref(modelValue.value); -const id = Math.random().toString(); // TODO: uuid? +const id = genId(); const focused = ref(false); const changed = ref(false); const invalid = ref(false); diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue index 98bd471438..582073b878 100644 --- a/packages/frontend/src/components/MkMiniChart.vue +++ b/packages/frontend/src/components/MkMiniChart.vue @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { watch, ref } from 'vue'; -import { v4 as uuid } from 'uuid'; +import { genId } from '@/utility/id.js'; import tinycolor from 'tinycolor2'; import { useInterval } from '@@/js/use-interval.js'; @@ -42,7 +42,7 @@ const props = defineProps<{ const viewBoxX = 50; const viewBoxY = 50; -const gradientId = uuid(); +const gradientId = genId(); const polylinePoints = ref(''); const polygonPoints = ref(''); const headX = ref<number | null>(null); diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index fd4262c17d..b8d9da0a13 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -4,15 +4,16 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')"> - <div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }"> +<MkModal ref="modal" v-slot="{ type }" :preferType="deviceKind === 'smartphone' ? 'drawer' : 'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')"> + <div ref="rootEl" :class="[$style.root, type === 'drawer' ? $style.asDrawer : null]" :style="{ width: type === 'drawer' ? '' : `${width}px`, height: type === 'drawer' ? '' : `min(${height}px, 100%)` }"> <div :class="$style.header"> - <button v-if="withOkButton && withCloseButton" :class="$style.headerButton" class="_button" @click="emit('close')"><i class="ti ti-x"></i></button> + <button v-if="withCloseButton" :class="$style.headerButton" class="_button" data-cy-modal-window-close @click="emit('close')"><i class="ti ti-x"></i></button> <span :class="$style.title"> <slot name="header"></slot> </span> - <button v-if="!withOkButton && withCloseButton" :class="$style.headerButton" class="_button" data-cy-modal-window-close @click="emit('close')"><i class="ti ti-x"></i></button> - <button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="emit('ok')"><i class="ti ti-check"></i></button> + <div v-if="withOkButton" style="padding: 0 16px; place-content: center;"> + <MkButton primary gradate small rounded :disabled="okButtonDisabled" @click="emit('ok')">{{ i18n.ts.done }} <i class="ti ti-check"></i></MkButton> + </div> </div> <div :class="$style.body"> <slot></slot> @@ -26,7 +27,10 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, onUnmounted, useTemplateRef, ref } from 'vue'; -import MkModal from './MkModal.vue'; +import MkModal from '@/components/MkModal.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n'; +import { deviceKind } from '@/utility/device-kind.js'; const props = withDefaults(defineProps<{ withOkButton?: boolean; @@ -82,6 +86,19 @@ defineExpose({ @media (max-width: 500px) { --root-margin: 16px; } + + &.asDrawer { + height: calc(100dvh - 30px); + border-radius: 0; + + .body { + padding-bottom: env(safe-area-inset-bottom, 0px); + } + + .footer { + padding-bottom: max(12px, env(safe-area-inset-bottom, 0px)); + } + } } .header { diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 4a78d00665..794a091f30 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -265,21 +265,21 @@ const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', nul let note = deepClone(props.note); -// plugin -const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); -if (noteViewInterruptors.length > 0) { - onMounted(async () => { - let result: Misskey.entities.Note | null = deepClone(note); - for (const interruptor of noteViewInterruptors) { - try { - result = await interruptor.handler(result!) as Misskey.entities.Note | null; - } catch (err) { - console.error(err); - } - } - note = result as Misskey.entities.Note; - }); -} +// コンポーネント初期化に非同期的な処理を行うとTransitionのレンダリングがバグるため同期的に実行できるメソッドが実装されるのを待つ必要がある +// https://github.com/aiscript-dev/aiscript/issues/937 +//// plugin +//const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); +//if (noteViewInterruptors.length > 0) { +// let result: Misskey.entities.Note | null = deepClone(note); +// for (const interruptor of noteViewInterruptors) { +// try { +// result = await interruptor.handler(result!) as Misskey.entities.Note | null; +// } catch (err) { +// console.error(err); +// } +// } +// note = result as Misskey.entities.Note; +//} const isRenote = Misskey.note.isPureRenote(note); const appearNote = getAppearNote(note); @@ -321,20 +321,27 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ url: `https://${host}/notes/${appearNote.id}`, })); -/* Overload FunctionにLintが対応していないのでコメントアウト +/* eslint-disable no-redeclare */ +/** checkOnlyでは純粋なワードミュート結果をbooleanで返却する */ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean; -function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): Array<string | string[]> | false | 'sensitiveMute'; -*/ -function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' { +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly?: false): Array<string | string[]> | false | 'sensitiveMute'; + +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | boolean | 'sensitiveMute' { if (mutedWords != null) { const result = checkWordMute(noteToCheck, $i, mutedWords); - if (Array.isArray(result)) return result; + if (Array.isArray(result)) { + return checkOnly ? (result.length > 0) : result; + } const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords); - if (Array.isArray(replyResult)) return replyResult; + if (Array.isArray(replyResult)) { + return checkOnly ? (replyResult.length > 0) : replyResult; + } const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords); - if (Array.isArray(renoteResult)) return renoteResult; + if (Array.isArray(renoteResult)) { + return checkOnly ? (renoteResult.length > 0) : renoteResult; + } } if (checkOnly) return false; @@ -345,6 +352,7 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string return false; } +/* eslint-enable no-redeclare */ const keymap = { 'r': () => { @@ -417,7 +425,7 @@ if (!props.mock) { const users = renotes.map(x => x.user); - if (users.length < 1) return; + if (users.length < 1 || renoteButton.value == null) return; const { dispose } = os.popup(MkUsersTooltip, { showing, diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index e090901875..cc26b0d0dc 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -286,21 +286,20 @@ const inChannel = inject('inChannel', null); let note = deepClone(props.note); -// plugin -const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); -if (noteViewInterruptors.length > 0) { - onMounted(async () => { - let result: Misskey.entities.Note | null = deepClone(note); - for (const interruptor of noteViewInterruptors) { - try { - result = await interruptor.handler(result!) as Misskey.entities.Note | null; - } catch (err) { - console.error(err); - } - } - note = result as Misskey.entities.Note; - }); -} +// コンポーネント初期化に非同期的な処理を行うとTransitionのレンダリングがバグるため同期的に実行できるメソッドが実装されるのを待つ必要がある +//// plugin +//const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); +//if (noteViewInterruptors.length > 0) { +// let result: Misskey.entities.Note | null = deepClone(note); +// for (const interruptor of noteViewInterruptors) { +// try { +// result = await interruptor.handler(result!) as Misskey.entities.Note | null; +// } catch (err) { +// console.error(err); +// } +// } +// note = result as Misskey.entities.Note; +//} const isRenote = Misskey.note.isPureRenote(note); const appearNote = getAppearNote(note); diff --git a/packages/frontend/src/components/MkPositionSelector.vue b/packages/frontend/src/components/MkPositionSelector.vue new file mode 100644 index 0000000000..002950cdf1 --- /dev/null +++ b/packages/frontend/src/components/MkPositionSelector.vue @@ -0,0 +1,53 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.root]"> + <div :class="$style.items"> + <button class="_button" :class="[$style.item, x === 'left' && y === 'top' ? $style.active : null]" @click="() => { x = 'left'; y = 'top'; }"><i class="ti ti-align-box-left-top"></i></button> + <button class="_button" :class="[$style.item, x === 'center' && y === 'top' ? $style.active : null]" @click="() => { x = 'center'; y = 'top'; }"><i class="ti ti-align-box-center-top"></i></button> + <button class="_button" :class="[$style.item, x === 'right' && y === 'top' ? $style.active : null]" @click="() => { x = 'right'; y = 'top'; }"><i class="ti ti-align-box-right-top"></i></button> + <button class="_button" :class="[$style.item, x === 'left' && y === 'center' ? $style.active : null]" @click="() => { x = 'left'; y = 'center'; }"><i class="ti ti-align-box-left-middle"></i></button> + <button class="_button" :class="[$style.item, x === 'center' && y === 'center' ? $style.active : null]" @click="() => { x = 'center'; y = 'center'; }"><i class="ti ti-align-box-center-middle"></i></button> + <button class="_button" :class="[$style.item, x === 'right' && y === 'center' ? $style.active : null]" @click="() => { x = 'right'; y = 'center'; }"><i class="ti ti-align-box-right-middle"></i></button> + <button class="_button" :class="[$style.item, x === 'left' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'left'; y = 'bottom'; }"><i class="ti ti-align-box-left-bottom"></i></button> + <button class="_button" :class="[$style.item, x === 'center' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'center'; y = 'bottom'; }"><i class="ti ti-align-box-center-bottom"></i></button> + <button class="_button" :class="[$style.item, x === 'right' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'right'; y = 'bottom'; }"><i class="ti ti-align-box-right-bottom"></i></button> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; + +const x = defineModel<string>('x', { default: 'center' }); +const y = defineModel<string>('y', { default: 'center' }); +</script> + +<style lang="scss" module> +.root { + position: relative; +} + +.items { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(3, 1fr); + gap: 4px; + border-radius: 8px; + overflow: clip; +} + +.item { + height: 32px; + background: var(--MI_THEME-panel); + border-radius: 4px; + + &.active { + background: var(--MI_THEME-accentedBg); + color: var(--MI_THEME-accent); + } +} +</style> diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 982ed88003..e319c9bacb 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -72,24 +72,29 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> <XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/> + <div v-if="uploader.items.value.length > 0" style="padding: 12px;"> + <MkTip k="postFormUploader"> + {{ i18n.ts._postForm.uploaderTip }} + </MkTip> + <MkUploaderItems :items="uploader.items.value" @showMenu="(item, ev) => showPerUploadItemMenu(item, ev)" @showMenuViaContextmenu="(item, ev) => showPerUploadItemMenuViaContextmenu(item, ev)"/> + </div> <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> <MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/> <div v-if="showingOptions" style="padding: 8px 16px;"> </div> <footer :class="$style.footer"> <div :class="$style.footerLeft"> - <button v-tooltip="i18n.ts.attachFile" class="_button" :class="$style.footerButton" @click="chooseFileFrom"><i class="ti ti-photo-plus"></i></button> + <button v-tooltip="i18n.ts.attachFile + ' (' + i18n.ts.upload + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromPc"><i class="ti ti-photo-plus"></i></button> + <button v-tooltip="i18n.ts.attachFile + ' (' + i18n.ts.fromDrive + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromDrive"><i class="ti ti-cloud-download"></i></button> <button v-tooltip="i18n.ts.poll" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: poll }]" @click="togglePoll"><i class="ti ti-chart-arrows"></i></button> <button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button> - <button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button> <button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button> - <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button> - <button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> + <button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button> <button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ti ti-palette"></i></button> + <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button> </div> <div :class="$style.footerRight"> - <button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="[$style.footerButton, { [$style.previewButtonActive]: showPreview }]" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button> - <!--<button v-tooltip="i18n.ts.more" class="_button" :class="$style.footerButton" @click="showingOptions = !showingOptions"><i class="ti ti-dots"></i></button>--> + <button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> </div> </footer> <datalist id="hashtags"> @@ -105,10 +110,12 @@ import * as Misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; import { toASCII } from 'punycode.js'; import { host, url } from '@@/js/config.js'; +import MkUploaderItems from './MkUploaderItems.vue'; import type { ShallowRef } from 'vue'; import type { PostFormProps } from '@/types/post-form.js'; import type { MenuItem } from '@/types/menu.js'; import type { PollEditorModelValue } from '@/components/MkPollEditor.vue'; +import type { UploaderItem } from '@/composables/use-uploader.js'; import MkNotePreview from '@/components/MkNotePreview.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; import XTextCounter from '@/components/MkPostForm.TextCounter.vue'; @@ -120,7 +127,7 @@ import { formatTimeString } from '@/utility/format-time-string.js'; import { Autocomplete } from '@/utility/autocomplete.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { selectFiles } from '@/utility/drive.js'; +import { chooseDriveFile } from '@/utility/drive.js'; import { store } from '@/store.js'; import MkInfo from '@/components/MkInfo.vue'; import { i18n } from '@/i18n.js'; @@ -138,6 +145,7 @@ import { getPluginHandlers } from '@/plugin.js'; import { DI } from '@/di.js'; import { globalEvents } from '@/events.js'; import { checkDragDataType, getDragData } from '@/drag-and-drop.js'; +import { useUploader } from '@/composables/use-uploader.js'; const $i = ensureSignin(); @@ -201,6 +209,15 @@ const justEndedComposition = ref(false); const renoteTargetNote: ShallowRef<PostFormProps['renote'] | null> = shallowRef(props.renote); const postFormActions = getPluginHandlers('post_form_action'); +const uploader = useUploader({ + multiple: true, +}); + +uploader.events.on('itemUploaded', ctx => { + files.value.push(ctx.item.uploaded!); + uploader.removeItem(ctx.item); +}); + const draftKey = computed((): string => { let key = props.channel ? `channel:${props.channel.id}` : ''; @@ -258,10 +275,11 @@ const cwTextLength = computed((): number => { const maxCwTextLength = 100; const canPost = computed((): boolean => { - return !props.mock && !posting.value && !posted.value && + return !props.mock && !posting.value && !posted.value && !uploader.uploading.value && (uploader.items.value.length === 0 || uploader.readyForUpload.value) && ( 1 <= textLength.value || 1 <= files.value.length || + 1 <= uploader.items.value.length || poll.value != null || renoteTargetNote.value != null || quoteId.value != null @@ -434,13 +452,20 @@ function focus() { } } -function chooseFileFrom(ev) { +function chooseFileFromPc(ev: MouseEvent) { if (props.mock) return; - selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => { - for (const file of files_) { - files.value.push(file); - } + os.chooseFileFromPc({ multiple: true }).then(files => { + if (files.length === 0) return; + uploader.addFiles(files); + }); +} + +function chooseFileFromDrive(ev: MouseEvent) { + if (props.mock) return; + + chooseDriveFile({ multiple: true }).then(driveFiles => { + files.value.push(...driveFiles); }); } @@ -567,6 +592,11 @@ function showOtherSettings() { toggleReactionAcceptance(); }, }, { type: 'divider' }, { + type: 'switch', + icon: 'ti ti-eye', + text: i18n.ts.preview, + ref: showPreview, + }, { icon: 'ti ti-trash', text: i18n.ts.reset, danger: true, @@ -793,6 +823,15 @@ function isAnnoying(text: string): boolean { text.includes('$[position'); } +async function uploadFiles() { + await uploader.upload(); + + for (const uploadedItem of uploader.items.value.filter(x => x.uploaded != null)) { + files.value.push(uploadedItem.uploaded!); + uploader.removeItem(uploadedItem); + } +} + async function post(ev?: MouseEvent) { if (ev) { const el = (ev.currentTarget ?? ev.target) as HTMLElement | null; @@ -836,6 +875,10 @@ async function post(ev?: MouseEvent) { } } + if (uploader.items.value.some(x => x.uploaded == null)) { + await uploadFiles(); + } + let postData = { text: text.value === '' ? null : text.value, fileIds: files.value.length > 0 ? files.value.map(f => f.id) : undefined, @@ -1039,6 +1082,16 @@ function openAccountMenu(ev: MouseEvent) { }, ev); } +function showPerUploadItemMenu(item: UploaderItem, ev: MouseEvent) { + const menu = uploader.getMenu(item); + os.popupMenu(menu, ev.currentTarget ?? ev.target); +} + +function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent) { + const menu = uploader.getMenu(item); + os.contextMenu(menu, ev); +} + onMounted(() => { if (props.autofocus) { focus(); @@ -1107,8 +1160,23 @@ onMounted(() => { }); }); +async function canClose() { + if (!uploader.allItemsUploaded.value) { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts._postForm.quitInspiteOfThereAreUnuploadedFilesConfirm, + okText: i18n.ts.yes, + cancelText: i18n.ts.no, + }); + if (canceled) return false; + } + + return true; +} + defineExpose({ clear, + canClose, }); </script> diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index c467e29df6..0a655bab99 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -7,9 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkModal ref="modal" :preferType="'dialog'" - @click="modal?.close()" + @click="_close()" @closed="onModalClosed()" - @esc="modal?.close()" + @esc="_close()" > <MkPostForm ref="form" @@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only autofocus freezeAfterPosted @posted="onPosted" - @cancel="modal?.close()" - @esc="modal?.close()" + @cancel="_close()" + @esc="_close()" /> </MkModal> </template> @@ -43,6 +43,7 @@ const emit = defineEmits<{ }>(); const modal = useTemplateRef('modal'); +const form = useTemplateRef('form'); function onPosted() { modal.value?.close({ @@ -50,6 +51,12 @@ function onPosted() { }); } +async function _close() { + const canClose = await form.value?.canClose(); + if (!canClose) return; + modal.value?.close(); +} + function onModalClosed() { emit('closed'); } diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index f36e68b687..7a5848de48 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -12,7 +12,12 @@ SPDX-License-Identifier: AGPL-3.0-only <slot name="prefix"></slot> <div ref="containerEl" class="container"> <div class="track"> - <div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div> + <div class="highlight right" :style="{ width: rightTrackWidth, left: rightTrackPosition }"> + <div class="shine right"></div> + </div> + <div class="highlight left" :style="{ width: leftTrackWidth, left: leftTrackPosition }"> + <div class="shine left"></div> + </div> </div> <div v-if="steps && showTicks" class="ticks"> <div v-for="i in (steps + 1)" class="tick" :style="{ left: (((i - 1) / steps) * 100) + '%' }"></div> @@ -24,7 +29,9 @@ SPDX-License-Identifier: AGPL-3.0-only @mouseenter.passive="onMouseenter" @mousedown="onMousedown" @touchstart="onMousedown" - ></div> + > + <div class="thumbInner"></div> + </div> </div> <slot name="suffix"></slot> </div> @@ -35,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'; +import { computed, defineAsyncComponent, onMounted, onUnmounted, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'; import { isTouchUsing } from '@/utility/touch.js'; import * as os from '@/os.js'; @@ -51,19 +58,40 @@ const props = withDefaults(defineProps<{ continuousUpdate?: boolean; }>(), { step: 1, - textConverter: (v) => v.toString(), + textConverter: (v: number) => (Math.round(v * 1000) / 1000).toString(), easing: false, }); const emit = defineEmits<{ (ev: 'update:modelValue', value: number): void; (ev: 'dragEnded', value: number): void; + (ev: 'thumbDoubleClicked'): void; }>(); const containerEl = useTemplateRef('containerEl'); const thumbEl = useTemplateRef('thumbEl'); -const rawValue = ref((props.modelValue - props.min) / (props.max - props.min)); +const maxRatio = computed(() => Math.abs(props.max) / (props.max + Math.abs(Math.min(0, props.min)))); +const minRatio = computed(() => Math.abs(Math.min(0, props.min)) / (props.max + Math.abs(Math.min(0, props.min)))); + +const rightTrackWidth = computed(() => { + return Math.max(0, (steppedRawValue.value - minRatio.value) * 100) + '%'; +}); +const leftTrackWidth = computed(() => { + return Math.max(0, (minRatio.value - steppedRawValue.value) * 100) + '%'; +}); +const rightTrackPosition = computed(() => { + return (Math.abs(Math.min(0, props.min)) / (props.max + Math.abs(Math.min(0, props.min)))) * 100 + '%'; +}); +const leftTrackPosition = computed(() => { + return (Math.min(minRatio.value, steppedRawValue.value) * 100) + '%'; +}); + +const calcRawValue = (value: number) => { + return (value - props.min) / (props.max - props.min); +}; + +const rawValue = ref(calcRawValue(props.modelValue)); const steppedRawValue = computed(() => { if (props.step) { const step = props.step / (props.max - props.min); @@ -93,6 +121,11 @@ const calcThumbPosition = () => { } }; watch([steppedRawValue, containerEl], calcThumbPosition); +watch(() => props.modelValue, (newVal) => { + const newRawValue = calcRawValue(newVal); + if (rawValue.value === newRawValue) return; + rawValue.value = newRawValue; +}); let ro: ResizeObserver | undefined; @@ -118,6 +151,12 @@ const steps = computed(() => { const tooltipForDragShowing = ref(false); const tooltipForHoverShowing = ref(false); +onBeforeUnmount(() => { + // 何らかの問題で表示されっぱなしでもコンポーネントを離れたら消えるように + tooltipForDragShowing.value = false; + tooltipForHoverShowing.value = false; +}); + function onMouseenter() { if (isTouchUsing) return; @@ -128,7 +167,7 @@ function onMouseenter() { text: computed(() => { return props.textConverter(finalValue.value); }), - targetElement: thumbEl, + targetElement: thumbEl.value ?? undefined, }, { closed: () => dispose(), }); @@ -138,6 +177,8 @@ function onMouseenter() { }, { once: true, passive: true }); } +let lastClickTime: number | null = null; + function onMousedown(ev: MouseEvent | TouchEvent) { ev.preventDefault(); @@ -148,7 +189,7 @@ function onMousedown(ev: MouseEvent | TouchEvent) { text: computed(() => { return props.textConverter(finalValue.value); }), - targetElement: thumbEl, + targetElement: thumbEl.value ?? undefined, }, { closed: () => dispose(), }); @@ -193,6 +234,20 @@ function onMousedown(ev: MouseEvent | TouchEvent) { window.addEventListener('touchmove', onDrag); window.addEventListener('mouseup', onMouseup, { once: true }); window.addEventListener('touchend', onMouseup, { once: true }); + + if (lastClickTime == null) { + lastClickTime = Date.now(); + return; + } else { + const now = Date.now(); + if (now - lastClickTime < 300) { // 300ms以内のクリックはダブルクリックとみなす + lastClickTime = null; + emit('thumbDoubleClicked'); + return; + } else { + lastClickTime = now; + } + } } </script> @@ -222,15 +277,17 @@ function onMousedown(ev: MouseEvent | TouchEvent) { } } - $thumbHeight: 20px; - $thumbWidth: 20px; + $thumbHeight: 32px; + $thumbWidth: 32px; + $thumbInnerHeight: 19px; + $thumbInnerWidth: 19px; > .body { display: flex; align-items: center; justify-content: center; gap: 8px; - padding: 7px 12px; + padding: 0px 4px; background: var(--MI_THEME-panel); border: solid 1px var(--MI_THEME-panel); border-radius: 6px; @@ -256,10 +313,30 @@ function onMousedown(ev: MouseEvent | TouchEvent) { > .highlight { position: absolute; top: 0; - left: 0; height: 100%; - background: var(--MI_THEME-accent); - opacity: 0.5; + background: color(from var(--MI_THEME-buttonGradateA) srgb r g b / 0.5); + overflow: clip; + + > .shine { + position: absolute; + top: 0; + width: 64px; + height: 100%; + } + } + + > .highlight.right { + > .shine.right { + right: calc(#{$thumbInnerWidth} / 2); + background: linear-gradient(-90deg, var(--MI_THEME-buttonGradateB), color(from var(--MI_THEME-buttonGradateA) srgb r g b / 0)); + } + } + + > .highlight.left { + > .shine.left { + left: calc(#{$thumbInnerWidth} / 2); + background: linear-gradient(90deg, var(--MI_THEME-buttonGradateB), color(from var(--MI_THEME-buttonGradateA) srgb r g b / 0)); + } } } @@ -290,11 +367,25 @@ function onMousedown(ev: MouseEvent | TouchEvent) { width: $thumbWidth; height: $thumbHeight; cursor: grab; - background: var(--MI_THEME-accent); - border-radius: 999px; &:hover { - background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); + > .thumbInner { + background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); + } + } + + > .thumbInner { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: $thumbInnerWidth; + height: $thumbInnerHeight; + background: var(--MI_THEME-accent); + border-radius: 999px; + pointer-events: none; } } } diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index 725978179e..bd9ef50157 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -33,7 +33,10 @@ import * as Misskey from 'misskey-js'; import { inject, watch, ref } from 'vue'; import { TransitionGroup } from 'vue'; import XReaction from '@/components/MkReactionsViewer.reaction.vue'; +import { $i } from '@/i.js'; import { prefer } from '@/preferences.js'; +import { customEmojisMap } from '@/custom-emojis.js'; +import { isSupportedEmoji } from '@@/js/emojilist.js'; import { DI } from '@/di.js'; const props = withDefaults(defineProps<{ @@ -70,6 +73,12 @@ function onMockToggleReaction(emoji: string, count: number) { emit('mockUpdateMyReaction', emoji, (count - _reactions.value[i][1])); } +function canReact(reaction: string) { + if (!$i) return false; + // TODO: CheckPermissions + return !reaction.match(/@\w/) && (customEmojisMap.has(reaction) || isSupportedEmoji(reaction)); +} + watch([() => props.reactions, () => props.maxNumber], ([newSource, maxNumber]) => { let newReactions: [string, number][] = []; hasMoreReactions.value = Object.keys(newSource).length > maxNumber; @@ -86,7 +95,15 @@ watch([() => props.reactions, () => props.maxNumber], ([newSource, maxNumber]) = newReactions = [ ...newReactions, ...Object.entries(newSource) - .sort(([, a], [, b]) => b - a) + .sort(([emojiA, countA], [emojiB, countB]) => { + if (prefer.s.showAvailableReactionsFirstInNote) { + if (!canReact(emojiA) && canReact(emojiB)) return 1; + if (canReact(emojiA) && !canReact(emojiB)) return -1; + return countB - countA; + } else { + return countB - countA; + } + }) .filter(([y], i) => i < maxNumber && !newReactionsNames.includes(y)), ]; diff --git a/packages/frontend/src/components/MkSparkle.vue b/packages/frontend/src/components/MkSparkle.vue index 2400c5ec7f..47955a7fd7 100644 --- a/packages/frontend/src/components/MkSparkle.vue +++ b/packages/frontend/src/components/MkSparkle.vue @@ -57,6 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; +import { genId } from '@/utility/id.js'; const particles = ref<{ id: string, @@ -86,7 +87,7 @@ onMounted(() => { const y = (Math.random() * (height.value - 64)); const sizeFactor = Math.random(); const particle = { - id: Math.random().toString(), + id: genId(), x, y, size: 0.2 + ((sizeFactor / 10) * 3), diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue index 576a0cf8cc..7e72840b7b 100644 --- a/packages/frontend/src/components/MkStreamingNotesTimeline.vue +++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue @@ -62,6 +62,7 @@ import { useInterval } from '@@/js/use-interval.js'; import { getScrollContainer, scrollToTop } from '@@/js/scroll.js'; import type { BasicTimelineType } from '@/timelines.js'; import type { PagingCtx } from '@/composables/use-pagination.js'; +import type { SoundStore } from '@/preferences/def.js'; import { usePagination } from '@/composables/use-pagination.js'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import { useStream } from '@/stream.js'; @@ -83,6 +84,7 @@ const props = withDefaults(defineProps<{ channel?: string; role?: string; sound?: boolean; + customSound?: SoundStore | null; withRenotes?: boolean; withReplies?: boolean; withSensitive?: boolean; @@ -92,6 +94,8 @@ const props = withDefaults(defineProps<{ withReplies: false, withSensitive: true, onlyFiles: false, + sound: false, + customSound: null, }); provide('inTimeline', true); @@ -190,7 +194,11 @@ function prepend(note: Misskey.entities.Note) { } if (props.sound) { - sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note'); + if (props.customSound) { + sound.playMisskeySfxFile(props.customSound); + } else { + sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note'); + } } } @@ -420,7 +428,7 @@ defineExpose({ background: var(--MI_THEME-panel); } -.note { +.note:not(:empty) { border-bottom: solid 0.5px var(--MI_THEME-divider); } diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue index 95f53e7635..b77e67e9c6 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Note.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue @@ -29,6 +29,7 @@ import { i18n } from '@/i18n.js'; import { globalEvents } from '@/events.js'; import { $i } from '@/i.js'; import MkNote from '@/components/MkNote.vue'; +import { genId } from '@/utility/id.js'; const props = defineProps<{ phase: 'aboutNote' | 'howToReact'; @@ -83,7 +84,7 @@ function doNotification(emoji: string): void { if (!$i || !emoji) return; const notification: Misskey.entities.Notification = { - id: Math.random().toString(), + id: genId(), createdAt: new Date().toUTCString(), type: 'reaction', reaction: emoji, diff --git a/packages/frontend/src/components/MkUploaderDialog.vue b/packages/frontend/src/components/MkUploaderDialog.vue index 3f5f0776a8..ce098d71e4 100644 --- a/packages/frontend/src/components/MkUploaderDialog.vue +++ b/packages/frontend/src/components/MkUploaderDialog.vue @@ -23,55 +23,12 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._uploader.tip }} </MkTip> - <div class="_gaps_s"> - <div - v-for="ctx in items" - :key="ctx.id" - v-panel - :class="[$style.item, ctx.waiting ? $style.itemWaiting : null, ctx.uploaded ? $style.itemCompleted : null, ctx.uploadFailed ? $style.itemFailed : null]" - :style="{ '--p': ctx.progress != null ? `${ctx.progress.value / ctx.progress.max * 100}%` : '0%' }" - > - <div :class="$style.itemInner"> - <div :class="$style.itemActionWrapper"> - <MkButton :iconOnly="true" rounded @click="showMenu($event, ctx)"><i class="ti ti-dots"></i></MkButton> - </div> - <div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ ctx.thumbnail })` }"></div> - <div :class="$style.itemBody"> - <div><MkCondensedLine :minScale="2 / 3">{{ ctx.name }}</MkCondensedLine></div> - <div :class="$style.itemInfo"> - <span>{{ ctx.file.type }}</span> - <span>{{ bytes(ctx.file.size) }}</span> - <span v-if="ctx.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(ctx.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - ctx.compressedSize / ctx.file.size) * 100) }) }})</span> - </div> - <div> - </div> - </div> - <div :class="$style.itemIconWrapper"> - <MkSystemIcon v-if="ctx.uploading" :class="$style.itemIcon" type="waiting"/> - <MkSystemIcon v-else-if="ctx.uploaded" :class="$style.itemIcon" type="success"/> - <MkSystemIcon v-else-if="ctx.uploadFailed" :class="$style.itemIcon" type="error"/> - </div> - </div> - </div> - </div> + <MkUploaderItems :items="items" @showMenu="(item, ev) => showPerItemMenu(item, ev)" @showMenuViaContextmenu="(item, ev) => showPerItemMenuViaContextmenu(item, ev)"/> <div v-if="props.multiple"> <MkButton style="margin: auto;" :iconOnly="true" rounded @click="chooseFile($event)"><i class="ti ti-plus"></i></MkButton> </div> - <MkSelect - v-if="items.length > 0" - v-model="compressionLevel" - :items="[ - { value: 0, label: i18n.ts.none }, - { value: 1, label: i18n.ts.low }, - { value: 2, label: i18n.ts.middle }, - { value: 3, label: i18n.ts.high }, - ]" - > - <template #label>{{ i18n.ts.compress }}</template> - </MkSelect> - <div>{{ i18n.tsx._uploader.maxFileSizeIsX({ x: $i.policies.maxFileSizeMb + 'MB' }) }}</div> <!-- クライアントで検出するMIME typeとサーバーで検出するMIME typeが異なる場合があり、混乱の元になるのでとりあえず隠しとく --> @@ -82,8 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #footer> <div class="_buttonsCenter"> - <MkButton v-if="isUploading" rounded @click="abortWithConfirm()"><i class="ti ti-x"></i> {{ i18n.ts.abort }}</MkButton> - <MkButton v-else-if="!firstUploadAttempted" primary rounded @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton> + <MkButton v-if="uploader.uploading.value" rounded @click="abortWithConfirm()"><i class="ti ti-x"></i> {{ i18n.ts.abort }}</MkButton> + <MkButton v-else-if="!firstUploadAttempted" primary rounded :disabled="!uploader.readyForUpload.value" @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton> <MkButton v-if="canRetry" rounded @click="upload()"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton> <MkButton v-if="canDone" rounded @click="done()"><i class="ti ti-arrow-right"></i> {{ i18n.ts.done }}</MkButton> @@ -93,48 +50,24 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, markRaw, onMounted, ref, useTemplateRef, watch } from 'vue'; +import { computed, onMounted, ref, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; -import { v4 as uuid } from 'uuid'; -import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; -import isAnimated from 'is-file-animated'; -import type { MenuItem } from '@/types/menu.js'; +import type { UploaderFeatures, UploaderItem } from '@/composables/use-uploader.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; -import { prefer } from '@/preferences.js'; import MkButton from '@/components/MkButton.vue'; -import bytes from '@/filters/bytes.js'; -import MkSelect from '@/components/MkSelect.vue'; -import { isWebpSupported } from '@/utility/isWebpSupported.js'; -import { uploadFile, UploadAbortedError } from '@/utility/drive.js'; import * as os from '@/os.js'; import { ensureSignin } from '@/i.js'; +import { useUploader } from '@/composables/use-uploader.js'; +import MkUploaderItems from '@/components/MkUploaderItems.vue'; const $i = ensureSignin(); -const COMPRESSION_SUPPORTED_TYPES = [ - 'image/jpeg', - 'image/png', - 'image/webp', - 'image/svg+xml', -]; - -const CROPPING_SUPPORTED_TYPES = [ - 'image/jpeg', - 'image/png', - 'image/webp', -]; - -const mimeTypeMap = { - 'image/webp': 'webp', - 'image/jpeg': 'jpg', - 'image/png': 'png', -} as const; - const props = withDefaults(defineProps<{ files: File[]; folderId?: string | null; multiple?: boolean; + features?: UploaderFeatures; }>(), { multiple: true, }); @@ -145,27 +78,22 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const items = ref<{ - id: string; - name: string; - progress: { max: number; value: number } | null; - thumbnail: string; - waiting: boolean; - uploading: boolean; - uploaded: Misskey.entities.DriveFile | null; - uploadFailed: boolean; - aborted: boolean; - compressedSize?: number | null; - compressedImage?: Blob | null; - file: File; - abort?: (() => void) | null; -}[]>([]); - const dialog = useTemplateRef('dialog'); +const uploader = useUploader({ + multiple: props.multiple, + folderId: props.folderId, + features: props.features, +}); + +onMounted(() => { + uploader.addFiles(props.files); +}); + +const items = uploader.items; + const firstUploadAttempted = ref(false); -const isUploading = computed(() => items.value.some(item => item.uploading)); -const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.waiting) && items.value.some(item => item.uploaded == null)); +const canRetry = computed(() => firstUploadAttempted.value && uploader.readyForUpload.value); const canDone = computed(() => items.value.some(item => item.uploaded != null)); const overallProgress = computed(() => { const max = items.value.length; @@ -178,28 +106,6 @@ const overallProgress = computed(() => { return Math.round((v / max) * 100); }); -const compressionLevel = ref<0 | 1 | 2 | 3>(2); -const compressionSettings = computed(() => { - if (compressionLevel.value === 1) { - return { - maxWidth: 2000, - maxHeight: 2000, - }; - } else if (compressionLevel.value === 2) { - return { - maxWidth: 2000 * 0.75, // =1500 - maxHeight: 2000 * 0.75, // =1500 - }; - } else if (compressionLevel.value === 3) { - return { - maxWidth: 2000 * 0.75 * 0.75, // =1125 - maxHeight: 2000 * 0.75 * 0.75, // =1125 - }; - } else { - return null; - } -}); - watch(items, () => { if (items.value.length === 0) { emit('canceled'); @@ -222,11 +128,16 @@ async function cancel() { }); if (canceled) return; - abortAll(); + uploader.abortAll(); emit('canceled'); dialog.value?.close(); } +function upload() { + firstUploadAttempted.value = true; + uploader.upload(); +} + async function abortWithConfirm() { const { canceled } = await os.confirm({ type: 'question', @@ -236,11 +147,11 @@ async function abortWithConfirm() { }); if (canceled) return; - abortAll(); + uploader.abortAll(); } async function done() { - if (items.value.some(item => item.uploaded == null)) { + if (!uploader.allItemsUploaded.value) { const { canceled } = await os.confirm({ type: 'question', text: i18n.ts._uploader.doneConfirm, @@ -254,194 +165,20 @@ async function done() { dialog.value?.close(); } -function showMenu(ev: MouseEvent, item: typeof items.value[0]) { - const menu: MenuItem[] = []; - - menu.push({ - icon: 'ti ti-cursor-text', - text: i18n.ts.rename, - action: async () => { - const { result, canceled } = await os.inputText({ - type: 'text', - title: i18n.ts.rename, - placeholder: item.name, - default: item.name, - }); - if (canceled) return; - if (result.trim() === '') return; - - item.name = result; - }, - }); - - if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.waiting && !item.uploading && !item.uploaded) { - menu.push({ - icon: 'ti ti-crop', - text: i18n.ts.cropImage, - action: async () => { - const cropped = await os.cropImageFile(item.file, { aspectRatio: null }); - items.value.splice(items.value.indexOf(item), 1, { - ...item, - file: markRaw(cropped), - thumbnail: window.URL.createObjectURL(cropped), - }); - }, - }); - } - - if (!item.waiting && !item.uploading && !item.uploaded) { - menu.push({ - icon: 'ti ti-x', - text: i18n.ts.remove, - action: () => { - items.value.splice(items.value.indexOf(item), 1); - }, - }); - } else if (item.uploading) { - menu.push({ - icon: 'ti ti-cloud-pause', - text: i18n.ts.abort, - danger: true, - action: () => { - if (item.abort != null) { - item.abort(); - } - }, - }); - } - - os.popupMenu(menu, ev.currentTarget ?? ev.target); -} - -async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる - firstUploadAttempted.value = true; - - items.value = items.value.map(item => ({ - ...item, - aborted: false, - uploadFailed: false, - waiting: false, - uploading: false, - })); - - for (const item of items.value.filter(item => item.uploaded == null)) { - // アップロード処理途中で値が変わる場合(途中で全キャンセルされたりなど)もあるので、Array filterではなくここでチェック - if (item.aborted) { - continue; - } - - item.waiting = true; - item.uploadFailed = false; - - const shouldCompress = item.compressedImage == null && compressionLevel.value !== 0 && compressionSettings.value && COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !(await isAnimated(item.file)); - - if (shouldCompress) { - const config = { - mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg', - maxWidth: compressionSettings.value.maxWidth, - maxHeight: compressionSettings.value.maxHeight, - quality: isWebpSupported() ? 0.85 : 0.8, - }; - - try { - const result = await readAndCompressImage(item.file, config); - if (result.size < item.file.size || item.file.type === 'image/webp') { - // The compression may not always reduce the file size - // (and WebP is not browser safe yet) - item.compressedImage = markRaw(result); - item.compressedSize = result.size; - item.name = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name; - } - } catch (err) { - console.error('Failed to resize image', err); - } - } - - item.uploading = true; - - const { filePromise, abort } = uploadFile(item.compressedImage ?? item.file, { - name: item.name, - folderId: props.folderId, - onProgress: (progress) => { - item.waiting = false; - if (item.progress == null) { - item.progress = { max: progress.total, value: progress.loaded }; - } else { - item.progress.value = progress.loaded; - item.progress.max = progress.total; - } - }, - }); - - item.abort = () => { - item.abort = null; - abort(); - item.uploading = false; - item.waiting = false; - item.uploadFailed = true; - }; - - await filePromise.then((file) => { - item.uploaded = file; - item.abort = null; - }).catch(err => { - item.uploadFailed = true; - item.progress = null; - if (!(err instanceof UploadAbortedError)) { - throw err; - } - }).finally(() => { - item.uploading = false; - item.waiting = false; - }); - } -} - -function abortAll() { - for (const item of items.value) { - if (item.uploaded != null) { - continue; - } - - if (item.abort != null) { - item.abort(); - } - item.aborted = true; - item.uploadFailed = true; - } -} - async function chooseFile(ev: MouseEvent) { const newFiles = await os.chooseFileFromPc({ multiple: true }); - - for (const file of newFiles) { - initializeFile(file); - } + uploader.addFiles(newFiles); } -function initializeFile(file: File) { - const id = uuid(); - const filename = file.name ?? 'untitled'; - const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : ''; - items.value.push({ - id, - name: prefer.s.keepOriginalFilename ? filename : id + extension, - progress: null, - thumbnail: window.URL.createObjectURL(file), - waiting: false, - uploading: false, - aborted: false, - uploaded: null, - uploadFailed: false, - file: markRaw(file), - }); +function showPerItemMenu(item: UploaderItem, ev: MouseEvent) { + const menu = uploader.getMenu(item); + os.popupMenu(menu, ev.currentTarget ?? ev.target); } -onMounted(() => { - for (const file of props.files) { - initializeFile(file); - } -}); +function showPerItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent) { + const menu = uploader.getMenu(item); + os.contextMenu(menu, ev); +} </script> <style lang="scss" module> @@ -463,127 +200,4 @@ onMounted(() => { background: var(--MI_THEME-warn); } } - -.item { - position: relative; - border-radius: 10px; - overflow: clip; - - &::before { - content: ''; - display: block; - position: absolute; - top: 0; - left: 0; - width: var(--p); - height: 100%; - background: color(from var(--MI_THEME-accent) srgb r g b / 0.5); - transition: width 0.2s ease, left 0.2s ease; - } - - &.itemWaiting { - &::after { - --c: color(from var(--MI_THEME-accent) srgb r g b / 0.25); - - content: ''; - display: block; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: linear-gradient(-45deg, transparent 25%, var(--c) 25%,var(--c) 50%, transparent 50%, transparent 75%, var(--c) 75%, var(--c)); - background-size: 25px 25px; - animation: stripe .8s infinite linear; - } - } - - &.itemCompleted { - &::before { - left: 100%; - width: var(--p); - } - - .itemBody { - color: var(--MI_THEME-accent); - } - } - - &.itemFailed { - .itemBody { - color: var(--MI_THEME-error); - } - } -} - -@keyframes stripe { - 0% { background-position-x: 0; } - 100% { background-position-x: -25px; } -} - -.itemInner { - position: relative; - z-index: 1; - padding: 8px 16px; - display: flex; - align-items: center; - gap: 12px; -} - -.itemThumbnail { - width: 70px; - height: 70px; - background-color: var(--MI_THEME-bg); - background-size: contain; - background-position: center; - background-repeat: no-repeat; - border-radius: 6px; -} - -.itemBody { - flex: 1; - min-width: 0; -} - -.itemInfo { - opacity: 0.7; - margin-top: 4px; - font-size: 90%; - display: flex; - gap: 8px; -} - -.itemIcon { - width: 35px; -} - -@container (max-width: 500px) { - .itemInner { - flex-direction: column; - gap: 8px; - } - - .itemBody { - font-size: 90%; - text-align: center; - width: 100%; - min-width: 0; - } - - .itemActionWrapper { - position: absolute; - top: 8px; - left: 8px; - } - - .itemInfo { - justify-content: center; - } - - .itemIconWrapper { - position: absolute; - top: 8px; - right: 8px; - } -} </style> diff --git a/packages/frontend/src/components/MkUploaderItems.vue b/packages/frontend/src/components/MkUploaderItems.vue new file mode 100644 index 0000000000..2d624cf344 --- /dev/null +++ b/packages/frontend/src/components/MkUploaderItems.vue @@ -0,0 +1,196 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root" class="_gaps_s"> + <div + v-for="item in props.items" + :key="item.id" + v-panel + :class="[$style.item, { [$style.itemWaiting]: item.preprocessing, [$style.itemCompleted]: item.uploaded, [$style.itemFailed]: item.uploadFailed }]" + :style="{ '--p': item.progress != null ? `${item.progress.value / item.progress.max * 100}%` : '0%' }" + @contextmenu.prevent.stop="onContextmenu(item, $event)" + > + <div :class="$style.itemInner"> + <div :class="$style.itemActionWrapper"> + <MkButton :iconOnly="true" rounded @click="emit('showMenu', item, $event)"><i class="ti ti-dots"></i></MkButton> + </div> + <div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ item.thumbnail })` }" @click="onThumbnailClick(item, $event)"></div> + <div :class="$style.itemBody"> + <div><MkCondensedLine :minScale="2 / 3">{{ item.name }}</MkCondensedLine></div> + <div :class="$style.itemInfo"> + <span>{{ item.file.type }}</span> + <span v-if="item.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(item.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - item.compressedSize / item.file.size) * 100) }) }})</span> + <span v-else>{{ bytes(item.file.size) }}</span> + </div> + <div> + </div> + </div> + <div :class="$style.itemIconWrapper"> + <MkSystemIcon v-if="item.uploading" :class="$style.itemIcon" type="waiting"/> + <MkSystemIcon v-else-if="item.uploaded" :class="$style.itemIcon" type="success"/> + <MkSystemIcon v-else-if="item.uploadFailed" :class="$style.itemIcon" type="error"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { isLink } from '@@/js/is-link.js'; +import type { UploaderItem } from '@/composables/use-uploader.js'; +import { i18n } from '@/i18n.js'; +import MkButton from '@/components/MkButton.vue'; +import bytes from '@/filters/bytes.js'; + +const props = defineProps<{ + items: UploaderItem[]; +}>(); + +const emit = defineEmits<{ + (ev: 'showMenu', item: UploaderItem, event: MouseEvent): void; + (ev: 'showMenuViaContextmenu', item: UploaderItem, event: MouseEvent): void; +}>(); + +function onContextmenu(item: UploaderItem, ev: MouseEvent) { + if (ev.target && isLink(ev.target as HTMLElement)) return; + if (window.getSelection()?.toString() !== '') return; + + emit('showMenuViaContextmenu', item, ev); +} + +function onThumbnailClick(item: UploaderItem, ev: MouseEvent) { + // TODO: preview when item is image +} +</script> + +<style lang="scss" module> +.root { + position: relative; +} + +.item { + position: relative; + border-radius: 10px; + overflow: clip; + + &::before { + content: ''; + display: block; + position: absolute; + top: 0; + left: 0; + width: var(--p); + height: 100%; + background: color(from var(--MI_THEME-accent) srgb r g b / 0.5); + transition: width 0.2s ease, left 0.2s ease; + } + + &.itemWaiting { + &::after { + --c: color(from var(--MI_THEME-accent) srgb r g b / 0.25); + + content: ''; + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(-45deg, transparent 25%, var(--c) 25%,var(--c) 50%, transparent 50%, transparent 75%, var(--c) 75%, var(--c)); + background-size: 25px 25px; + animation: stripe .8s infinite linear; + } + } + + &.itemCompleted { + &::before { + left: 100%; + width: var(--p); + } + + .itemBody { + color: var(--MI_THEME-accent); + } + } + + &.itemFailed { + .itemBody { + color: var(--MI_THEME-error); + } + } +} + +@keyframes stripe { + 0% { background-position-x: 0; } + 100% { background-position-x: -25px; } +} + +.itemInner { + position: relative; + z-index: 1; + padding: 8px 16px; + display: flex; + align-items: center; + gap: 12px; +} + +.itemThumbnail { + width: 70px; + height: 70px; + background-color: var(--MI_THEME-bg); + background-size: contain; + background-position: center; + background-repeat: no-repeat; + border-radius: 6px; +} + +.itemBody { + flex: 1; + min-width: 0; +} + +.itemInfo { + opacity: 0.7; + margin-top: 4px; + font-size: 90%; + display: flex; + gap: 8px; +} + +.itemIcon { + width: 35px; +} + +@container (max-width: 500px) { + .itemInner { + flex-direction: column; + gap: 8px; + } + + .itemBody { + font-size: 90%; + text-align: center; + width: 100%; + min-width: 0; + } + + .itemActionWrapper { + position: absolute; + top: 8px; + left: 8px; + } + + .itemInfo { + justify-content: center; + } + + .itemIconWrapper { + position: absolute; + top: 8px; + right: 8px; + } +} +</style> diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue new file mode 100644 index 0000000000..11ae091d90 --- /dev/null +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue @@ -0,0 +1,325 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root" class="_gaps"> + <template v-if="layer.type === 'text'"> + <MkInput v-model="layer.text"> + <template #label>{{ i18n.ts._watermarkEditor.text }}</template> + </MkInput> + + <FormSlot> + <template #label>{{ i18n.ts._watermarkEditor.position }}</template> + <MkPositionSelector + v-model:x="layer.align.x" + v-model:y="layer.align.y" + ></MkPositionSelector> + </FormSlot> + + <MkRange + v-model="layer.scale" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.scale }}</template> + </MkRange> + + <MkRange + v-model="layer.angle" + :min="-1" + :max="1" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.angle }}</template> + </MkRange> + + <MkRange + v-model="layer.opacity" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.opacity }}</template> + </MkRange> + + <MkSwitch v-model="layer.repeat"> + <template #label>{{ i18n.ts._watermarkEditor.repeat }}</template> + </MkSwitch> + </template> + + <template v-else-if="layer.type === 'image'"> + <MkButton inline rounded primary @click="chooseFile">{{ i18n.ts.selectFile }}</MkButton> + + <FormSlot> + <template #label>{{ i18n.ts._watermarkEditor.position }}</template> + <MkPositionSelector + v-model:x="layer.align.x" + v-model:y="layer.align.y" + ></MkPositionSelector> + </FormSlot> + + <MkRange + v-model="layer.scale" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.scale }}</template> + </MkRange> + + <MkRange + v-model="layer.angle" + :min="-1" + :max="1" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.angle }}</template> + </MkRange> + + <MkRange + v-model="layer.opacity" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.opacity }}</template> + </MkRange> + + <MkSwitch v-model="layer.repeat"> + <template #label>{{ i18n.ts._watermarkEditor.repeat }}</template> + </MkSwitch> + + <MkSwitch v-model="layer.cover"> + <template #label>{{ i18n.ts._watermarkEditor.cover }}</template> + </MkSwitch> + </template> + + <template v-else-if="layer.type === 'stripe'"> + <MkRange + v-model="layer.frequency" + :min="1" + :max="30" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.stripeFrequency }}</template> + </MkRange> + + <MkRange + v-model="layer.threshold" + :min="0" + :max="1" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.stripeWidth }}</template> + </MkRange> + + <MkRange + v-model="layer.angle" + :min="-1" + :max="1" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.angle }}</template> + </MkRange> + + <MkRange + v-model="layer.opacity" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.opacity }}</template> + </MkRange> + </template> + + <template v-else-if="layer.type === 'polkadot'"> + <MkRange + v-model="layer.angle" + :min="-1" + :max="1" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.angle }}</template> + </MkRange> + + <MkRange + v-model="layer.scale" + :min="0" + :max="10" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.scale }}</template> + </MkRange> + + <MkRange + v-model="layer.majorRadius" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.polkadotMainDotRadius }}</template> + </MkRange> + + <MkRange + v-model="layer.majorOpacity" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.polkadotMainDotOpacity }}</template> + </MkRange> + + <MkRange + v-model="layer.minorDivisions" + :min="0" + :max="16" + :step="1" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.polkadotSubDotDivisions }}</template> + </MkRange> + + <MkRange + v-model="layer.minorRadius" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.polkadotSubDotRadius }}</template> + </MkRange> + + <MkRange + v-model="layer.minorOpacity" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.polkadotSubDotOpacity }}</template> + </MkRange> + </template> + + <template v-else-if="layer.type === 'checker'"> + <MkRange + v-model="layer.angle" + :min="-1" + :max="1" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.angle }}</template> + </MkRange> + + <MkRange + v-model="layer.scale" + :min="0" + :max="10" + :step="0.01" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.scale }}</template> + </MkRange> + + <MkRange + v-model="layer.opacity" + :min="0" + :max="1" + :step="0.01" + :textConverter="(v) => (v * 100).toFixed(1) + '%'" + continuousUpdate + > + <template #label>{{ i18n.ts._watermarkEditor.opacity }}</template> + </MkRange> + </template> +</div> +</template> + +<script setup lang="ts"> +import { ref, onMounted } from 'vue'; +import * as Misskey from 'misskey-js'; +import type { WatermarkPreset } from '@/utility/watermark.js'; +import { i18n } from '@/i18n.js'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkRange from '@/components/MkRange.vue'; +import FormSlot from '@/components/form/slot.vue'; +import MkPositionSelector from '@/components/MkPositionSelector.vue'; +import * as os from '@/os.js'; +import { selectFile } from '@/utility/drive.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; + +const layer = defineModel<WatermarkPreset['layers'][number]>('layer', { required: true }); + +const driveFile = ref<Misskey.entities.DriveFile | null>(null); +const driveFileError = ref(false); +onMounted(async () => { + if (layer.value.type === 'image' && layer.value.imageId != null) { + await misskeyApi('drive/files/show', { + fileId: layer.value.imageId, + }).then((res) => { + driveFile.value = res; + }).catch((err) => { + driveFileError.value = true; + }); + } +}); + +function chooseFile(ev: MouseEvent) { + selectFile({ + anchorElement: ev.currentTarget ?? ev.target, + multiple: false, + label: i18n.ts.selectFile, + features: { + watermark: false, + }, + }).then((file) => { + if (layer.value.type !== 'image') return; + if (!file.type.startsWith('image')) { + os.alert({ + type: 'warning', + title: i18n.ts._watermarkEditor.driveFileTypeWarn, + text: i18n.ts._watermarkEditor.driveFileTypeWarnDescription, + }); + return; + } + + layer.value.imageId = file.id; + layer.value.imageUrl = file.url; + driveFileError.value = false; + }); +} +</script> + +<style module> +.root { + +} +</style> diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.vue new file mode 100644 index 0000000000..206298b194 --- /dev/null +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.vue @@ -0,0 +1,456 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialog" + :width="1000" + :height="600" + :scroll="false" + :withOkButton="true" + @close="cancel()" + @ok="save()" + @closed="emit('closed')" +> + <template #header><i class="ti ti-copyright"></i> {{ i18n.ts._watermarkEditor.title }}</template> + + <div :class="$style.root"> + <div :class="$style.container"> + <div :class="$style.preview"> + <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> + <div :class="$style.previewContainer"> + <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> + <div v-if="props.image == null" class="_acrylic" :class="$style.previewControls"> + <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button> + <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button> + </div> + </div> + </div> + <div :class="$style.controls"> + <div class="_spacer _gaps"> + <MkSelect v-model="type" :items="[{ label: i18n.ts._watermarkEditor.text, value: 'text' }, { label: i18n.ts._watermarkEditor.image, value: 'image' }, { label: i18n.ts._watermarkEditor.advanced, value: 'advanced' }]"> + <template #label>{{ i18n.ts._watermarkEditor.type }}</template> + </MkSelect> + + <div v-if="type === 'text' || type === 'image'"> + <XLayer + v-for="(layer, i) in preset.layers" + :key="layer.id" + v-model:layer="preset.layers[i]" + ></XLayer> + </div> + <div v-else-if="type === 'advanced'" class="_gaps_s"> + <MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false"> + <template #label> + <div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div> + <div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div> + <div v-if="layer.type === 'stripe'">{{ i18n.ts._watermarkEditor.stripe }}</div> + <div v-if="layer.type === 'polkadot'">{{ i18n.ts._watermarkEditor.polkadot }}</div> + <div v-if="layer.type === 'checker'">{{ i18n.ts._watermarkEditor.checker }}</div> + </template> + <template #footer> + <div class="_buttons"> + <MkButton iconOnly @click="removeLayer(layer)"><i class="ti ti-trash"></i></MkButton> + <MkButton iconOnly @click="swapUpLayer(layer)"><i class="ti ti-arrow-up"></i></MkButton> + <MkButton iconOnly @click="swapDownLayer(layer)"><i class="ti ti-arrow-down"></i></MkButton> + </div> + </template> + + <XLayer + v-model:layer="preset.layers[i]" + ></XLayer> + </MkFolder> + + <MkButton rounded primary style="margin: 0 auto;" @click="addLayer"><i class="ti ti-plus"></i></MkButton> + </div> + </div> + </div> + </div> + </div> +</MkModalWindow> +</template> + +<script setup lang="ts"> +import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue'; +import type { WatermarkPreset } from '@/utility/watermark.js'; +import { WatermarkRenderer } from '@/utility/watermark.js'; +import { i18n } from '@/i18n.js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import XLayer from '@/components/MkWatermarkEditorDialog.Layer.vue'; +import * as os from '@/os.js'; +import { deepClone } from '@/utility/clone.js'; +import { ensureSignin } from '@/i.js'; +import { genId } from '@/utility/id.js'; + +const $i = ensureSignin(); + +function createTextLayer(): WatermarkPreset['layers'][number] { + return { + id: genId(), + type: 'text', + text: `(c) @${$i.username}`, + align: { x: 'right', y: 'bottom' }, + scale: 0.3, + angle: 0, + opacity: 0.75, + repeat: false, + }; +} + +function createImageLayer(): WatermarkPreset['layers'][number] { + return { + id: genId(), + type: 'image', + imageId: null, + imageUrl: null, + align: { x: 'right', y: 'bottom' }, + scale: 0.3, + angle: 0, + opacity: 0.75, + repeat: false, + cover: false, + }; +} + +function createStripeLayer(): WatermarkPreset['layers'][number] { + return { + id: genId(), + type: 'stripe', + angle: 0.5, + frequency: 10, + threshold: 0.1, + color: [1, 1, 1], + opacity: 0.75, + }; +} + +function createPolkadotLayer(): WatermarkPreset['layers'][number] { + return { + id: genId(), + type: 'polkadot', + angle: 0.5, + scale: 3, + majorRadius: 0.1, + minorRadius: 0.25, + majorOpacity: 0.75, + minorOpacity: 0.5, + minorDivisions: 4, + color: [1, 1, 1], + opacity: 0.75, + }; +} + +function createCheckerLayer(): WatermarkPreset['layers'][number] { + return { + id: genId(), + type: 'checker', + angle: 0.5, + scale: 3, + color: [1, 1, 1], + opacity: 0.75, + }; +} + +const props = defineProps<{ + preset?: WatermarkPreset | null; + image?: File | null; +}>(); + +const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? { + id: genId(), + name: '', + layers: [createTextLayer()], +}); + +const emit = defineEmits<{ + (ev: 'ok', preset: WatermarkPreset): void; + (ev: 'cancel'): void; + (ev: 'closed'): void; +}>(); + +const dialog = useTemplateRef('dialog'); + +async function cancel() { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm, + }); + if (canceled) return; + + emit('cancel'); + dialog.value?.close(); +} + +const type = ref(preset.layers.length > 1 ? 'advanced' : preset.layers[0].type); +watch(type, () => { + if (type.value === 'text') { + preset.layers = [createTextLayer()]; + } else if (type.value === 'image') { + preset.layers = [createImageLayer()]; + } else if (type.value === 'advanced') { + // nop + } +}); + +watch(preset, async (newValue, oldValue) => { + if (renderer != null) { + renderer.setLayers(preset.layers); + } +}, { deep: true }); + +const canvasEl = useTemplateRef('canvasEl'); + +const sampleImage_3_2 = new Image(); +sampleImage_3_2.src = '/client-assets/sample/3-2.jpg'; +const sampleImage_3_2_loading = new Promise<void>(resolve => { + sampleImage_3_2.onload = () => resolve(); +}); + +const sampleImage_2_3 = new Image(); +sampleImage_2_3.src = '/client-assets/sample/2-3.jpg'; +const sampleImage_2_3_loading = new Promise<void>(resolve => { + sampleImage_2_3.onload = () => resolve(); +}); + +const sampleImageType = ref(props.image != null ? 'provided' : '3_2'); +watch(sampleImageType, async () => { + if (renderer != null) { + renderer.destroy(false); + renderer = null; + initRenderer(); + } +}); + +let renderer: WatermarkRenderer | null = null; +let imageBitmap: ImageBitmap | null = null; + +async function initRenderer() { + if (canvasEl.value == null) return; + + if (sampleImageType.value === '3_2') { + renderer = new WatermarkRenderer({ + canvas: canvasEl.value, + renderWidth: 1500, + renderHeight: 1000, + image: sampleImage_3_2, + }); + } else if (sampleImageType.value === '2_3') { + renderer = new WatermarkRenderer({ + canvas: canvasEl.value, + renderWidth: 1000, + renderHeight: 1500, + image: sampleImage_2_3, + }); + } else if (props.image != null) { + imageBitmap = await window.createImageBitmap(props.image); + + const MAX_W = 1000; + const MAX_H = 1000; + let w = imageBitmap.width; + let h = imageBitmap.height; + + if (w > MAX_W || h > MAX_H) { + const scale = Math.min(MAX_W / w, MAX_H / h); + w *= scale; + h *= scale; + } + + renderer = new WatermarkRenderer({ + canvas: canvasEl.value, + renderWidth: w, + renderHeight: h, + image: imageBitmap, + }); + } + + await renderer!.setLayers(preset.layers); + + renderer!.render(); +} + +onMounted(async () => { + const closeWaiting = os.waiting(); + + await nextTick(); // waitingがレンダリングされるまで待つ + + await sampleImage_3_2_loading; + await sampleImage_2_3_loading; + + await initRenderer(); + + closeWaiting(); +}); + +onUnmounted(() => { + if (renderer != null) { + renderer.destroy(); + renderer = null; + } + if (imageBitmap != null) { + imageBitmap.close(); + imageBitmap = null; + } +}); + +async function save() { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts.name, + default: preset.name, + }); + if (canceled) return; + + preset.name = name || ''; + + dialog.value?.close(); + if (renderer != null) { + renderer.destroy(); + renderer = null; + } + + emit('ok', preset); +} + +function addLayer(ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts._watermarkEditor.text, + action: () => { + preset.layers.push(createTextLayer()); + }, + }, { + text: i18n.ts._watermarkEditor.image, + action: () => { + preset.layers.push(createImageLayer()); + }, + }, { + text: i18n.ts._watermarkEditor.stripe, + action: () => { + preset.layers.push(createStripeLayer()); + }, + }, { + text: i18n.ts._watermarkEditor.polkadot, + action: () => { + preset.layers.push(createPolkadotLayer()); + }, + }, { + text: i18n.ts._watermarkEditor.checker, + action: () => { + preset.layers.push(createCheckerLayer()); + }, + }], ev.currentTarget ?? ev.target); +} + +function swapUpLayer(layer: WatermarkPreset['layers'][number]) { + const index = preset.layers.findIndex(l => l.id === layer.id); + if (index > 0) { + const tmp = preset.layers[index - 1]; + preset.layers[index - 1] = preset.layers[index]; + preset.layers[index] = tmp; + } +} + +function swapDownLayer(layer: WatermarkPreset['layers'][number]) { + const index = preset.layers.findIndex(l => l.id === layer.id); + if (index < preset.layers.length - 1) { + const tmp = preset.layers[index + 1]; + preset.layers[index + 1] = preset.layers[index]; + preset.layers[index] = tmp; + } +} + +function removeLayer(layer: WatermarkPreset['layers'][number]) { + preset.layers = preset.layers.filter(l => l.id !== layer.id); +} +</script> + +<style module> +.root { + container-type: inline-size; + height: 100%; +} + +.container { + height: 100%; + display: grid; + grid-template-columns: 1fr 400px; +} + +.preview { + position: relative; + background-color: var(--MI_THEME-bg); + background-size: auto auto; + background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px); +} + +.previewContainer { + display: flex; + flex-direction: column; + height: 100%; + user-select: none; + -webkit-user-drag: none; +} + +.previewTitle { + position: absolute; + z-index: 100; + top: 8px; + left: 8px; + padding: 6px 10px; + border-radius: 6px; + font-size: 85%; +} + +.previewControls { + position: absolute; + z-index: 100; + bottom: 8px; + right: 8px; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 6px; +} + +.previewControlsButton { + &.active { + color: var(--MI_THEME-accent); + } +} + +.previewSpinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} + +.previewCanvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 20px; + box-sizing: border-box; + object-fit: contain; +} + +.controls { + overflow-y: scroll; +} + +@container (max-width: 800px) { + .container { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + } +} +</style> diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index 44f6921a85..f606b0b001 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -51,7 +51,7 @@ export type DefaultStoredWidget = { <script lang="ts" setup> import { defineAsyncComponent, ref, computed } from 'vue'; -import { v4 as uuid } from 'uuid'; +import { genId } from '@/utility/id.js'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; import { widgets as widgetDefs, federationWidgets } from '@/widgets/index.js'; @@ -95,7 +95,7 @@ const addWidget = () => { emit('addWidget', { name: widgetAdderSelected.value, - id: uuid(), + id: genId(), data: {}, }); diff --git a/packages/frontend/src/components/global/MkTip.vue b/packages/frontend/src/components/global/MkTip.vue index 384511a0ed..231957a232 100644 --- a/packages/frontend/src/components/global/MkTip.vue +++ b/packages/frontend/src/components/global/MkTip.vue @@ -7,7 +7,10 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="!store.r.tips.value[props.k]" :class="[$style.root, { [$style.warn]: warn }]" class="_selectable _gaps_s"> <div style="font-weight: bold;"><i class="ti ti-bulb"></i> {{ i18n.ts.tip }}:</div> <div><slot></slot></div> - <MkButton primary rounded small @click="closeTip()"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton> + <div> + <MkButton inline primary rounded small @click="_closeTip()"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton> + <button class="_button" style="padding: 8px; margin-left: 4px;" @click="showMenu"><i class="ti ti-dots"></i></button> + </div> </div> </template> @@ -15,19 +18,30 @@ SPDX-License-Identifier: AGPL-3.0-only import { i18n } from '@/i18n.js'; import { store } from '@/store.js'; import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os.js'; +import { TIPS, hideAllTips, closeTip } from '@/tips.js'; const props = withDefaults(defineProps<{ - k: keyof (typeof store['s']['tips']); + k: typeof TIPS[number]; warn?: boolean; }>(), { warn: false, }); -function closeTip() { - store.set('tips', { - ...store.r.tips.value, - [props.k]: true, - }); +function _closeTip() { + closeTip(props.k); +} + +function showMenu(ev: MouseEvent) { + os.popupMenu([{ + icon: 'ti ti-bulb-off', + text: i18n.ts.hideAllTips, + danger: true, + action: () => { + hideAllTips(); + os.success(); + }, + }], ev.currentTarget ?? ev.target); } </script> |