This commit is contained in:
kakkokari-gtyih 2024-11-07 22:46:13 +09:00
parent 3c0a878b1a
commit 5c126d0703
10 changed files with 325 additions and 45 deletions

4
locales/index.d.ts vendored
View File

@ -9284,6 +9284,10 @@ export interface Locale extends ILocale {
*
*/
"moveToHere": string;
/**
*
*/
"blockDeleteAreYouSure": string;
"blocks": {
/**
*

View File

@ -2448,6 +2448,7 @@ _pages:
specialBlocks: "特殊"
inputTitleHere: "タイトルを入力"
moveToHere: "ここに移動"
blockDeleteAreYouSure: "このブロックを削除しますか?"
blocks:
text: "テキスト"
textarea: "テキストエリア"

View File

@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<!-- eslint-disable vue/no-mutating-props -->
<XContainer :draggable="true" :blockId="modelValue.id" @remove="() => emit('remove')">
<XContainer
:draggable="true"
:blockId="modelValue.id"
@remove="() => emit('remove')"
@move="(direction) => emit('move', direction)"
>
<template #header><i class="ti ti-photo"></i> {{ i18n.ts._pages.blocks.image }}</template>
<template #func>
<button @click="choose()">
@ -36,6 +41,7 @@ const props = defineProps<{
const emit = defineEmits<{
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'image' }): void;
(ev: 'remove'): void;
(ev: 'move', direction: 'up' | 'down'): void;
}>();
const file = ref<Misskey.entities.DriveFile | null>(null);

View File

@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<!-- eslint-disable vue/no-mutating-props -->
<XContainer :draggable="true" :blockId="modelValue.id" @remove="() => emit('remove')">
<XContainer
:draggable="true"
:blockId="modelValue.id"
@remove="() => emit('remove')"
@move="(direction) => emit('move', direction)"
>
<template #header><i class="ti ti-note"></i> {{ i18n.ts._pages.blocks.note }}</template>
<section style="padding: 16px;" class="_gaps_s">
@ -39,6 +44,8 @@ const props = defineProps<{
const emit = defineEmits<{
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'note' }): void;
(ev: 'remove'): void;
(ev: 'move', direction: 'up' | 'down'): void;
}>();
const id = ref(props.modelValue.note);

View File

@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<!-- eslint-disable vue/no-mutating-props -->
<XContainer :draggable="true" :blockId="modelValue.id" @remove="() => emit('remove')">
<XContainer
:draggable="true"
:blockId="modelValue.id"
@remove="() => emit('remove')"
@move="(direction) => emit('move', direction)"
>
<template #header><i class="ti ti-note"></i> {{ props.modelValue.title }}</template>
<template #func>
<button class="_button" @click="rename()">
@ -41,6 +46,7 @@ const props = defineProps<{
const emit = defineEmits<{
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'section' }): void;
(ev: 'remove'): void;
(ev: 'move', direction: 'up' | 'down'): void;
}>();
const children = ref(deepClone(props.modelValue.children ?? []));

View File

@ -5,18 +5,31 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<!-- eslint-disable vue/no-mutating-props -->
<XContainer :draggable="true" :blockId="modelValue.id" @remove="() => emit('remove')">
<template #header><i class="ti ti-align-left"></i> {{ i18n.ts._pages.blocks.text }}</template>
<section>
<textarea ref="inputEl" v-model="text" :class="$style.textarea"></textarea>
</section>
<XContainer
:draggable="true"
:blockId="modelValue.id"
@remove="() => emit('remove')"
@move="(direction) => emit('move', direction)"
>
<template #header><i class="ti ti-align-left"></i></template>
<template #actions>
<button class="_button" :class="$style.previewToggleRoot" @click="toggleEnablePreview">
<MkSwitchButton :class="$style.previewToggleSwitch" :checked="enablePreview" @toggle="toggleEnablePreview"></MkSwitchButton>{{ i18n.ts.preview }}
</button>
</template>
<template #default="{ focus }">
<section>
<div v-if="enablePreview" ref="previewEl" :class="$style.previewRoot"><Mfm :text="text"></Mfm></div>
<textarea v-else ref="inputEl" v-model="text" :class="$style.textarea" @input.passive="calcTextAreaHeight"></textarea>
</section>
</template>
</XContainer>
</template>
<script lang="ts" setup>
import { watch, ref, shallowRef, onMounted, onUnmounted } from 'vue';
import { watch, ref, computed, useTemplateRef, onMounted, onUnmounted, nextTick } from 'vue';
import * as Misskey from 'misskey-js';
import MkSwitchButton from '@/components/MkSwitch.button.vue';
import XContainer from '../page-editor.container.vue';
import { i18n } from '@/i18n.js';
import { Autocomplete } from '@/scripts/autocomplete.js';
@ -27,12 +40,37 @@ const props = defineProps<{
const emit = defineEmits<{
(ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'text' }): void;
(ev: 'remove'): void;
(ev: 'move', direction: 'up' | 'down'): void;
}>();
let autocomplete: Autocomplete;
const inputEl = useTemplateRef('inputEl');
const inputHeight = ref(150);
const previewEl = useTemplateRef('previewEl');
const previewHeight = ref(150);
const editorHeight = computed(() => Math.max(inputHeight.value, previewHeight.value));
function calcTextAreaHeight() {
if (!inputEl.value) return;
inputEl.value.setAttribute('style', 'min-height: auto');
inputHeight.value = Math.max(150, inputEl.value.scrollHeight ?? 0);
inputEl.value.removeAttribute('style');
}
const enablePreview = ref(false);
function toggleEnablePreview() {
enablePreview.value = !enablePreview.value;
if (enablePreview.value === true) {
nextTick(() => {
previewHeight.value = Math.max(150, previewEl.value?.scrollHeight ?? 0);
});
}
}
const text = ref(props.modelValue.text ?? '');
const inputEl = shallowRef<HTMLTextAreaElement | null>(null);
watch(text, () => {
emit('update:modelValue', {
@ -42,6 +80,7 @@ watch(text, () => {
});
onMounted(() => {
if (!inputEl.value) return;
autocomplete = new Autocomplete(inputEl.value, text);
});
@ -58,7 +97,7 @@ onUnmounted(() => {
appearance: none;
width: 100%;
min-width: 100%;
min-height: 150px;
min-height: v-bind("editorHeight + 'px'");
border: none;
box-shadow: none;
padding: 16px;
@ -67,4 +106,19 @@ onUnmounted(() => {
font-size: 14px;
box-sizing: border-box;
}
.previewRoot {
padding: 16px;
min-height: v-bind("editorHeight + 'px'");
box-sizing: border-box;
}
.previewToggleRoot {
display: flex;
gap: 4px;
}
.previewToggleSwitch {
--height: 1.35em;
}
</style>

View File

@ -13,12 +13,13 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="[$style.insertBetweenRoot, {
[$style.insertBetweenDraggingOver]: draggingOverAfterId === '__FIRST__' && draggingBlockId !== modelValue[0]?.id,
}]"
@click="insertNewBlock('__FIRST__')"
@dragover="insertBetweenDragOver($event, '__FIRST__')"
@dragleave="insertBetweenDragLeave"
@drop="insertBetweenDrop($event, '__FIRST__')"
>
<div :class="$style.insertBetweenBorder"></div>
<span :class="$style.insertBetweenText">{{ i18n.ts._pages.moveToHere }}</span>
<span :class="$style.insertBetweenText"><i v-if="!isDragging" class="ti ti-plus"></i> {{ isDragging ? i18n.ts._pages.moveToHere : i18n.ts.add }}</span>
</div>
<div v-for="block, index in modelValue" :key="block.id" :class="$style.item">
@ -28,18 +29,20 @@ SPDX-License-Identifier: AGPL-3.0-only
:modelValue="block"
@update:modelValue="updateItem"
@remove="() => removeItem(block)"
@move="(direction: 'up' | 'down') => moveItem(block.id, direction)"
/>
<div
:data-after-id="block.id"
:class="[$style.insertBetweenRoot, {
[$style.insertBetweenDraggingOver]: draggingOverAfterId === block.id && draggingBlockId !== block.id && draggingBlockId !== modelValue[index + 1]?.id,
}]"
@click="insertNewBlock(block.id)"
@dragover="insertBetweenDragOver($event, block.id, modelValue[index + 1]?.id)"
@dragleave="insertBetweenDragLeave"
@drop="insertBetweenDrop($event, block.id, modelValue[index + 1]?.id)"
>
<div :class="$style.insertBetweenBorder"></div>
<span :class="$style.insertBetweenText">{{ i18n.ts._pages.moveToHere }}</span>
<span :class="$style.insertBetweenText"><i v-if="!isDragging" class="ti ti-plus"></i> {{ isDragging ? i18n.ts._pages.moveToHere : i18n.ts.add }}</span>
</div>
</div>
</div>
@ -48,7 +51,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import { v4 as uuid } from 'uuid';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { getScrollContainer } from '@@/js/scroll.js';
import { getPageBlockList } from '@/pages/page-editor/common.js';
import XSection from './els/page-editor.el.section.vue';
import XText from './els/page-editor.el.text.vue';
import XImage from './els/page-editor.el.image.vue';
@ -65,6 +72,7 @@ function getComponent(type: string) {
}
const props = defineProps<{
scrollContainer?: HTMLElement | null;
modelValue: Misskey.entities.Page['content'];
}>();
@ -81,9 +89,10 @@ function dragStart(ev: DragEvent) {
const blockId = ev.target.dataset.blockId;
if (blockId != null) {
console.log('dragStart', blockId);
ev.dataTransfer!.setData('text/plain', blockId);
ev.dataTransfer!.setData('application/x-misskey-pageblock-id', blockId);
isDragging.value = true;
draggingBlockId.value = blockId;
document.addEventListener('dragover', watchForMouseMove);
}
}
}
@ -91,10 +100,39 @@ function dragStart(ev: DragEvent) {
function dragEnd() {
isDragging.value = false;
draggingBlockId.value = null;
document.removeEventListener('dragover', watchForMouseMove);
}
function watchForMouseMove(ev: DragEvent) {
if (isDragging.value) {
// 1/4
const scrollContainer = getScrollContainer(props.scrollContainer ?? null) ?? document.scrollingElement;
if (scrollContainer != null) {
const rect = scrollContainer.getBoundingClientRect();
const y = ev.clientY - rect.top;
const h = rect.height;
const scrollSpeed = 30;
if (y < h / 4) {
const acceralation = Math.max(0, 1 - (y / (h / 4)));
scrollContainer.scrollBy({
top: -scrollSpeed * acceralation,
});
} else if (y > (h / 4 * 3)) {
const acceralation = Math.max(0, 1 - ((h - y) / (h / 4)));
scrollContainer.scrollBy({
top: scrollSpeed * acceralation,
});
}
}
}
}
function insertBetweenDragOver(ev: DragEvent, id: string, nextId?: string) {
if (draggingBlockId.value === id || draggingBlockId.value === nextId) return;
if (
draggingBlockId.value === id ||
draggingBlockId.value === nextId ||
![...(ev.dataTransfer?.types ?? [])].includes('application/x-misskey-pageblock-id')
) return;
ev.preventDefault();
if (ev.target instanceof HTMLElement) {
@ -110,12 +148,16 @@ function insertBetweenDragLeave() {
}
function insertBetweenDrop(ev: DragEvent, id: string, nextId?: string) {
if (draggingBlockId.value === id || draggingBlockId.value === nextId) return;
if (
draggingBlockId.value === id ||
draggingBlockId.value === nextId ||
![...(ev.dataTransfer?.types ?? [])].includes('application/x-misskey-pageblock-id')
) return;
ev.preventDefault();
if (ev.target instanceof HTMLElement) {
const afterId = ev.target.dataset.afterId; // insert after this
const moveId = ev.dataTransfer?.getData('text/plain');
const moveId = ev.dataTransfer?.getData('application/x-misskey-pageblock-id');
if (afterId != null && moveId != null) {
const oldValue = props.modelValue.filter((x) => x.id !== moveId);
const afterIdAt = afterId === '__FIRST__' ? 0 : oldValue.findIndex((x) => x.id === afterId);
@ -140,6 +182,49 @@ function insertBetweenDrop(ev: DragEvent, id: string, nextId?: string) {
draggingOverAfterId.value = null;
}
async function insertNewBlock(id: string) {
const { canceled, result: type } = await os.select({
title: i18n.ts._pages.chooseBlock,
items: getPageBlockList(),
});
if (canceled || type == null) return;
const blockId = uuid();
let newValue: Misskey.entities.Page['content'];
if (id === '__FIRST__') {
newValue = [
{ id: blockId, type },
...props.modelValue,
];
} else {
const afterIdAt = props.modelValue.findIndex((x) => x.id === id);
newValue = [
...props.modelValue.slice(0, afterIdAt + 1),
{ id: blockId, type },
...props.modelValue.slice(afterIdAt + 1),
];
}
emit('update:modelValue', newValue);
}
function moveItem(id: string, direction: 'up' | 'down') {
const i = props.modelValue.findIndex(x => x.id === id);
if (i === -1) return;
const newValue = [...props.modelValue];
const [removed] = newValue.splice(i, 1);
if (direction === 'up') {
newValue.splice(i - 1, 0, removed);
} else {
newValue.splice(i + 1, 0, removed);
}
emit('update:modelValue', newValue);
}
function updateItem(v: Misskey.entities.PageBlock) {
const i = props.modelValue.findIndex(x => x.id === v.id);
const newValue = [
@ -164,8 +249,20 @@ function removeItem(v: Misskey.entities.PageBlock) {
.insertBetweenRoot {
height: calc(var(--MI-margin) * 2);
width: 100%;
padding: 5px 0;
border-radius: 2px;
position: relative;
&:hover {
cursor: pointer;
.insertBetweenBorder {
display: block;
}
.insertBetweenText {
display: inline-block;
}
}
}
.insertBetweenBorder {
@ -187,6 +284,7 @@ function removeItem(v: Misskey.entities.PageBlock) {
transform: translate(-50%, -50%);
color: var(--MI_THEME-fgOnAccent);
padding: 0 14px;
font-size: 13px;
line-height: 24px;
border-radius: 999px;
display: none;

View File

@ -5,41 +5,63 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
ref="containerRootEl"
:class="[$style.blockContainerRoot, {
[$style.dragging]: isDragging,
[$style.draggingOver]: isDraggingOver,
}]"
@focus.capture="toggleFocus"
@blur.capture="toggleFocus"
@dragover="dragOver"
@dragleave="dragLeave"
@drop="drop"
>
<header :class="$style.blockContainerHeader">
<header :class="$style.blockContainerHeader" tabindex="1">
<div :class="$style.title"><slot name="header"></slot></div>
<div :class="$style.buttons">
<div v-if="$slots.actions != null"><slot name="actions"></slot></div>
<button v-if="removable" :class="$style.blockContainerActionButton" class="_button" @click="remove()">
<i class="ti ti-trash"></i>
</button>
<button
v-if="draggable"
draggable="true"
:class="$style.blockContainerActionButton"
class="_button"
:data-block-id="blockId"
@dragstart="dragStart"
@dragend="dragEnd"
>
<i class="ti ti-menu-2"></i>
</button>
<template v-if="draggable">
<div :class="$style.divider"></div>
<button
:class="$style.blockContainerActionButton"
class="_button"
@click="() => emit('move', 'up')"
>
<i class="ti ti-arrow-up"></i>
</button>
<button
:class="$style.blockContainerActionButton"
class="_button"
@click="() => emit('move', 'down')"
>
<i class="ti ti-arrow-down"></i>
</button>
<button
draggable="true"
:class="$style.blockContainerActionButton"
class="_button"
:data-block-id="blockId"
@dragstart="dragStart"
@dragend="dragEnd"
>
<i class="ti ti-menu-2"></i>
</button>
</template>
</div>
</header>
<div :class="$style.blockContainerBody" tabindex="0">
<slot></slot>
<slot :focus="focus"></slot>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { ref, useTemplateRef } from 'vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n';
const props = withDefaults(defineProps<{
blockId: string;
@ -53,15 +75,28 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(ev: 'remove'): void;
(ev: 'move', direction: 'up' | 'down'): void;
}>();
function remove() {
async function remove() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts._pages.blockDeleteAreYouSure,
});
if (canceled) return;
emit('remove');
}
const containerRootEl = useTemplateRef('containerRootEl');
const focus = ref(false);
function toggleFocus() {
focus.value = containerRootEl.value?.contains(document.activeElement) ?? false;
}
const isDragging = ref(false);
function dragStart(ev: DragEvent) {
ev.dataTransfer?.setData('text/plain', props.blockId);
ev.dataTransfer?.setData('application/x-misskey-pageblock-id', props.blockId);
isDragging.value = true;
}
function dragEnd() {
@ -98,10 +133,10 @@ function drop() {
transform: translateY(-100%);
z-index: 1;
display: none;
gap: var(--MI-margin);
gap: 8px;
height: 42px;
padding: 6px 14px;
padding: 6px 8px;
background-color: var(--MI_THEME-panel);
border: 2px solid var(--MI_THEME-accent);
border-bottom: none;
@ -109,11 +144,21 @@ function drop() {
> .title {
line-height: 26px;
padding-left: 2px;
padding-right: 8px;
border-right: 0.5px solid var(--MI_THEME-divider);
}
> .buttons {
display: flex;
align-items: center;
gap: 8px;
> .divider {
width: 0.5px;
height: 26px;
background-color: var(--MI_THEME-divider);
}
}
}

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<MkStickyContainer ref="containerEl">
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="800">
<div v-if="fetchStatus === 'loading'">
@ -31,8 +31,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
<div :class="$style.pageContent">
<XBlocks v-model="content"/>
<div>
<XPage v-if="enableGlobalPreview" key="preview" :page="page" />
<XBlocks v-else key="editor" v-model="content" :scrollContainer="containerEl?.rootEl"/>
</div>
</div>
<div v-else-if="fetchStatus === 'notMe'" class="_fullInfo">
@ -41,10 +42,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSpacer>
<template #footer>
<div :class="$style.footer">
<div class="_buttons" :class="$style.footerInner">
<MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
<MkButton @click="show"><i class="ti ti-eye"></i> {{ i18n.ts.show }}</MkButton>
<MkButton v-if="initPageId != null" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
<div :class="$style.footerInner">
<div :class="$style.footerActionSwitchWrapper">
<MkSwitch v-model="enableGlobalPreview">{{ i18n.ts.preview }}</MkSwitch>
</div>
<div :class="$style.footerActionButtons" class="_buttons">
<MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</div>
</div>
</div>
</template>
@ -52,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, provide, watch, ref } from 'vue';
import { computed, ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import { v4 as uuid } from 'uuid';
import XBlocks from './page-editor.blocks.vue';
@ -61,6 +65,7 @@ import MkSelect from '@/components/MkSelect.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkInput from '@/components/MkInput.vue';
import MkMediaImage from '@/components/MkMediaImage.vue';
import XPage from '@/components/page/page.vue';
import { url } from '@@/js/config.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
@ -70,6 +75,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { signinRequired } from '@/account.js';
import { mainRouter } from '@/router/main.js';
import { getPageBlockList } from '@/pages/page-editor/common.js';
import type { SlimPage } from '@/types/page.js';
const props = defineProps<{
initPageId?: string;
@ -78,7 +84,7 @@ const props = defineProps<{
const $i = signinRequired();
const fetchStatus = ref<'loading' | 'done' | 'notMe'>('loading');
const page = ref<Partial<Misskey.entities.Page> | null>(null);
const page = ref<Partial<SlimPage> | null>(null);
const title = computed({
get: () => page.value?.title ?? '',
set: (value) => {
@ -104,6 +110,10 @@ const content = computed<Misskey.entities.Page['content']>({
},
});
const enableGlobalPreview = ref(false);
const containerEl = useTemplateRef('containerEl');
function onTitleUpdated(ev: Event) {
title.value = (ev.target as HTMLDivElement).innerText;
}
@ -246,6 +256,24 @@ definePageMetadata(() => ({
}
}
.editorMenu {
position: sticky;
top: var(--MI-stickyTop, 0px);
left: 0;
width: calc(100% + 4rem);
margin: 0 -2rem 0;
backdrop-filter: var(--MI-blur, blur(15px));
background: var(--MI_THEME-acrylicBg);
border-bottom: solid .5px var(--MI_THEME-divider);
z-index: 2;
}
.editorMenuInner {
padding: 16px;
margin: 0 auto;
padding: 2rem;
}
.footer {
backdrop-filter: var(--MI-blur, blur(15px));
background: var(--MI_THEME-acrylicBg);
@ -256,5 +284,18 @@ definePageMetadata(() => ({
padding: 16px;
margin: 0 auto;
max-width: 800px;
display: flex;
gap: 8px;
align-items: center;
}
.footerActionSwitchWrapper {
flex-shrink: 0;
}
.footerActionButtons {
margin-left: auto;
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as Misskey from 'misskey-js';
export type SlimPage = Pick<Misskey.entities.Page,
'alignCenter' |
'attachedFiles' |
'content' |
'eyeCatchingImage' |
'eyeCatchingImageId' |
'font' |
'title' |
'user' |
'userId'
>;