feat: add supports for hide the sidebar of editor (#4942)

#### What type of PR is this?

/area console
/area editor
/kind feature
/milestone 2.11.0

#### What this PR does / why we need it:

支持隐藏/显示默认编辑器的侧边栏。

<img width="1451" alt="图片" src="https://github.com/halo-dev/halo/assets/21301288/6f641230-8f31-4f6b-83d2-e92dbc0303e8">
<img width="1363" alt="图片" src="https://github.com/halo-dev/halo/assets/21301288/921aa75e-ffd5-4785-9507-0b3b361efa31">

#### Which issue(s) this PR fixes:

Fixes #

#### Special notes for your reviewer:

测试截图中隐藏/显示侧边栏的按钮是否正常工作即可。

#### Does this PR introduce a user-facing change?

```release-note
支持隐藏/显示默认编辑器的侧边栏。
```
pull/4955/head v2.11.0-rc.2
Ryan Wang 2023-11-30 12:14:08 +08:00 committed by GitHub
parent 8f83df415c
commit 7a84f55300
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 80 additions and 97 deletions

View File

@ -5,9 +5,10 @@ import { VTooltip } from "floating-vue";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
tooltip?: string; tooltip?: string;
modelValue: string; modelValue?: string;
}>(), }>(),
{ {
modelValue: "",
tooltip: undefined, tooltip: undefined,
} }
); );

View File

@ -6,7 +6,7 @@ import { ref, type Component } from "vue";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
editor: Editor; editor: Editor;
isActive: ({ editor }: { editor: Editor }) => boolean; isActive?: ({ editor }: { editor: Editor }) => boolean;
visible?: ({ editor }: { editor: Editor }) => boolean; visible?: ({ editor }: { editor: Editor }) => boolean;
icon?: Component; icon?: Component;
iconStyle?: string; iconStyle?: string;

View File

