From 3c0a878b1afa1cfc9377c6eb0c65838baae6351a Mon Sep 17 00:00:00 2001
From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Thu, 7 Nov 2024 18:00:58 +0900
Subject: [PATCH] wip

---
 locales/index.d.ts                            |   8 +
 locales/ja-JP.yml                             |   2 +
 .../src/components/MkNoteDetailed.vue         |   6 +-
 .../components/global/MkStickyContainer.vue   |  12 +-
 .../page-editor/els/page-editor.el.image.vue  |   2 +-
 .../page-editor/els/page-editor.el.note.vue   |   2 +-
 .../els/page-editor.el.section.vue            |   4 +-
 .../page-editor/els/page-editor.el.text.vue   |   3 +-
 .../pages/page-editor/page-editor.blocks.vue  | 174 ++++++++++++++--
 .../page-editor/page-editor.container.vue     | 191 +++++++++++-------
 .../src/pages/page-editor/page-editor.vue     |  90 ++++++++-
 11 files changed, 391 insertions(+), 103 deletions(-)

diff --git a/locales/index.d.ts b/locales/index.d.ts
index 440f24ac84..e80e40bcea 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -9276,6 +9276,14 @@ export interface Locale extends ILocale {
          * 特殊
          */
         "specialBlocks": string;
+        /**
+         * タイトルを入力
+         */
+        "inputTitleHere": string;
+        /**
+         * ここに移動
+         */
+        "moveToHere": string;
         "blocks": {
             /**
              * テキスト
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 5d8e1a5e72..3a14d73c0e 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2446,6 +2446,8 @@ _pages:
   contentBlocks: "コンテンツ"
   inputBlocks: "入力"
   specialBlocks: "特殊"
+  inputTitleHere: "タイトルを入力"
+  moveToHere: "ここに移動"
   blocks:
     text: "テキスト"
     textarea: "テキストエリア"
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index e0473dce5e..8da6279d6b 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -246,9 +246,11 @@ import { isEnabledUrlPreview } from '@/instance.js';
 import { getAppearNote } from '@/scripts/get-appear-note.js';
 import { type Keymap } from '@/scripts/hotkey.js';
 
+type Tab = 'replies' | 'renotes' | 'reactions';
+
 const props = withDefaults(defineProps<{
 	note: Misskey.entities.Note;
-	initialTab: string;
+	initialTab?: Tab;
 }>(), {
 	initialTab: 'replies',
 });
@@ -332,7 +334,7 @@ provide('react', (reaction: string) => {
 	});
 });
 
-const tab = ref(props.initialTab);
+const tab = ref<Tab>(props.initialTab);
 const reactionTabType = ref<string | null>(null);
 
 const renotesPagination = computed<Paging>(() => ({
diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue
index 1aebf487bb..24f9333f74 100644
--- a/packages/frontend/src/components/global/MkStickyContainer.vue
+++ b/packages/frontend/src/components/global/MkStickyContainer.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div ref="rootEl">
+<div ref="rootEl" :class="$style.root">
 	<div ref="headerEl" :class="$style.header">
 		<slot name="header"></slot>
 	</div>
@@ -84,8 +84,16 @@ defineExpose({
 </script>
 
 <style lang='scss' module>
+.root {
+	position: relative;
+	display: flex;
+	flex-direction: column;
+	height: 100%;
+}
+
 .body {
 	position: relative;
+	flex-grow: 1;
 	z-index: 0;
 	--MI-stickyTop: v-bind("childStickyTop + 'px'");
 	--MI-stickyBottom: v-bind("childStickyBottom + 'px'");
@@ -93,12 +101,14 @@ defineExpose({
 
 .header {
 	position: sticky;
+	flex-shrink: 0;
 	top: var(--MI-stickyTop, 0);
 	z-index: 1;
 }
 
 .footer {
 	position: sticky;
+	flex-shrink: 0;
 	bottom: var(--MI-stickyBottom, 0);
 	z-index: 1;
 }
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue
index c3ad6657b0..a17a532edd 100644
--- a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <!-- eslint-disable vue/no-mutating-props -->
-<XContainer :draggable="true" @remove="() => emit('remove')">
+<XContainer :draggable="true" :blockId="modelValue.id" @remove="() => emit('remove')">
 	<template #header><i class="ti ti-photo"></i> {{ i18n.ts._pages.blocks.image }}</template>
 	<template #func>
 		<button @click="choose()">
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue
index 36e03b4790..18057c01b5 100644
--- a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <!-- eslint-disable vue/no-mutating-props -->
-<XContainer :draggable="true" @remove="() => emit('remove')">
+<XContainer :draggable="true" :blockId="modelValue.id" @remove="() => emit('remove')">
 	<template #header><i class="ti ti-note"></i> {{ i18n.ts._pages.blocks.note }}</template>
 
 	<section style="padding: 16px;" class="_gaps_s">
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue
index 3fed07f7e8..6d189ba134 100644
--- a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <!-- eslint-disable vue/no-mutating-props -->
-<XContainer :draggable="true" @remove="() => emit('remove')">
+<XContainer :draggable="true" :blockId="modelValue.id" @remove="() => emit('remove')">
 	<template #header><i class="ti ti-note"></i> {{ props.modelValue.title }}</template>
 	<template #func>
 		<button class="_button" @click="rename()">
@@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
- 
+
 import { defineAsyncComponent, inject, onMounted, watch, ref } from 'vue';
 import * as Misskey from 'misskey-js';
 import { v4 as uuid } from 'uuid';
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
index 5795b46c00..238e41bc83 100644
--- a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <template>
 <!-- eslint-disable vue/no-mutating-props -->
-<XContainer :draggable="true" @remove="() => emit('remove')">
+<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>
@@ -15,7 +15,6 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
- 
 import { watch, ref, shallowRef, onMounted, onUnmounted } from 'vue';
 import * as Misskey from 'misskey-js';
 import XContainer from '../page-editor.container.vue';
diff --git a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue
index f191320180..65999c3cc0 100644
--- a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue
+++ b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue
@@ -2,21 +2,53 @@
 SPDX-FileCopyrightText: syuilo and misskey-project
 SPDX-License-Identifier: AGPL-3.0-only
 -->
-
 <template>
-<Sortable :modelValue="modelValue" tag="div" itemKey="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swapThreshold="0.5" @update:modelValue="v => emit('update:modelValue', v)">
-	<template #item="{element}">
-		<div :class="$style.item">
-			<!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 -->
-			<component :is="getComponent(element.type)" :modelValue="element" @update:modelValue="updateItem" @remove="() => removeItem(element)"/>
+<div
+	@dragstart.capture="dragStart"
+	@dragend.capture="dragEnd"
+	@drop.capture="dragEnd"
+>
+	<div
+		data-after-id="__FIRST__"
+		:class="[$style.insertBetweenRoot, {
+			[$style.insertBetweenDraggingOver]: draggingOverAfterId === '__FIRST__' && draggingBlockId !== modelValue[0]?.id,
+		}]"
+		@dragover="insertBetweenDragOver($event, '__FIRST__')"
+		@dragleave="insertBetweenDragLeave"
+		@drop="insertBetweenDrop($event, '__FIRST__')"
+	>
+		<div :class="$style.insertBetweenBorder"></div>
+		<span :class="$style.insertBetweenText">{{ i18n.ts._pages.moveToHere }}</span>
+	</div>
+
+	<div v-for="block, index in modelValue" :key="block.id" :class="$style.item">
+		<!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 -->
+		<component
+			:is="getComponent(block.type)"
+			:modelValue="block"
+			@update:modelValue="updateItem"
+			@remove="() => removeItem(block)"
+		/>
+		<div
+			:data-after-id="block.id"
+			:class="[$style.insertBetweenRoot, {
+				[$style.insertBetweenDraggingOver]: draggingOverAfterId === block.id && draggingBlockId !== block.id && draggingBlockId !== modelValue[index + 1]?.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>
 		</div>
-	</template>
-</Sortable>
+	</div>
+</div>
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent } from 'vue';
+import { ref } from 'vue';
 import * as Misskey from 'misskey-js';
+import { i18n } from '@/i18n.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';
@@ -32,8 +64,6 @@ function getComponent(type: string) {
 	}
 }
 
-const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
-
 const props = defineProps<{
 	modelValue: Misskey.entities.Page['content'];
 }>();
@@ -42,7 +72,75 @@ const emit = defineEmits<{
 	(ev: 'update:modelValue', value: Misskey.entities.Page['content']): void;
 }>();
 
-function updateItem(v) {
+const isDragging = ref(false);
+const draggingOverAfterId = ref<string | null>(null);
+const draggingBlockId = ref<string | null>(null);
+
+function dragStart(ev: DragEvent) {
+	if (ev.target instanceof HTMLElement) {
+		const blockId = ev.target.dataset.blockId;
+		if (blockId != null) {
+			console.log('dragStart', blockId);
+			ev.dataTransfer!.setData('text/plain', blockId);
+			isDragging.value = true;
+			draggingBlockId.value = blockId;
+		}
+	}
+}
+
+function dragEnd() {
+	isDragging.value = false;
+	draggingBlockId.value = null;
+}
+
+function insertBetweenDragOver(ev: DragEvent, id: string, nextId?: string) {
+	if (draggingBlockId.value === id || draggingBlockId.value === nextId) return;
+
+	ev.preventDefault();
+	if (ev.target instanceof HTMLElement) {
+		const afterId = ev.target.dataset.afterId;
+		if (afterId != null) {
+			draggingOverAfterId.value = afterId;
+		}
+	}
+}
+
+function insertBetweenDragLeave() {
+	draggingOverAfterId.value = null;
+}
+
+function insertBetweenDrop(ev: DragEvent, id: string, nextId?: string) {
+	if (draggingBlockId.value === id || draggingBlockId.value === nextId) return;
+
+	ev.preventDefault();
+	if (ev.target instanceof HTMLElement) {
+		const afterId = ev.target.dataset.afterId; // insert after this
+		const moveId = ev.dataTransfer?.getData('text/plain');
+		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);
+			const movingBlock = props.modelValue.find((x) => x.id === moveId);
+			if (afterId === '__FIRST__' && movingBlock != null) {
+				const newValue = [
+					movingBlock,
+					...oldValue,
+				];
+				emit('update:modelValue', newValue);
+			} else if (afterIdAt >= 0 && movingBlock != null) {
+				const newValue = [
+					...oldValue.slice(0, afterIdAt + 1),
+					movingBlock,
+					...oldValue.slice(afterIdAt + 1),
+				];
+				emit('update:modelValue', newValue);
+			}
+		}
+	}
+	isDragging.value = false;
+	draggingOverAfterId.value = null;
+}
+
+function updateItem(v: Misskey.entities.PageBlock) {
 	const i = props.modelValue.findIndex(x => x.id === v.id);
 	const newValue = [
 		...props.modelValue.slice(0, i),
@@ -52,8 +150,8 @@ function updateItem(v) {
 	emit('update:modelValue', newValue);
 }
 
-function removeItem(el) {
-	const i = props.modelValue.findIndex(x => x.id === el.id);
+function removeItem(v: Misskey.entities.PageBlock) {
+	const i = props.modelValue.findIndex(x => x.id === v.id);
 	const newValue = [
 		...props.modelValue.slice(0, i),
 		...props.modelValue.slice(i + 1),
@@ -63,9 +161,51 @@ function removeItem(el) {
 </script>
 
 <style lang="scss" module>
-.item {
-	& + .item {
-		margin-top: 16px;
+.insertBetweenRoot {
+	height: calc(var(--MI-margin) * 2);
+	width: 100%;
+	border-radius: 2px;
+	position: relative;
+}
+
+.insertBetweenBorder {
+	position: absolute;
+	top: 50%;
+	left: 0;
+	transform: translateY(-50%);
+	height: 4px;
+	width: 100%;
+	border-radius: 2px;
+	background-color: var(--MI_THEME-accent);
+	display: none;
+}
+
+.insertBetweenText {
+	position: absolute;
+	top: 50%;
+	left: 50%;
+	transform: translate(-50%, -50%);
+	color: var(--MI_THEME-fgOnAccent);
+	padding: 0 14px;
+	line-height: 24px;
+	border-radius: 999px;
+	display: none;
+	background-color: var(--MI_THEME-accent);
+}
+
+.insertBetweenBorder,
+.insertBetweenText {
+	pointer-events: none;
+}
+
+.insertBetweenDraggingOver {
+	padding: 10px 0;
+
+	.insertBetweenBorder {
+		display: block;
+	}
+	.insertBetweenText {
+		display: inline-block;
 	}
 }
 </style>
diff --git a/packages/frontend/src/pages/page-editor/page-editor.container.vue b/packages/frontend/src/pages/page-editor/page-editor.container.vue
index a96c2c2a77..1bc0494ce9 100644
--- a/packages/frontend/src/pages/page-editor/page-editor.container.vue
+++ b/packages/frontend/src/pages/page-editor/page-editor.container.vue
@@ -4,24 +4,35 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div class="cpjygsrt">
-	<header>
-		<div class="title"><slot name="header"></slot></div>
-		<div class="buttons">
-			<slot name="func"></slot>
-			<button v-if="removable" class="_button" @click="remove()">
+<div
+	:class="[$style.blockContainerRoot, {
+		[$style.dragging]: isDragging,
+		[$style.draggingOver]: isDraggingOver,
+	}]"
+	@dragover="dragOver"
+	@dragleave="dragLeave"
+	@drop="drop"
+>
+	<header :class="$style.blockContainerHeader">
+		<div :class="$style.title"><slot name="header"></slot></div>
+		<div :class="$style.buttons">
+			<button v-if="removable" :class="$style.blockContainerActionButton" class="_button" @click="remove()">
 				<i class="ti ti-trash"></i>
 			</button>
-			<button v-if="draggable" class="drag-handle _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>
-			<button class="_button" @click="toggleContent(!showBody)">
-				<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
-				<template v-else><i class="ti ti-chevron-down"></i></template>
-			</button>
+		</button>
 		</div>
 	</header>
-	<div v-show="showBody" class="body">
+	<div :class="$style.blockContainerBody" tabindex="0">
 		<slot></slot>
 	</div>
 </div>
@@ -31,6 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { ref } from 'vue';
 
 const props = withDefaults(defineProps<{
+	blockId: string;
 	expanded?: boolean;
 	removable?: boolean;
 	draggable?: boolean;
@@ -40,24 +52,80 @@ const props = withDefaults(defineProps<{
 });
 
 const emit = defineEmits<{
-	(ev: 'toggle', show: boolean): void;
 	(ev: 'remove'): void;
 }>();
 
-const showBody = ref(props.expanded);
-
-function toggleContent(show: boolean) {
-	showBody.value = show;
-	emit('toggle', show);
-}
-
 function remove() {
 	emit('remove');
 }
+
+const isDragging = ref(false);
+function dragStart(ev: DragEvent) {
+	ev.dataTransfer?.setData('text/plain', props.blockId);
+	isDragging.value = true;
+}
+function dragEnd() {
+	isDragging.value = false;
+}
+
+const isDraggingOver = ref(false);
+function dragOver(ev: DragEvent) {
+	if (isDragging.value) {
+		// ブロックの中にドロップできるのは自分自身だけ
+		ev.preventDefault();
+		isDraggingOver.value = true;
+	}
+}
+function dragLeave() {
+	isDraggingOver.value = false;
+}
+function drop() {
+	// 自分自身しかドロップできないので何もしない
+	isDraggingOver.value = false;
+}
 </script>
 
-<style lang="scss" scoped>
-.cpjygsrt {
+<style lang="scss" module>
+.blockContainerRoot {
+	position: relative;
+}
+
+.blockContainerHeader {
+	position: absolute;
+	box-sizing: border-box;
+	top: 0;
+	right: 0;
+	transform: translateY(-100%);
+	z-index: 1;
+	display: none;
+	gap: var(--MI-margin);
+
+	height: 42px;
+	padding: 6px 14px;
+	background-color: var(--MI_THEME-panel);
+	border: 2px solid var(--MI_THEME-accent);
+	border-bottom: none;
+	border-radius: 8px 8px 0 0;
+
+	> .title {
+		line-height: 26px;
+	}
+
+	> .buttons {
+		display: flex;
+		gap: 8px;
+	}
+}
+
+.blockContainerActionButton {
+	display: block;
+	width: 26px;
+	height: 26px;
+	line-height: 26px;
+	text-align: center;
+}
+
+.blockContainerBody {
 	position: relative;
 	overflow: hidden;
 	background: var(--MI_THEME-panel);
@@ -67,62 +135,45 @@ function remove() {
 	&:hover {
 		border: solid 2px var(--MI_THEME-X13);
 	}
+}
 
-	&.warn {
-		border: solid 2px #dec44c;
+.blockContainerRoot.dragging {
+	&::after {
+		content: '';
+		position: absolute;
+		top: 0;
+		left: 0;
+		right: 0;
+		bottom: 0;
+		background: var(--MI_THEME-bg);
+		z-index: 1;
+		border-radius: 8px 0 8px 8px;
 	}
 
-	&.error {
-		border: solid 2px #f00;
+	&.draggingOver::after {
+		outline: dashed 2px var(--MI_THEME-accent);
+		outline-offset: -2px;
 	}
 
-	> header {
-		> .title {
-			z-index: 1;
-			margin: 0;
-			padding: 0 16px;
-			line-height: 42px;
-			font-size: 0.9em;
-			font-weight: bold;
-			box-shadow: 0 1px rgba(#000, 0.07);
+	.blockContainerHeader {
+		display: flex;
+	}
 
-			> i {
-				margin-right: 6px;
-			}
+	.blockContainerBody {
+		border: solid 2px var(--MI_THEME-accent);
+		border-top-right-radius: 0;
+	}
+}
 
-			&:empty {
-				display: none;
-			}
+@container (min-width: 700px) {
+	.blockContainerRoot:focus-within {
+		.blockContainerHeader {
+			display: flex;
 		}
 
-		> .buttons {
-			position: absolute;
-			z-index: 2;
-			top: 0;
-			right: 0;
-
-			> button {
-				padding: 0;
-				width: 42px;
-				font-size: 0.9em;
-				line-height: 42px;
-			}
-
-			.drag-handle {
-				cursor: move;
-			}
-		}
-	}
-
-	> .body {
-		::v-deep(.juejbjww), ::v-deep(.eiipwacr) {
-			&:not(.inline):first-child {
-				margin-top: 28px;
-			}
-
-			&:not(.inline):last-child {
-				margin-bottom: 20px;
-			}
+		.blockContainerBody {
+			border: solid 2px var(--MI_THEME-accent);
+			border-top-right-radius: 0;
 		}
 	}
 }
diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue
index e8059dbecd..435221a2a3 100644
--- a/packages/frontend/src/pages/page-editor/page-editor.vue
+++ b/packages/frontend/src/pages/page-editor/page-editor.vue
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<div v-if="fetchStatus === 'loading'">
 			<MkLoading/>
 		</div>
-		<div v-else-if="fetchStatus === 'done' && page != null" :class="$style.pageMain">
+		<div v-else-if="fetchStatus === 'done' && page != null" class="_gaps" :class="$style.pageMain">
 			<div :class="$style.pageBanner">
 				<div v-if="page?.eyeCatchingImageId" :class="$style.pageBannerImage">
 					<MkMediaImage
@@ -22,22 +22,32 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</div>
 			</div>
 			<div :class="$style.pageBannerTitle" class="_gaps_s">
-				<h1></h1>
+				<input v-model="title" :class="$style.titleForm" :placeholder="i18n.ts._pages.inputTitleHere"/>
 				<div :class="$style.pageBannerTitleSub">
 					<div v-if="page?.user" :class="$style.pageBannerTitleUser">
-						<MkAvatar :user="page.user" :class="$style.avatar" indicator link preview/> <MkA :to="`/@${username}`"><MkUserName :user="page.user" :nowrap="false"/></MkA>
+						<MkAvatar :user="page.user" :class="$style.avatar" indicator/> <MkUserName :user="page.user" :nowrap="false"/>
 					</div>
 					<div :class="$style.pageBannerTitleSubActions">
-						<MkA v-if="page?.userId === $i?.id" v-tooltip="i18n.ts._pages.editThisPage" :to="`/pages/edit/${page.id}`" class="_button" :class="$style.generalActionButton"><i class="ti ti-pencil ti-fw"></i></MkA>
-						<button v-tooltip="i18n.ts.share" class="_button" :class="$style.generalActionButton" @click="share"><i class="ti ti-share ti-fw"></i></button>
 					</div>
 				</div>
 			</div>
+			<div :class="$style.pageContent">
+				<XBlocks v-model="content"/>
+			</div>
 		</div>
 		<div v-else-if="fetchStatus === 'notMe'" class="_fullInfo">
 			This page is not yours
 		</div>
 	</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>
+		</div>
+	</template>
 </MkStickyContainer>
 </template>
 
@@ -69,6 +79,18 @@ const $i = signinRequired();
 
 const fetchStatus = ref<'loading' | 'done' | 'notMe'>('loading');
 const page = ref<Partial<Misskey.entities.Page> | null>(null);
+const title = computed({
+	get: () => page.value?.title ?? '',
+	set: (value) => {
+		if (page.value) {
+			page.value.title = value;
+		} else {
+			page.value = {
+				title: value,
+			};
+		}
+	},
+});
 const content = computed<Misskey.entities.Page['content']>({
 	get: () => page.value?.content ?? [],
 	set: (value) => {
@@ -82,6 +104,22 @@ const content = computed<Misskey.entities.Page['content']>({
 	},
 });
 
+function onTitleUpdated(ev: Event) {
+	title.value = (ev.target as HTMLDivElement).innerText;
+}
+
+async function save() {
+
+}
+
+async function show() {
+
+}
+
+async function del() {
+
+}
+
 async function init() {
 	if (props.initPageId) {
 		const _page = await misskeyApi('pages/show', {
@@ -91,6 +129,7 @@ async function init() {
 			fetchStatus.value = 'notMe';
 			return;
 		}
+		page.value = _page;
 	}
 
 	if (page.value === null) {
@@ -127,7 +166,7 @@ definePageMetadata(() => ({
 
 .pageBanner {
 	width: calc(100% + 4rem);
-	margin: -2rem -2rem 1.5rem;
+	margin: -2rem -2rem 0.5rem;
 	border-radius: var(--MI-radius) var(--MI-radius) 0 0;
 	overflow: hidden;
 	position: relative;
@@ -149,11 +188,36 @@ definePageMetadata(() => ({
 .pageBannerTitle {
 	position: relative;
 
-	h1 {
+	.titleForm {
+		appearance: none;
+		-webkit-appearance: none;
+		box-sizing: border-box;
+		display: block;
+		padding: 6px 12px;
+		font: inherit;
 		font-size: 2rem;
 		font-weight: 700;
 		color: var(--MI_THEME-fg);
 		margin: 0;
+		border: none;
+		border-bottom: 2px solid var(--MI_THEME-divider);
+		transition: border-color 0.1s ease-out;
+		background-color: var(--MI_THEME-bg);
+		border-radius: var(--MI-radius) var(--MI-radius) 0 0;
+
+		&:hover {
+			border-color: var(--MI_THEME-inputBorderHover);
+		}
+
+		&:focus {
+			outline: none;
+			border-color: var(--MI_THEME-accent);
+		}
+
+		&:focus-visible {
+			outline: 2px solid var(--MI_THEME-focus);
+			outline-offset: -2px;
+		}
 	}
 
 	.pageBannerTitleSub {
@@ -181,4 +245,16 @@ definePageMetadata(() => ({
 		margin-left: auto;
 	}
 }
+
+.footer {
+	backdrop-filter: var(--MI-blur, blur(15px));
+	background: var(--MI_THEME-acrylicBg);
+	border-top: solid .5px var(--MI_THEME-divider);
+}
+
+.footerInner {
+	padding: 16px;
+	margin: 0 auto;
+	max-width: 800px;
+}
 </style>