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
Takagi 2024-08-26 10:55:13 +08:00 committed by GitHub
parent fe842e8d77
commit 21db06a507
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 679 additions and 316 deletions

View File

@ -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"
},

View File

@ -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,

View File

@ -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>

View File

@ -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

View File

@ -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),
},
},
],

View File

@ -1,2 +1,2 @@
export { default as ExtensionCodeBlock } from "./code-block";
export { default as lowlight } from "./lowlight";
export * from "./code-block";

View File

@ -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;

View File

@ -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 };

View File

@ -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";

View File

@ -83,6 +83,10 @@
display: initial;
}
}
.v-popper__arrow-container {
display: none;
}
}
.v-popper--theme-tooltip {

View File

@ -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:

View File

@ -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,
},

View File

@ -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,
};
}