@ -44,7 +44,7 @@ onMounted(() => {
<template> <template>
<node-view-wrapper as="div" class="inline-block w-full"> <node-view-wrapper as="div" class="inline-block w-full">
<div <div
class="inline-block overflow-hidden transition-all text-center relative h-full" class="inline-block overflow-hidden transition-all text-center relative h-full max-w-full"
:style="{ :style="{
width: node.attrs.width, width: node.attrs.width,
}" }"

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { i18n } from "@/locales"; import { i18n } from "@/locales";
import type { Editor } from "@/tiptap/vue-3"; import type { Editor } from "@/tiptap/vue-3";
import { computed, type Component, onUnmounted, ref, watch } from "vue"; import { computed, type Component } from "vue";
import Image from "./index"; import Image from "./index";
import { import {
BlockActionButton, BlockActionButton,
@ -12,30 +12,16 @@ import MdiBackupRestore from "~icons/mdi/backup-restore";
import MdiImageSizeSelectActual from "~icons/mdi/image-size-select-actual"; import MdiImageSizeSelectActual from "~icons/mdi/image-size-select-actual";
import MdiImageSizeSelectLarge from "~icons/mdi/image-size-select-large"; import MdiImageSizeSelectLarge from "~icons/mdi/image-size-select-large";
import MdiImageSizeSelectSmall from "~icons/mdi/image-size-select-small"; import MdiImageSizeSelectSmall from "~icons/mdi/image-size-select-small";
import { useResizeObserver } from "@vueuse/core";
const props = defineProps<{ const props = defineProps<{
editor: Editor; editor: Editor;
isActive: ({ editor }: { editor: Editor }) => boolean; isActive?: ({ editor }: { editor: Editor }) => boolean;
visible?: ({ editor }: { editor: Editor }) => boolean; visible?: ({ editor }: { editor: Editor }) => boolean;
icon?: Component; icon?: Component;
title?: string; title?: string;
action?: ({ editor }: { editor: Editor }) => void; action?: ({ editor }: { editor: Editor }) => void;
}>(); }>();
const nodeDom = computed(() => {
if (!props.editor.isActive(Image.name)) {
return;
}
const nodeDomParent = props.editor.view.nodeDOM(
props.editor.state.selection.from
) as HTMLElement;
if (nodeDomParent && nodeDomParent.hasChildNodes()) {
return nodeDomParent.childNodes[0] as HTMLElement;
}
return undefined;
});
const width = computed({ const width = computed({
get: () => { get: () => {
return props.editor.getAttributes(Image.name).width; return props.editor.getAttributes(Image.name).width;
@ -54,70 +40,13 @@ const height = computed({
}, },
}); });
let mounted = false;
const imgScale = ref<number>(0);
watch(nodeDom, () => {
resetResizeObserver();
});
const reuseResizeObserver = () => {
let init = true;
return useResizeObserver(
nodeDom.value,
(entries) => {
// Skip first call
if (!mounted) {
mounted = true;
return;
}
const entry = entries[0];
const { width: w, height: h } = entry.contentRect;
if (init) {
imgScale.value = parseFloat((h / w).toFixed(2));
init = false;
return;
}
const node = props.editor.view.nodeDOM(props.editor.state.selection.from);
if (!node) {
return;
}
props.editor
.chain()
.updateAttributes(Image.name, {
width: w + "px",
height: w * imgScale.value + "px",
})
.setNodeSelection(props.editor.state.selection.from)
.focus()
.run();
},
{ box: "border-box" }
);
};
let resizeObserver = reuseResizeObserver();
window.addEventListener("resize", resetResizeObserver);
onUnmounted(() => {
window.removeEventListener("resize", resetResizeObserver);
});
function resetResizeObserver() {
resizeObserver.stop();
resizeObserver = reuseResizeObserver();
}
function handleSetSize(width?: string, height?: string) { function handleSetSize(width?: string, height?: string) {
resizeObserver.stop();
props.editor props.editor
.chain() .chain()
.updateAttributes(Image.name, { width, height }) .updateAttributes(Image.name, { width, height })
.setNodeSelection(props.editor.state.selection.from) .setNodeSelection(props.editor.state.selection.from)
.focus() .focus()
.run(); .run();
resizeObserver = reuseResizeObserver();
} }
</script> </script>

View File

@ -4,7 +4,7 @@ import type { Editor, Node } from "@/tiptap/vue-3";
import { NodeViewWrapper } from "@/tiptap/vue-3"; import { NodeViewWrapper } from "@/tiptap/vue-3";
import type { Node as ProseMirrorNode, Decoration } from "@/tiptap/pm"; import type { Node as ProseMirrorNode, Decoration } from "@/tiptap/pm";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useResizeObserver } from "@vueuse/core"; import Image from "./index";
const props = defineProps<{ const props = defineProps<{
editor: Editor; editor: Editor;
@ -45,29 +45,55 @@ const href = computed({
props.updateAttributes({ href: href }); props.updateAttributes({ href: href });
}, },
}); });
function handleSetFocus() { function handleSetFocus() {
props.editor.commands.setNodeSelection(props.getPos()); props.editor.commands.setNodeSelection(props.getPos());
} }
const inputRef = ref(); const inputRef = ref();
const resizeRef = ref(); const resizeRef = ref<HTMLDivElement>();
const init = ref(true);
onMounted(() => { onMounted(() => {
if (!src.value) { if (!src.value) {
inputRef.value.focus(); inputRef.value.focus();
} else { return;
useResizeObserver(resizeRef.value, (entries) => { }
const entry = entries[0];
const { height } = entry.contentRect; if (!resizeRef.value) return;
if (height == 0) {
return; let startX: number, startWidth: number;
}
if (!props.selected && !init.value) { resizeRef.value.addEventListener("mousedown", function (e) {
handleSetFocus(); startX = e.clientX;
} startWidth = resizeRef.value?.clientWidth || 1;
init.value = false; document.documentElement.addEventListener("mousemove", doDrag, false);
}); document.documentElement.addEventListener("mouseup", stopDrag, false);
});
function doDrag(e: MouseEvent) {
if (!resizeRef.value) return;
const aspectRatio =
resizeRef.value.clientWidth / resizeRef.value.clientHeight;
const newWidth = Math.min(
startWidth + e.clientX - startX,
resizeRef.value.parentElement?.clientWidth || 0
);
const width = newWidth.toFixed(0) + "px";
const height = (newWidth / aspectRatio).toFixed(0) + "px";
props.editor
.chain()
.updateAttributes(Image.name, { width, height })
.setNodeSelection(props.editor.state.selection.from)
.focus()
.run();
}
function stopDrag() {
document.documentElement.removeEventListener("mousemove", doDrag, false);
document.documentElement.removeEventListener("mouseup", stopDrag, false);
} }
}); });
</script> </script>
@ -87,7 +113,7 @@ onMounted(() => {
<div <div
v-else v-else
ref="resizeRef" ref="resizeRef"
class="resize-x inline-block overflow-hidden text-center relative rounded-md" class="resize-x inline-block overflow-hidden text-center relative rounded-md max-w-full"
:class="{ :class="{
'ring-2 rounded': selected, 'ring-2 rounded': selected,
}" }"

View File

@ -131,6 +131,7 @@ const Image = TiptapImage.extend<ExtensionOptions & ImageOptions>({
shouldShow: ({ state }: { state: EditorState }): boolean => { shouldShow: ({ state }: { state: EditorState }): boolean => {
return isActive(state, Image.name); return isActive(state, Image.name);
}, },
defaultAnimation: false,
items: [ items: [
{ {
priority: 10, priority: 10,

View File

@ -6,7 +6,7 @@ import Video from "./index";
import { i18n } from "@/locales"; import { i18n } from "@/locales";
const props = defineProps<{ const props = defineProps<{
editor: Editor; editor: Editor;
isActive: ({ editor }: { editor: Editor }) => boolean; isActive?: ({ editor }: { editor: Editor }) => boolean;
visible?: ({ editor }: { editor: Editor }) => boolean; visible?: ({ editor }: { editor: Editor }) => boolean;
icon?: Component; icon?: Component;
title?: string; title?: string;

View File

@ -53,7 +53,7 @@ onMounted(() => {
<template> <template>
<node-view-wrapper as="div" class="inline-block w-full"> <node-view-wrapper as="div" class="inline-block w-full">
<div <div
class="inline-block overflow-hidden transition-all text-center relative h-full" class="inline-block overflow-hidden transition-all text-center relative h-full max-w-full"
:style="{ :style="{
width: node.attrs.width, width: node.attrs.width,
}" }"

View File

@ -44,6 +44,7 @@ import {
ExtensionColumn, ExtensionColumn,
ExtensionNodeSelected, ExtensionNodeSelected,
ExtensionTrailingNode, ExtensionTrailingNode,
ToolbarItem,
} from "@halo-dev/richtext-editor"; } from "@halo-dev/richtext-editor";
import { import {
IconCalendar, IconCalendar,
@ -62,6 +63,7 @@ import MdiFormatHeader3 from "~icons/mdi/format-header-3";
import MdiFormatHeader4 from "~icons/mdi/format-header-4"; import MdiFormatHeader4 from "~icons/mdi/format-header-4";
import MdiFormatHeader5 from "~icons/mdi/format-header-5"; import MdiFormatHeader5 from "~icons/mdi/format-header-5";
import MdiFormatHeader6 from "~icons/mdi/format-header-6"; import MdiFormatHeader6 from "~icons/mdi/format-header-6";
import RiLayoutRightLine from "~icons/ri/layout-right-line";
import { import {
inject, inject,
markRaw, markRaw,
@ -82,7 +84,7 @@ import { i18n } from "@/locales";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue"; import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import { usePluginModuleStore } from "@/stores/plugin"; import { usePluginModuleStore } from "@/stores/plugin";
import type { PluginModule } from "@halo-dev/console-shared"; import type { PluginModule } from "@halo-dev/console-shared";
import { useDebounceFn } from "@vueuse/core"; import { useDebounceFn, useLocalStorage } from "@vueuse/core";
import { onBeforeUnmount } from "vue"; import { onBeforeUnmount } from "vue";
import { generateAnchor } from "@/utils/anchor"; import { generateAnchor } from "@/utils/anchor";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
@ -137,6 +139,8 @@ const editor = shallowRef<Editor>();
const { pluginModules } = usePluginModuleStore(); const { pluginModules } = usePluginModuleStore();
const showSidebar = useLocalStorage("halo:editor:show-sidebar", true);
onMounted(() => { onMounted(() => {
const extensionsFromPlugins: AnyExtension[] = []; const extensionsFromPlugins: AnyExtension[] = [];
pluginModules.forEach((pluginModule: PluginModule) => { pluginModules.forEach((pluginModule: PluginModule) => {
@ -259,6 +263,23 @@ onMounted(() => {
}, },
]; ];
}, },
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 1000,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: showSidebar.value,
icon: markRaw(RiLayoutRightLine),
title: i18n.global.t(
"core.components.default_editor.toolbox.show_hide_sidebar"
),
action: () => {
showSidebar.value = !showSidebar.value;
},
},
};
},
}; };
}, },
}), }),
@ -463,7 +484,7 @@ const currentLocale = i18n.global.locale.value as
@select="onAttachmentSelect" @select="onAttachmentSelect"
/> />
<RichTextEditor v-if="editor" :editor="editor" :locale="currentLocale"> <RichTextEditor v-if="editor" :editor="editor" :locale="currentLocale">
<template #extra> <template v-if="showSidebar" #extra>
<OverlayScrollbarsComponent <OverlayScrollbarsComponent
element="div" element="div"
:options="{ scrollbars: { autoHide: 'scroll' } }" :options="{ scrollbars: { autoHide: 'scroll' } }"
@ -496,7 +517,9 @@ const currentLocale = i18n.global.locale.value as
:is="headingIcons[node.level]" :is="headingIcons[node.level]"
class="h-4 w-4 rounded-sm bg-gray-100 p-0.5 group-hover:bg-white" class="h-4 w-4 rounded-sm bg-gray-100 p-0.5 group-hover:bg-white"
:class="[ :class="[
{ '!bg-white': node.id === selectedHeadingNode?.id }, {
'!bg-white': node.id === selectedHeadingNode?.id,
},
]" ]"
/> />
<span class="flex-1 truncate">{{ node.text }}</span> <span class="flex-1 truncate">{{ node.text }}</span>

View File

@ -1205,6 +1205,7 @@ core:
placeholder: "Enter / to select input type." placeholder: "Enter / to select input type."
toolbox: toolbox:
attachment: Attachment attachment: Attachment
show_hide_sidebar: Show/Hide Sidebar
global_search: global_search:
placeholder: Enter keywords to search placeholder: Enter keywords to search
no_results: No search results no_results: No search results

View File

@ -1205,6 +1205,7 @@ core:
placeholder: "输入 / 以选择输入类型" placeholder: "输入 / 以选择输入类型"
toolbox: toolbox:
attachment: 选择附件 attachment: 选择附件
show_hide_sidebar: 显示 / 隐藏侧边栏
global_search: global_search:
placeholder: 输入关键词以搜索 placeholder: 输入关键词以搜索
no_results: 没有搜索结果 no_results: 没有搜索结果

View File

@ -1205,6 +1205,7 @@ core:
placeholder: "輸入 / 以選擇輸入類型" placeholder: "輸入 / 以選擇輸入類型"
toolbox: toolbox:
attachment: 選擇附件 attachment: 選擇附件
show_hide_sidebar: 顯示 / 隱藏側邊欄
global_search: global_search:
placeholder: 輸入關鍵字以搜尋 placeholder: 輸入關鍵字以搜尋
no_results: 沒有搜尋結果 no_results: 沒有搜尋結果