mirror of https://github.com/halo-dev/halo
refactor: editor code block to be extensible by plugins (#6428)
#### What type of PR is this? /kind improvement /area editor /area ui #### What this PR does / why we need it: 此 PR 重构了默认编辑器中代码块的相关代码,使编辑器能够被插件扩展。这样做的优点是当用户使用例如 [highlightjs](https://github.com/halo-sigs/plugin-highlightjs) 这类的高亮插件时,可以保证在 Console 端选择的语言及主题可以在主题端完美适配。 具体做了以下几处重构: 1. 在默认编辑器的代码块中,不再提供高亮样式,代码块高亮将完全交由插件来适配。(但可以在默认的代码块中自定义输入语言,主题端自行处理)。 2. 为了防止出现重复的插件功能,将会根据插件优先级及加载顺序(后加载优先级高于先加载),对同类型且同名的插件进行过滤,只保留一个。 3. 重构代码块 `Select` 组件,使得选择语言或主题更加方便。 4. 为代码块提供主题的扩展设置项。 建议在此 PR 合并之后,由 Halo 默认提供一个高亮插件作为预设插件,这样可以用于解决原有功能升级之后丢失的问题。 <img width="1067" alt="image" src="https://github.com/user-attachments/assets/f9e2c5eb-a48a-4d2c-9fee-442e9d16ef19"> #### How to test it? 测试是否会改变已有代码块的语言等。 测试使用第三方插件之后,是否具有高亮。 设置高亮语言后,保存并刷新,查看高亮语言是否存在。主题同理。 查看主题端是否能够正常渲染。 #### Does this PR introduce a user-facing change? ```release-note 重构默认编辑器代码块使其能够被插件扩展。 ```pull/6515/head
parent
fe842e8d77
commit
21db06a507
|
@ -47,7 +47,6 @@
|
|||
"@tiptap/extension-bullet-list": "^2.6.5",
|
||||
"@tiptap/extension-code": "^2.6.5",
|
||||
"@tiptap/extension-code-block": "^2.6.5",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.6.5",
|
||||
"@tiptap/extension-color": "^2.6.5",
|
||||
"@tiptap/extension-document": "^2.6.5",
|
||||
"@tiptap/extension-dropcursor": "^2.6.5",
|
||||
|
@ -80,9 +79,7 @@
|
|||
"@tiptap/vue-3": "^2.6.5",
|
||||
"floating-vue": "^5.2.2",
|
||||
"github-markdown-css": "^5.2.0",
|
||||
"highlight.js": "11.8.0",
|
||||
"linkifyjs": "^4.1.3",
|
||||
"lowlight": "^3.0.0",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
|
|
|
@ -45,7 +45,6 @@ import {
|
|||
ExtensionUnderline,
|
||||
ExtensionVideo,
|
||||
RichTextEditor,
|
||||
lowlight,
|
||||
useEditor,
|
||||
} from "../index";
|
||||
|
||||
|
@ -99,9 +98,7 @@ const editor = useEditor({
|
|||
ExtensionVideo,
|
||||
ExtensionAudio,
|
||||
ExtensionCommands,
|
||||
ExtensionCodeBlock.configure({
|
||||
lowlight,
|
||||
}),
|
||||
ExtensionCodeBlock,
|
||||
ExtensionIframe,
|
||||
ExtensionColor,
|
||||
ExtensionFontSize,
|
||||
|
|
|
@ -0,0 +1,215 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, defineProps, ref, watch } from "vue";
|
||||
import IconArrowDownLine from "~icons/ri/arrow-down-s-line";
|
||||
import { Dropdown as VDropdown } from "floating-vue";
|
||||
|
||||
export interface Option {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
const props = defineProps<{
|
||||
container?: any;
|
||||
containerClass?: string;
|
||||
options: Option[];
|
||||
filterSort?: (options: Option[], query: string) => number;
|
||||
}>();
|
||||
|
||||
const value = defineModel({
|
||||
default: "",
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "select"): void;
|
||||
}>();
|
||||
|
||||
const isFocus = ref(false);
|
||||
const inputValue = ref<string>("");
|
||||
const selectedOption = ref<Option | null>(null);
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const displayLabel = computed(() => {
|
||||
if (selectedOption.value) {
|
||||
return selectedOption.value.label;
|
||||
}
|
||||
return value.value;
|
||||
});
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
if (!inputValue.value) {
|
||||
return props.options;
|
||||
}
|
||||
return props.options.filter((option) =>
|
||||
option.value
|
||||
.toLocaleLowerCase()
|
||||
.includes(inputValue.value.toLocaleLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const handleInputFocus = () => {
|
||||
isFocus.value = true;
|
||||
setTimeout(() => {
|
||||
handleScrollIntoView();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
isFocus.value = false;
|
||||
if (inputValue.value) {
|
||||
value.value = inputValue.value;
|
||||
inputValue.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectOption = (option: Option) => {
|
||||
selectedOption.value = option;
|
||||
value.value = option.value;
|
||||
inputValue.value = "";
|
||||
inputRef.value?.blur();
|
||||
emit("select");
|
||||
};
|
||||
|
||||
const selectedIndex = ref(-1);
|
||||
|
||||
const handleOptionKeydown = (event: KeyboardEvent) => {
|
||||
const key = event.key;
|
||||
if (key === "ArrowUp") {
|
||||
selectedIndex.value =
|
||||
(selectedIndex.value - 1 + filterOptions.value.length) %
|
||||
filterOptions.value.length;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === "ArrowDown") {
|
||||
selectedIndex.value =
|
||||
(selectedIndex.value + 1) % filterOptions.value.length;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === "Enter") {
|
||||
if (selectedIndex.value === -1) {
|
||||
return true;
|
||||
}
|
||||
handleSelectOption(filterOptions.value[selectedIndex.value]);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
value,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
selectedOption.value =
|
||||
props.options.find((option) => option.value === newValue) || null;
|
||||
selectedIndex.value = props.options.findIndex(
|
||||
(option) => option.value === newValue
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
selectedIndex,
|
||||
() => {
|
||||
setTimeout(() => {
|
||||
handleScrollIntoView();
|
||||
});
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
const handleScrollIntoView = () => {
|
||||
if (selectedIndex.value === -1) {
|
||||
return;
|
||||
}
|
||||
const optionElement = document.querySelector(
|
||||
`.select > div:nth-child(${selectedIndex.value + 1})`
|
||||
);
|
||||
if (optionElement) {
|
||||
optionElement.scrollIntoView({
|
||||
behavior: "instant",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<VDropdown
|
||||
:triggers="[]"
|
||||
:shown="isFocus"
|
||||
:auto-hide="false"
|
||||
:distance="0"
|
||||
auto-size
|
||||
:container="container || 'body'"
|
||||
>
|
||||
<div class="relative inline-block w-full" @keydown="handleOptionKeydown">
|
||||
<div class="h-8">
|
||||
<div
|
||||
class="select-input w-full h-full grid items-center text-sm rounded-md px-3 cursor-pointer box-border"
|
||||
:class="{
|
||||
'bg-white': isFocus,
|
||||
'border-[1px]': isFocus,
|
||||
}"
|
||||
>
|
||||
<span class="absolute top-0 bottom-0">
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
class="appearance-none bg-transparent h-full ps-0 pe-0 border-none outline-none m-0 p-0 cursor-auto"
|
||||
:placeholder="isFocus ? displayLabel : ''"
|
||||
@focus="handleInputFocus"
|
||||
@blur="handleInputBlur"
|
||||
/>
|
||||
</span>
|
||||
<span v-show="!isFocus" class="text-ellipsis text-sm">
|
||||
{{ displayLabel }}
|
||||
</span>
|
||||
<span class="justify-self-end" @click="inputRef?.focus()">
|
||||
<IconArrowDownLine />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #popper>
|
||||
<div class="bg-white">
|
||||
<div class="select max-h-64 cursor-pointer p-1">
|
||||
<template v-if="filterOptions && filterOptions.length > 0">
|
||||
<div
|
||||
v-for="(option, index) in filterOptions"
|
||||
:key="option.value"
|
||||
:index="index"
|
||||
class="w-full h-8 flex items-center rounded-md text-base px-3 py-1 hover:bg-zinc-100"
|
||||
:class="{
|
||||
'bg-zinc-200': option.value === value,
|
||||
'bg-zinc-100': selectedIndex === index,
|
||||
}"
|
||||
@mousedown="handleSelectOption(option)"
|
||||
>
|
||||
<span class="flex-1 text-ellipsis text-sm">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
class="w-full h-8 flex items-center rounded-md text-base px-3 py-1"
|
||||
>
|
||||
<span class="flex-1 text-ellipsis text-sm">No options</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.select-input {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
</style>
|
|
@ -1,40 +1,80 @@
|
|||
<script lang="ts" setup>
|
||||
import { i18n } from "@/locales";
|
||||
import type { Decoration, Node as ProseMirrorNode } from "@/tiptap/pm";
|
||||
import type { Editor, Node } from "@/tiptap/vue-3";
|
||||
import { NodeViewContent, NodeViewWrapper } from "@/tiptap/vue-3";
|
||||
import {
|
||||
NodeViewContent,
|
||||
NodeViewWrapper,
|
||||
type NodeViewProps,
|
||||
} from "@/tiptap/vue-3";
|
||||
import { useTimeout } from "@vueuse/core";
|
||||
import { computed } from "vue";
|
||||
import BxBxsCopy from "~icons/bx/bxs-copy";
|
||||
import RiArrowDownSFill from "~icons/ri/arrow-down-s-fill";
|
||||
import RiArrowRightSFill from "~icons/ri/arrow-right-s-fill";
|
||||
import IconCheckboxCircle from "~icons/ri/checkbox-circle-line";
|
||||
import lowlight from "./lowlight";
|
||||
import CodeBlockSelect from "./CodeBlockSelect.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
editor: Editor;
|
||||
node: ProseMirrorNode;
|
||||
decorations: Decoration[];
|
||||
selected: boolean;
|
||||
extension: Node<any, any>;
|
||||
getPos: () => number;
|
||||
updateAttributes: (attributes: Record<string, any>) => void;
|
||||
deleteNode: () => void;
|
||||
}>();
|
||||
const props = defineProps<NodeViewProps>();
|
||||
|
||||
const languages = computed(() => {
|
||||
return lowlight.listLanguages();
|
||||
const languageOptions = computed(() => {
|
||||
let languages: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}> = [];
|
||||
const lang = props.extension.options.languages;
|
||||
if (typeof lang === "function") {
|
||||
languages = lang(props.editor.state);
|
||||
} else {
|
||||
languages = lang;
|
||||
}
|
||||
languages = languages || [];
|
||||
const languageValues = languages.map((language) => language.value);
|
||||
if (languageValues.indexOf("auto") === -1) {
|
||||
languages.unshift({
|
||||
label: "Auto",
|
||||
value: "auto",
|
||||
});
|
||||
}
|
||||
return languages;
|
||||
});
|
||||
|
||||
const selectedLanguage = computed({
|
||||
get: () => {
|
||||
return props.node?.attrs.language;
|
||||
return props.node?.attrs.language || "auto";
|
||||
},
|
||||
set: (language: string) => {
|
||||
props.updateAttributes({ language: language });
|
||||
},
|
||||
});
|
||||
|
||||
const themeOptions = computed(() => {
|
||||
let themes:
|
||||
| Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>
|
||||
| undefined = [];
|
||||
const theme = props.extension.options.themes;
|
||||
if (typeof theme === "function") {
|
||||
themes = theme(props.editor.state);
|
||||
} else {
|
||||
themes = theme;
|
||||
}
|
||||
|
||||
if (!themes) {
|
||||
return undefined;
|
||||
}
|
||||
return themes;
|
||||
});
|
||||
|
||||
const selectedTheme = computed({
|
||||
get: () => {
|
||||
return props.node?.attrs.theme || themeOptions.value?.[0].value;
|
||||
},
|
||||
set: (theme: string) => {
|
||||
props.updateAttributes({ theme: theme });
|
||||
},
|
||||
});
|
||||
|
||||
const collapsed = computed<boolean>({
|
||||
get: () => {
|
||||
return props.node.attrs.collapsed || false;
|
||||
|
@ -76,19 +116,23 @@ const handleCopyCode = () => {
|
|||
<RiArrowDownSFill v-else />
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
<CodeBlockSelect
|
||||
v-model="selectedLanguage"
|
||||
class="block !leading-8 text-sm text-gray-900 border select-none border-transparent rounded-md bg-transparent focus:ring-blue-500 focus:border-blue-500 cursor-pointer hover:bg-zinc-200"
|
||||
class="w-48"
|
||||
:container="editor.options.element"
|
||||
:options="languageOptions"
|
||||
@select="editor.commands.focus()"
|
||||
>
|
||||
<option :value="null">auto</option>
|
||||
<option
|
||||
v-for="(language, index) in languages"
|
||||
:key="index"
|
||||
:value="language"
|
||||
>
|
||||
{{ language }}
|
||||
</option>
|
||||
</select>
|
||||
</CodeBlockSelect>
|
||||
<CodeBlockSelect
|
||||
v-if="themeOptions && themeOptions.length > 0"
|
||||
v-model="selectedTheme"
|
||||
:container="editor.options.element"
|
||||
class="w-48"
|
||||
:options="themeOptions"
|
||||
@select="editor.commands.focus()"
|
||||
>
|
||||
</CodeBlockSelect>
|
||||
</div>
|
||||
<div class="pr-3 flex items-center">
|
||||
<div
|
||||
|
|
|
@ -19,17 +19,10 @@ import {
|
|||
type Range,
|
||||
} from "@/tiptap/vue-3";
|
||||
import { deleteNode } from "@/utils";
|
||||
import type { CodeBlockLowlightOptions } from "@tiptap/extension-code-block-lowlight";
|
||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import { markRaw } from "vue";
|
||||
import MdiCodeBracesBox from "~icons/mdi/code-braces-box";
|
||||
import CodeBlockViewRenderer from "./CodeBlockViewRenderer.vue";
|
||||
|
||||
export interface CustomCodeBlockLowlightOptions
|
||||
extends CodeBlockLowlightOptions {
|
||||
lowlight: any;
|
||||
defaultLanguage: string | null | undefined;
|
||||
}
|
||||
import TiptapCodeBlock from "@tiptap/extension-code-block";
|
||||
|
||||
declare module "@/tiptap" {
|
||||
interface Commands<ReturnType> {
|
||||
|
@ -97,13 +90,77 @@ const getRenderContainer = (node: HTMLElement) => {
|
|||
return container;
|
||||
};
|
||||
|
||||
export default CodeBlockLowlight.extend<
|
||||
CustomCodeBlockLowlightOptions & CodeBlockLowlightOptions
|
||||
>({
|
||||
export interface Option {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface CodeBlockOptions {
|
||||
/**
|
||||
* Define whether the node should be exited on triple enter.
|
||||
* @default true
|
||||
*/
|
||||
exitOnTripleEnter: boolean;
|
||||
/**
|
||||
* Define whether the node should be exited on arrow down if there is no node after it.
|
||||
* @default true
|
||||
*/
|
||||
exitOnArrowDown: boolean;
|
||||
/**
|
||||
* Custom HTML attributes that should be added to the rendered HTML tag.
|
||||
* @default {}
|
||||
* @example { class: 'foo' }
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>;
|
||||
|
||||
/**
|
||||
* The default language for code block
|
||||
* @default null
|
||||
*/
|
||||
defaultLanguage: string | null | undefined;
|
||||
|
||||
/**
|
||||
* The default theme for code block
|
||||
* @default null
|
||||
*/
|
||||
defaultTheme: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface ExtensionCodeBlockOptions extends CodeBlockOptions {
|
||||
/**
|
||||
* Used for language list
|
||||
*
|
||||
* @default []
|
||||
*/
|
||||
languages:
|
||||
| Array<Option>
|
||||
| ((state: EditorState) => Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>);
|
||||
|
||||
/**
|
||||
* Used for theme list
|
||||
*
|
||||
* @default []
|
||||
*/
|
||||
themes?:
|
||||
| Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>
|
||||
| ((state: EditorState) => Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>);
|
||||
}
|
||||
|
||||
export default TiptapCodeBlock.extend<ExtensionCodeBlockOptions>({
|
||||
allowGapCursor: true,
|
||||
// It needs to have a higher priority than range-selection,
|
||||
// otherwise the Mod-a shortcut key will be overridden.
|
||||
priority: 110,
|
||||
|
||||
fakeSelection: true,
|
||||
|
||||
addAttributes() {
|
||||
|
@ -121,6 +178,18 @@ export default CodeBlockLowlight.extend<
|
|||
return {};
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
default: this.options.defaultTheme,
|
||||
parseHTML: (element) => element.getAttribute("theme") || null,
|
||||
renderHTML: (attributes) => {
|
||||
if (attributes.theme) {
|
||||
return {
|
||||
theme: attributes.theme,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -197,7 +266,7 @@ export default CodeBlockLowlight.extend<
|
|||
if (this.editor.isActive("codeBlock")) {
|
||||
const { tr, selection } = this.editor.state;
|
||||
const codeBlack = findParentNode(
|
||||
(node) => node.type.name === CodeBlockLowlight.name
|
||||
(node) => node.type.name === TiptapCodeBlock.name
|
||||
)(selection);
|
||||
if (!codeBlack) {
|
||||
return false;
|
||||
|
@ -218,9 +287,14 @@ export default CodeBlockLowlight.extend<
|
|||
addNodeView() {
|
||||
return VueNodeViewRenderer(CodeBlockViewRenderer);
|
||||
},
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
languages: [],
|
||||
themes: [],
|
||||
defaultLanguage: null,
|
||||
defaultTheme: null,
|
||||
getToolbarItems({ editor }: { editor: Editor }) {
|
||||
return {
|
||||
priority: 160,
|
||||
|
@ -265,7 +339,7 @@ export default CodeBlockLowlight.extend<
|
|||
return {
|
||||
pluginKey: "codeBlockBubbleMenu",
|
||||
shouldShow: ({ state }: { state: EditorState }) => {
|
||||
return isActive(state, CodeBlockLowlight.name);
|
||||
return isActive(state, TiptapCodeBlock.name);
|
||||
},
|
||||
getRenderContainer: (node: HTMLElement) => {
|
||||
return getRenderContainer(node);
|
||||
|
@ -277,7 +351,7 @@ export default CodeBlockLowlight.extend<
|
|||
icon: markRaw(MdiDeleteForeverOutline),
|
||||
title: i18n.global.t("editor.common.button.delete"),
|
||||
action: ({ editor }: { editor: Editor }) =>
|
||||
deleteNode(CodeBlockLowlight.name, editor),
|
||||
deleteNode(TiptapCodeBlock.name, editor),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
export { default as ExtensionCodeBlock } from "./code-block";
|
||||
export { default as lowlight } from "./lowlight";
|
||||
export * from "./code-block";
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
import dart from "highlight.js/lib/languages/dart";
|
||||
import xml from "highlight.js/lib/languages/xml";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("html", xml);
|
||||
lowlight.register("dart", dart);
|
||||
export default lowlight;
|
|
@ -29,7 +29,10 @@ import ExtensionUnderline from "./underline";
|
|||
|
||||
// Custom extensions
|
||||
import ExtensionTextStyle from "@/extensions/text-style";
|
||||
import { ExtensionCodeBlock, lowlight } from "@/extensions/code-block";
|
||||
import {
|
||||
ExtensionCodeBlock,
|
||||
type ExtensionCodeBlockOptions,
|
||||
} from "@/extensions/code-block";
|
||||
import { ExtensionCommands } from "../extensions/commands-menu";
|
||||
import ExtensionAudio from "./audio";
|
||||
import ExtensionClearFormat from "./clear-format";
|
||||
|
@ -67,7 +70,6 @@ const allExtensions = [
|
|||
ExtensionOrderedList,
|
||||
ExtensionStrike,
|
||||
ExtensionText,
|
||||
ExtensionTextStyle,
|
||||
ExtensionImage,
|
||||
ExtensionTaskList,
|
||||
ExtensionHighlight,
|
||||
|
@ -92,9 +94,7 @@ const allExtensions = [
|
|||
ExtensionCommands.configure({
|
||||
suggestion: {},
|
||||
}),
|
||||
ExtensionCodeBlock.configure({
|
||||
lowlight,
|
||||
}),
|
||||
ExtensionCodeBlock,
|
||||
ExtensionIframe,
|
||||
ExtensionVideo,
|
||||
ExtensionAudio,
|
||||
|
@ -157,5 +157,6 @@ export {
|
|||
ExtensionVideo,
|
||||
RangeSelection,
|
||||
allExtensions,
|
||||
lowlight,
|
||||
};
|
||||
|
||||
export type { ExtensionCodeBlockOptions };
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import "floating-vue/dist/style.css";
|
||||
import "github-markdown-css/github-markdown-light.css";
|
||||
import "highlight.js/styles/github.css";
|
||||
import type { App, Plugin } from "vue";
|
||||
import { RichTextEditor } from "./components";
|
||||
import "./styles/index.scss";
|
||||
|
|
|
@ -83,6 +83,10 @@
|
|||
display: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.v-popper__arrow-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.v-popper--theme-tooltip {
|
||||
|
|
|
@ -470,9 +470,6 @@ importers:
|
|||
'@tiptap/extension-code-block':
|
||||
specifier: ^2.6.5
|
||||
version: 2.6.5(@tiptap/core@2.6.5(@tiptap/pm@2.6.5))(@tiptap/pm@2.6.5)
|
||||
'@tiptap/extension-code-block-lowlight':
|
||||
specifier: ^2.6.5
|
||||
version: 2.6.5(@tiptap/core@2.6.5(@tiptap/pm@2.6.5))(@tiptap/extension-code-block@2.6.5(@tiptap/core@2.6.5(@tiptap/pm@2.6.5))(@tiptap/pm@2.6.5))(@tiptap/pm@2.6.5)(highlight.js@11.8.0)(lowlight@3.0.0)
|
||||
'@tiptap/extension-color':
|
||||
specifier: ^2.6.5
|
||||
version: 2.6.5(@tiptap/core@2.6.5(@tiptap/pm@2.6.5))(@tiptap/extension-text-style@2.6.5(@tiptap/core@2.6.5(@tiptap/pm@2.6.5)))
|
||||
|
@ -569,15 +566,9 @@ importers:
|
|||
github-markdown-css:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0
|
||||
highlight.js:
|
||||
specifier: 11.8.0
|
||||
version: 11.8.0
|
||||
linkifyjs:
|
||||
specifier: ^4.1.3
|
||||
version: 4.1.3
|
||||
lowlight:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
scroll-into-view-if-needed:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
|
@ -3603,15 +3594,6 @@ packages:
|
|||
'@tiptap/core': ^2.6.5
|
||||
'@tiptap/pm': ^2.6.5
|
||||
|
||||
'@tiptap/extension-code-block-lowlight@2.6.5':
|
||||
resolution: {integrity: sha512-zb8zQuOBhsVJ3UWTHh1xvPWnpNFA5etuD7rQdzLHXGtowz9DUOqxdMmVMnBcwXE5g5MKQQBJJwqqDw0jGJn/jw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.6.5
|
||||
'@tiptap/extension-code-block': ^2.6.5
|
||||
'@tiptap/pm': ^2.6.5
|
||||
highlight.js: ^11
|
||||
lowlight: ^2 || ^3
|
||||
|
||||
'@tiptap/extension-code-block@2.6.5':
|
||||
resolution: {integrity: sha512-kV8VAerd3z23zv6vZSVkq0JJs0emzWb3KyrLsiUuhR1Yj+zgcxer3zw4IJlmDeDhp6qIXK/qTgHzNcxS+fV4Rw==}
|
||||
peerDependencies:
|
||||
|
@ -3873,9 +3855,6 @@ packages:
|
|||
'@types/graceful-fs@4.1.9':
|
||||
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
|
||||
|
||||
'@types/hast@3.0.1':
|
||||
resolution: {integrity: sha512-hs/iBJx2aydugBQx5ETV3ZgeSS0oIreQrFJ4bjBl0XvM4wAmDjFEALY7p0rTSLt2eL+ibjRAAs9dTPiCLtmbqQ==}
|
||||
|
||||
'@types/http-cache-semantics@4.0.4':
|
||||
resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==}
|
||||
|
||||
|
@ -4011,9 +3990,6 @@ packages:
|
|||
'@types/unist@2.0.10':
|
||||
resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==}
|
||||
|
||||
'@types/unist@3.0.0':
|
||||
resolution: {integrity: sha512-MFETx3tbTjE7Uk6vvnWINA/1iJ7LuMdO4fcq8UfF0pRbj01aGLduVvQcRyswuACJdpnHgg8E3rQLhaRdNEJS0w==}
|
||||
|
||||
'@types/uuid@9.0.7':
|
||||
resolution: {integrity: sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==}
|
||||
|
||||
|
@ -5582,9 +5558,6 @@ packages:
|
|||
resolution: {integrity: sha512-aBzdj76lueB6uUst5iAs7+0H/oOjqI5D16XUWxlWMIMROhcM0rfsNVk93zTngq1dDNpoXRr++Sus7ETAExppAQ==}
|
||||
hasBin: true
|
||||
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
|
@ -6613,10 +6586,6 @@ packages:
|
|||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
|
||||
highlight.js@11.8.0:
|
||||
resolution: {integrity: sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
hookable@5.4.2:
|
||||
resolution: {integrity: sha512-6rOvaUiNKy9lET1X0ECnyZ5O5kSV0PJbtA5yZUgdEF7fGJEVwSLSislltyt7nFwVVALYHQJtfGeAR2Y0A0uJkg==}
|
||||
|
||||
|
@ -7488,9 +7457,6 @@ packages:
|
|||
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
lowlight@3.0.0:
|
||||
resolution: {integrity: sha512-kedX6yxvgak8P4LGh3vKRDQuMbVcnP+qRuDJlve2w+mNJAbEhEQPjYCp9QJnpVL5F2aAAVjeIzzrbQZUKHiDJw==}
|
||||
|
||||
lru-cache@10.1.0:
|
||||
resolution: {integrity: sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==}
|
||||
engines: {node: 14 || >=16.14}
|
||||
|
@ -14601,14 +14567,6 @@ snapshots:
|
|||
'@tiptap/core': 2.6.5(@tiptap/pm@2.6.5)
|
||||
'@tiptap/pm': 2.6.5
|
||||
|
||||
'@tiptap/extension-code-block-lowlight@2.6.5(@tiptap/core@2.6.5(@tiptap/pm@2.6.5))(@tiptap/extension-code-block@2.6.5(@tiptap/core@2.6.5(@tiptap/pm@2.6.5))(@tiptap/pm@2.6.5))(@tiptap/pm@2.6.5)(highlight.js@11.8.0)(lowlight@3.0.0)':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.6.5(@tiptap/pm@2.6.5)
|
||||
'@tiptap/extension-code-block': 2.6.5(@tiptap/core@2.6.5(@tiptap/pm@2.6.5))(@tiptap/pm@2.6.5)
|
||||
'@tiptap/pm': 2.6.5
|
||||
highlight.js: 11.8.0
|
||||
lowlight: 3.0.0
|
||||
|
||||
'@tiptap/extension-code-block@2.6.5(@tiptap/core@2.6.5(@tiptap/pm@2.6.5))(@tiptap/pm@2.6.5)':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.6.5(@tiptap/pm@2.6.5)
|
||||
|
@ -14871,10 +14829,6 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/node': 18.19.34
|
||||
|
||||
'@types/hast@3.0.1':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.0
|
||||
|
||||
'@types/http-cache-semantics@4.0.4': {}
|
||||
|
||||
'@types/http-errors@2.0.4': {}
|
||||
|
@ -14999,8 +14953,6 @@ snapshots:
|
|||
|
||||
'@types/unist@2.0.10': {}
|
||||
|
||||
'@types/unist@3.0.0': {}
|
||||
|
||||
'@types/uuid@9.0.7': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.20': {}
|
||||
|
@ -16838,10 +16790,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
devlop@1.1.0:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
didyoumean@1.2.2: {}
|
||||
|
||||
diff-sequences@29.4.3: {}
|
||||
|
@ -18144,8 +18092,6 @@ snapshots:
|
|||
|
||||
he@1.2.0: {}
|
||||
|
||||
highlight.js@11.8.0: {}
|
||||
|
||||
hookable@5.4.2: {}
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
@ -19070,12 +19016,6 @@ snapshots:
|
|||
|
||||
lowercase-keys@3.0.0: {}
|
||||
|
||||
lowlight@3.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.1
|
||||
devlop: 1.1.0
|
||||
highlight.js: 11.8.0
|
||||
|
||||
lru-cache@10.1.0: {}
|
||||
|
||||
lru-cache@4.1.5:
|
||||
|
|
|
@ -48,8 +48,7 @@ import {
|
|||
RichTextEditor,
|
||||
ToolbarItem,
|
||||
ToolboxItem,
|
||||
lowlight,
|
||||
type AnyExtension,
|
||||
type Extensions,
|
||||
} from "@halo-dev/richtext-editor";
|
||||
// ui custom extension
|
||||
import { i18n } from "@/locales";
|
||||
|
@ -98,6 +97,7 @@ import {
|
|||
UiExtensionVideo,
|
||||
} from "./extensions";
|
||||
import { getContents } from "./utils/attachment";
|
||||
import { useExtension } from "./composables/use-extension";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { currentUserHasPermission } = usePermission();
|
||||
|
@ -190,8 +190,196 @@ const handleCloseAttachmentSelectorModal = () => {
|
|||
attachmentOptions.value = initAttachmentOptions;
|
||||
};
|
||||
|
||||
const { filterDuplicateExtensions } = useExtension();
|
||||
|
||||
const presetExtensions = [
|
||||
ExtensionBlockquote,
|
||||
ExtensionBold,
|
||||
ExtensionBulletList,
|
||||
ExtensionCode,
|
||||
ExtensionDocument,
|
||||
ExtensionDropcursor.configure({
|
||||
width: 2,
|
||||
class: "dropcursor",
|
||||
color: "skyblue",
|
||||
}),
|
||||
ExtensionGapcursor,
|
||||
ExtensionHardBreak,
|
||||
ExtensionHeading,
|
||||
ExtensionHistory,
|
||||
ExtensionHorizontalRule,
|
||||
ExtensionItalic,
|
||||
ExtensionOrderedList,
|
||||
ExtensionStrike,
|
||||
ExtensionText,
|
||||
UiExtensionImage.configure({
|
||||
inline: true,
|
||||
allowBase64: false,
|
||||
HTMLAttributes: {
|
||||
loading: "lazy",
|
||||
},
|
||||
uploadImage: props.uploadImage,
|
||||
}),
|
||||
ExtensionTaskList,
|
||||
ExtensionLink.configure({
|
||||
autolink: false,
|
||||
openOnClick: false,
|
||||
}),
|
||||
ExtensionTextAlign.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
}),
|
||||
ExtensionUnderline,
|
||||
ExtensionTable.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
ExtensionSubscript,
|
||||
ExtensionSuperscript,
|
||||
ExtensionPlaceholder.configure({
|
||||
placeholder: t(
|
||||
"core.components.default_editor.extensions.placeholder.options.placeholder"
|
||||
),
|
||||
}),
|
||||
ExtensionHighlight,
|
||||
ExtensionCommands,
|
||||
ExtensionCodeBlock,
|
||||
ExtensionIframe,
|
||||
UiExtensionVideo.configure({
|
||||
uploadVideo: props.uploadImage,
|
||||
}),
|
||||
UiExtensionAudio.configure({
|
||||
uploadAudio: props.uploadImage,
|
||||
}),
|
||||
ExtensionCharacterCount,
|
||||
ExtensionFontSize,
|
||||
ExtensionColor,
|
||||
ExtensionIndent,
|
||||
Extension.create({
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: ["heading"],
|
||||
attributes: {
|
||||
id: {
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
Extension.create({
|
||||
addOptions() {
|
||||
// If user has no permission to view attachments, return
|
||||
if (!currentUserHasPermission(["system:attachments:view"])) {
|
||||
return this;
|
||||
}
|
||||
|
||||
return {
|
||||
getToolboxItems({ editor }: { editor: Editor }) {
|
||||
return [
|
||||
{
|
||||
priority: 0,
|
||||
component: markRaw(ToolboxItem),
|
||||
props: {
|
||||
editor,
|
||||
icon: markRaw(IconFolder),
|
||||
title: i18n.global.t(
|
||||
"core.components.default_editor.toolbox.attachment"
|
||||
),
|
||||
action: () => {
|
||||
editor.commands.openAttachmentSelector((attachment) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent(getContents(attachment))
|
||||
.run();
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
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;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
openAttachmentSelector: (callback, options) => () => {
|
||||
if (options) {
|
||||
attachmentOptions.value = options;
|
||||
}
|
||||
attachmentSelectorModal.value = true;
|
||||
attachmentResult.updateAttachment = (
|
||||
attachments: AttachmentLike[]
|
||||
) => {
|
||||
callback(attachments);
|
||||
};
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
ExtensionDraggable,
|
||||
ExtensionColumns,
|
||||
ExtensionColumn,
|
||||
ExtensionNodeSelected,
|
||||
ExtensionTrailingNode,
|
||||
Extension.create({
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("get-heading-id"),
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
const headings: HeadingNode[] = [];
|
||||
const { doc } = state;
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === ExtensionHeading.name) {
|
||||
headings.push({
|
||||
level: node.attrs.level,
|
||||
text: node.textContent,
|
||||
id: node.attrs.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
headingNodes.value = headings;
|
||||
if (!selectedHeadingNode.value) {
|
||||
selectedHeadingNode.value = headings[0];
|
||||
}
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
}),
|
||||
ExtensionListKeymap,
|
||||
UiExtensionUpload,
|
||||
ExtensionSearchAndReplace,
|
||||
ExtensionClearFormat,
|
||||
ExtensionFormatBrush,
|
||||
ExtensionRangeSelection,
|
||||
];
|
||||
|
||||
onMounted(async () => {
|
||||
const extensionsFromPlugins: AnyExtension[] = [];
|
||||
const extensionsFromPlugins: Extensions = [];
|
||||
|
||||
for (const pluginModule of pluginModules) {
|
||||
const callbackFunction =
|
||||
|
@ -214,196 +402,14 @@ onMounted(async () => {
|
|||
emit("update", html);
|
||||
}, 250);
|
||||
|
||||
const extensions = filterDuplicateExtensions([
|
||||
...presetExtensions,
|
||||
...extensionsFromPlugins,
|
||||
]);
|
||||
|
||||
editor.value = new Editor({
|
||||
content: props.raw,
|
||||
extensions: [
|
||||
ExtensionBlockquote,
|
||||
ExtensionBold,
|
||||
ExtensionBulletList,
|
||||
ExtensionCode,
|
||||
ExtensionDocument,
|
||||
ExtensionDropcursor.configure({
|
||||
width: 2,
|
||||
class: "dropcursor",
|
||||
color: "skyblue",
|
||||
}),
|
||||
ExtensionGapcursor,
|
||||
ExtensionHardBreak,
|
||||
ExtensionHeading,
|
||||
ExtensionHistory,
|
||||
ExtensionHorizontalRule,
|
||||
ExtensionItalic,
|
||||
ExtensionOrderedList,
|
||||
ExtensionStrike,
|
||||
ExtensionText,
|
||||
UiExtensionImage.configure({
|
||||
inline: true,
|
||||
allowBase64: false,
|
||||
HTMLAttributes: {
|
||||
loading: "lazy",
|
||||
},
|
||||
uploadImage: props.uploadImage,
|
||||
}),
|
||||
ExtensionTaskList,
|
||||
ExtensionLink.configure({
|
||||
autolink: false,
|
||||
openOnClick: false,
|
||||
}),
|
||||
ExtensionTextAlign.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
}),
|
||||
ExtensionUnderline,
|
||||
ExtensionTable.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
ExtensionSubscript,
|
||||
ExtensionSuperscript,
|
||||
ExtensionPlaceholder.configure({
|
||||
placeholder: t(
|
||||
"core.components.default_editor.extensions.placeholder.options.placeholder"
|
||||
),
|
||||
}),
|
||||
ExtensionHighlight,
|
||||
ExtensionCommands,
|
||||
ExtensionCodeBlock.configure({
|
||||
lowlight,
|
||||
}),
|
||||
ExtensionIframe,
|
||||
UiExtensionVideo.configure({
|
||||
uploadVideo: props.uploadImage,
|
||||
}),
|
||||
UiExtensionAudio.configure({
|
||||
uploadAudio: props.uploadImage,
|
||||
}),
|
||||
ExtensionCharacterCount,
|
||||
ExtensionFontSize,
|
||||
ExtensionColor,
|
||||
ExtensionIndent,
|
||||
...extensionsFromPlugins,
|
||||
Extension.create({
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: ["heading"],
|
||||
attributes: {
|
||||
id: {
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
Extension.create({
|
||||
addOptions() {
|
||||
// If user has no permission to view attachments, return
|
||||
if (!currentUserHasPermission(["system:attachments:view"])) {
|
||||
return this;
|
||||
}
|
||||
|
||||
return {
|
||||
getToolboxItems({ editor }: { editor: Editor }) {
|
||||
return [
|
||||
{
|
||||
priority: 0,
|
||||
component: markRaw(ToolboxItem),
|
||||
props: {
|
||||
editor,
|
||||
icon: markRaw(IconFolder),
|
||||
title: i18n.global.t(
|
||||
"core.components.default_editor.toolbox.attachment"
|
||||
),
|
||||
action: () => {
|
||||
editor.commands.openAttachmentSelector((attachment) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent(getContents(attachment))
|
||||
.run();
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
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;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
openAttachmentSelector: (callback, options) => () => {
|
||||
if (options) {
|
||||
attachmentOptions.value = options;
|
||||
}
|
||||
attachmentSelectorModal.value = true;
|
||||
attachmentResult.updateAttachment = (
|
||||
attachments: AttachmentLike[]
|
||||
) => {
|
||||
callback(attachments);
|
||||
};
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
ExtensionDraggable,
|
||||
ExtensionColumns,
|
||||
ExtensionColumn,
|
||||
ExtensionNodeSelected,
|
||||
ExtensionTrailingNode,
|
||||
Extension.create({
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("get-heading-id"),
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
const headings: HeadingNode[] = [];
|
||||
const { doc } = state;
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === ExtensionHeading.name) {
|
||||
headings.push({
|
||||
level: node.attrs.level,
|
||||
text: node.textContent,
|
||||
id: node.attrs.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
headingNodes.value = headings;
|
||||
if (!selectedHeadingNode.value) {
|
||||
selectedHeadingNode.value = headings[0];
|
||||
}
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
}),
|
||||
ExtensionListKeymap,
|
||||
UiExtensionUpload,
|
||||
ExtensionSearchAndReplace,
|
||||
ExtensionClearFormat,
|
||||
ExtensionFormatBrush,
|
||||
ExtensionRangeSelection,
|
||||
],
|
||||
extensions,
|
||||
parseOptions: {
|
||||
preserveWhitespace: true,
|
||||
},
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
import {
|
||||
getExtensionField,
|
||||
type AnyConfig,
|
||||
type AnyExtension,
|
||||
type Extensions,
|
||||
} from "@halo-dev/richtext-editor";
|
||||
|
||||
export function useExtension() {
|
||||
const filterDuplicateExtensions = (extensions: Extensions | undefined) => {
|
||||
if (!extensions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedExtensions = sort(flatten(extensions));
|
||||
|
||||
const map = new Map<string, AnyExtension>();
|
||||
|
||||
resolvedExtensions.forEach((extension) => {
|
||||
const key = `${extension.type}-${extension.name}`;
|
||||
if (map.has(key)) {
|
||||
console.warn(
|
||||
`Duplicate found for Extension, type: ${extension.type}, name: ${extension.name}. Keeping the later one.`
|
||||
);
|
||||
}
|
||||
map.set(key, extension);
|
||||
});
|
||||
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a flattened array of extensions by traversing the `addExtensions` field.
|
||||
* @param extensions An array of Tiptap extensions
|
||||
* @returns A flattened array of Tiptap extensions
|
||||
*/
|
||||
const flatten = (extensions: Extensions): Extensions => {
|
||||
return (
|
||||
extensions
|
||||
.map((extension) => {
|
||||
const context = {
|
||||
name: extension.name,
|
||||
options: extension.options,
|
||||
storage: extension.storage,
|
||||
};
|
||||
|
||||
const addExtensions = getExtensionField<AnyConfig["addExtensions"]>(
|
||||
extension,
|
||||
"addExtensions",
|
||||
context
|
||||
);
|
||||
|
||||
if (addExtensions) {
|
||||
return [extension, ...flatten(addExtensions())];
|
||||
}
|
||||
|
||||
return extension;
|
||||
})
|
||||
// `Infinity` will break TypeScript so we set a number that is probably high enough
|
||||
.flat(10)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sort extensions by priority.
|
||||
* @param extensions An array of Tiptap extensions
|
||||
* @returns A sorted array of Tiptap extensions by priority
|
||||
*/
|
||||
const sort = (extensions: Extensions): Extensions => {
|
||||
const defaultPriority = 100;
|
||||
|
||||
return extensions.sort((a, b) => {
|
||||
const priorityA =
|
||||
getExtensionField<AnyConfig["priority"]>(a, "priority") ||
|
||||
defaultPriority;
|
||||
const priorityB =
|
||||
getExtensionField<AnyConfig["priority"]>(b, "priority") ||
|
||||
defaultPriority;
|
||||
|
||||
if (priorityA > priorityB) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (priorityA < priorityB) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
filterDuplicateExtensions,
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue