mirror of https://github.com/halo-dev/halo
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
parent
8f83df415c
commit
7a84f55300
|
@ -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,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
}"
|
}"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
}"
|
}"
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
}"
|
}"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1205,6 +1205,7 @@ core:
|
||||||
placeholder: "输入 / 以选择输入类型"
|
placeholder: "输入 / 以选择输入类型"
|
||||||
toolbox:
|
toolbox:
|
||||||
attachment: 选择附件
|
attachment: 选择附件
|
||||||
|
show_hide_sidebar: 显示 / 隐藏侧边栏
|
||||||
global_search:
|
global_search:
|
||||||
placeholder: 输入关键词以搜索
|
placeholder: 输入关键词以搜索
|
||||||
no_results: 没有搜索结果
|
no_results: 没有搜索结果
|
||||||
|
|
|
@ -1205,6 +1205,7 @@ core:
|
||||||
placeholder: "輸入 / 以選擇輸入類型"
|
placeholder: "輸入 / 以選擇輸入類型"
|
||||||
toolbox:
|
toolbox:
|
||||||
attachment: 選擇附件
|
attachment: 選擇附件
|
||||||
|
show_hide_sidebar: 顯示 / 隱藏側邊欄
|
||||||
global_search:
|
global_search:
|
||||||
placeholder: 輸入關鍵字以搜尋
|
placeholder: 輸入關鍵字以搜尋
|
||||||
no_results: 沒有搜尋結果
|
no_results: 沒有搜尋結果
|
||||||
|
|
Loading…
Reference in New Issue