chore: move @halo-sigs/richtext-editor to this repository (#4934)

* chore: move @halo-sigs/richtext-editor to this repository

Signed-off-by: Ryan Wang <i@ryanc.cc>
pull/4937/head^2
Ryan Wang 2023-11-29 09:14:23 +08:00 committed by GitHub
parent ce5c1f9052
commit e054e9b8a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
121 changed files with 11948 additions and 184 deletions

View File

@ -59,7 +59,7 @@
"@halo-dev/api-client": "workspace:*",
"@halo-dev/components": "workspace:*",
"@halo-dev/console-shared": "workspace:*",
"@halo-dev/richtext-editor": "0.0.0-alpha.33",
"@halo-dev/richtext-editor": "workspace:*",
"@tanstack/vue-query": "^4.29.1",
"@tiptap/extension-character-count": "^2.0.4",
"@uppy/core": "^3.4.0",

View File

@ -47,7 +47,6 @@
"@histoire/plugin-vue": "^0.11.7",
"@iconify-json/ri": "^1.1.4",
"histoire": "^0.11.7",
"unplugin-icons": "^0.14.14",
"vite-plugin-dts": "^2.3.0"
},
"peerDependencies": {

View File

@ -0,0 +1,6 @@
module.exports = {
extends: ["../../.eslintrc.cjs"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
};

28
console/packages/editor/.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,8 @@
{
"git": {
"commitMessage": "chore: release v${version}"
},
"github": {
"release": true
}
}

View File

@ -0,0 +1,442 @@
# 扩展说明
本文档介绍如何对编辑器的功能进行扩展包括但不限于扩展工具栏、悬浮工具栏、Slash Command、拖拽功能等。各扩展区域参考下图
![编辑器扩展说明](extension.png)
目前支持的所有扩展类型 [ExtensionOptions](../packages/editor/src/types/index.ts) 如下所示:
```ts
export interface ExtensionOptions {
// 顶部工具栏扩展
getToolbarItems?: ({
editor,
}: {
editor: Editor;
}) => ToolbarItem | ToolbarItem[];
// Slash Command 扩展
getCommandMenuItems?: () => CommandMenuItem | CommandMenuItem[];
// 悬浮菜单扩展
getBubbleMenu?: ({ editor }: { editor: Editor }) => NodeBubbleMenu;
// 工具箱扩展
getToolboxItems?: ({
editor,
}: {
editor: Editor;
}) => ToolboxItem | ToolboxItem[];
// 拖拽扩展
getDraggable?: ({ editor }: { editor: Editor }) => DraggableItem | boolean;
}
```
> 对于 Tiptap 本身的扩展方式可以参考 <https://tiptap.dev/api/introduction>
## 1. 顶部工具栏扩展
编辑器顶部功能区域内容的扩展,通常用于增加用户常用操作,例如文本加粗、变更颜色等。
<https://github.com/halo-sigs/richtext-editor/pull/16> 中,我们实现了对顶部工具栏的扩展,如果需要添加额外的功能,只需要在具体的 Tiptap Extension 中的 `addOptions` 中定义 `getToolbarItems` 函数即可,如:
```ts
{
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return []
},
};
},
}
```
其中 `getToolbarItems` 即为对顶部工具栏的扩展。其返回类型为:
```ts
// 顶部工具栏扩展
getToolbarItems?: ({
editor,
}: {
editor: Editor;
}) => ToolbarItem | ToolbarItem[];
// 工具栏
export interface ToolbarItem {
priority: number;
component: Component;
props: {
editor: Editor;
isActive: boolean;
disabled?: boolean;
icon?: Component;
title?: string;
action?: () => void;
};
children?: ToolbarItem[];
}
```
如下为 [`Bold`](../packages/editor/src/extensions/bold/index.ts) 扩展中对于 `getToolbarItems` 的扩展示例:
```ts
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 40,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive("bold"),
icon: markRaw(MdiFormatBold),
title: i18n.global.t("editor.common.bold"),
action: () => editor.chain().focus().toggleBold().run(),
},
};
},
};
},
```
## 2. 工具箱扩展
编辑器工具箱区域的扩展,可用于增加编辑器附属操作,例如插入表格,插入第三方组件等功能。
<https://github.com/halo-sigs/richtext-editor/pull/27> 中,我们实现了对编辑器工具箱区域的扩展,如果需要添加额外的功能,只需要在具体的 Tiptap Extension 中的 `addOptions` 中定义 `getToolboxItems` 函数即可,如:
```ts
{
addOptions() {
return {
...this.parent?.(),
getToolboxItems({ editor }: { editor: Editor }) {
return []
},
};
},
}
```
其中 `getToolboxItems` 即为对工具箱的扩展。其返回类型为:
```ts
// 工具箱扩展
getToolboxItems?: ({
editor,
}: {
editor: Editor;
}) => ToolboxItem | ToolboxItem[];
export interface ToolboxItem {
priority: number;
component: Component;
props: {
editor: Editor;
icon?: Component;
title?: string;
description?: string;
action?: () => void;
};
}
```
如下为 [`Table`](../packages/editor/src/extensions/table/index.ts) 扩展中对于 `getToolboxItems` 工具箱的扩展示例:
```ts
addOptions() {
return {
...this.parent?.(),
getToolboxItems({ editor }: { editor: Editor }) {
return {
priority: 15,
component: markRaw(ToolboxItem),
props: {
editor,
icon: markRaw(MdiTablePlus),
title: i18n.global.t("editor.menus.table.add"),
action: () =>
editor
.chain()
.focus()
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run(),
},
};
},
}
}
```
## 3. Slash Command 扩展
Slash Command (斜杠命令)的扩展,可用于在当前行快捷执行功能操作,例如转换当前行为标题、在当前行添加代码块等功能。
<https://github.com/halo-sigs/richtext-editor/pull/16> 中,我们实现了对 Slash Command 指令的扩展,如果需要添加额外的功能,只需要在具体的 Tiptap Extension 中的 `addOptions` 中定义 `getCommandMenuItems` 函数即可,如:
```ts
{
addOptions() {
return {
...this.parent?.(),
getCommandMenuItems() {
return []
},
};
},
}
```
其中 `getCommandMenuItems` 即为对工具箱的扩展。其返回类型为:
```ts
// Slash Command 扩展
getCommandMenuItems?: () => CommandMenuItem | CommandMenuItem[];
export interface CommandMenuItem {
priority: number;
icon: Component;
title: string;
keywords: string[];
command: ({ editor, range }: { editor: Editor; range: Range }) => void;
}
```
如下为 [`Table`](../packages/editor/src/extensions/table/index.ts) 扩展中对于 `getCommandMenuItems` 的扩展示例:
```ts
addOptions() {
return {
...this.parent?.(),
getCommandMenuItems() {
return {
priority: 120,
icon: markRaw(MdiTable),
title: "editor.extensions.commands_menu.table",
keywords: ["table", "biaoge"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
},
};
},
}
}
```
## 4. 悬浮菜单扩展
编辑器悬浮菜单的扩展。可用于支持目标元素组件的功能扩展及操作简化。例如 `Table` 扩展中的添加下一列、添加上一列等操作。
<https://github.com/halo-sigs/richtext-editor/pull/38> 中,我们重构了对编辑器悬浮区域的扩展,如果需要对某个块进行支持,只需要在具体的 Tiptap Extension 中的 `addOptions` 中定义 `getBubbleMenu` 函数即可,如:
```ts
{
addOptions() {
return {
...this.parent?.(),
getBubbleMenu({ editor }: { editor: Editor }) {
return []
},
};
},
}
```
其中 `getBubbleMenu` 即为对悬浮菜单的扩展。其返回类型为:
```ts
// 悬浮菜单扩展
getBubbleMenu?: ({ editor }: { editor: Editor }) => NodeBubbleMenu;
interface BubbleMenuProps {
pluginKey?: string; // 悬浮菜单插件 Key建议命名方式 xxxBubbleMenu
editor?: Editor;
shouldShow: (props: { // 悬浮菜单显示的条件
editor: Editor;
node?: HTMLElement;
view?: EditorView;
state?: EditorState;
oldState?: EditorState;
from?: number;
to?: number;
}) => boolean;
tippyOptions?: Record<string, unknown>; // 可自由定制悬浮菜单所用的 tippy 组件的选项
getRenderContainer?: (node: HTMLElement) => HTMLElement; // 悬浮菜单所基准的 DOM
defaultAnimation?: boolean; // 是否启用默认动画。默认为 true
}
// 悬浮菜单
export interface NodeBubbleMenu extends BubbleMenuProps {
component?: Component; // 不使用默认的样式,与 items 二选一
items?: BubbleItem[]; // 悬浮菜单子项,使用默认的形式进行,与 items 二选一
}
// 悬浮菜单子项
export interface BubbleItem {
priority: number; // 优先级,数字越小优先级越大,越靠前
component?: Component; // 完全自定义子项样式
props: {
isActive: ({ editor }: { editor: Editor }) => boolean; // 当前功能是否已经处于活动状态
visible?: ({ editor }: { editor: Editor }) => boolean; // 是否显示当前子项
icon?: Component; // 图标
iconStyle?: string; // 图标自定义样式
title?: string; // 标题
action?: ({ editor }: { editor: Editor }) => Component | void; // 点击子项后的操作,如果返回 Component则会将其包含在下拉框中。
};
}
```
如下为 [`Table`](../packages/editor/src/extensions/table/index.ts) 扩展中对于 `getBubbleMenu` 悬浮菜单的部分扩展示例:
```ts
addOptions() {
return {
...this.parent?.(),
getBubbleMenu({ editor }) {
return {
pluginKey: "tableBubbleMenu",
shouldShow: ({ state }: { state: EditorState }): boolean => {
return isActive(state, Table.name);
},
getRenderContainer(node) {
let container = node;
if (container.nodeName === "#text") {
container = node.parentElement as HTMLElement;
}
while (
container &&
container.classList &&
!container.classList.contains("tableWrapper")
) {
container = container.parentElement as HTMLElement;
}
return container;
},
tippyOptions: {
offset: [26, 0],
},
items: [
{
priority: 10,
props: {
icon: markRaw(MdiTableColumnPlusBefore),
title: i18n.global.t("editor.menus.table.add_column_before"),
action: () => editor.chain().focus().addColumnBefore().run(),
},
},
]
}
}
}
}
```
## 5. 拖拽功能扩展
拖拽功能的扩展,可用于支持当前块元素的拖拽功能。
<https://github.com/halo-sigs/richtext-editor/pull/48> 中,我们实现了对所有元素的拖拽功能,如果需要让当前扩展支持拖拽,只需要在具体的 Tiptap Extension 中的 `addOptions` 中定义 `getDraggable` 函数,并让其返回 true 即可。如:
```ts
{
addOptions() {
return {
...this.parent?.(),
getDraggable() {
return true;
},
};
},
}
```
其中 `getDraggable` 即为为当前扩展增加可拖拽的功能。其返回类型为:
```ts
// 拖拽扩展
getDraggable?: ({ editor }: { editor: Editor }) => DraggableItem | boolean;
export interface DraggableItem {
getRenderContainer?: ({ // 拖拽按钮计算偏移位置的基准 DOM
dom,
view,
}: {
dom: HTMLElement;
view: EditorView;
}) => DragSelectionNode;
handleDrop?: ({ // 完成拖拽功能之后的处理。返回 true 则会阻止拖拽的发生
view,
event,
slice,
insertPos,
node,
}: {
view: EditorView;
event: DragEvent;
slice: Slice;
insertPos: number;
node: Node;
}) => boolean | void;
allowPropagationDownward?: boolean; // 是否允许拖拽事件向内部传播,
}
export interface DragSelectionNode {
$pos?: ResolvedPos;
node?: Node;
el: HTMLElement;
nodeOffset?: number;
dragDomOffset?: {
x: number;
y: number;
};
}
```
> 拖拽会从父 Node 节点开始触发,直到找到一个实现 `getDraggable` 的扩展,如果没有找到,则不会触发拖拽事件。父 Node 可以通过 `allowPropagationDownward` 来控制是否允许拖拽事件向内部传播。如果 `allowPropagationDownward` 设置为 true则会继续向内部寻找实现 `getDraggable` 的扩展,如果没有找到,则触发父 Node 的 `getDraggable` 实现,否则继续进行传播。
如下为 [`Iframe`](../packages/editor/src/extensions/iframe/index.ts) 扩展中对于 `getDraggable` 拖拽功能的扩展示例:
```ts
addOptions() {
return {
...this.parent?.(),
getDraggable() {
return {
getRenderContainer({ dom, view }) {
let container = dom;
while (
container.parentElement &&
container.parentElement.tagName !== "P"
) {
container = container.parentElement;
}
if (container) {
container = container.firstElementChild
?.firstElementChild as HTMLElement;
}
let node;
if (container.firstElementChild) {
const pos = view.posAtDOM(container.firstElementChild, 0);
const $pos = view.state.doc.resolve(pos);
node = $pos.node();
}
return {
node: node,
el: container as HTMLElement,
};
},
};
},
}
}
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

2
console/packages/editor/env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="unplugin-icons/types/vue" />

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/dev/main.ts"></script>
</body>
</html>

View File

@ -0,0 +1,96 @@
{
"name": "@halo-dev/richtext-editor",
"version": "0.0.0-alpha.33",
"description": "Default editor for Halo",
"homepage": "https://github.com/halo-dev/halo/tree/main/console/packages/editor#readme",
"bugs": {
"url": "https://github.com/halo-dev/halo/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/halo-dev/halo.git",
"directory": "console/packages/editor"
},
"license": "GPL-3.0",
"author": "@halo-dev",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/rich-text-editor.es.js"
},
"./dist/style.css": "./dist/style.css"
},
"main": "./dist/rich-text-editor.iife.js",
"jsdelivr": "./dist/rich-text-editor.iife.js",
"unpkg": "./dist/rich-text-editor.iife.js",
"module": "./dist/rich-text-editor.es.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "vite build --config ./vite.lib.config.ts",
"dev": "vite",
"lint": "eslint ./src --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
"prettier": "prettier --write './src/**/*.{vue,js,jsx,ts,tsx,css,scss,json,yml,yaml,html}'",
"release": "release-it",
"test:unit:coverage": "vitest run --environment jsdom --coverage",
"test:unit:ui": "vitest --environment jsdom --watch --ui",
"test:unit:watch": "vitest --environment jsdom --watch",
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json --composite false"
},
"dependencies": {
"@ckpack/vue-color": "^1.5.0",
"@tiptap/core": "^2.1.10",
"@tiptap/extension-blockquote": "^2.1.10",
"@tiptap/extension-bold": "^2.1.10",
"@tiptap/extension-bullet-list": "^2.1.10",
"@tiptap/extension-code": "^2.1.10",
"@tiptap/extension-code-block": "^2.1.10",
"@tiptap/extension-code-block-lowlight": "^2.1.10",
"@tiptap/extension-color": "^2.1.10",
"@tiptap/extension-document": "^2.1.10",
"@tiptap/extension-dropcursor": "^2.1.10",
"@tiptap/extension-gapcursor": "^2.1.10",
"@tiptap/extension-hard-break": "^2.1.10",
"@tiptap/extension-heading": "^2.1.10",
"@tiptap/extension-highlight": "^2.1.10",
"@tiptap/extension-history": "^2.1.10",
"@tiptap/extension-horizontal-rule": "^2.1.10",
"@tiptap/extension-image": "^2.1.10",
"@tiptap/extension-italic": "^2.1.10",
"@tiptap/extension-link": "^2.1.10",
"@tiptap/extension-list-item": "^2.1.10",
"@tiptap/extension-ordered-list": "^2.1.10",
"@tiptap/extension-paragraph": "^2.1.10",
"@tiptap/extension-placeholder": "^2.1.10",
"@tiptap/extension-strike": "^2.1.10",
"@tiptap/extension-subscript": "^2.1.10",
"@tiptap/extension-superscript": "^2.1.10",
"@tiptap/extension-table": "^2.1.10",
"@tiptap/extension-table-row": "^2.1.10",
"@tiptap/extension-task-item": "^2.1.10",
"@tiptap/extension-task-list": "^2.1.10",
"@tiptap/extension-text": "^2.1.10",
"@tiptap/extension-text-align": "^2.1.10",
"@tiptap/extension-text-style": "^2.1.10",
"@tiptap/extension-underline": "^2.1.10",
"@tiptap/pm": "^2.1.10",
"@tiptap/suggestion": "^2.1.10",
"@tiptap/vue-3": "^2.1.10",
"floating-vue": "2.0.0-beta.24",
"github-markdown-css": "^5.2.0",
"highlight.js": "11.8.0",
"lowlight": "^3.0.0",
"scroll-into-view-if-needed": "^3.1.0",
"tippy.js": "^6.3.7"
},
"devDependencies": {
"@iconify/json": "^2.2.117",
"release-it": "^16.1.5",
"vite-plugin-dts": "^3.5.3"
},
"peerDependencies": {
"vue": "^3.0.0"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,3 @@
module.exports = {
plugins: ["../../prettier.config.js"],
};

View File

@ -0,0 +1,55 @@
<script lang="ts" setup>
import { Editor, EditorContent } from "@/tiptap/vue-3";
import EditorHeader from "./EditorHeader.vue";
import EditorBubbleMenu from "./EditorBubbleMenu.vue";
import { watch, type CSSProperties, type PropType } from "vue";
import { i18n } from "@/locales";
const props = defineProps({
editor: {
type: Object as PropType<Editor>,
required: true,
},
contentStyles: {
type: Object as PropType<CSSProperties>,
required: false,
default: () => ({}),
},
locale: {
type: String as PropType<"zh-CN" | "en" | "zh" | "en-US">,
required: false,
default: "zh-CN",
},
});
watch(
() => props.locale,
() => {
i18n.global.locale.value = props.locale;
},
{
immediate: true,
}
);
</script>
<template>
<div v-if="editor" class="halo-rich-text-editor">
<editor-bubble-menu :editor="editor" />
<editor-header :editor="editor" />
<div class="h-full flex flex-row w-full">
<div class="overflow-y-auto overflow-x-hidden flex-1 relative bg-white">
<editor-content
:editor="editor"
:style="contentStyles"
class="editor-content markdown-body"
/>
</div>
<div
v-if="$slots.extra"
class="h-full hidden sm:!block w-72 flex-shrink-0"
>
<slot name="extra"></slot>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,102 @@
<script lang="ts" setup>
import type { PropType } from "vue";
import type { Editor, AnyExtension } from "@/tiptap/vue-3";
import BubbleMenu from "@/components/bubble/BubbleMenu.vue";
import type { NodeBubbleMenu } from "@/types";
import BubbleItem from "@/components/bubble/BubbleItem.vue";
import type { EditorView, EditorState } from "@/tiptap/pm";
const props = defineProps({
editor: {
type: Object as PropType<Editor>,
required: true,
},
});
const getBubbleMenuFromExtensions = () => {
const extensionManager = props.editor?.extensionManager;
return extensionManager.extensions
.map((extension: AnyExtension) => {
const { getBubbleMenu } = extension.options;
if (!getBubbleMenu) {
return null;
}
const nodeBubbleMenu = getBubbleMenu({
editor: props.editor,
}) as NodeBubbleMenu;
if (nodeBubbleMenu.items) {
nodeBubbleMenu.items = nodeBubbleMenu.items.sort(
(a, b) => a.priority - b.priority
);
}
return nodeBubbleMenu;
})
.filter(Boolean) as NodeBubbleMenu[];
};
const shouldShow = (
props: {
editor: Editor;
state: EditorState;
node?: HTMLElement;
view?: EditorView;
oldState?: EditorState;
from?: number;
to?: number;
},
bubbleMenu: NodeBubbleMenu
) => {
if (!props.editor.isEditable) {
return false;
}
return bubbleMenu.shouldShow?.(props);
};
</script>
<template>
<bubble-menu
v-for="(bubbleMenu, index) in getBubbleMenuFromExtensions()"
:key="index"
:plugin-key="bubbleMenu?.pluginKey"
:should-show="(prop) => shouldShow(prop, bubbleMenu)"
:editor="editor"
:tippy-options="{
maxWidth: '100%',
...bubbleMenu.tippyOptions,
}"
:get-render-container="bubbleMenu.getRenderContainer"
:default-animation="bubbleMenu.defaultAnimation"
>
<div
class="bubble-menu bg-white flex items-center rounded-md p-1 border drop-shadow space-x-0.5"
>
<template v-if="bubbleMenu.items">
<template
v-for="(item, itemIndex) in bubbleMenu.items"
:key="itemIndex"
>
<template v-if="item.component">
<component
:is="item.component"
v-bind="item.props"
:editor="editor"
/>
</template>
<bubble-item v-else :editor="editor" v-bind="item.props" />
</template>
</template>
<template v-else-if="bubbleMenu.component">
<component :is="bubbleMenu?.component" :editor="editor" />
</template>
</div>
</bubble-menu>
</template>
<style scoped>
.bubble-menu {
max-width: calc(100vw - 30px);
overflow-x: auto;
}
</style>

View File

@ -0,0 +1,111 @@
<script lang="ts" setup>
import { Menu as VMenu } from "floating-vue";
import { Editor, type AnyExtension } from "@/tiptap/vue-3";
import MdiPlusCircle from "~icons/mdi/plus-circle";
import type { ToolbarItem, ToolboxItem } from "@/types";
const props = defineProps({
editor: {
type: Editor,
required: true,
},
});
function getToolbarItemsFromExtensions() {
const extensionManager = props.editor?.extensionManager;
return extensionManager.extensions
.reduce((acc: ToolbarItem[], extension: AnyExtension) => {
const { getToolbarItems } = extension.options;
if (!getToolbarItems) {
return acc;
}
const items = getToolbarItems({
editor: props.editor,
});
if (Array.isArray(items)) {
return [...acc, ...items];
}
return [...acc, items];
}, [])
.sort((a, b) => a.priority - b.priority);
}
function getToolboxItemsFromExtensions() {
const extensionManager = props.editor?.extensionManager;
return extensionManager.extensions
.reduce((acc: ToolboxItem[], extension: AnyExtension) => {
const { getToolboxItems } = extension.options;
if (!getToolboxItems) {
return acc;
}
const items = getToolboxItems({
editor: props.editor,
});
if (Array.isArray(items)) {
return [...acc, ...items];
}
return [...acc, items];
}, [])
.sort((a, b) => a.priority - b.priority);
}
</script>
<template>
<div
class="editor-header flex items-center py-1 space-x-0.5 justify-start px-1 overflow-auto sm:!justify-center border-b drop-shadow-sm bg-white"
>
<div class="inline-flex items-center justify-center">
<VMenu>
<button class="p-1 rounded-sm hover:bg-gray-100">
<MdiPlusCircle class="text-[#4CCBA0]" />
</button>
<template #popper>
<div
class="relative rounded-md bg-white overflow-hidden drop-shadow w-56 p-1 max-h-96 overflow-y-auto space-y-1.5"
>
<component
:is="toolboxItem.component"
v-for="(toolboxItem, index) in getToolboxItemsFromExtensions()"
v-bind="toolboxItem.props"
:key="index"
/>
</div>
</template>
</VMenu>
</div>
<div class="h-5 bg-gray-100 w-[1px] !mx-1"></div>
<div
v-for="(item, index) in getToolbarItemsFromExtensions()"
:key="index"
class="inline-flex items-center justify-center"
>
<component
:is="item.component"
v-if="!item.children?.length"
v-bind="item.props"
/>
<VMenu v-else class="inline-flex">
<component :is="item.component" v-bind="item.props" />
<template #popper>
<div
class="relative rounded-md bg-white overflow-hidden drop-shadow w-48 p-1 max-h-72 overflow-y-auto"
>
<component
v-bind="child.props"
:is="child.component"
v-for="(child, childIndex) in item.children"
:key="childIndex"
/>
</div>
</template>
</VMenu>
</div>
</div>
</template>

View File

@ -0,0 +1,36 @@
<script lang="ts" setup>
import { VTooltip } from "floating-vue";
withDefaults(
defineProps<{
tooltip?: string;
selected?: boolean;
}>(),
{
tooltip: undefined,
selected: false,
}
);
</script>
<template>
<div
v-tooltip="tooltip"
class="editor-block__actions-button"
:class="{
'editor-block__actions-button--selected': selected,
}"
>
<slot name="icon" />
</div>
</template>
<style lang="scss">
.editor-block__actions-button {
@apply p-1.5 bg-gray-50 rounded-md cursor-pointer hover:bg-gray-200;
&--selected {
@apply bg-gray-200;
}
}
</style>

View File

@ -0,0 +1,37 @@
<script lang="ts" setup>
import { computed } from "vue";
import { VTooltip } from "floating-vue";
const props = withDefaults(
defineProps<{
tooltip?: string;
modelValue: string;
}>(),
{
tooltip: undefined,
}
);
const emit = defineEmits<{
(emit: "update:modelValue", value: string): void;
}>();
const value = computed({
get: () => props.modelValue || "",
set: (value: string) => emit("update:modelValue", value),
});
</script>
<template>
<input
v-model.lazy.trim="value"
v-tooltip="tooltip"
class="editor-block__actions-input"
/>
</template>
<style lang="scss">
.editor-block__actions-input {
@apply bg-gray-50 rounded-md hover:bg-gray-100 block px-2 w-32 py-1 text-sm text-gray-900 border border-gray-300 focus:ring-blue-500 focus:border-blue-500;
}
</style>

View File

@ -0,0 +1,10 @@
<template>
<div class="editor-block__actions-separator"></div>
</template>
<style>
.editor-block__actions-separator {
@apply h-5 bg-slate-200 mx-1.5;
width: 1px;
}
</style>

View File

@ -0,0 +1,100 @@
<script lang="ts" setup>
import type { Editor } from "@/tiptap/vue-3";
import MdiDeleteForeverOutline from "@/components/icon/MdiDeleteForeverOutline.vue";
import MdiArrowULeftBottom from "~icons/mdi/arrow-u-left-bottom";
import BlockActionSeparator from "./BlockActionSeparator.vue";
import BlockActionButton from "./BlockActionButton.vue";
import { i18n } from "@/locales";
const props = withDefaults(
defineProps<{
selected: boolean;
editor: Editor;
getPos: () => number;
deleteNode: () => void;
}>(),
{
selected: false,
}
);
function handleInsertNewLine() {
props.editor.commands.insertContentAt(
props.getPos() + 1,
[{ type: "paragraph", content: "" }],
{
updateSelection: true,
}
);
props.editor.commands.focus(props.getPos() + 2, {
scrollIntoView: true,
});
}
</script>
<template>
<section
class="editor-block group"
:class="{ 'editor-block--selected': selected }"
>
<div class="editor-block__content">
<slot name="content"></slot>
</div>
<div
class="invisible group-hover:visible pb-2 absolute -top-12 right-0"
:class="{ '!visible': selected }"
>
<div class="editor-block__actions">
<slot name="actions"></slot>
<!-- @vue-ignore -->
<BlockActionButton
:tooltip="i18n.global.t('editor.common.button.new_line')"
@click="handleInsertNewLine"
>
<template #icon>
<MdiArrowULeftBottom />
</template>
</BlockActionButton>
<BlockActionSeparator />
<BlockActionButton
:tooltip="i18n.global.t('editor.common.button.delete')"
@click="deleteNode"
>
<template #icon>
<MdiDeleteForeverOutline />
</template>
</BlockActionButton>
</div>
</div>
</section>
</template>
<style lang="scss">
.editor-block {
@apply relative my-9;
&__content {
@apply transition-all
rounded;
}
&__actions {
@apply p-1 flex flex-row rounded-lg border gap-0.5 items-center bg-gray-100 h-11 shadow-lg;
}
&:hover & {
&__content {
@apply bg-gray-50;
}
}
&--selected & {
&__content {
@apply bg-gray-50;
}
}
}
</style>

View File

@ -0,0 +1,4 @@
export { default as BlockActionButton } from "./BlockActionButton.vue";
export { default as BlockActionInput } from "./BlockActionInput.vue";
export { default as BlockActionSeparator } from "./BlockActionSeparator.vue";
export { default as BlockCard } from "./BlockCard.vue";

View File

@ -0,0 +1,86 @@
<script lang="ts" setup>
import { VTooltip, Dropdown as VDropdown } from "floating-vue";
import type { Editor } from "@/tiptap/vue-3";
import { ref, type Component } from "vue";
const props = withDefaults(
defineProps<{
editor: Editor;
isActive: ({ editor }: { editor: Editor }) => boolean;
visible?: ({ editor }: { editor: Editor }) => boolean;
icon?: Component;
iconStyle?: string;
title?: string;
action?: ({ editor }: { editor: Editor }) => Component | void;
}>(),
{
isActive: () => false,
visible: () => true,
title: undefined,
action: undefined,
icon: undefined,
iconStyle: undefined,
}
);
const componentRef = ref<Component | void>();
const handleBubbleItemClick = (editor: Editor) => {
if (!props.action) {
return;
}
const callback = props.action?.({ editor });
if (typeof callback === "object") {
if (componentRef.value) {
componentRef.value = undefined;
} else {
componentRef.value = callback;
}
}
};
</script>
<template>
<VDropdown
class="inline-flex"
:triggers="[]"
:auto-hide="true"
:shown="componentRef"
:distance="10"
>
<button
v-if="visible({ editor })"
v-tooltip="{
content: title,
distance: 8,
delay: {
show: 0,
},
}"
:class="{ 'bg-gray-200 !text-black': isActive({ editor }) }"
:title="title"
class="text-gray-600 text-lg hover:bg-gray-100 p-2 rounded-md"
@click="handleBubbleItemClick(editor)"
>
<component :is="icon" :style="iconStyle" class="w-5 h-5" />
</button>
<template #popper>
<div
class="relative rounded-md bg-white overflow-hidden drop-shadow w-96 p-1 max-h-72 overflow-y-auto"
>
<KeepAlive>
<component :is="componentRef" v-bind="props"></component>
</KeepAlive>
</div>
</template>
</VDropdown>
</template>
<style>
.v-popper__popper.v-popper__popper--show-from .v-popper__wrapper {
transform: scale(0.9);
}
.v-popper__popper.v-popper__popper--show-to .v-popper__wrapper {
transform: none;
transition: transform 0.1s;
}
</style>

View File

@ -0,0 +1,78 @@
<script setup lang="ts" name="BubbleMenu">
import { ref, type PropType, onMounted, onBeforeUnmount } from "vue";
import {
BubbleMenuPlugin,
type BubbleMenuPluginProps,
} from "./BubbleMenuPlugin";
const props = defineProps({
pluginKey: {
type: [String, Object] as PropType<BubbleMenuPluginProps["pluginKey"]>,
default: "bubbleMenu",
},
editor: {
type: Object as PropType<BubbleMenuPluginProps["editor"]>,
required: true,
},
tippyOptions: {
type: Object as PropType<BubbleMenuPluginProps["tippyOptions"]>,
default: () => ({}),
},
shouldShow: {
type: Function as PropType<
Exclude<Required<BubbleMenuPluginProps>["shouldShow"], null>
>,
default: null,
},
getRenderContainer: {
type: Function as PropType<
Exclude<Required<BubbleMenuPluginProps>["getRenderContainer"], null>
>,
default: null,
},
defaultAnimation: {
type: Boolean as PropType<BubbleMenuPluginProps["defaultAnimation"]>,
default: true,
},
});
const root = ref<HTMLElement | null>(null);
onMounted(() => {
const {
editor,
pluginKey,
shouldShow,
tippyOptions,
getRenderContainer,
defaultAnimation,
} = props;
editor.registerPlugin(
BubbleMenuPlugin({
editor,
element: root.value as HTMLElement,
pluginKey,
shouldShow,
tippyOptions,
getRenderContainer,
defaultAnimation,
})
);
});
onBeforeUnmount(() => {
const { pluginKey, editor } = props;
editor.unregisterPlugin(pluginKey);
});
</script>
<template>
<div ref="root">
<slot></slot>
</div>
</template>

View File

@ -0,0 +1,357 @@
import {
Editor,
isNodeSelection,
isTextSelection,
posToDOMRect,
} from "@/tiptap/vue-3";
import { EditorState, Plugin, PluginKey } from "@/tiptap/pm";
import type { EditorView } from "@/tiptap/pm";
import tippy, { type Instance, type Props, sticky } from "tippy.js";
export interface TippyOptionProps extends Props {
fixed?: boolean;
}
export interface BubbleMenuPluginProps {
pluginKey: PluginKey | string;
editor: Editor;
element: HTMLElement;
tippyOptions?: Partial<TippyOptionProps>;
updateDelay?: number;
shouldShow?:
| ((props: {
editor: Editor;
state: EditorState;
node?: HTMLElement;
view?: EditorView;
oldState?: EditorState;
from?: number;
to?: number;
}) => boolean)
| null;
getRenderContainer?: (node: HTMLElement) => HTMLElement;
defaultAnimation?: boolean;
}
export type BubbleMenuViewProps = BubbleMenuPluginProps & {
view: EditorView;
};
const ACTIVE_BUBBLE_MENUS: Instance[] = [];
export class BubbleMenuView {
public editor: Editor;
public element: HTMLElement;
public view: EditorView;
public preventHide = false;
public tippy: Instance<TippyOptionProps> | undefined;
public tippyOptions?: Partial<TippyOptionProps>;
public getRenderContainer?: BubbleMenuPluginProps["getRenderContainer"];
public defaultAnimation?: BubbleMenuPluginProps["defaultAnimation"];
public shouldShow: Exclude<BubbleMenuPluginProps["shouldShow"], null> = ({
view,
state,
from,
to,
}) => {
const { doc, selection } = state as EditorState;
const { empty } = selection;
// Sometime check for `empty` is not enough.
// Doubleclick an empty paragraph returns a node size of 2.
// So we check also for an empty text size.
const isEmptyTextBlock =
!doc.textBetween(from || 0, to || 0).length && isTextSelection(selection);
if (!(view as EditorView).hasFocus() || empty || isEmptyTextBlock) {
return false;
}
return true;
};
constructor({
editor,
element,
view,
tippyOptions = {},
shouldShow,
getRenderContainer,
defaultAnimation = true,
}: BubbleMenuViewProps) {
this.editor = editor;
this.element = element;
this.view = view;
this.getRenderContainer = getRenderContainer;
this.defaultAnimation = defaultAnimation;
if (shouldShow) {
this.shouldShow = shouldShow;
}
this.element.addEventListener("mousedown", this.mousedownHandler, {
capture: true,
});
this.view.dom.addEventListener("dragstart", this.dragstartHandler);
this.tippyOptions = tippyOptions || {};
// Detaches menu content from its current parent
this.element.remove();
this.element.style.visibility = "visible";
}
mousedownHandler = () => {
this.preventHide = true;
};
dragstartHandler = () => {
this.hide();
};
blurHandler = ({ event }: { event: FocusEvent }) => {
if (this.preventHide) {
this.preventHide = false;
return;
}
if (
event?.relatedTarget &&
this.element.parentNode?.contains(event.relatedTarget as Node)
) {
return;
}
const shouldShow =
this.editor.isEditable &&
this.shouldShow?.({
editor: this.editor,
state: this.editor.state,
});
if (shouldShow) return;
this.hide();
};
createTooltip() {
const { element: editorElement } = this.editor.options;
const editorIsAttached = !!editorElement.parentElement;
if (this.tippy || !editorIsAttached) {
return;
}
this.tippy = tippy(editorElement, {
getReferenceClientRect: null,
content: this.element,
interactive: true,
trigger: "manual",
placement: "bottom-start",
hideOnClick: "toggle",
plugins: [sticky],
popperOptions: {
modifiers: [
{
name: "customWidth",
enabled: true,
phase: "beforeWrite",
requires: ["computeStyles"],
fn({ state }) {
state.styles.popper.maxWidth = "98%";
},
},
],
},
...Object.assign(
{
zIndex: 999,
...(this.defaultAnimation
? {
animation: "shift-toward-subtle",
moveTransition: "transform 0.2s ease-in-out",
}
: {}),
fixed: true,
},
this.tippyOptions
),
});
// maybe we have to hide tippy on its own blur event as well
if (this.tippy.popper.firstChild) {
(this.tippy.popper.firstChild as HTMLElement).addEventListener(
"blur",
(event) => {
this.blurHandler({ event });
}
);
}
}
update(view: EditorView, oldState?: EditorState) {
const { state, composing } = view;
const { doc, selection } = state;
const isSame =
oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection);
if (composing || isSame) {
return;
}
// support for CellSelections
const { ranges } = selection;
const from = Math.min(...ranges.map((range) => range.$from.pos));
const to = Math.max(...ranges.map((range) => range.$to.pos));
const domAtPos = view.domAtPos(from).node as HTMLElement;
const nodeDOM = view.nodeDOM(from) as HTMLElement;
const node = nodeDOM || domAtPos;
const shouldShow =
this.editor.isEditable &&
this.shouldShow?.({
editor: this.editor,
view,
node,
state,
oldState,
from,
to,
});
if (!shouldShow) {
this.hide();
return;
}
this.createTooltip();
const cursorAt = selection.$anchor.pos;
// prevent the menu from being obscured
const placement = this.tippyOptions?.placement
? this.tippyOptions?.placement
: isNodeSelection(selection)
? ACTIVE_BUBBLE_MENUS.length > 1
? "bottom"
: "top"
: this.tippy?.props.fixed
? "bottom-start"
: Math.abs(cursorAt - to) <= Math.abs(cursorAt - from)
? "bottom-start"
: "top-start";
const otherBubbleMenus = ACTIVE_BUBBLE_MENUS.filter(
(instance) =>
instance.id !== this.tippy?.id &&
instance.popperInstance &&
instance.popperInstance.state
);
const offset = this.tippyOptions?.offset as [number, number];
const offsetX = offset?.[0] ?? 0;
let offsetY = otherBubbleMenus.length
? otherBubbleMenus.reduce((prev, instance, currentIndex, array) => {
const prevY = array[currentIndex - 1]
? array[currentIndex - 1]?.popperInstance?.state?.modifiersData
?.popperOffsets?.y ?? 0
: 0;
const currentY =
instance?.popperInstance?.state?.modifiersData?.popperOffsets?.y ??
0;
const currentHeight =
instance?.popperInstance?.state?.rects?.popper?.height ?? 10;
if (Math.abs(prevY - currentY) <= currentHeight) {
prev += currentHeight;
}
return prev;
}, 0)
: offset?.[1] ?? 10;
if (!offsetY) {
offsetY = 10;
}
this.tippy?.setProps({
offset: [offsetX, offsetY],
placement,
getReferenceClientRect: () => {
let toMountNode = null;
if (isNodeSelection(state.selection)) {
if (this.getRenderContainer && node) {
toMountNode = this.getRenderContainer(node);
}
}
if (this.getRenderContainer && node) {
toMountNode = this.getRenderContainer(node);
}
if (toMountNode && toMountNode.getBoundingClientRect) {
return toMountNode.getBoundingClientRect();
}
if (node && node.getBoundingClientRect) {
return node.getBoundingClientRect();
}
return posToDOMRect(view, from, to);
},
});
this.show();
}
addActiveBubbleMenu = () => {
const idx = ACTIVE_BUBBLE_MENUS.findIndex(
(instance) => instance?.id === this.tippy?.id
);
if (idx < 0) {
ACTIVE_BUBBLE_MENUS.push(this.tippy as Instance);
}
};
removeActiveBubbleMenu = () => {
const idx = ACTIVE_BUBBLE_MENUS.findIndex(
(instance) => instance?.id === this.tippy?.id
);
if (idx > -1) {
ACTIVE_BUBBLE_MENUS.splice(idx, 1);
}
};
show() {
this.addActiveBubbleMenu();
this.tippy?.show();
}
hide() {
this.removeActiveBubbleMenu();
this.tippy?.hide();
}
destroy() {
this.removeActiveBubbleMenu();
this.tippy?.destroy();
this.element.removeEventListener("mousedown", this.mousedownHandler, {
capture: true,
});
this.view.dom.removeEventListener("dragstart", this.dragstartHandler);
// this.editor.off("focus", this.focusHandler);
// this.editor.off("blur", this.blurHandler);
}
}
export const BubbleMenuPlugin = (options: BubbleMenuPluginProps) => {
return new Plugin({
key:
typeof options.pluginKey === "string"
? new PluginKey(options.pluginKey)
: options.pluginKey,
view: (view) => new BubbleMenuView({ view, ...options }),
});
};

View File

@ -0,0 +1,2 @@
export { default as BubbleItem } from "./BubbleItem.vue";
export { default as NodeBubbleMenu } from "./BubbleMenu.vue";

View File

@ -0,0 +1,118 @@
<script lang="ts" setup>
import { Dropdown as VDropdown } from "floating-vue";
import MdiChevronRight from "~icons/mdi/chevron-right";
import MdiPalette from "~icons/mdi/palette";
import { Sketch } from "@ckpack/vue-color";
import type { Payload } from "@ckpack/vue-color";
import tailwindcssColors from "tailwindcss/colors";
import { i18n } from "@/locales";
interface Color {
color: string;
name: string;
}
withDefaults(
defineProps<{
modelValue?: string;
}>(),
{
modelValue: undefined,
}
);
const emit = defineEmits<{
(emit: "update:modelValue", value?: string): void;
}>();
function getColors(): Color[] {
const result: Color[] = [];
const colors: { [key: string]: { [key: string]: string } } = Object.keys(
tailwindcssColors
).reduce((acc, key) => {
if (
[
"gray",
"red",
"orange",
"yellow",
"green",
"blue",
"purple",
"pink",
].includes(key)
) {
// @ts-ignore
acc[key] = tailwindcssColors[key];
}
return acc;
}, {});
for (const color in colors) {
const colorShades = colors[color];
const colorShadesArr = Object.entries(colorShades);
const sortedShades = colorShadesArr
.filter(([shade]) => parseInt(shade) >= 100 && parseInt(shade) <= 900)
.sort((a, b) => parseInt(b[0]) - parseInt(a[0]));
const formattedShades = sortedShades.map(([shade, value]) => ({
color: value,
name: `${color} ${shade}`,
}));
result.push(...formattedShades);
}
return result;
}
function handleSetColor(color: string) {
emit("update:modelValue", color);
}
function onColorChange(color: Payload) {
handleSetColor(color.hex);
}
</script>
<template>
<VDropdown class="inline-flex">
<slot />
<template #popper>
<slot name="prefix" />
<div class="grid grid-cols-9 gap-1.5 p-2 pt-1">
<div
v-for="item in getColors()"
:key="item.color"
:style="{ backgroundColor: item.color }"
class="h-5 w-5 rounded-sm cursor-pointer hover:ring-1 ring-offset-1 ring-gray-300"
:title="item.name"
@click="handleSetColor(item.color)"
></div>
</div>
<VDropdown placement="right">
<div class="p-1">
<div
class="flex items-center rounded cursor-pointer hover:bg-gray-100 p-1 justify-between"
>
<div class="inline-flex items-center gap-2">
<MdiPalette />
<span class="text-xs text-gray-600">
{{ i18n.global.t("editor.components.color_picker.more_color") }}
</span>
</div>
<div>
<MdiChevronRight />
</div>
</div>
</div>
<template #popper>
<Sketch model-value="#000" @update:model-value="onColorChange" />
</template>
</VDropdown>
</template>
</VDropdown>
</template>

View File

@ -0,0 +1,7 @@
<script lang="ts" setup>
import MdiDeleteForeverOutline from "~icons/mdi/delete-forever-outline";
</script>
<template>
<MdiDeleteForeverOutline class="text-red-600" />
</template>

View File

@ -0,0 +1,15 @@
// block
export * from "./block";
// bubble
export * from "./bubble";
// toolbar
export * from "./toolbar";
// toolbox
export * from "./toolbox";
export { default as RichTextEditor } from "./Editor.vue";
export * from "./EditorHeader.vue";
export * from "./EditorBubbleMenu.vue";

View File

@ -0,0 +1,37 @@
<script lang="ts" setup>
import type { Component } from "vue";
import { VTooltip } from "floating-vue";
withDefaults(
defineProps<{
isActive?: boolean;
disabled?: boolean;
title?: string;
action?: () => void;
icon?: Component;
}>(),
{
isActive: false,
disabled: false,
title: undefined,
action: undefined,
icon: undefined,
}
);
</script>
<template>
<button
v-tooltip="title"
:class="[
{ 'bg-gray-200': isActive },
{ 'cursor-not-allowed opacity-70': disabled },
{ 'hover:bg-gray-100': !disabled },
]"
class="p-1 rounded-sm"
:disabled="disabled"
@click="action"
>
<component :is="icon" />
</button>
</template>

View File

@ -0,0 +1,55 @@
<script lang="ts" setup>
import type { Component } from "vue";
const props = withDefaults(
defineProps<{
isActive?: boolean;
disabled?: boolean;
title?: string;
action?: () => void;
icon?: Component;
}>(),
{
isActive: false,
disabled: false,
title: undefined,
action: undefined,
icon: undefined,
}
);
const action = () => {
if (props.disabled) return;
props.action?.();
};
</script>
<template>
<div
:class="[
{ '!bg-gray-100': isActive },
{ 'cursor-not-allowed opacity-70 ': disabled },
{ 'hover:bg-gray-100': !disabled },
]"
class="flex flex-row items-center rounded gap-4 p-1 group cursor-pointer"
@click="action"
>
<component
:is="icon"
class="bg-gray-100 p-1 rounded w-6 h-6"
:class="[
{ '!bg-white': isActive },
{ 'group-hover:bg-white': !disabled },
]"
/>
<span
class="text-xs text-gray-600"
:class="[
{ '!text-gray-900 !font-medium': isActive },
{ 'group-hover:font-medium group-hover:text-gray-900': !disabled },
]"
>
{{ title }}
</span>
</div>
</template>

View File

@ -0,0 +1,2 @@
export { default as ToolbarItem } from "./ToolbarItem.vue";
export { default as ToolbarSubItem } from "./ToolbarSubItem.vue";

View File

@ -0,0 +1,47 @@
<script lang="ts" setup>
import type { Component } from "vue";
import type { Editor } from "@/tiptap/vue-3";
const props = withDefaults(
defineProps<{
editor?: Editor;
title?: string;
description?: string;
action?: () => void;
icon?: Component;
}>(),
{
editor: undefined,
title: undefined,
description: undefined,
action: undefined,
icon: undefined,
}
);
const action = () => {
props.action?.();
};
</script>
<template>
<div
class="flex flex-row items-center rounded gap-3 py-1 px-1.5 group cursor-pointer hover:bg-gray-100"
@click="action"
>
<component
:is="icon"
class="bg-gray-100 p-1.5 rounded w-7 h-7 group-hover:bg-white"
/>
<div class="flex flex-col gap-0.5">
<span
class="text-sm text-gray-600 group-hover:font-medium group-hover:text-gray-900"
>
{{ title }}
</span>
<span v-if="description" class="text-xs text-gray-500">
{{ description }}
</span>
</div>
</div>
</template>

View File

@ -0,0 +1 @@
export { default as ToolboxItem } from "./ToolboxItem.vue";

View File

@ -0,0 +1,125 @@
<script lang="ts" setup>
import { watchEffect } from "vue";
import { useLocalStorage } from "@vueuse/core";
import {
ExtensionBlockquote,
ExtensionBold,
ExtensionBulletList,
ExtensionCode,
ExtensionDocument,
ExtensionDropcursor,
ExtensionGapcursor,
ExtensionHardBreak,
ExtensionHeading,
ExtensionHistory,
ExtensionHorizontalRule,
ExtensionItalic,
ExtensionOrderedList,
ExtensionStrike,
ExtensionText,
ExtensionImage,
ExtensionTaskList,
ExtensionLink,
ExtensionTextAlign,
ExtensionUnderline,
ExtensionTable,
ExtensionSubscript,
ExtensionSuperscript,
ExtensionPlaceholder,
ExtensionHighlight,
ExtensionCommands,
ExtensionIframe,
ExtensionVideo,
ExtensionAudio,
ExtensionCodeBlock,
ExtensionColor,
ExtensionFontSize,
lowlight,
RichTextEditor,
useEditor,
ExtensionIndent,
ExtensionDraggable,
ExtensionColumns,
ExtensionColumn,
ExtensionNodeSelected,
ExtensionTrailingNode,
} from "../index";
const content = useLocalStorage("content", "");
const editor = useEditor({
content: content.value,
extensions: [
ExtensionBlockquote,
ExtensionBold,
ExtensionBulletList,
ExtensionCode,
ExtensionDocument,
ExtensionDropcursor.configure({
width: 2,
class: "dropcursor",
color: "skyblue",
}),
ExtensionGapcursor,
ExtensionHardBreak,
ExtensionHeading,
ExtensionHistory,
ExtensionHorizontalRule,
ExtensionItalic,
ExtensionOrderedList,
ExtensionStrike,
ExtensionText,
ExtensionImage.configure({
HTMLAttributes: {
loading: "lazy",
},
}),
ExtensionTaskList,
ExtensionLink.configure({
autolink: false,
openOnClick: false,
}),
ExtensionTextAlign.configure({
types: ["heading", "paragraph"],
}),
ExtensionUnderline,
ExtensionTable.configure({
resizable: true,
}),
ExtensionSubscript,
ExtensionSuperscript,
ExtensionPlaceholder.configure({
placeholder: "输入 / 以选择输入类型",
}),
ExtensionHighlight,
ExtensionVideo,
ExtensionAudio,
ExtensionCommands,
ExtensionCodeBlock.configure({
lowlight,
}),
ExtensionIframe,
ExtensionColor,
ExtensionFontSize,
ExtensionIndent,
ExtensionDraggable,
ExtensionColumns,
ExtensionColumn,
ExtensionNodeSelected,
ExtensionTrailingNode,
],
onUpdate: () => {
content.value = editor.value?.getHTML() + "";
},
});
watchEffect(() => {
// console.log(editor.value?.getHTML());
});
</script>
<template>
<div style="height: 100vh" class="flex">
<RichTextEditor v-if="editor" :editor="editor" />
</div>
</template>

View File

@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
const app = createApp(App);
app.mount("#app");

View File

@ -0,0 +1,74 @@
<script lang="ts" setup>
import type { Node as ProseMirrorNode, Decoration } from "@/tiptap/pm";
import type { Editor, Node } from "@/tiptap/vue-3";
import { NodeViewWrapper } from "@/tiptap/vue-3";
import { computed, onMounted, ref } from "vue";
import { i18n } from "@/locales";
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 src = computed({
get: () => {
return props.node?.attrs.src;
},
set: (src: string) => {
props.updateAttributes({ src: src });
},
});
const autoplay = computed(() => {
return props.node.attrs.autoplay;
});
const loop = computed(() => {
return props.node.attrs.loop;
});
function handleSetFocus() {
props.editor.commands.setNodeSelection(props.getPos());
}
const inputRef = ref();
onMounted(() => {
if (!src.value) {
inputRef.value.focus();
}
});
</script>
<template>
<node-view-wrapper as="div" class="inline-block w-full">
<div
class="inline-block overflow-hidden transition-all text-center relative h-full w-full"
>
<div v-if="!src" class="p-1.5">
<input
ref="inputRef"
v-model.lazy="src"
class="block px-2 w-full py-1.5 text-sm text-gray-900 border border-gray-300 rounded-md bg-gray-50 focus:ring-blue-500 focus:border-blue-500"
:placeholder="i18n.global.t('editor.common.placeholder.link_input')"
tabindex="-1"
@focus="handleSetFocus"
/>
</div>
<audio
v-else
controls
:autoplay="autoplay"
:loop="loop"
:src="node!.attrs.src"
@mouseenter="handleSetFocus"
></audio>
</div>
</node-view-wrapper>
</template>

View File

@ -0,0 +1,37 @@
<script lang="ts" setup>
import type { Editor } from "@/tiptap/vue-3";
import { computed, type Component } from "vue";
import Audio from "./index";
import { i18n } from "@/locales";
const props = defineProps<{
editor: Editor;
isActive: ({ editor }: { editor: Editor }) => boolean;
visible?: ({ editor }: { editor: Editor }) => boolean;
icon?: Component;
title?: string;
action?: ({ editor }: { editor: Editor }) => void;
}>();
const src = computed({
get: () => {
return props.editor.getAttributes(Audio.name)?.src;
},
set: (src: string) => {
props.editor
.chain()
.updateAttributes(Audio.name, { src: src })
.setNodeSelection(props.editor.state.selection.from)
.focus()
.run();
},
});
</script>
<template>
<input
v-model.lazy="src"
:placeholder="i18n.global.t('editor.common.placeholder.link_input')"
class="bg-gray-50 rounded-md hover:bg-gray-100 block px-2 w-full py-1.5 text-sm text-gray-900 border border-gray-300 focus:ring-blue-500 focus:border-blue-500"
/>
</template>

View File

@ -0,0 +1,295 @@
import type { ExtensionOptions, NodeBubbleMenu } from "@/types";
import {
Editor,
isActive,
mergeAttributes,
Node,
nodeInputRule,
type Range,
VueNodeViewRenderer,
} from "@/tiptap/vue-3";
import type { EditorState } from "@/tiptap/pm";
import { markRaw } from "vue";
import AudioView from "./AudioView.vue";
import MdiMusicCircleOutline from "~icons/mdi/music-circle-outline";
import ToolboxItemVue from "@/components/toolbox/ToolboxItem.vue";
import { i18n } from "@/locales";
import MdiPlayCircle from "~icons/mdi/play-circle";
import MdiPlayCircleOutline from "~icons/mdi/play-circle-outline";
import MdiMotionPlayOutline from "~icons/mdi/motion-play-outline";
import MdiMotionPlay from "~icons/mdi/motion-play";
import { BlockActionSeparator } from "@/components";
import BubbleItemAudioLink from "./BubbleItemAudioLink.vue";
import MdiLinkVariant from "~icons/mdi/link-variant";
import MdiShare from "~icons/mdi/share";
import { deleteNode } from "@/utils";
import MdiDeleteForeverOutline from "@/components/icon/MdiDeleteForeverOutline.vue";
declare module "@/tiptap" {
interface Commands<ReturnType> {
audio: {
setAudio: (options: { src: string }) => ReturnType;
};
}
}
const Audio = Node.create<ExtensionOptions>({
name: "audio",
inline() {
return true;
},
group() {
return "inline";
},
addAttributes() {
return {
...this.parent?.(),
src: {
default: null,
parseHTML: (element) => {
return element.getAttribute("src");
},
},
autoplay: {
default: null,
parseHTML: (element) => {
return element.getAttribute("autoplay");
},
renderHTML: (attributes) => {
return {
autoplay: attributes.autoplay,
};
},
},
controls: {
default: true,
parseHTML: (element) => {
return element.getAttribute("controls");
},
renderHTML: (attributes) => {
return {
controls: attributes.controls,
};
},
},
loop: {
default: null,
parseHTML: (element) => {
return element.getAttribute("loop");
},
renderHTML: (attributes) => {
return {
loop: attributes.loop,
};
},
},
};
},
parseHTML() {
return [
{
tag: "audio",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["audio", mergeAttributes(HTMLAttributes)];
},
addCommands() {
return {
setAudio:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
addInputRules() {
return [
nodeInputRule({
find: /^\$audio\$$/,
type: this.type,
getAttributes: () => {
return { width: "100%" };
},
}),
];
},
addNodeView() {
return VueNodeViewRenderer(AudioView);
},
addOptions() {
return {
...this.parent?.(),
getCommandMenuItems() {
return {
priority: 110,
icon: markRaw(MdiMusicCircleOutline),
title: "editor.extensions.commands_menu.audio",
keywords: ["audio", "yinpin"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent([
{ type: "audio", attrs: { src: "" } },
{ type: "paragraph", content: "" },
])
.run();
},
};
},
getToolboxItems({ editor }: { editor: Editor }) {
return {
priority: 20,
component: markRaw(ToolboxItemVue),
props: {
editor,
icon: markRaw(MdiMusicCircleOutline),
title: i18n.global.t("editor.extensions.commands_menu.audio"),
action: () => {
editor
.chain()
.focus()
.insertContent([{ type: "audio", attrs: { src: "" } }])
.run();
},
},
};
},
getBubbleMenu({ editor }: { editor: Editor }): NodeBubbleMenu {
return {
pluginKey: "audioBubbleMenu",
shouldShow: ({ state }: { state: EditorState }) => {
return isActive(state, Audio.name);
},
items: [
{
priority: 10,
props: {
isActive: () => {
return editor.getAttributes(Audio.name).autoplay;
},
icon: markRaw(
editor.getAttributes(Audio.name).autoplay
? MdiPlayCircle
: MdiPlayCircleOutline
),
action: () => {
editor
.chain()
.updateAttributes(Audio.name, {
autoplay: editor.getAttributes(Audio.name).autoplay
? null
: true,
})
.setNodeSelection(editor.state.selection.from)
.focus()
.run();
},
title: editor.getAttributes(Audio.name).autoplay
? i18n.global.t("editor.extensions.audio.disable_autoplay")
: i18n.global.t("editor.extensions.audio.enable_autoplay"),
},
},
{
priority: 20,
props: {
isActive: () => {
return editor.getAttributes(Audio.name).loop;
},
icon: markRaw(
editor.getAttributes(Audio.name).loop
? MdiMotionPlay
: MdiMotionPlayOutline
),
action: () => {
editor
.chain()
.updateAttributes(Audio.name, {
loop: editor.getAttributes(Audio.name).loop ? null : true,
})
.setNodeSelection(editor.state.selection.from)
.focus()
.run();
},
title: editor.getAttributes(Audio.name).loop
? i18n.global.t("editor.extensions.audio.disable_loop")
: i18n.global.t("editor.extensions.audio.enable_loop"),
},
},
{
priority: 30,
component: markRaw(BlockActionSeparator),
},
{
priority: 40,
props: {
icon: markRaw(MdiLinkVariant),
title: i18n.global.t("editor.common.button.edit_link"),
action: () => {
return markRaw(BubbleItemAudioLink);
},
},
},
{
priority: 50,
props: {
icon: markRaw(MdiShare),
title: i18n.global.t("editor.common.tooltip.open_link"),
action: () => {
window.open(editor.getAttributes(Audio.name).src, "_blank");
},
},
},
{
priority: 60,
component: markRaw(BlockActionSeparator),
},
{
priority: 70,
props: {
icon: markRaw(MdiDeleteForeverOutline),
title: i18n.global.t("editor.common.button.delete"),
action: ({ editor }) => {
deleteNode(Audio.name, editor);
},
},
},
],
};
},
getDraggable() {
return {
getRenderContainer({ dom }) {
let container = dom;
while (
container &&
!container.hasAttribute("data-node-view-wrapper")
) {
container = container.parentElement as HTMLElement;
}
return {
el: container,
};
},
};
},
};
},
});
export default Audio;

View File

@ -0,0 +1,51 @@
import type { Editor } from "@/tiptap/vue-3";
import TiptapBlockquote from "@tiptap/extension-blockquote";
import type { BlockquoteOptions } from "@tiptap/extension-blockquote";
import ToolbarItemVue from "@/components/toolbar/ToolbarItem.vue";
import MdiFormatQuoteOpen from "~icons/mdi/format-quote-open";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import type { ExtensionOptions } from "@/types";
const Blockquote = TiptapBlockquote.extend<
ExtensionOptions & BlockquoteOptions
>({
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 90,
component: markRaw(ToolbarItemVue),
props: {
editor,
isActive: editor.isActive("blockquote"),
icon: markRaw(MdiFormatQuoteOpen),
title: i18n.global.t("editor.common.quote"),
action: () => {
editor.commands.toggleBlockquote();
},
},
};
},
getDraggable() {
return {
getRenderContainer({ dom }) {
let element: HTMLElement | null = dom;
while (element && element.parentElement) {
if (element.tagName === "BLOCKQUOTE") {
break;
}
element = element.parentElement;
}
return {
el: element,
};
},
};
},
};
},
});
export default Blockquote;

View File

@ -0,0 +1,33 @@
import type { Editor } from "@/tiptap/vue-3";
import TiptapBold from "@tiptap/extension-bold";
import type { BoldOptions } from "@tiptap/extension-bold";
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
import MdiFormatBold from "~icons/mdi/format-bold";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import type { ExtensionOptions } from "@/types";
const Bold = TiptapBold.extend<ExtensionOptions & BoldOptions>({
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 40,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive("bold"),
icon: markRaw(MdiFormatBold),
title: i18n.global.t("editor.common.bold"),
action: () => {
editor.chain().focus().toggleBold().run();
},
},
};
},
};
},
});
export default Bold;

View File

@ -0,0 +1,64 @@
import type { Editor, Range } from "@/tiptap/vue-3";
import TiptapBulletList from "@tiptap/extension-bullet-list";
import type { BulletListOptions } from "@tiptap/extension-bullet-list";
import ExtensionListItem from "@tiptap/extension-list-item";
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
import MdiFormatListBulleted from "~icons/mdi/format-list-bulleted";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import type { ExtensionOptions } from "@/types";
const BulletList = TiptapBulletList.extend<
ExtensionOptions & BulletListOptions
>({
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 130,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive("bulletList"),
icon: markRaw(MdiFormatListBulleted),
title: i18n.global.t("editor.common.bullet_list"),
action: () => editor.chain().focus().toggleBulletList().run(),
},
};
},
getCommandMenuItems() {
return {
priority: 130,
icon: markRaw(MdiFormatListBulleted),
title: "editor.common.bullet_list",
keywords: ["bulletlist", "wuxuliebiao"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
};
},
getDraggable() {
return {
getRenderContainer({ dom }) {
let container = dom;
while (container && !(container.tagName === "LI")) {
container = container.parentElement as HTMLElement;
}
return {
el: container,
dragDomOffset: {
x: -12,
},
};
},
};
},
};
},
addExtensions() {
return [ExtensionListItem];
},
});
export default BulletList;

View File

@ -0,0 +1,52 @@
<script lang="ts" setup>
import type { Node as ProseMirrorNode, Decoration } from "@/tiptap/pm";
import type { Editor, Node } from "@/tiptap/vue-3";
import { NodeViewContent, NodeViewWrapper } from "@/tiptap/vue-3";
import lowlight from "./lowlight";
import { computed } from "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 languages = computed(() => {
return lowlight.listLanguages();
});
const selectedLanguage = computed({
get: () => {
return props.node?.attrs.language;
},
set: (language: string) => {
props.updateAttributes({ language: language });
},
});
</script>
<template>
<node-view-wrapper as="div" class="code-node">
<div class="py-1.5">
<select
v-model="selectedLanguage"
contenteditable="false"
class="block px-2 py-1.5 text-sm text-gray-900 border border-gray-300 rounded-md bg-gray-50 focus:ring-blue-500 focus:border-blue-500"
>
<option :value="null">auto</option>
<option
v-for="(language, index) in languages"
:key="index"
:value="language"
>
{{ language }}
</option>
</select>
</div>
<pre><node-view-content as="code" class="hljs" /></pre>
</node-view-wrapper>
</template>

View File

@ -0,0 +1,240 @@
import {
Editor,
type Range,
type CommandProps,
isActive,
findParentNode,
VueNodeViewRenderer,
} from "@/tiptap/vue-3";
import { EditorState, TextSelection, type Transaction } from "@/tiptap/pm";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import type { CodeBlockLowlightOptions } from "@tiptap/extension-code-block-lowlight";
import CodeBlockViewRenderer from "./CodeBlockViewRenderer.vue";
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
import MdiCodeBracesBox from "~icons/mdi/code-braces-box";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import ToolboxItem from "@/components/toolbox/ToolboxItem.vue";
import MdiDeleteForeverOutline from "@/components/icon/MdiDeleteForeverOutline.vue";
import { deleteNode } from "@/utils";
export interface CustomCodeBlockLowlightOptions
extends CodeBlockLowlightOptions {
lowlight: any;
defaultLanguage: string | null | undefined;
}
declare module "@/tiptap" {
interface Commands<ReturnType> {
codeIndent: {
codeIndent: () => ReturnType;
codeOutdent: () => ReturnType;
};
}
}
type IndentType = "indent" | "outdent";
const updateIndent = (tr: Transaction, type: IndentType): Transaction => {
const { doc, selection } = tr;
if (!doc || !selection) return tr;
if (!(selection instanceof TextSelection)) {
return tr;
}
const { from, to } = selection;
doc.nodesBetween(from, to, (node, pos) => {
if (from - to == 0 && type === "indent") {
tr.insertText("\t", from, to);
return false;
}
const precedeContent = doc.textBetween(pos + 1, from, "\n");
const precedeLineBreakPos = precedeContent.lastIndexOf("\n");
const startBetWeenIndex =
precedeLineBreakPos === -1 ? pos + 1 : pos + precedeLineBreakPos + 1;
const text = doc.textBetween(startBetWeenIndex, to, "\n");
if (type === "indent") {
let replacedStr = text.replace(/\n/g, "\n\t");
if (startBetWeenIndex === pos + 1) {
replacedStr = "\t" + replacedStr;
}
tr.insertText(replacedStr, startBetWeenIndex, to);
} else {
let replacedStr = text.replace(/\n\t/g, "\n");
if (startBetWeenIndex === pos + 1) {
const firstNewLineIndex = replacedStr.indexOf("\t");
if (firstNewLineIndex == 0) {
replacedStr = replacedStr.replace("\t", "");
}
}
tr.insertText(replacedStr, startBetWeenIndex, to);
}
return false;
});
return tr;
};
const getRenderContainer = (node: HTMLElement) => {
let container = node;
// 文本节点
if (container.nodeName === "#text") {
container = node.parentElement as HTMLElement;
}
while (
container &&
container.classList &&
!container.classList.contains("code-node")
) {
container = container.parentElement as HTMLElement;
}
return container;
};
export default CodeBlockLowlight.extend<
CustomCodeBlockLowlightOptions & CodeBlockLowlightOptions
>({
addCommands() {
return {
...this.parent?.(),
codeIndent:
() =>
({ tr, state, dispatch }: CommandProps) => {
const { selection } = state;
tr = tr.setSelection(selection);
tr = updateIndent(tr, "indent");
if (tr.docChanged && dispatch) {
dispatch(tr);
return true;
}
return false;
},
codeOutdent:
() =>
({ tr, state, dispatch }: CommandProps) => {
const { selection } = state;
tr = tr.setSelection(selection);
tr = updateIndent(tr, "outdent");
if (tr.docChanged && dispatch) {
dispatch(tr);
return true;
}
return false;
},
};
},
addKeyboardShortcuts() {
return {
Tab: () => {
if (this.editor.isActive("codeBlock")) {
return this.editor.chain().focus().codeIndent().run();
}
return false;
},
"Shift-Tab": () => {
if (this.editor.isActive("codeBlock")) {
return this.editor.chain().focus().codeOutdent().run();
}
return false;
},
"Mod-a": () => {
if (this.editor.isActive("codeBlock")) {
const { tr, selection } = this.editor.state;
const codeBlack = findParentNode(
(node) => node.type.name === CodeBlockLowlight.name
)(selection);
if (!codeBlack) {
return false;
}
const head = codeBlack.start;
const anchor = codeBlack.start + codeBlack.node.nodeSize - 1;
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
this.editor.view.dispatch(
tr.setSelection(new TextSelection($head, $anchor))
);
return true;
}
return false;
},
};
},
addNodeView() {
return VueNodeViewRenderer(CodeBlockViewRenderer);
},
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 160,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive("codeBlock"),
icon: markRaw(MdiCodeBracesBox),
title: i18n.global.t("editor.common.codeblock"),
action: () => editor.chain().focus().toggleCodeBlock().run(),
},
};
},
getCommandMenuItems() {
return {
priority: 80,
icon: markRaw(MdiCodeBracesBox),
title: "editor.common.codeblock",
keywords: ["codeblock", "daimakuai"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor.chain().focus().deleteRange(range).setCodeBlock().run();
},
};
},
getToolboxItems({ editor }: { editor: Editor }) {
return [
{
priority: 50,
component: markRaw(ToolboxItem),
props: {
editor,
icon: markRaw(MdiCodeBracesBox),
title: i18n.global.t("editor.common.codeblock"),
action: () => {
editor.chain().focus().setCodeBlock().run();
},
},
},
];
},
getBubbleMenu() {
return {
pluginKey: "codeBlockBubbleMenu",
shouldShow: ({ state }: { state: EditorState }) => {
return isActive(state, CodeBlockLowlight.name);
},
getRenderContainer: (node: HTMLElement) => {
return getRenderContainer(node);
},
items: [
{
priority: 10,
props: {
icon: markRaw(MdiDeleteForeverOutline),
title: i18n.global.t("editor.common.button.delete"),
action: ({ editor }: { editor: Editor }) =>
deleteNode(CodeBlockLowlight.name, editor),
},
},
],
};
},
getDraggable() {
return {
getRenderContainer({ dom }: { dom: HTMLElement }) {
return {
el: getRenderContainer(dom),
};
},
};
},
};
},
});

View File

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

View File

@ -0,0 +1,6 @@
import { common, createLowlight } from "lowlight";
import xml from "highlight.js/lib/languages/xml";
const lowlight = createLowlight(common);
lowlight.register("html", xml);
export default lowlight;

View File

@ -0,0 +1,32 @@
import type { Editor } from "@/tiptap/vue-3";
import TiptapCode from "@tiptap/extension-code";
import type { CodeOptions } from "@tiptap/extension-code";
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
import MdiCodeTags from "~icons/mdi/code-tags";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import type { ExtensionOptions } from "@/types";
const Code = TiptapCode.extend<ExtensionOptions & CodeOptions>({
exitable: true,
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 100,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive("code"),
icon: markRaw(MdiCodeTags),
title: i18n.global.t("editor.common.code"),
action: () => editor.chain().focus().toggleCode().run(),
},
};
},
};
},
});
export default Code;

View File

@ -0,0 +1,48 @@
<script lang="ts" setup>
import { BubbleItem } from "@/components";
import ColorPickerDropdown from "@/components/common/ColorPickerDropdown.vue";
import { i18n } from "@/locales";
import type { Editor } from "@/tiptap/vue-3";
import type { Component } from "vue";
const props = defineProps<{
editor: Editor;
isActive: ({ editor }: { editor: Editor }) => boolean;
visible?: ({ editor }: { editor: Editor }) => boolean;
icon?: Component;
title?: string;
action?: ({ editor }: { editor: Editor }) => void;
}>();
function handleSetColor(color?: string) {
if (!color) {
return;
}
props.editor?.chain().focus().setColor(color).run();
}
function handleUnsetColor() {
props.editor?.chain().focus().unsetColor().run();
}
</script>
<template>
<ColorPickerDropdown @update:model-value="handleSetColor">
<BubbleItem v-bind="props" :editor="editor" />
<template #prefix>
<div class="p-1">
<div
class="flex items-center gap-2 rounded cursor-pointer hover:bg-gray-100 p-1"
@click="handleUnsetColor"
>
<div
class="h-5 w-5 rounded-sm cursor-pointer hover:ring-1 ring-offset-1 ring-gray-300 bg-black"
></div>
<span class="text-xs text-gray-600">
{{ i18n.global.t("editor.common.button.restore_default") }}
</span>
</div>
</div>
</template>
</ColorPickerDropdown>
</template>

View File

@ -0,0 +1,58 @@
<script lang="ts" setup>
import { ToolbarItem } from "@/components";
import type { Component } from "vue";
import type { Editor } from "@/tiptap/vue-3";
import ColorPickerDropdown from "@/components/common/ColorPickerDropdown.vue";
import { i18n } from "@/locales";
const props = withDefaults(
defineProps<{
editor?: Editor;
isActive?: boolean;
disabled?: boolean;
title?: string;
action?: () => void;
icon?: Component;
}>(),
{
editor: undefined,
isActive: false,
disabled: false,
title: undefined,
action: undefined,
icon: undefined,
}
);
function handleSetColor(color?: string) {
if (!color) {
return;
}
props.editor?.chain().focus().setColor(color).run();
}
function handleUnsetColor() {
props.editor?.chain().focus().unsetColor().run();
}
</script>
<template>
<ColorPickerDropdown @update:model-value="handleSetColor">
<ToolbarItem v-bind="props" />
<template #prefix>
<div class="p-1">
<div
class="flex items-center gap-2 rounded cursor-pointer hover:bg-gray-100 p-1"
@click="handleUnsetColor"
>
<div
class="h-5 w-5 rounded-sm cursor-pointer hover:ring-1 ring-offset-1 ring-gray-300 bg-black"
></div>
<span class="text-xs text-gray-600">
{{ i18n.global.t("editor.common.button.restore_default") }}
</span>
</div>
</div>
</template>
</ColorPickerDropdown>
</template>

View File

@ -0,0 +1,34 @@
import type { ExtensionOptions } from "@/types";
import TiptapColor from "@tiptap/extension-color";
import type { ColorOptions } from "@tiptap/extension-color";
import TiptapTextStyle from "@tiptap/extension-text-style";
import type { Editor } from "@/tiptap/vue-3";
import { markRaw } from "vue";
import MdiFormatColor from "~icons/mdi/format-color";
import ColorToolbarItem from "./ColorToolbarItem.vue";
import { i18n } from "@/locales";
const Color = TiptapColor.extend<ColorOptions & ExtensionOptions>({
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 81,
component: markRaw(ColorToolbarItem),
props: {
editor,
isActive: false,
icon: markRaw(MdiFormatColor),
title: i18n.global.t("editor.common.color"),
},
};
},
};
},
addExtensions() {
return [TiptapTextStyle];
},
});
export default Color;

View File

@ -0,0 +1,46 @@
import { mergeAttributes, Node } from "@/tiptap/vue-3";
const Column = Node.create({
name: "column",
content: "block+",
isolating: true,
addOptions() {
return {
HTMLAttributes: {
class: "column",
},
};
},
addAttributes() {
return {
index: {
default: 0,
parseHTML: (element) => element.getAttribute("index"),
},
style: {
default: "min-width: 0;padding: 12px;flex: 1 1;box-sizing: border-box;",
parseHTML: (element) => element.getAttribute("style"),
},
};
},
parseHTML() {
return [
{
tag: "div[class=column]",
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
});
export default Column;

View File

@ -0,0 +1,386 @@
import {
Editor,
findParentNode,
isActive,
mergeAttributes,
Node,
type Range,
} from "@/tiptap/vue-3";
import { Node as PMNode, EditorState, TextSelection } from "@/tiptap/pm";
import type { NodeType, Schema } from "@/tiptap/pm";
import { markRaw } from "vue";
import Column from "./column";
import RiInsertColumnLeft from "~icons/ri/insert-column-left";
import RiInsertColumnRight from "~icons/ri/insert-column-right";
import RiDeleteColumn from "~icons/ri/delete-column";
import { BlockActionSeparator, ToolboxItem } from "@/components";
import MdiDeleteForeverOutline from "@/components/icon/MdiDeleteForeverOutline.vue";
import { i18n } from "@/locales";
import { deleteNode } from "@/utils";
import MdiCollage from "~icons/mdi/collage";
declare module "@/tiptap" {
interface Commands<ReturnType> {
columns: {
insertColumns: (attrs?: { cols: number }) => ReturnType;
addColBefore: () => ReturnType;
addColAfter: () => ReturnType;
deleteCol: () => ReturnType;
};
}
}
const createColumns = (schema: Schema, colsCount: number) => {
const types = getColumnsNodeTypes(schema);
const cols = [];
for (let index = 0; index < colsCount; index += 1) {
const col = types.column.createAndFill({ index });
if (col) {
cols.push(col);
}
}
return types.columns.createChecked({ cols: colsCount }, cols);
};
const getColumnsNodeTypes = (
schema: Schema
): {
columns: NodeType;
column: NodeType;
} => {
if (schema.cached.columnsNodeTypes) {
return schema.cached.columnsNodeTypes;
}
const roles = {
columns: schema.nodes["columns"],
column: schema.nodes["column"],
};
schema.cached.columnsNodeTypes = roles;
return roles;
};
type ColOperateType = "addBefore" | "addAfter" | "delete";
const addOrDeleteCol = (
dispatch: any,
state: EditorState,
type: ColOperateType
) => {
const maybeColumns = findParentNode(
(node) => node.type.name === Columns.name
)(state.selection);
const maybeColumn = findParentNode((node) => node.type.name === Column.name)(
state.selection
);
if (dispatch && maybeColumns && maybeColumn) {
const cols = maybeColumns.node;
const colIndex = maybeColumn.node.attrs.index;
const colsJSON = cols.toJSON();
let nextIndex = colIndex;
if (type === "delete") {
nextIndex = colIndex - 1;
colsJSON.content.splice(colIndex, 1);
} else {
nextIndex = type === "addBefore" ? colIndex : colIndex + 1;
colsJSON.content.splice(nextIndex, 0, {
type: "column",
attrs: {
index: colIndex,
},
content: [
{
type: "paragraph",
},
],
});
}
colsJSON.attrs.cols = colsJSON.content.length;
colsJSON.content.forEach((colJSON: any, index: number) => {
colJSON.attrs.index = index;
});
const nextCols = PMNode.fromJSON(state.schema, colsJSON);
let nextSelectPos = maybeColumns.pos;
nextCols.content.forEach((col, pos, index) => {
if (index < nextIndex) {
nextSelectPos += col.nodeSize;
}
});
const tr = state.tr.setTime(Date.now());
tr.replaceWith(
maybeColumns.pos,
maybeColumns.pos + maybeColumns.node.nodeSize,
nextCols
).setSelection(TextSelection.near(tr.doc.resolve(nextSelectPos)));
dispatch(tr);
}
return true;
};
type GotoColType = "before" | "after";
const gotoCol = (state: EditorState, dispatch: any, type: GotoColType) => {
const maybeColumns = findParentNode(
(node) => node.type.name === Columns.name
)(state.selection);
const maybeColumn = findParentNode((node) => node.type.name === Column.name)(
state.selection
);
if (dispatch && maybeColumns && maybeColumn) {
const cols = maybeColumns.node;
const colIndex = maybeColumn.node.attrs.index;
let nextIndex = 0;
if (type === "before") {
nextIndex = (colIndex - 1 + cols.attrs.cols) % cols.attrs.cols;
} else {
nextIndex = (colIndex + 1) % cols.attrs.cols;
}
let nextSelectPos = maybeColumns.pos;
cols.content.forEach((col, pos, index) => {
if (index < nextIndex) {
nextSelectPos += col.nodeSize;
}
});
const tr = state.tr.setTime(Date.now());
tr.setSelection(TextSelection.near(tr.doc.resolve(nextSelectPos)));
dispatch(tr);
return true;
}
return false;
};
const Columns = Node.create({
name: "columns",
group: "block",
priority: 10,
defining: true,
isolating: true,
allowGapCursor: false,
content: "column{1,}",
addOptions() {
return {
HTMLAttributes: {
class: "columns",
},
getToolboxItems({ editor }: { editor: Editor }) {
return [
{
priority: 50,
component: markRaw(ToolboxItem),
props: {
editor,
icon: markRaw(MdiCollage),
title: i18n.global.t("editor.extensions.commands_menu.columns"),
action: () => {
editor
.chain()
.focus()
.insertColumns({
cols: 2,
})
.run();
},
},
},
];
},
getCommandMenuItems() {
return {
priority: 70,
icon: markRaw(MdiCollage),
title: "editor.extensions.commands_menu.columns",
keywords: ["fenlan", "columns"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertColumns({
cols: 2,
})
.run();
},
};
},
getBubbleMenu() {
return {
pluginKey: "columnsBubbleMenu",
shouldShow: ({ state }: { state: EditorState }) => {
return isActive(state, Columns.name);
},
getRenderContainer: (node: HTMLElement) => {
let container = node;
// 文本节点
if (container.nodeName === "#text") {
container = node.parentElement as HTMLElement;
}
while (
container &&
container.classList &&
!container.classList.contains("column")
) {
container = container.parentElement as HTMLElement;
}
return container;
},
items: [
{
priority: 10,
props: {
icon: markRaw(RiInsertColumnLeft),
title: i18n.global.t(
"editor.extensions.columns.add_column_before"
),
action: ({ editor }: { editor: Editor }) => {
editor.chain().focus().addColBefore().run();
},
},
},
{
priority: 20,
props: {
icon: markRaw(RiInsertColumnRight),
title: i18n.global.t(
"editor.extensions.columns.add_column_after"
),
action: ({ editor }: { editor: Editor }) => {
editor.chain().focus().addColAfter().run();
},
},
},
{
priority: 30,
props: {
icon: markRaw(RiDeleteColumn),
title: i18n.global.t("editor.extensions.columns.delete_column"),
action: ({ editor }: { editor: Editor }) => {
editor.chain().focus().deleteCol().run();
},
},
},
{
priority: 40,
component: markRaw(BlockActionSeparator),
},
{
priority: 50,
props: {
icon: markRaw(MdiDeleteForeverOutline),
title: i18n.global.t("editor.common.button.delete"),
action: ({ editor }: { editor: Editor }) => {
deleteNode(Columns.name, editor);
},
},
},
],
};
},
getDraggable() {
return {
getRenderContainer({ dom }: { dom: HTMLElement }) {
let container = dom;
while (container && !container.classList.contains("columns")) {
container = container.parentElement as HTMLElement;
}
return {
el: container,
dragDomOffset: {
y: -5,
},
};
},
allowPropagationDownward: true,
};
},
};
},
addAttributes() {
return {
cols: {
default: 2,
parseHTML: (element) => element.getAttribute("cols"),
},
style: {
default: "display: flex;width: 100%;grid-gap: 8px;gap: 8px;",
parseHTML: (element) => element.getAttribute("style"),
},
};
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addCommands() {
return {
insertColumns:
(attrs) =>
({ tr, dispatch, editor }) => {
const node = createColumns(editor.schema, (attrs && attrs.cols) || 3);
if (dispatch) {
const offset = tr.selection.anchor + 1;
tr.replaceSelectionWith(node)
.scrollIntoView()
.setSelection(TextSelection.near(tr.doc.resolve(offset)));
}
return true;
},
addColBefore:
() =>
({ dispatch, state }) => {
return addOrDeleteCol(dispatch, state, "addBefore");
},
addColAfter:
() =>
({ dispatch, state }) => {
return addOrDeleteCol(dispatch, state, "addAfter");
},
deleteCol:
() =>
({ dispatch, state }) => {
return addOrDeleteCol(dispatch, state, "delete");
},
};
},
addKeyboardShortcuts() {
return {
"Mod-Alt-G": () => this.editor.commands.insertColumns(),
Tab: () => {
return gotoCol(this.editor.state, this.editor.view.dispatch, "after");
},
"Shift-Tab": () => {
return gotoCol(this.editor.state, this.editor.view.dispatch, "before");
},
};
},
});
export default Columns;

View File

@ -0,0 +1,2 @@
export { default as ExtensionColumns } from "./columns";
export { default as ExtensionColumn } from "./column";

View File

@ -0,0 +1,152 @@
<script lang="ts" setup>
import { type PropType, ref, watch } from "vue";
import type { CommandMenuItem } from "@/types";
import { i18n } from "@/locales";
import scrollIntoView from "scroll-into-view-if-needed";
const props = defineProps({
items: {
type: Array as PropType<CommandMenuItem[]>,
required: true,
},
command: {
type: Function as PropType<(item: CommandMenuItem) => void>,
required: true,
},
});
const selectedIndex = ref(0);
watch(
() => props.items,
() => {
selectedIndex.value = 0;
}
);
function onKeyDown({ event }: { event: KeyboardEvent }) {
if (event.key === "ArrowUp" || (event.key === "k" && event.ctrlKey)) {
handleKeyUp();
return true;
}
if (event.key === "ArrowDown" || (event.key === "j" && event.ctrlKey)) {
handleKeyDown();
return true;
}
if (event.key === "Enter") {
handleKeyEnter();
return true;
}
return false;
}
function handleKeyUp() {
selectedIndex.value =
(selectedIndex.value + props.items.length - 1) % props.items.length;
}
function handleKeyDown() {
selectedIndex.value = (selectedIndex.value + 1) % props.items.length;
}
function handleKeyEnter() {
handleSelectItem(selectedIndex.value);
}
function handleSelectItem(index: number) {
const item = props.items[index];
if (item) {
props.command(item);
}
}
watch(
() => selectedIndex.value,
() => {
const selected = document.getElementById(
`command-item-${selectedIndex.value}`
);
if (selected) {
scrollIntoView(selected, { behavior: "smooth", scrollMode: "if-needed" });
}
}
);
defineExpose({
onKeyDown,
});
</script>
<template>
<div class="command-items">
<template v-if="items.length">
<div
v-for="(item, index) in items"
:id="`command-item-${index}`"
:key="index"
:class="{ 'is-selected': index === selectedIndex }"
class="command-item group hover:bg-gray-100"
@click="handleSelectItem(index)"
>
<component :is="item.icon" class="command-icon group-hover:!bg-white" />
<span
class="command-title group-hover:text-gray-900 group-hover:font-medium"
>
{{ i18n.global.t(item.title) }}
</span>
</div>
</template>
<div v-else class="command-empty">
<span>
{{ i18n.global.t("editor.extensions.commands_menu.no_results") }}
</span>
</div>
</div>
</template>
<style lang="scss">
.command-items {
@apply relative
rounded-md
bg-white
overflow-hidden
drop-shadow
w-52
p-1
max-h-72
overflow-y-auto;
.command-item {
@apply flex flex-row items-center rounded gap-4 p-1;
&.is-selected {
@apply bg-gray-100;
.command-icon {
@apply bg-white;
}
.command-title {
@apply text-gray-900 font-medium;
}
}
.command-icon {
@apply bg-gray-100 p-1 rounded w-6 h-6;
}
.command-title {
@apply text-xs
text-gray-600;
}
}
.command-empty {
@apply flex justify-center items-center p-1 text-xs text-gray-600;
}
}
</style>

View File

@ -0,0 +1,123 @@
import {
Extension,
VueRenderer,
type Editor,
type AnyExtension,
type Range,
} from "@/tiptap/vue-3";
import Suggestion from "@tiptap/suggestion";
import type { CommandMenuItem } from "@/types";
import type { Instance } from "tippy.js";
import CommandsView from "./CommandsView.vue";
import tippy from "tippy.js";
export default Extension.create({
name: "commands-menu",
addProseMirrorPlugins() {
const commandMenuItems = getToolbarItemsFromExtensions(
this.editor as Editor
);
return [
Suggestion({
editor: this.editor,
char: "/",
// @ts-ignore
command: ({
editor,
range,
props,
}: {
editor: Editor;
range: Range;
props: CommandMenuItem;
}) => {
props.command({ editor, range });
},
items: ({ query }: { query: string }) => {
return commandMenuItems.filter((item) =>
[...item.keywords, item.title].some((keyword) =>
keyword.includes(query)
)
);
},
render: () => {
let component: VueRenderer;
let popup: Instance[];
return {
onStart: (props: Record<string, any>) => {
component = new VueRenderer(CommandsView, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate(props: Record<string, any>) {
component.updateProps(props);
if (!props.clientRect) {
return;
}
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props: Record<string, any>) {
if (props.event.key === "Escape") {
popup[0].hide();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
popup[0].destroy();
component.destroy();
},
};
},
}),
];
},
});
function getToolbarItemsFromExtensions(editor: Editor) {
const extensionManager = editor?.extensionManager;
return extensionManager.extensions
.reduce((acc: CommandMenuItem[], extension: AnyExtension) => {
const { getCommandMenuItems } = extension.options;
if (!getCommandMenuItems) {
return acc;
}
const items = getCommandMenuItems();
if (Array.isArray(items)) {
return [...acc, ...items];
}
return [...acc, items];
}, [])
.sort((a, b) => a.priority - b.priority);
}

View File

@ -0,0 +1 @@
export { default as ExtensionCommands } from "./commands";

View File

@ -0,0 +1,587 @@
import { Editor, Extension } from "@/tiptap/vue-3";
import {
Fragment,
Node,
NodeType,
ResolvedPos,
Slice,
NodeSelection,
Plugin,
PluginKey,
Decoration,
DecorationSet,
// @ts-ignore
__serializeForClipboard as serializeForClipboard,
} from "@/tiptap/pm";
import {} from "@/tiptap";
import type { EditorView } from "@/tiptap/pm";
import type { DraggableItem, ExtensionOptions } from "@/types";
// https://developer.mozilla.org/zh-CN/docs/Web/API/HTML_Drag_and_Drop_API
// https://github.com/ueberdosis/tiptap/blob/7832b96afbfc58574785043259230801e179310f/demos/src/Experiments/GlobalDragHandle/Vue/DragHandle.js
export interface ActiveNode {
$pos: ResolvedPos;
node: Node;
el: HTMLElement;
offset: number;
domOffsetLeft: number;
domOffsetTop: number;
}
let draggableItem: DraggableItem | boolean | undefined = undefined;
let draggableHandleDom: HTMLElement | null = null;
let currEditorView: EditorView;
let activeNode: ActiveNode | null = null;
let activeSelection: NodeSelection | null = null;
let mouseleaveTimer: any;
let dragging = false;
let hoverOrClickDragItem = false;
const createDragHandleDom = () => {
const dom = document.createElement("div");
dom.classList.add("draggable");
dom.draggable = true;
dom.setAttribute("data-drag-handle", "true");
return dom;
};
const showDragHandleDOM = () => {
draggableHandleDom?.classList?.add("show");
draggableHandleDom?.classList?.remove("hide");
};
const hideDragHandleDOM = () => {
draggableHandleDom?.classList?.remove("show");
draggableHandleDom?.classList?.remove("active");
draggableHandleDom?.classList?.add("hide");
};
/**
* Correct the position of draggableHandleDom to match the current position of activeNode.
*
* @param view
* @param referenceRectDOM
*/
const renderDragHandleDOM = (
view: EditorView,
activeNode: ActiveNode | undefined
) => {
const root = view.dom.parentElement;
if (!root) {
return;
}
if (!draggableHandleDom) {
return;
}
const referenceRectDOM = activeNode?.el;
if (!referenceRectDOM) {
return;
}
const targetNodeRect = referenceRectDOM.getBoundingClientRect();
const rootRect = root.getBoundingClientRect();
const handleRect = draggableHandleDom.getBoundingClientRect();
const left =
targetNodeRect.left -
rootRect.left -
handleRect.width -
5 +
activeNode.domOffsetLeft;
const top =
targetNodeRect.top -
rootRect.top +
handleRect.height / 2 +
root.scrollTop +
activeNode.domOffsetTop;
draggableHandleDom.style.left = `${left}px`;
draggableHandleDom.style.top = `${top - 2}px`;
showDragHandleDOM();
};
const handleMouseEnterEvent = () => {
if (!activeNode) {
return;
}
hoverOrClickDragItem = true;
currEditorView.dispatch(currEditorView.state.tr);
clearTimeout(mouseleaveTimer);
showDragHandleDOM();
};
const handleMouseLeaveEvent = () => {
if (!activeNode) {
return;
}
hoverOrClickDragItem = false;
currEditorView.dispatch(currEditorView.state.tr);
hideDragHandleDOM();
};
const handleMouseDownEvent = () => {
if (!activeNode) {
return null;
}
hoverOrClickDragItem = false;
currEditorView.dispatch(currEditorView.state.tr);
if (NodeSelection.isSelectable(activeNode.node)) {
const nodeSelection = NodeSelection.create(
currEditorView.state.doc,
activeNode.$pos.pos - activeNode.offset
);
currEditorView.dispatch(
currEditorView.state.tr.setSelection(nodeSelection)
);
currEditorView.focus();
activeSelection = nodeSelection;
return nodeSelection;
}
return null;
};
const handleMouseUpEvent = () => {
if (!dragging) return;
dragging = false;
activeSelection = null;
activeNode = null;
};
const handleDragStartEvent = (event: DragEvent) => {
dragging = true;
hoverOrClickDragItem = false;
if (event.dataTransfer && activeNode && activeSelection) {
const slice = activeSelection.content();
event.dataTransfer.effectAllowed = "move";
const { dom, text } = serializeForClipboard(currEditorView, slice);
event.dataTransfer.clearData();
event.dataTransfer.setData("text/html", dom.innerHTML);
event.dataTransfer.setData("text/plain", text);
event.dataTransfer.setDragImage(activeNode?.el as any, 0, 0);
currEditorView.dragging = {
slice,
move: true,
};
}
};
const getDOMByPos = (
view: EditorView,
root: HTMLElement,
$pos: ResolvedPos
) => {
const { node } = view.domAtPos($pos.pos);
let el: HTMLElement = node as HTMLElement;
let parent = el.parentElement;
while (parent && parent !== root && $pos.pos === view.posAtDOM(parent, 0)) {
el = parent;
parent = parent.parentElement;
}
return el;
};
const getPosByDOM = (
view: EditorView,
dom: HTMLElement
): ResolvedPos | null => {
const domPos = view.posAtDOM(dom, 0);
if (domPos < 0) {
return null;
}
return view.state.doc.resolve(domPos);
};
export const selectAncestorNodeByDom = (
dom: HTMLElement,
view: EditorView
): ActiveNode | null => {
const root = view.dom.parentElement;
if (!root) {
return null;
}
const $pos = getPosByDOM(view, dom);
if (!$pos) {
return null;
}
const node = $pos.node();
const el = getDOMByPos(view, root, $pos);
return { node, $pos, el, offset: 1, domOffsetLeft: 0, domOffsetTop: 0 };
};
const getExtensionDraggableItem = (editor: Editor, node: Node) => {
const extension = editor.extensionManager.extensions.find((extension) => {
return extension.name === node.type.name;
});
if (!extension) {
return;
}
const draggableItem = (extension.options as ExtensionOptions).getDraggable?.({
editor,
});
return draggableItem;
};
/**
* According to the extension, obtain different rendering positions.
*
* @param editor
* @param parentNode
* @param dom
* @returns
**/
const getRenderContainer = (
view: EditorView,
draggableItem: DraggableItem | undefined,
dom: HTMLElement
): ActiveNode => {
const renderContainer = draggableItem?.getRenderContainer?.({ dom, view });
const node = selectAncestorNodeByDom(renderContainer?.el || dom, view);
return {
el: renderContainer?.el || dom,
node: renderContainer?.node || (node?.node as Node),
$pos: renderContainer?.$pos || (node?.$pos as ResolvedPos),
offset: renderContainer?.nodeOffset || (node?.offset as number),
domOffsetLeft: renderContainer?.dragDomOffset?.x || 0,
domOffsetTop: renderContainer?.dragDomOffset?.y || 0,
};
};
const findParentNodeByDepth = (
view: EditorView,
dom: HTMLElement,
depth = 1
): Node | undefined => {
const $pos = getPosByDOM(view, dom);
if (!$pos) {
return;
}
if (depth > $pos.depth) {
// 解决 audio 等不是块元素的问题,目前仅寻找其直接第一个子节点
if (depth - $pos.depth == 1) {
const parentNode = $pos.node();
if (parentNode.firstChild && !parentNode.firstChild.type.isBlock) {
return parentNode.firstChild;
}
}
return;
}
const node = $pos.node(depth);
if (!node) {
return;
}
return node;
};
const getDraggableItem = ({
editor,
view,
dom,
event,
depth = 1,
}: {
editor: Editor;
view: EditorView;
dom: HTMLElement;
event?: any;
depth?: number;
}): DraggableItem | boolean | undefined => {
const parentNode = findParentNodeByDepth(view, dom, depth);
if (!parentNode) {
return;
}
const draggableItem = getExtensionDraggableItem(editor, parentNode);
if (draggableItem) {
if (typeof draggableItem === "boolean") {
return draggableItem;
}
const container = getRenderContainer(view, draggableItem, dom);
const coords = { left: event.clientX, top: event.clientY };
const pos = view.posAtCoords(coords);
if (pos) {
if (pos.inside == -1) {
return draggableItem;
}
if (
!(
pos.inside >= container.$pos.start() &&
pos.inside <= container.$pos.end()
)
) {
return draggableItem;
}
}
if (draggableItem.allowPropagationDownward) {
const draggable = getDraggableItem({
editor,
view,
dom,
event,
depth: ++depth,
});
if (draggable) {
return draggable;
}
}
return draggableItem;
}
return getDraggableItem({
editor,
view,
dom,
event,
depth: ++depth,
});
};
/**
* Get the insertion point of the target position relative to doc
*
* @param doc
* @param pos
* @param slice
* @returns
*/
const dropPoint = (doc: Node, pos: number, slice: Slice) => {
const $pos = doc.resolve(pos);
if (!slice.content.size) {
return pos;
}
let content: Fragment | undefined = slice.content;
for (let i = 0; i < slice.openStart; i++) {
content = content?.firstChild?.content;
}
for (
let pass = 1;
pass <= (slice.openStart == 0 && slice.size ? 2 : 1);
pass++
) {
for (let dep = $pos.depth; dep >= 0; dep--) {
const bias =
dep == $pos.depth
? 0
: $pos.pos <= ($pos.start(dep + 1) + $pos.end(dep + 1)) / 2
? -1
: 1;
const insertPos = $pos.index(dep) + (bias > 0 ? 1 : 0);
const parent = $pos.node(dep);
let fits = false;
if (pass == 1) {
fits = parent.canReplace(insertPos, insertPos, content);
} else {
const wrapping = parent
.contentMatchAt(insertPos)
.findWrapping(content?.firstChild?.type as NodeType);
fits =
(wrapping &&
parent.canReplaceWith(insertPos, insertPos, wrapping[0])) ||
false;
}
if (fits) {
return bias == 0
? $pos.pos
: bias < 0
? $pos.before(dep + 1)
: $pos.after(dep + 1);
}
}
}
return null;
};
const Draggable = Extension.create({
name: "draggable",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("node-draggable"),
view: (view) => {
draggableHandleDom = createDragHandleDom();
draggableHandleDom.addEventListener(
"mouseenter",
handleMouseEnterEvent
);
draggableHandleDom.addEventListener(
"mouseleave",
handleMouseLeaveEvent
);
draggableHandleDom.addEventListener(
"mousedown",
handleMouseDownEvent
);
draggableHandleDom.addEventListener("mouseup", handleMouseUpEvent);
draggableHandleDom.addEventListener(
"dragstart",
handleDragStartEvent
);
const viewDomParentNode = view.dom.parentNode as HTMLElement;
viewDomParentNode.appendChild(draggableHandleDom);
viewDomParentNode.style.position = "relative";
return {
update: (view) => {
currEditorView = view;
},
destroy: () => {
if (!draggableHandleDom) {
return;
}
clearTimeout(mouseleaveTimer);
draggableHandleDom.removeEventListener(
"mouseenter",
handleMouseEnterEvent
);
draggableHandleDom.removeEventListener(
"mouseleave",
handleMouseLeaveEvent
);
draggableHandleDom.removeEventListener(
"mousedown",
handleMouseDownEvent
);
draggableHandleDom.removeEventListener(
"mouseup",
handleMouseUpEvent
);
draggableHandleDom.removeEventListener(
"dragstart",
handleDragStartEvent
);
draggableHandleDom.remove();
},
};
},
props: {
handleDOMEvents: {
// @ts-ignore
mousemove: (view: EditorView, event) => {
const coords = { left: event.clientX, top: event.clientY };
const pos = view.posAtCoords(coords);
if (!pos || !pos.pos) return false;
const nodeDom =
view.nodeDOM(pos.pos) ||
view.domAtPos(pos.pos)?.node ||
event.target;
if (!nodeDom) {
hideDragHandleDOM();
return false;
}
let dom: HTMLElement | null = nodeDom as HTMLElement;
while (dom && dom.nodeType === 3) {
dom = dom.parentElement;
}
if (!(dom instanceof HTMLElement)) {
hideDragHandleDOM();
return false;
}
const editor = this.editor;
draggableItem = getDraggableItem({
// @ts-ignore
editor,
view,
dom,
event,
});
// skip the current extension if getDraggable() is not implemented or returns false.
if (!draggableItem) {
return false;
}
if (typeof draggableItem === "boolean") {
activeNode = selectAncestorNodeByDom(dom, view);
} else {
activeNode = getRenderContainer(view, draggableItem, dom);
}
if (!activeNode) {
return;
}
renderDragHandleDOM(view, activeNode);
return false;
},
mouseleave: () => {
clearTimeout(mouseleaveTimer);
mouseleaveTimer = setTimeout(() => {
hideDragHandleDOM();
}, 400);
return false;
},
},
handleKeyDown() {
if (!draggableHandleDom) return false;
draggableItem = undefined;
hideDragHandleDOM();
return false;
},
handleDrop: (view, event, slice) => {
if (!draggableHandleDom) {
return false;
}
if (!activeSelection) {
return false;
}
const eventPos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!eventPos) {
return true;
}
const $mouse = view.state.doc.resolve(eventPos.pos);
const insertPos = dropPoint(view.state.doc, $mouse.pos, slice);
if (!insertPos) {
return false;
}
let isDisableDrop = false;
if (dragging) {
if (typeof draggableItem !== "boolean") {
const handleDrop = draggableItem?.handleDrop?.({
view,
event,
slice,
insertPos,
node: activeNode?.node as Node,
});
if (typeof handleDrop === "boolean") {
isDisableDrop = handleDrop;
}
}
}
dragging = false;
draggableItem = undefined;
activeSelection = null;
activeNode = null;
return isDisableDrop;
},
decorations: (state) => {
if (!hoverOrClickDragItem || !activeNode) {
return DecorationSet.empty;
}
const { $pos } = activeNode;
return DecorationSet.create(state.doc, [
Decoration.node($pos.before(), $pos.after(), {
class: "has-draggable-handle",
}),
]);
},
},
}),
];
},
});
export default Draggable;

View File

@ -0,0 +1,117 @@
import { ToolbarItem, ToolbarSubItem } from "@/components";
import { Extension, type Editor } from "@/tiptap/vue-3";
import { markRaw } from "vue";
import MdiFormatSize from "~icons/mdi/format-size";
import TiptapTextStyle from "@tiptap/extension-text-style";
import { i18n } from "@/locales";
export type FontSizeOptions = {
types: string[];
};
declare module "@/tiptap" {
interface Commands<ReturnType> {
fontSize: {
setFontSize: (size: number) => ReturnType;
unsetFontSize: () => ReturnType;
};
}
}
const FontSize = Extension.create<FontSizeOptions>({
name: "fontSize",
addOptions() {
return {
types: ["textStyle"],
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 31,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: false,
icon: markRaw(MdiFormatSize),
},
children: [
{
priority: 0,
component: markRaw(ToolbarSubItem),
props: {
editor,
isActive: false,
title: i18n.global.t("editor.common.text.default"),
action: () => editor.chain().focus().unsetFontSize().run(),
},
},
...[8, 10, 12, 14, 16, 18, 20, 24, 30, 36, 48, 60, 72].map(
(size) => {
return {
priority: size,
component: markRaw(ToolbarSubItem),
props: {
editor,
isActive: false,
title: `${size} px`,
action: () =>
editor.chain().focus().setFontSize(size).run(),
},
};
}
),
],
};
},
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
fontSize: {
default: null,
parseHTML: (element) => {
return element.style.fontSize || "";
},
renderHTML: (attributes) => {
if (!attributes.fontSize) {
return attributes;
}
return {
style: `font-size: ${attributes.fontSize
.toString()
.replace("px", "")}px`,
};
},
},
},
},
];
},
addCommands() {
return {
setFontSize:
(size) =>
({ chain }) => {
return chain().setMark("textStyle", { fontSize: size }).run();
},
unsetFontSize:
() =>
({ chain }) => {
return chain()
.setMark("textStyle", { fontSize: null })
.removeEmptyTextStyle()
.run();
},
};
},
addExtensions() {
return [TiptapTextStyle];
},
});
export default FontSize;

View File

@ -0,0 +1,270 @@
import type { Editor, Range } from "@/tiptap/vue-3";
import TiptapParagraph from "@/extensions/paragraph";
import TiptapHeading from "@tiptap/extension-heading";
import type { HeadingOptions } from "@tiptap/extension-heading";
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
import ToolbarSubItem from "@/components/toolbar/ToolbarSubItem.vue";
import MdiFormatParagraph from "~icons/mdi/format-paragraph";
import MdiFormatHeaderPound from "~icons/mdi/format-header-pound";
import MdiFormatHeader1 from "~icons/mdi/format-header-1";
import MdiFormatHeader2 from "~icons/mdi/format-header-2";
import MdiFormatHeader3 from "~icons/mdi/format-header-3";
import MdiFormatHeader4 from "~icons/mdi/format-header-4";
import MdiFormatHeader5 from "~icons/mdi/format-header-5";
import MdiFormatHeader6 from "~icons/mdi/format-header-6";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import type { ExtensionOptions } from "@/types";
const Blockquote = TiptapHeading.extend<ExtensionOptions & HeadingOptions>({
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 30,
component: markRaw(ToolbarItem),
props: {
editor,
isActive:
editor.isActive("paragraph") || editor.isActive("heading"),
icon: markRaw(MdiFormatHeaderPound),
},
children: [
{
priority: 10,
component: markRaw(ToolbarSubItem),
props: {
editor,
isActive: editor.isActive("paragraph"),
icon: markRaw(MdiFormatParagraph),
title: i18n.global.t("editor.common.heading.paragraph"),
action: () => editor.chain().focus().setParagraph().run(),
},
},
{
priority: 20,
component: markRaw(ToolbarSubItem),
props: {
editor,
isActive: editor.isActive("heading", { level: 1 }),
icon: markRaw(MdiFormatHeader1),
title: i18n.global.t("editor.common.heading.header1"),
action: () =>
editor.chain().focus().toggleHeading({ level: 1 }).run(),
},
},
{
priority: 30,
component: markRaw(ToolbarSubItem),
props: {
editor,
isActive: editor.isActive("heading", { level: 2 }),
icon: markRaw(MdiFormatHeader2),
title: i18n.global.t("editor.common.heading.header2"),
action: () =>
editor.chain().focus().toggleHeading({ level: 2 }).run(),
},
},
{
priority: 40,
component: markRaw(ToolbarSubItem),
props: {
editor,
isActive: editor.isActive("heading", { level: 3 }),
icon: markRaw(MdiFormatHeader3),
title: i18n.global.t("editor.common.heading.header3"),
action: () =>
editor.chain().focus().toggleHeading({ level: 3 }).run(),
},
},
{
priority: 50,
component: markRaw(ToolbarSubItem),
props: {
editor,
isActive: editor.isActive("heading", { level: 4 }),
icon: markRaw(MdiFormatHeader4),
title: i18n.global.t("editor.common.heading.header4"),
action: () =>
editor.chain().focus().toggleHeading({ level: 4 }).run(),
},
},
{
priority: 60,
component: markRaw(ToolbarSubItem),
props: {
editor,
isActive: editor.isActive("heading", { level: 5 }),
icon: markRaw(MdiFormatHeader5),
title: i18n.global.t("editor.common.heading.header5"),
action: () =>
editor.chain().focus().toggleHeading({ level: 5 }).run(),
},
},
{
priority: 70,
component: markRaw(ToolbarSubItem),
props: {
editor,
isActive: editor.isActive("heading", { level: 6 }),
icon: markRaw(MdiFormatHeader6),
title: i18n.global.t("editor.common.heading.header6"),
action: () =>
editor.chain().focus().toggleHeading({ level: 6 }).run(),
},
},
],
};
},
getCommandMenuItems() {
return [
{
priority: 10,
icon: markRaw(MdiFormatParagraph),
title: "editor.common.heading.paragraph",
keywords: ["paragraph", "text", "putongwenben"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor.chain().focus().deleteRange(range).setParagraph().run();
},
},
{
priority: 20,
icon: markRaw(MdiFormatHeader1),
title: "editor.common.heading.header1",
keywords: ["h1", "header1", "1", "yijibiaoti"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 1 })
.run();
},
},
{
priority: 30,
icon: markRaw(MdiFormatHeader2),
title: "editor.common.heading.header2",
keywords: ["h2", "header2", "2", "erjibiaoti"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 2 })
.run();
},
},
{
priority: 40,
icon: markRaw(MdiFormatHeader3),
title: "editor.common.heading.header3",
keywords: ["h3", "header3", "3", "sanjibiaoti"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 3 })
.run();
},
},
{
priority: 50,
icon: markRaw(MdiFormatHeader4),
title: "editor.common.heading.header4",
keywords: ["h4", "header4", "4", "sijibiaoti"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 4 })
.run();
},
},
{
priority: 60,
icon: markRaw(MdiFormatHeader5),
title: "editor.common.heading.header5",
keywords: ["h5", "header5", "5", "wujibiaoti"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 5 })
.run();
},
},
{
priority: 70,
icon: markRaw(MdiFormatHeader6),
title: "editor.common.heading.header6",
keywords: ["h6", "header6", "6", "liujibiaoti"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 6 })
.run();
},
},
];
},
getDraggable() {
return {
getRenderContainer({ dom }: { dom: HTMLElement }) {
const tagNames = ["H1", "H2", "H3", "H4", "H5", "H6"];
let container = dom;
while (container && !tagNames.includes(container.tagName)) {
container = container.parentElement as HTMLElement;
}
if (!container) {
return {
el: dom,
};
}
let y;
switch (container?.tagName) {
case "H1":
y = 10;
break;
case "H2":
y = 2;
break;
case "H3":
y = 0;
break;
case "H4":
y = -3;
break;
case "H5":
y = -5;
break;
case "H6":
y = -5;
break;
default:
y = 0;
break;
}
return {
el: container,
dragDomOffset: {
y: y,
},
};
},
};
},
};
},
addExtensions() {
return [TiptapParagraph];
},
});
export default Blockquote;

View File

@ -0,0 +1,63 @@
<script lang="ts" setup>
import { BubbleItem } from "@/components";
import ColorPickerDropdown from "@/components/common/ColorPickerDropdown.vue";
import MdiFormatColorMarkerCancel from "~icons/mdi/format-color-marker-cancel";
import { i18n } from "@/locales";
import type { Editor } from "@/tiptap/vue-3";
import type { Component } from "vue";
const props = defineProps<{
editor: Editor;
isActive: ({ editor }: { editor: Editor }) => boolean;
visible?: ({ editor }: { editor: Editor }) => boolean;
icon?: Component;
title?: string;
action?: ({ editor }: { editor: Editor }) => void;
}>();
function handleSetColor(color?: string) {
if (!color) {
return;
}
props.editor?.chain().focus().toggleHighlight({ color }).run();
}
function handleUnsetColor() {
props.editor?.chain().focus().unsetHighlight().run();
}
</script>
<template>
<ColorPickerDropdown @update:model-value="handleSetColor">
<BubbleItem v-bind="props" @click="handleSetColor()" />
<template #prefix>
<div class="p-1">
<div
class="flex items-center gap-2 rounded cursor-pointer hover:bg-gray-100 p-1"
@click="handleUnsetColor"
>
<div class="inline-flex items-center gap-2">
<MdiFormatColorMarkerCancel />
<span class="text-xs text-gray-600">
{{ i18n.global.t("editor.extensions.highlight.unset") }}
</span>
</div>
</div>
</div>
<div class="p-1">
<div
class="flex items-center gap-2 rounded cursor-pointer hover:bg-gray-100 p-1"
@click="handleSetColor()"
>
<div
class="h-5 w-5 rounded-sm cursor-pointer hover:ring-1 ring-offset-1 ring-gray-300"
:style="{ 'background-color': '#fff8c5' }"
></div>
<span class="text-xs text-gray-600">
{{ i18n.global.t("editor.common.button.restore_default") }}
</span>
</div>
</div>
</template>
</ColorPickerDropdown>
</template>

View File

@ -0,0 +1,74 @@
<script lang="ts" setup>
import { ToolbarItem } from "@/components";
import type { Component } from "vue";
import type { Editor } from "@/tiptap/vue-3";
import ColorPickerDropdown from "@/components/common/ColorPickerDropdown.vue";
import MdiFormatColorMarkerCancel from "~icons/mdi/format-color-marker-cancel";
import { i18n } from "@/locales";
const props = withDefaults(
defineProps<{
editor?: Editor;
isActive?: boolean;
disabled?: boolean;
title?: string;
action?: () => void;
icon?: Component;
}>(),
{
editor: undefined,
isActive: false,
disabled: false,
title: undefined,
action: undefined,
icon: undefined,
}
);
function handleSetColor(color?: string) {
props.editor
?.chain()
.focus()
.setHighlight(color ? { color } : undefined)
.run();
}
function handleUnsetColor() {
props.editor?.chain().focus().unsetHighlight().run();
}
</script>
<template>
<ColorPickerDropdown @update:model-value="handleSetColor">
<ToolbarItem v-bind="props" @click="handleSetColor()" />
<template #prefix>
<div class="p-1">
<div
class="flex items-center gap-2 rounded cursor-pointer hover:bg-gray-100 p-1"
@click="handleUnsetColor"
>
<div class="inline-flex items-center gap-2">
<MdiFormatColorMarkerCancel />
<span class="text-xs text-gray-600">
{{ i18n.global.t("editor.extensions.highlight.unset") }}
</span>
</div>
</div>
</div>
<div class="p-1">
<div
class="flex items-center gap-2 rounded cursor-pointer hover:bg-gray-100 p-1"
@click="handleSetColor()"
>
<div
class="h-5 w-5 rounded-sm cursor-pointer hover:ring-1 ring-offset-1 ring-gray-300"
:style="{ 'background-color': '#fff8c5' }"
></div>
<span class="text-xs text-gray-600">
{{ i18n.global.t("editor.common.button.restore_default") }}
</span>
</div>
</div>
</template>
</ColorPickerDropdown>
</template>

View File

@ -0,0 +1,43 @@
import type { Editor } from "@/tiptap/vue-3";
import TiptapHighlight from "@tiptap/extension-highlight";
import type { HighlightOptions } from "@tiptap/extension-highlight";
import MdiFormatColorHighlight from "~icons/mdi/format-color-highlight";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import type { ExtensionOptions } from "@/types";
import HighlightToolbarItem from "./HighlightToolbarItem.vue";
const Highlight = TiptapHighlight.extend<ExtensionOptions & HighlightOptions>({
addAttributes() {
if (!this.options.multicolor) {
return {};
}
return {
...this.parent?.(),
style: {
default: "display: inline-block;",
parseHTML: (element) => element.getAttribute("style"),
},
};
},
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 80,
component: markRaw(HighlightToolbarItem),
props: {
editor,
isActive: editor.isActive("highlight"),
icon: markRaw(MdiFormatColorHighlight),
title: i18n.global.t("editor.common.highlight"),
},
};
},
};
},
}).configure({ multicolor: true });
export default Highlight;

View File

@ -0,0 +1,45 @@
import type { Editor } from "@/tiptap/vue-3";
import TiptapHistory from "@tiptap/extension-history";
import type { HistoryOptions } from "@tiptap/extension-history";
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
import MdiUndoVariant from "~icons/mdi/undo-variant";
import MdiRedoVariant from "~icons/mdi/redo-variant";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import type { ExtensionOptions } from "@/types";
const History = TiptapHistory.extend<ExtensionOptions & HistoryOptions>({
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return [
{
priority: 10,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: false,
icon: markRaw(MdiUndoVariant),
title: i18n.global.t("editor.menus.undo"),
action: () => editor.chain().undo().focus().run(),
},
},
{
priority: 20,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: false,
icon: markRaw(MdiRedoVariant),
title: i18n.global.t("editor.menus.redo"),
action: () => editor.chain().redo().focus().run(),
},
},
];
},
};
},
});
export default History;

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import { i18n } from "@/locales";
import type { Editor } from "@/tiptap/vue-3";
import { computed, type Component } from "vue";
import Iframe from "./index";
const props = defineProps<{
editor: Editor;
isActive: ({ editor }: { editor: Editor }) => boolean;
visible?: ({ editor }: { editor: Editor }) => boolean;
icon?: Component;
title?: string;
action?: ({ editor }: { editor: Editor }) => void;
}>();
const src = computed({
get: () => {
return props.editor.getAttributes(Iframe.name).src;
},
set: (src: string) => {
props.editor.chain().updateAttributes(Iframe.name, { src: src }).run();
},
});
</script>
<template>
<input
v-model.lazy="src"
:placeholder="i18n.global.t('editor.common.placeholder.link_input')"
class="bg-gray-50 rounded-md hover:bg-gray-100 block px-2 w-full py-1.5 text-sm text-gray-900 border border-gray-300 focus:ring-blue-500 focus:border-blue-500"
/>
</template>

View File

@ -0,0 +1,51 @@
<script setup lang="ts">
import { BlockActionInput, BlockActionSeparator } from "@/components";
import { i18n } from "@/locales";
import type { Editor } from "@/tiptap/vue-3";
import { computed } from "vue";
import Iframe from "./index";
const props = defineProps<{
editor: Editor;
}>();
const width = computed({
get: () => {
return props.editor.getAttributes(Iframe.name).width;
},
set: (value: string) => {
handleSetSize(value, height.value);
},
});
const height = computed({
get: () => {
return props.editor.getAttributes(Iframe.name).height;
},
set: (value: string) => {
handleSetSize(width.value, value);
},
});
const handleSetSize = (width: string, height: string) => {
props.editor
.chain()
.updateAttributes(Iframe.name, { width, height })
.focus()
.setNodeSelection(props.editor.state.selection.from)
.run();
};
</script>
<template>
<BlockActionInput
v-model.lazy.trim="width"
:tooltip="i18n.global.t('editor.common.tooltip.custom_width_input')"
/>
<BlockActionInput
v-model.lazy.trim="height"
:tooltip="i18n.global.t('editor.common.tooltip.custom_height_input')"
/>
<BlockActionSeparator />
</template>

View File

@ -0,0 +1,79 @@
<script lang="ts" setup>
import type { Node as ProseMirrorNode, Decoration } from "@/tiptap/pm";
import type { Editor, Node } from "@/tiptap/vue-3";
import { NodeViewWrapper } from "@/tiptap/vue-3";
import { computed, onMounted, ref } from "vue";
import { i18n } from "@/locales";
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 src = computed({
get: () => {
return props.node?.attrs.src;
},
set: (src: string) => {
props.updateAttributes({ src: src });
},
});
const frameborder = computed(() => {
return props.node.attrs.frameborder;
});
function handleSetFocus() {
props.editor.commands.setNodeSelection(props.getPos());
}
const inputRef = ref();
onMounted(() => {
if (!src.value) {
inputRef.value.focus();
}
});
</script>
<template>
<node-view-wrapper as="div" class="inline-block w-full">
<div
class="inline-block overflow-hidden transition-all text-center relative h-full"
:style="{
width: node.attrs.width,
}"
>
<div v-if="!src" class="p-1.5">
<input
ref="inputRef"
v-model.lazy="src"
class="block px-2 w-full py-1.5 text-sm text-gray-900 border border-gray-300 rounded-md bg-gray-50 focus:ring-blue-500 focus:border-blue-500"
:placeholder="i18n.global.t('editor.common.placeholder.link_input')"
tabindex="-1"
@focus="handleSetFocus"
/>
</div>
<iframe
v-else
class="rounded-md"
:src="node!.attrs.src"
:width="node.attrs.width"
:height="node.attrs.height"
scrolling="yes"
:frameborder="frameborder"
framespacing="0"
allowfullscreen="true"
:class="{
'border-2': frameborder === '1',
}"
@mouseenter="handleSetFocus"
></iframe>
</div>
</node-view-wrapper>
</template>

View File

@ -0,0 +1,482 @@
import type { ExtensionOptions, NodeBubbleMenu } from "@/types";
import {
Editor,
isActive,
mergeAttributes,
Node,
nodeInputRule,
nodePasteRule,
type Range,
VueNodeViewRenderer,
} from "@/tiptap/vue-3";
import type { EditorState } from "@/tiptap/pm";
import { markRaw } from "vue";
import IframeView from "./IframeView.vue";
import MdiWeb from "~icons/mdi/web";
import ToolboxItem from "@/components/toolbox/ToolboxItem.vue";
import { i18n } from "@/locales";
import { BlockActionSeparator } from "@/components";
import BubbleIframeSize from "./BubbleItemIframeSize.vue";
import BubbleIframeLink from "./BubbleItemIframeLink.vue";
import MdiBorderAllVariant from "~icons/mdi/border-all-variant";
import MdiBorderNoneVariant from "~icons/mdi/border-none-variant";
import MdiDesktopMac from "~icons/mdi/desktop-mac";
import MdiTabletIpad from "~icons/mdi/tablet-ipad";
import MdiCellphoneIphone from "~icons/mdi/cellphone-iphone";
import MdiFormatAlignLeft from "~icons/mdi/format-align-left";
import MdiFormatAlignCenter from "~icons/mdi/format-align-center";
import MdiFormatAlignRight from "~icons/mdi/format-align-right";
import MdiFormatAlignJustify from "~icons/mdi/format-align-justify";
import { deleteNode } from "@/utils";
import MdiDeleteForeverOutline from "@/components/icon/MdiDeleteForeverOutline.vue";
import MdiShare from "~icons/mdi/share";
import MdiLinkVariant from "~icons/mdi/link-variant";
import MdiWebSync from "~icons/mdi/web-sync";
declare module "@/tiptap" {
interface Commands<ReturnType> {
iframe: {
setIframe: (options: { src: string }) => ReturnType;
};
}
}
const Iframe = Node.create<ExtensionOptions>({
name: "iframe",
inline() {
return true;
},
group() {
return "inline";
},
addAttributes() {
return {
...this.parent?.(),
src: {
default: null,
parseHTML: (element) => {
const src = element.getAttribute("src");
return src;
},
},
width: {
default: "100%",
parseHTML: (element) => {
return element.getAttribute("width");
},
renderHTML(attributes) {
return {
width: attributes.width,
};
},
},
height: {
default: "300px",
parseHTML: (element) => {
const height = element.getAttribute("height");
return height;
},
renderHTML: (attributes) => {
return {
height: attributes.height,
};
},
},
scrolling: {
default: null,
parseHTML: (element) => {
return element.getAttribute("scrolling");
},
renderHTML: (attributes) => {
return {
scrolling: attributes.scrolling,
};
},
},
frameborder: {
default: "0",
parseHTML: (element) => {
return element.getAttribute("frameborder");
},
renderHTML: (attributes) => {
return {
frameborder: attributes.frameborder,
};
},
},
allowfullscreen: {
default: true,
parseHTML: (element) => {
return element.getAttribute("allowfullscreen");
},
renderHTML: (attributes) => {
return {
allowfullscreen: attributes.allowfullscreen,
};
},
},
framespacing: {
default: 0,
parseHTML: (element) => {
const framespacing = element.getAttribute("framespacing");
return framespacing ? parseInt(framespacing, 10) : null;
},
renderHTML: (attributes) => {
return {
framespacing: attributes.framespacing,
};
},
},
style: {
renderHTML() {
return {
style: "display: inline-block",
};
},
},
};
},
parseHTML() {
return [
{
tag: "iframe",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["iframe", mergeAttributes(HTMLAttributes)];
},
addCommands() {
return {
setIframe:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
addInputRules() {
return [
nodeInputRule({
find: /^\$iframe\$$/,
type: this.type,
getAttributes: () => {
return { width: "100%" };
},
}),
];
},
addPasteRules() {
return [
nodePasteRule({
find: /<iframe.*?src="(.*?)".*?<\/iframe>/g,
type: this.type,
getAttributes: (match) => {
const parse = document
.createRange()
.createContextualFragment(match[0]);
const iframe = parse.querySelector("iframe");
if (!iframe) {
return;
}
return {
src: iframe.src,
width: iframe.width || "100%",
height: iframe.height || "300px",
};
},
}),
];
},
addNodeView() {
return VueNodeViewRenderer(IframeView);
},
addOptions() {
return {
getCommandMenuItems() {
return {
priority: 90,
icon: markRaw(MdiWeb),
title: "editor.extensions.commands_menu.iframe",
keywords: ["iframe", "qianruwangye"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent([{ type: "iframe", attrs: { src: "" } }])
.run();
},
};
},
getToolboxItems({ editor }: { editor: Editor }) {
return [
{
priority: 40,
component: markRaw(ToolboxItem),
props: {
editor,
icon: markRaw(MdiWeb),
title: i18n.global.t("editor.extensions.commands_menu.iframe"),
action: () => {
editor
.chain()
.focus()
.insertContent([{ type: "iframe", attrs: { src: "" } }])
.run();
},
},
},
];
},
getBubbleMenu({ editor }: { editor: Editor }): NodeBubbleMenu {
return {
pluginKey: "iframeBubbleMenu",
shouldShow: ({ state }: { state: EditorState }) => {
return isActive(state, Iframe.name);
},
items: [
{
priority: 10,
props: {
isActive: () =>
editor.getAttributes(Iframe.name).frameborder === "1",
icon: markRaw(
editor.getAttributes(Iframe.name).frameborder === "1"
? MdiBorderAllVariant
: MdiBorderNoneVariant
),
action: () => {
editor
.chain()
.updateAttributes(Iframe.name, {
frameborder:
editor.getAttributes(Iframe.name).frameborder === "1"
? "0"
: "1",
})
.focus()
.setNodeSelection(editor.state.selection.from)
.run();
},
title:
editor.getAttributes(Iframe.name).frameborder === "1"
? i18n.global.t(
"editor.extensions.iframe.disable_frameborder"
)
: i18n.global.t(
"editor.extensions.iframe.enable_frameborder"
),
},
},
{
priority: 20,
component: markRaw(BlockActionSeparator),
},
{
priority: 30,
component: markRaw(BubbleIframeSize),
},
{
priority: 40,
props: {
isActive: () => sizeMatch(editor, "390px", "844px"),
icon: markRaw(MdiCellphoneIphone),
action: () => {
handleSetSize(editor, "390px", "844px");
},
title: i18n.global.t("editor.extensions.iframe.phone_size"),
},
},
{
priority: 50,
props: {
isActive: () => sizeMatch(editor, "834px", "1194px"),
icon: markRaw(MdiTabletIpad),
action: () => {
handleSetSize(editor, "834px", "1194px");
},
title: i18n.global.t(
"editor.extensions.iframe.tablet_vertical_size"
),
},
},
{
priority: 60,
props: {
isActive: () => sizeMatch(editor, "1194px", "834px"),
icon: markRaw(MdiTabletIpad),
iconStyle: "transform: rotate(90deg)",
action: () => {
handleSetSize(editor, "1194px", "834px");
},
title: i18n.global.t(
"editor.extensions.iframe.tablet_horizontal_size"
),
},
},
{
priority: 70,
props: {
isActive: () => sizeMatch(editor, "100%", "834px"),
icon: markRaw(MdiDesktopMac),
action: () => {
handleSetSize(editor, "100%", "834px");
},
title: i18n.global.t("editor.extensions.iframe.desktop_size"),
},
},
{
priority: 80,
component: markRaw(BlockActionSeparator),
},
{
priority: 90,
props: {
isActive: () => editor.isActive({ textAlign: "left" }),
icon: markRaw(MdiFormatAlignLeft),
action: () => handleSetTextAlign(editor, "left"),
},
},
{
priority: 100,
props: {
isActive: () => editor.isActive({ textAlign: "center" }),
icon: markRaw(MdiFormatAlignCenter),
action: () => handleSetTextAlign(editor, "center"),
},
},
{
priority: 110,
props: {
isActive: () => editor.isActive({ textAlign: "right" }),
icon: markRaw(MdiFormatAlignRight),
action: () => handleSetTextAlign(editor, "right"),
},
},
{
priority: 120,
props: {
isActive: () => editor.isActive({ textAlign: "justify" }),
icon: markRaw(MdiFormatAlignJustify),
action: () => handleSetTextAlign(editor, "justify"),
},
},
{
priority: 130,
component: markRaw(BlockActionSeparator),
},
{
priority: 140,
props: {
icon: markRaw(MdiWebSync),
action: () => {
editor
.chain()
.updateAttributes(Iframe.name, {
src: editor.getAttributes(Iframe.name).src,
})
.run();
},
},
},
{
priority: 150,
props: {
icon: markRaw(MdiLinkVariant),
title: i18n.global.t("editor.common.button.edit_link"),
action: () => {
return markRaw(BubbleIframeLink);
},
},
},
{
priority: 160,
props: {
icon: markRaw(MdiShare),
title: i18n.global.t("editor.common.tooltip.open_link"),
action: () => {
window.open(editor.getAttributes(Iframe.name).src, "_blank");
},
},
},
{
priority: 190,
props: {
icon: markRaw(MdiDeleteForeverOutline),
title: i18n.global.t("editor.common.button.delete"),
action: ({ editor }) => {
deleteNode(Iframe.name, editor);
},
},
},
],
};
},
getDraggable() {
return {
getRenderContainer({ dom, view }) {
let container = dom;
while (container && container.tagName !== "P") {
container = container.parentElement as HTMLElement;
}
if (container) {
container = container.firstElementChild
?.firstElementChild as HTMLElement;
}
let node;
if (container.firstElementChild) {
const pos = view.posAtDOM(container.firstElementChild, 0);
const $pos = view.state.doc.resolve(pos);
node = $pos.node();
}
return {
node: node,
el: container as HTMLElement,
};
},
};
},
};
},
});
const sizeMatch = (editor: Editor, width: string, height: string) => {
const attr = editor.getAttributes(Iframe.name);
return width === attr.width && height === attr.height;
};
const handleSetSize = (editor: Editor, width: string, height: string) => {
editor
.chain()
.updateAttributes(Iframe.name, { width, height })
.focus()
.setNodeSelection(editor.state.selection.from)
.run();
};
const handleSetTextAlign = (
editor: Editor,
align: "left" | "center" | "right" | "justify"
) => {
editor.chain().focus().setTextAlign(align).run();
};
export default Iframe;

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
import { i18n } from "@/locales";
import type { Editor } from "@/tiptap/vue-3";
import { computed, type Component } from "vue";
import Image from "./index";
const props = defineProps<{
editor: Editor;
isActive: ({ editor }: { editor: Editor }) => boolean;
visible?: ({ editor }: { editor: Editor }) => boolean;
icon?: Component;
title?: string;
action?: ({ editor }: { editor: Editor }) => void;
}>();
const alt = computed({
get: () => {
return props.editor.getAttributes(Image.name).alt;
},
set: (alt: string) => {
props.editor
.chain()
.updateAttributes(Image.name, { alt: alt })
.setNodeSelection(props.editor.state.selection.from)
.focus()
.run();
},
});
</script>
<template>
<input
v-model.lazy="alt"
:placeholder="i18n.global.t('editor.common.placeholder.alt_input')"
class="bg-gray-50 rounded-md hover:bg-gray-100 block px-2 w-full py-1.5 text-sm text-gray-900 border border-gray-300 focus:ring-blue-500 focus:border-blue-500"
/>
</template>

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
import { i18n } from "@/locales";
import type { Editor } from "@/tiptap/vue-3";
import { computed, type Component } from "vue";
import Image from "./index";
const props = defineProps<{
editor: Editor;
isActive: ({ editor }: { editor: Editor }) => boolean;
visible?: ({ editor }: { editor: Editor }) => boolean;
icon?: Component;
title?: string;
action?: ({ editor }: { editor: Editor }) => void;
}>();
const href = computed({
get: () => {
return props.editor.getAttributes(Image.name).href;
},
set: (href: string) => {
props.editor
.chain()
.updateAttributes(Image.name, { href: href })
.setNodeSelection(props.editor.state.selection.from)
.focus()
.run();
},
});
</script>
<template>
<input
v-model.lazy="href"
:placeholder="i18n.global.t('editor.common.placeholder.alt_href')"
class="bg-gray-50 rounded-md hover:bg-gray-100 block px-2 w-full py-1.5 text-sm text-gray-900 border border-gray-300 focus:ring-blue-500 focus:border-blue-500"
/>
</template>

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
import { i18n } from "@/locales";
import type { Editor } from "@/tiptap/vue-3";
import { computed, type Component } from "vue";
import Image from "./index";
const props = defineProps<{
editor: Editor;
isActive: ({ editor }: { editor: Editor }) => boolean;
visible?: ({ editor }: { editor: Editor }) => boolean;
icon?: Component;
title?: string;
action?: ({ editor }: { editor: Editor }) => void;
}>();
const src = computed({
get: () => {
return props.editor.getAttributes(Image.name).src;
},
set: (src: string) => {
props.editor
.chain()
.updateAttributes(Image.name, { src: src })
.setNodeSelection(props.editor.state.selection.from)
.focus()
.run();
},
});
</script>
<template>
<input
v-model.lazy="src"
:placeholder="i18n.global.t('editor.common.placeholder.link_input')"
class="bg-gray-50 rounded-md hover:bg-gray-100 block px-2 w-full py-1.5 text-sm text-gray-900 border border-gray-300 focus:ring-blue-500 focus:border-blue-500"
/>
</template>

View File

@ -0,0 +1,177 @@
<script setup lang="ts">
import { i18n } from "@/locales";
import type { Editor } from "@/tiptap/vue-3";
import { computed, type Component, onUnmounted, ref, watch } from "vue";
import Image from "./index";
import {
BlockActionButton,
BlockActionInput,
BlockActionSeparator,
} from "@/components";
import MdiBackupRestore from "~icons/mdi/backup-restore";
import MdiImageSizeSelectActual from "~icons/mdi/image-size-select-actual";
import MdiImageSizeSelectLarge from "~icons/mdi/image-size-select-large";
import MdiImageSizeSelectSmall from "~icons/mdi/image-size-select-small";
import { useResizeObserver } from "@vueuse/core";
const props = defineProps<{
editor: Editor;
isActive: ({ editor }: { editor: Editor }) => boolean;
visible?: ({ editor }: { editor: Editor }) => boolean;
icon?: Component;
title?: string;
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({
get: () => {
return props.editor.getAttributes(Image.name).width;
},
set: (value: string) => {
handleSetSize(value, height.value);
},
});
const height = computed({
get: () => {
return props.editor.getAttributes(Image.name).height;
},
set: (value: string) => {
handleSetSize(width.value, value);
},
});
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) {
resizeObserver.stop();
props.editor
.chain()
.updateAttributes(Image.name, { width, height })
.setNodeSelection(props.editor.state.selection.from)
.focus()
.run();
resizeObserver = reuseResizeObserver();
}
</script>
<template>
<BlockActionInput
v-model.lazy.trim="width"
:tooltip="i18n.global.t('editor.common.tooltip.custom_width_input')"
/>
<BlockActionInput
v-model.lazy.trim="height"
:tooltip="i18n.global.t('editor.common.tooltip.custom_height_input')"
/>
<BlockActionSeparator />
<BlockActionButton
:tooltip="i18n.global.t('editor.extensions.image.small_size')"
:selected="editor.getAttributes(Image.name).width === '25%'"
@click="handleSetSize('25%', 'auto')"
>
<template #icon>
<MdiImageSizeSelectSmall />
</template>
</BlockActionButton>
<BlockActionButton
:tooltip="i18n.global.t('editor.extensions.image.medium_size')"
:selected="editor.getAttributes(Image.name).width === '50%'"
@click="handleSetSize('50%', 'auto')"
>
<template #icon>
<MdiImageSizeSelectLarge />
</template>
</BlockActionButton>
<BlockActionButton
:tooltip="i18n.global.t('editor.extensions.image.large_size')"
:selected="editor.getAttributes(Image.name).width === '100%'"
@click="handleSetSize('100%', '100%')"
>
<template #icon>
<MdiImageSizeSelectActual />
</template>
</BlockActionButton>
<BlockActionButton
:tooltip="i18n.global.t('editor.extensions.image.restore_size')"
@click="handleSetSize(undefined, undefined)"
>
<template #icon>
<MdiBackupRestore />
</template>
</BlockActionButton>
<BlockActionSeparator />
</template>

View File

@ -0,0 +1,108 @@
<script lang="ts" setup>
import { i18n } from "@/locales";
import type { Editor, Node } from "@/tiptap/vue-3";
import { NodeViewWrapper } from "@/tiptap/vue-3";
import type { Node as ProseMirrorNode, Decoration } from "@/tiptap/pm";
import { computed, onMounted, ref } from "vue";
import { useResizeObserver } from "@vueuse/core";
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 src = computed({
get: () => {
return props.node?.attrs.src;
},
set: (src: string) => {
props.updateAttributes({
src: src,
});
},
});
const alt = computed({
get: () => {
return props.node?.attrs.alt;
},
set: (alt: string) => {
props.updateAttributes({ alt: alt });
},
});
const href = computed({
get: () => {
return props.node?.attrs.href;
},
set: (href: string) => {
props.updateAttributes({ href: href });
},
});
function handleSetFocus() {
props.editor.commands.setNodeSelection(props.getPos());
}
const inputRef = ref();
const resizeRef = ref();
const init = ref(true);
onMounted(() => {
if (!src.value) {
inputRef.value.focus();
} else {
useResizeObserver(resizeRef.value, (entries) => {
const entry = entries[0];
const { height } = entry.contentRect;
if (height == 0) {
return;
}
if (!props.selected && !init.value) {
handleSetFocus();
}
init.value = false;
});
}
});
</script>
<template>
<node-view-wrapper as="div" class="inline-block w-full">
<div v-if="!src" class="p-1.5 w-full">
<input
ref="inputRef"
v-model.lazy="src"
class="block px-2 w-full py-1.5 text-sm text-gray-900 border border-gray-300 rounded-md bg-gray-50 focus:ring-blue-500 focus:border-blue-500"
:placeholder="i18n.global.t('editor.common.placeholder.link_input')"
tabindex="-1"
@focus="handleSetFocus"
/>
</div>
<div
v-else
ref="resizeRef"
class="resize-x inline-block overflow-hidden text-center relative rounded-md"
:class="{
'ring-2 rounded': selected,
}"
:style="{
width: node.attrs.width,
height: node.attrs.height,
}"
>
<img
:src="src"
:title="node.attrs.title"
:alt="alt"
:href="href"
class="w-full h-full"
/>
</div>
</node-view-wrapper>
</template>

View File

@ -0,0 +1,281 @@
import TiptapImage from "@tiptap/extension-image";
import {
isActive,
mergeAttributes,
VueNodeViewRenderer,
type Editor,
} from "@/tiptap/vue-3";
import type { EditorState } from "@/tiptap/pm";
import ImageView from "./ImageView.vue";
import type { ImageOptions } from "@tiptap/extension-image";
import type { ExtensionOptions, NodeBubbleMenu } from "@/types";
import ToolboxItem from "@/components/toolbox/ToolboxItem.vue";
import MdiFileImageBox from "~icons/mdi/file-image-box";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import BubbleItemImageSize from "./BubbleItemImageSize.vue";
import BubbleItemImageAlt from "./BubbleItemImageAlt.vue";
import BubbleItemVideoLink from "./BubbleItemImageLink.vue";
import BubbleItemImageHref from "./BubbleItemImageHref.vue";
import { BlockActionSeparator } from "@/components";
import MdiFormatAlignLeft from "~icons/mdi/format-align-left";
import MdiFormatAlignCenter from "~icons/mdi/format-align-center";
import MdiFormatAlignRight from "~icons/mdi/format-align-right";
import MdiFormatAlignJustify from "~icons/mdi/format-align-justify";
import MdiLinkVariant from "~icons/mdi/link-variant";
import MdiShare from "~icons/mdi/share";
import MdiTextBoxEditOutline from "~icons/mdi/text-box-edit-outline";
import MdiDeleteForeverOutline from "@/components/icon/MdiDeleteForeverOutline.vue";
import { deleteNode } from "@/utils";
import MdiLink from "~icons/mdi/link";
const Image = TiptapImage.extend<ExtensionOptions & ImageOptions>({
inline() {
return true;
},
group() {
return "inline";
},
addAttributes() {
return {
...this.parent?.(),
width: {
default: undefined,
parseHTML: (element) => {
const width =
element.getAttribute("width") || element.style.width || null;
return width;
},
renderHTML: (attributes) => {
return {
width: attributes.width,
};
},
},
height: {
default: undefined,
parseHTML: (element) => {
const height =
element.getAttribute("height") || element.style.height || null;
return height;
},
renderHTML: (attributes) => {
return {
height: attributes.height,
};
},
},
href: {
default: null,
parseHTML: (element) => {
const href = element.getAttribute("href") || null;
return href;
},
renderHTML: (attributes) => {
return {
href: attributes.href,
};
},
},
style: {
renderHTML() {
return {
style: "display: inline-block",
};
},
},
};
},
addNodeView() {
return VueNodeViewRenderer(ImageView);
},
parseHTML() {
return [
{
tag: this.options.allowBase64
? "img[src]"
: 'img[src]:not([src^="data:"])',
},
];
},
addOptions() {
return {
...this.parent?.(),
getToolboxItems({ editor }: { editor: Editor }) {
return [
{
priority: 10,
component: markRaw(ToolboxItem),
props: {
editor,
icon: markRaw(MdiFileImageBox),
title: i18n.global.t("editor.common.image"),
action: () => {
editor
.chain()
.focus()
.insertContent([{ type: "image", attrs: { src: "" } }])
.run();
},
},
},
];
},
getBubbleMenu({ editor }: { editor: Editor }): NodeBubbleMenu {
return {
pluginKey: "imageBubbleMenu",
shouldShow: ({ state }: { state: EditorState }): boolean => {
return isActive(state, Image.name);
},
items: [
{
priority: 10,
component: markRaw(BubbleItemImageSize),
},
{
priority: 20,
props: {
isActive: () => editor.isActive({ textAlign: "left" }),
icon: markRaw(MdiFormatAlignLeft),
action: () => handleSetTextAlign(editor, "left"),
},
},
{
priority: 30,
props: {
isActive: () => editor.isActive({ textAlign: "center" }),
icon: markRaw(MdiFormatAlignCenter),
action: () => handleSetTextAlign(editor, "center"),
},
},
{
priority: 40,
props: {
isActive: () => editor.isActive({ textAlign: "right" }),
icon: markRaw(MdiFormatAlignRight),
action: () => handleSetTextAlign(editor, "right"),
},
},
{
priority: 50,
props: {
isActive: () => editor.isActive({ textAlign: "justify" }),
icon: markRaw(MdiFormatAlignJustify),
action: () => handleSetTextAlign(editor, "justify"),
},
},
{
priority: 60,
component: markRaw(BlockActionSeparator),
},
{
priority: 70,
props: {
icon: markRaw(MdiLinkVariant),
title: i18n.global.t("editor.common.button.edit_link"),
action: () => {
return markRaw(BubbleItemVideoLink);
},
},
},
{
priority: 80,
props: {
icon: markRaw(MdiShare),
title: i18n.global.t("editor.common.tooltip.open_link"),
action: () => {
window.open(editor.getAttributes(Image.name).src, "_blank");
},
},
},
{
priority: 90,
props: {
icon: markRaw(MdiTextBoxEditOutline),
title: i18n.global.t("editor.extensions.image.edit_alt"),
action: () => {
return markRaw(BubbleItemImageAlt);
},
},
},
{
priority: 100,
props: {
icon: markRaw(MdiLink),
title: i18n.global.t("editor.extensions.image.edit_href"),
action: () => {
return markRaw(BubbleItemImageHref);
},
},
},
{
priority: 110,
component: markRaw(BlockActionSeparator),
},
{
priority: 120,
props: {
icon: markRaw(MdiDeleteForeverOutline),
title: i18n.global.t("editor.common.button.delete"),
action: ({ editor }) => {
deleteNode(Image.name, editor);
},
},
},
],
};
},
getDraggable() {
return {
getRenderContainer({ dom, view }) {
let container = dom;
while (container && container.tagName !== "P") {
container = container.parentElement as HTMLElement;
}
if (container) {
container = container.firstElementChild
?.firstElementChild as HTMLElement;
}
let node;
if (container.firstElementChild) {
const pos = view.posAtDOM(container.firstElementChild, 0);
const $pos = view.state.doc.resolve(pos);
node = $pos.node();
}
return {
node: node,
el: container as HTMLElement,
dragDomOffset: {
y: -5,
},
};
},
};
},
};
},
renderHTML({ HTMLAttributes }) {
if (HTMLAttributes.href) {
return [
"a",
{ href: HTMLAttributes.href },
["img", mergeAttributes(HTMLAttributes)],
];
}
return ["img", mergeAttributes(HTMLAttributes)];
},
});
const handleSetTextAlign = (
editor: Editor,
align: "left" | "center" | "right" | "justify"
) => {
editor.chain().focus().setTextAlign(align).run();
};
export default Image;

View File

@ -0,0 +1,249 @@
import {
type CommandProps,
type Extensions,
type KeyboardShortcutCommand,
Extension,
isList,
Editor,
} from "@/tiptap/vue-3";
import { TextSelection, Transaction } from "@/tiptap/pm";
declare module "@/tiptap" {
interface Commands<ReturnType> {
indent: {
indent: () => ReturnType;
outdent: () => ReturnType;
};
}
}
type IndentOptions = {
names: Array<string>;
indentRange: number;
minIndentLevel: number;
maxIndentLevel: number;
defaultIndentLevel: number;
HTMLAttributes: Record<string, any>;
};
const Indent = Extension.create<IndentOptions, never>({
name: "indent",
addOptions() {
return {
names: ["heading", "paragraph"],
indentRange: 24,
minIndentLevel: 0,
maxIndentLevel: 24 * 10,
defaultIndentLevel: 0,
HTMLAttributes: {},
};
},
addGlobalAttributes() {
return [
{
types: this.options.names,
attributes: {
indent: {
default: this.options.defaultIndentLevel,
renderHTML: (attributes) => ({
style:
attributes.indent != 0
? `margin-left: ${attributes.indent}px!important;`
: "",
}),
parseHTML: (element) =>
parseInt(element.style.marginLeft, 10) ||
this.options.defaultIndentLevel,
},
},
},
];
},
addCommands(this) {
return {
indent:
() =>
({ tr, state, dispatch, editor }: CommandProps) => {
const { selection } = state;
tr = tr.setSelection(selection);
tr = updateIndentLevel(
tr,
this.options,
editor.extensionManager.extensions,
"indent"
);
if (tr.docChanged && dispatch) {
dispatch(tr);
return true;
}
return false;
},
outdent:
() =>
({ tr, state, dispatch, editor }: CommandProps) => {
const { selection } = state;
tr = tr.setSelection(selection);
tr = updateIndentLevel(
tr,
this.options,
editor.extensionManager.extensions,
"outdent"
);
if (tr.docChanged && dispatch) {
dispatch(tr);
return true;
}
return false;
},
};
},
addKeyboardShortcuts() {
return {
Tab: getIndent(),
"Shift-Tab": getOutdent(false),
Backspace: getOutdent(true),
"Mod-]": getIndent(),
"Mod-[": getOutdent(false),
};
},
onUpdate() {
const { editor } = this;
if (editor.isActive("listItem")) {
const node = editor.state.selection.$head.node();
if (node.attrs.indent) {
editor.commands.updateAttributes(node.type.name, { indent: 0 });
}
}
},
});
export const clamp = (val: number, min: number, max: number): number => {
if (val < min) {
return min;
}
if (val > max) {
return max;
}
return val;
};
function setNodeIndentMarkup(
tr: Transaction,
pos: number,
delta: number,
min: number,
max: number
): Transaction {
if (!tr.doc) return tr;
const node = tr.doc.nodeAt(pos);
if (!node) return tr;
const indent = clamp((node.attrs.indent || 0) + delta, min, max);
if (indent === node.attrs.indent) return tr;
const nodeAttrs = {
...node.attrs,
indent,
};
return tr.setNodeMarkup(pos, node.type, nodeAttrs, node.marks);
}
type IndentType = "indent" | "outdent";
const updateIndentLevel = (
tr: Transaction,
options: IndentOptions,
extensions: Extensions,
type: IndentType
): Transaction => {
const { doc, selection } = tr;
if (!doc || !selection) return tr;
if (!(selection instanceof TextSelection)) {
return tr;
}
const { from, to } = selection;
doc.nodesBetween(from, to, (node, pos) => {
if (options.names.includes(node.type.name)) {
if (isTextIndent(tr, pos) && type === "indent") {
tr.insertText("\t", from, to);
} else {
tr = setNodeIndentMarkup(
tr,
pos,
options.indentRange * (type === "indent" ? 1 : -1),
options.minIndentLevel,
options.maxIndentLevel
);
}
return false;
}
return !isList(node.type.name, extensions);
});
return tr;
};
const isTextIndent = (tr: Transaction, currNodePos: number) => {
const { selection } = tr;
const { from, to } = selection;
if (from == 0) {
return false;
}
if (from - to == 0 && currNodePos != from - 1) {
return true;
}
return false;
};
const isListActive = (editor: Editor) => {
return (
editor.isActive("bulletList") ||
editor.isActive("orderedList") ||
editor.isActive("taskList")
);
};
const isFilterActive = (editor: Editor) => {
return editor.isActive("table") || editor.isActive("columns");
};
export const getIndent: () => KeyboardShortcutCommand =
() =>
({ editor }) => {
// @ts-ignore
if (isFilterActive(editor)) {
return false;
}
// @ts-ignore
if (isListActive(editor)) {
const name = editor.can().sinkListItem("listItem")
? "listItem"
: "taskItem";
return editor.chain().focus().sinkListItem(name).run();
}
return editor.chain().focus().indent().run();
};
export const getOutdent: (
outdentOnlyAtHead: boolean
) => KeyboardShortcutCommand =
(outdentOnlyAtHead) =>
({ editor }) => {
if (outdentOnlyAtHead && editor.state.selection.$head.parentOffset > 0) {
return false;
}
// @ts-ignore
if (isFilterActive(editor)) {
return false;
}
// @ts-ignore
if (isListActive(editor)) {
const name = editor.can().liftListItem("listItem")
? "listItem"
: "taskItem";
return editor.chain().focus().liftListItem(name).run();
}
return editor.chain().focus().outdent().run();
};
export default Indent;

View File

@ -0,0 +1,141 @@
// Tiptap official extensions
import ExtensionHistory from "./history";
import ExtensionHeading from "./heading";
import ExtensionBold from "./bold";
import ExtensionItalic from "./italic";
import ExtensionStrike from "./strike";
import ExtensionUnderline from "./underline";
import ExtensionHighlight from "./highlight";
import ExtensionBlockquote from "./blockquote";
import ExtensionCode from "./code";
import ExtensionSuperscript from "./superscript";
import ExtensionSubscript from "./subscript";
import ExtensionBulletList from "./bullet-list";
import ExtensionOrderedList from "./ordered-list";
import ExtensionTaskList from "./task-list";
import ExtensionTable from "./table";
import ExtensionTextAlign from "./text-align";
import ExtensionLink from "./link";
import ExtensionColor from "./color";
import ExtensionFontSize from "./font-size";
import ExtensionDropcursor from "@tiptap/extension-dropcursor";
import ExtensionGapcursor from "@tiptap/extension-gapcursor";
import ExtensionHardBreak from "@tiptap/extension-hard-break";
import ExtensionHorizontalRule from "@tiptap/extension-horizontal-rule";
import ExtensionDocument from "@tiptap/extension-document";
import ExtensionPlaceholder from "@tiptap/extension-placeholder";
import { i18n } from "@/locales";
// Custom extensions
import { ExtensionCommands } from "../extensions/commands-menu";
import { ExtensionCodeBlock, lowlight } from "@/extensions/code-block";
import ExtensionIframe from "./iframe";
import ExtensionVideo from "./video";
import ExtensionAudio from "./audio";
import ExtensionImage from "./image";
import ExtensionIndent from "./indent";
import { ExtensionColumns, ExtensionColumn } from "./columns";
import ExtensionText from "./text";
import ExtensionDraggable from "./draggable";
import ExtensionNodeSelected from "./node-selected";
import ExtensionTrailingNode from "./trailing-node";
const allExtensions = [
ExtensionBlockquote,
ExtensionBold,
ExtensionBulletList,
ExtensionCode,
ExtensionDocument,
ExtensionDropcursor.configure({
width: 2,
class: "dropcursor",
color: "skyblue",
}),
ExtensionGapcursor,
ExtensionHardBreak,
ExtensionHeading,
ExtensionHistory,
ExtensionHorizontalRule,
ExtensionItalic,
ExtensionOrderedList,
ExtensionStrike,
ExtensionText,
ExtensionImage,
ExtensionTaskList,
ExtensionHighlight,
ExtensionColor,
ExtensionFontSize,
ExtensionLink.configure({
autolink: true,
openOnClick: false,
}),
ExtensionTextAlign.configure({
types: ["heading", "paragraph"],
}),
ExtensionUnderline,
ExtensionTable.configure({
resizable: true,
}),
ExtensionSubscript,
ExtensionSuperscript,
ExtensionPlaceholder.configure({
placeholder: i18n.global.t("editor.extensions.commands_menu.placeholder"),
}),
ExtensionCommands.configure({
suggestion: {},
}),
ExtensionCodeBlock.configure({
lowlight,
}),
ExtensionIframe,
ExtensionVideo,
ExtensionAudio,
ExtensionIndent,
ExtensionColumns,
ExtensionColumn,
ExtensionNodeSelected,
ExtensionTrailingNode,
];
export {
allExtensions,
ExtensionBlockquote,
ExtensionBold,
ExtensionBulletList,
ExtensionCode,
ExtensionDocument,
ExtensionDropcursor,
ExtensionGapcursor,
ExtensionHardBreak,
ExtensionHeading,
ExtensionHistory,
ExtensionHorizontalRule,
ExtensionItalic,
ExtensionOrderedList,
ExtensionStrike,
ExtensionText,
ExtensionImage,
ExtensionTaskList,
ExtensionLink,
ExtensionTextAlign,
ExtensionUnderline,
ExtensionTable,
ExtensionSubscript,
ExtensionSuperscript,
ExtensionPlaceholder,
ExtensionHighlight,
ExtensionCommands,
ExtensionCodeBlock,
lowlight,
ExtensionIframe,
ExtensionVideo,
ExtensionAudio,
ExtensionColor,
ExtensionFontSize,
ExtensionIndent,
ExtensionDraggable,
ExtensionColumns,
ExtensionColumn,
ExtensionNodeSelected,
ExtensionTrailingNode,
};

View File

@ -0,0 +1,31 @@
import type { Editor } from "@/tiptap/vue-3";
import TiptapItalic from "@tiptap/extension-italic";
import type { ItalicOptions } from "@tiptap/extension-italic";
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
import MdiFormatItalic from "~icons/mdi/format-italic";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import type { ExtensionOptions } from "@/types";
const Italic = TiptapItalic.extend<ExtensionOptions & ItalicOptions>({
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 50,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive("italic"),
icon: markRaw(MdiFormatItalic),
title: i18n.global.t("editor.common.italic"),
action: () => editor.chain().focus().toggleItalic().run(),
},
};
},
};
},
});
export default Italic;

View File

@ -0,0 +1,80 @@
<script lang="ts" setup>
import { computed, type Component } from "vue";
import { VTooltip, Dropdown as VDropdown } from "floating-vue";
import MdiLinkVariant from "~icons/mdi/link-variant";
import { i18n } from "@/locales";
import type { Editor } from "@/tiptap/vue-3";
const props = defineProps<{
editor: Editor;
isActive: ({ editor }: { editor: Editor }) => boolean;
visible?: ({ editor }: { editor: Editor }) => boolean;
icon?: Component;
title?: string;
action?: ({ editor }: { editor: Editor }) => void;
}>();
const href = computed({
get() {
const attrs = props.editor.getAttributes("link");
return attrs?.href;
},
set(value) {
props.editor.commands.setLink({
href: value,
target: target.value ? "_blank" : "_self",
});
},
});
const target = computed({
get() {
const attrs = props.editor.getAttributes("link");
return attrs?.target === "_blank";
},
set(value) {
props.editor.commands.setLink({
href: href.value,
target: value ? "_blank" : "_self",
});
},
});
</script>
<template>
<VDropdown class="inline-flex" :triggers="['click']" :distance="10">
<button
v-tooltip="
isActive({ editor })
? i18n.global.t('editor.extensions.link.edit_link')
: i18n.global.t('editor.extensions.link.add_link')
"
class="text-gray-600 text-lg hover:bg-gray-100 p-2 rounded-md"
:class="{ 'bg-gray-200 !text-black': isActive({ editor }) }"
>
<MdiLinkVariant />
</button>
<template #popper>
<div
class="relative rounded-md bg-white overflow-hidden drop-shadow w-96 p-1 max-h-72 overflow-y-auto"
>
<input
v-model.lazy="href"
:placeholder="i18n.global.t('editor.extensions.link.placeholder')"
class="bg-gray-50 rounded-md hover:bg-gray-100 block px-2 w-full py-1.5 text-sm text-gray-900 border border-gray-300 focus:ring-blue-500 focus:border-blue-500"
/>
<label class="inline-flex items-center mt-2">
<input
v-model="target"
type="checkbox"
class="form-checkbox text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span class="ml-2 text-sm text-gray-500">
{{ i18n.global.t("editor.extensions.link.open_in_new_window") }}
</span>
</label>
</div>
</template>
</VDropdown>
</template>

View File

@ -0,0 +1,7 @@
import TiptapLink from "@tiptap/extension-link";
import type { LinkOptions } from "@tiptap/extension-link";
import type { ExtensionOptions } from "@/types";
const Link = TiptapLink.extend<ExtensionOptions & LinkOptions>();
export default Link;

View File

@ -0,0 +1,59 @@
import { Extension } from "@/tiptap/vue-3";
import { Plugin, PluginKey, Decoration, DecorationSet } from "@/tiptap/pm";
export interface NodeSelectedOptions {
className: string;
}
const NodeSelected = Extension.create<NodeSelectedOptions>({
name: "nodeSelected",
addOptions() {
return {
className: "has-node-selected",
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("nodeSelected"),
props: {
decorations: ({ doc, selection }) => {
const { isEditable, isFocused } = this.editor;
const { anchor } = selection;
const decorations: Decoration[] = [];
if (!isEditable || !isFocused) {
return DecorationSet.create(doc, []);
}
doc.descendants((node, pos) => {
if (node.isText) {
return false;
}
const isCurrent =
anchor >= pos && anchor <= pos + node.nodeSize - 1;
if (!isCurrent) {
return false;
}
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
class: this.options.className,
})
);
});
return DecorationSet.create(doc, decorations);
},
},
}),
];
},
});
export default NodeSelected;

View File

@ -0,0 +1,65 @@
import type { Editor, Range } from "@/tiptap/vue-3";
import TiptapOrderedList from "@tiptap/extension-ordered-list";
import type { OrderedListOptions } from "@tiptap/extension-ordered-list";
import ExtensionListItem from "@tiptap/extension-list-item";
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
import MdiFormatListNumbered from "~icons/mdi/format-list-numbered";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import type { ExtensionOptions } from "@/types";
const OrderedList = TiptapOrderedList.extend<
ExtensionOptions & OrderedListOptions
>({
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 140,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive("orderedList"),
icon: markRaw(MdiFormatListNumbered),
title: i18n.global.t("editor.common.ordered_list"),
action: () => editor.chain().focus().toggleOrderedList().run(),
},
};
},
getCommandMenuItems() {
return {
priority: 140,
icon: markRaw(MdiFormatListNumbered),
title: "editor.common.ordered_list",
keywords: ["orderedlist", "youxuliebiao"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
};
},
getDraggable() {
return {
getRenderContainer({ dom }) {
let container = dom;
while (container && !(container.tagName === "LI")) {
container = container.parentElement as HTMLElement;
}
return {
el: container,
dragDomOffset: {
x: -16,
y: -1,
},
};
},
};
},
};
},
addExtensions() {
return [ExtensionListItem];
},
});
export default OrderedList;

View File

@ -0,0 +1,30 @@
import TiptapParagraph from "@tiptap/extension-paragraph";
import type { ParagraphOptions } from "@tiptap/extension-paragraph";
import type { ExtensionOptions } from "@/types";
const Paragraph = TiptapParagraph.extend<ExtensionOptions & ParagraphOptions>({
addOptions() {
return {
...this.parent?.(),
getDraggable() {
return {
getRenderContainer({ dom }) {
let container = dom;
while (container && container.tagName !== "P") {
container = container.parentElement as HTMLElement;
}
return {
el: container,
dragDomOffset: {
y: -1,
},
};
},
allowPropagationDownward: true,
};
},
};
},
});
export default Paragraph;

View File

@ -0,0 +1,31 @@
import type { Editor } from "@/tiptap/vue-3";
import TiptapStrike from "@tiptap/extension-strike";
import type { StrikeOptions } from "@tiptap/extension-strike";
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
import MdiFormatStrikethrough from "~icons/mdi/format-strikethrough";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import type { ExtensionOptions } from "@/types";
const Strike = TiptapStrike.extend<ExtensionOptions & StrikeOptions>({
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 70,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive("strike"),
icon: markRaw(MdiFormatStrikethrough),
title: i18n.global.t("editor.common.strike"),
action: () => editor.chain().focus().toggleStrike().run(),
},
};
},
};
},
});
export default Strike;

View File

@ -0,0 +1,33 @@
import type { Editor } from "@/tiptap/vue-3";
import TiptapSubscript from "@tiptap/extension-subscript";
import type { SubscriptExtensionOptions } from "@tiptap/extension-subscript";
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
import MdiFormatSubscript from "~icons/mdi/format-subscript";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import type { ExtensionOptions } from "@/types";
const Subscript = TiptapSubscript.extend<
ExtensionOptions & SubscriptExtensionOptions
>({
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 120,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive("subscript"),
icon: markRaw(MdiFormatSubscript),
title: i18n.global.t("editor.common.subscript"),
action: () => editor.chain().focus().toggleSubscript().run(),
},
};
},
};
},
});
export default Subscript;

View File

@ -0,0 +1,33 @@
import type { Editor } from "@/tiptap/vue-3";
import TiptapSuperscript from "@tiptap/extension-superscript";
import type { SuperscriptExtensionOptions } from "@tiptap/extension-superscript";
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
import MdiFormatSuperscript from "~icons/mdi/format-superscript";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import type { ExtensionOptions } from "@/types";
const Superscript = TiptapSuperscript.extend<
ExtensionOptions & SuperscriptExtensionOptions
>({
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 110,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive("superscript"),
icon: markRaw(MdiFormatSuperscript),
title: i18n.global.t("editor.common.superscript"),
action: () => editor.chain().focus().toggleSuperscript().run(),
},
};
},
};
},
});
export default Superscript;

View File

@ -0,0 +1,380 @@
import TiptapTable, { type TableOptions } from "@tiptap/extension-table";
import { isActive, type Editor, type Range } from "@/tiptap/vue-3";
import type {
Node as ProseMirrorNode,
NodeView,
EditorState,
} from "@/tiptap/pm";
import TableCell from "./table-cell";
import TableRow from "./table-row";
import TableHeader from "./table-header";
import MdiTable from "~icons/mdi/table";
import MdiTablePlus from "~icons/mdi/table-plus";
import MdiTableColumnPlusBefore from "~icons/mdi/table-column-plus-before";
import MdiTableColumnPlusAfter from "~icons/mdi/table-column-plus-after";
import MdiTableRowPlusAfter from "~icons/mdi/table-row-plus-after";
import MdiTableRowPlusBefore from "~icons/mdi/table-row-plus-before";
import MdiTableColumnRemove from "~icons/mdi/table-column-remove";
import MdiTableRowRemove from "~icons/mdi/table-row-remove";
import MdiTableRemove from "~icons/mdi/table-remove";
import MdiTableHeadersEye from "~icons/mdi/table-headers-eye";
import MdiTableMergeCells from "~icons/mdi/table-merge-cells";
import MdiTableSplitCell from "~icons/mdi/table-split-cell";
import FluentTableColumnTopBottom24Regular from "~icons/fluent/table-column-top-bottom-24-regular";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import type { ExtensionOptions, NodeBubbleMenu } from "@/types";
import { BlockActionSeparator, ToolboxItem } from "@/components";
function updateColumns(
node: ProseMirrorNode,
colgroup: Element,
table: HTMLElement,
cellMinWidth: number,
overrideCol?: number,
overrideValue?: any
) {
let totalWidth = 0;
let fixedWidth = true;
let nextDOM = colgroup.firstChild as HTMLElement;
const row = node.firstChild;
if (!row) return;
for (let i = 0, col = 0; i < row.childCount; i += 1) {
const { colspan, colwidth } = row.child(i).attrs;
for (let j = 0; j < colspan; j += 1, col += 1) {
const hasWidth =
overrideCol === col ? overrideValue : colwidth && colwidth[j];
const cssWidth = hasWidth ? `${hasWidth}px` : "";
totalWidth += hasWidth || cellMinWidth;
if (!hasWidth) {
fixedWidth = false;
}
if (!nextDOM) {
colgroup.appendChild(document.createElement("col")).style.width =
cssWidth;
} else {
if (nextDOM.style.width !== cssWidth) {
nextDOM.style.width = cssWidth;
}
nextDOM = nextDOM.nextSibling as HTMLElement;
}
}
}
while (nextDOM) {
const after = nextDOM.nextSibling as HTMLElement;
nextDOM.parentNode?.removeChild(nextDOM);
nextDOM = after;
}
if (fixedWidth) {
table.style.width = `${totalWidth}px`;
table.style.minWidth = "";
} else {
table.style.width = "";
table.style.minWidth = `${totalWidth}px`;
}
}
class TableView implements NodeView {
node: ProseMirrorNode;
cellMinWidth: number;
dom: HTMLElement;
scrollDom: HTMLElement;
table: HTMLElement;
colgroup: HTMLElement;
contentDOM: HTMLElement;
constructor(node: ProseMirrorNode, cellMinWidth: number) {
this.node = node;
this.cellMinWidth = cellMinWidth;
this.dom = document.createElement("div");
this.dom.className = "tableWrapper";
this.scrollDom = document.createElement("div");
this.scrollDom.className = "scrollWrapper";
this.dom.appendChild(this.scrollDom);
this.table = this.scrollDom.appendChild(document.createElement("table"));
this.colgroup = this.table.appendChild(document.createElement("colgroup"));
updateColumns(node, this.colgroup, this.table, cellMinWidth);
this.contentDOM = this.table.appendChild(document.createElement("tbody"));
}
update(node: ProseMirrorNode) {
if (node.type !== this.node.type) {
return false;
}
this.node = node;
updateColumns(node, this.colgroup, this.table, this.cellMinWidth);
return true;
}
ignoreMutation(
mutation: MutationRecord | { type: "selection"; target: Element }
) {
return (
mutation.type === "attributes" &&
(mutation.target === this.table ||
this.colgroup.contains(mutation.target))
);
}
}
const Table = TiptapTable.extend<ExtensionOptions & TableOptions>({
addExtensions() {
return [TableCell, TableRow, TableHeader];
},
addOptions() {
return {
...this.parent?.(),
HTMLAttributes: {},
resizable: true,
handleWidth: 5,
cellMinWidth: 25,
View: TableView as unknown as NodeView,
lastColumnResizable: true,
allowTableNodeSelection: false,
getToolboxItems({ editor }: { editor: Editor }) {
return {
priority: 15,
component: markRaw(ToolboxItem),
props: {
editor,
icon: markRaw(MdiTablePlus),
title: i18n.global.t("editor.menus.table.add"),
action: () =>
editor
.chain()
.focus()
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run(),
},
};
},
getCommandMenuItems() {
return {
priority: 120,
icon: markRaw(MdiTable),
title: "editor.extensions.commands_menu.table",
keywords: ["table", "biaoge"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
},
};
},
getBubbleMenu({ editor }): NodeBubbleMenu {
return {
pluginKey: "tableBubbleMenu",
shouldShow: ({ state }: { state: EditorState }): boolean => {
return isActive(state, Table.name);
},
getRenderContainer(node) {
let container = node;
if (container.nodeName === "#text") {
container = node.parentElement as HTMLElement;
}
while (
container &&
container.classList &&
!container.classList.contains("tableWrapper")
) {
container = container.parentElement as HTMLElement;
}
return container;
},
tippyOptions: {
offset: [26, 0],
},
items: [
{
priority: 10,
props: {
icon: markRaw(MdiTableColumnPlusBefore),
title: i18n.global.t("editor.menus.table.add_column_before"),
action: () => {
editor.chain().focus().addColumnBefore().run();
},
},
},
{
priority: 20,
props: {
icon: markRaw(MdiTableColumnPlusAfter),
title: i18n.global.t("editor.menus.table.add_column_after"),
action: () => editor.chain().focus().addColumnAfter().run(),
},
},
{
priority: 30,
props: {
icon: markRaw(MdiTableColumnRemove),
title: i18n.global.t("editor.menus.table.delete_column"),
action: () => editor.chain().focus().deleteColumn().run(),
},
},
{
priority: 40,
component: markRaw(BlockActionSeparator),
},
{
priority: 50,
props: {
icon: markRaw(MdiTableRowPlusBefore),
title: i18n.global.t("editor.menus.table.add_row_before"),
action: () => editor.chain().focus().addRowBefore().run(),
},
},
{
priority: 60,
props: {
icon: markRaw(MdiTableRowPlusAfter),
title: i18n.global.t("editor.menus.table.add_row_after"),
action: () => editor.chain().focus().addRowAfter().run(),
},
},
{
priority: 70,
props: {
icon: markRaw(MdiTableRowRemove),
title: i18n.global.t("editor.menus.table.delete_row"),
action: () => editor.chain().focus().deleteRow().run(),
},
},
{
priority: 80,
component: markRaw(BlockActionSeparator),
},
{
priority: 90,
props: {
icon: markRaw(MdiTableHeadersEye),
title: i18n.global.t("editor.menus.table.toggle_header_column"),
action: () => editor.chain().focus().toggleHeaderColumn().run(),
},
},
{
priority: 100,
props: {
icon: markRaw(MdiTableHeadersEye),
title: i18n.global.t("editor.menus.table.toggle_header_row"),
action: () => editor.chain().focus().toggleHeaderRow().run(),
},
},
{
priority: 101,
props: {
icon: markRaw(FluentTableColumnTopBottom24Regular),
title: i18n.global.t("editor.menus.table.toggle_header_cell"),
action: () => editor.chain().focus().toggleHeaderCell().run(),
},
},
{
priority: 110,
component: markRaw(BlockActionSeparator),
},
{
priority: 120,
props: {
icon: markRaw(MdiTableMergeCells),
title: i18n.global.t("editor.menus.table.merge_cells"),
action: () => editor.chain().focus().mergeCells().run(),
},
},
{
priority: 130,
props: {
icon: markRaw(MdiTableSplitCell),
title: i18n.global.t("editor.menus.table.split_cell"),
action: () => editor.chain().focus().splitCell().run(),
},
},
{
priority: 140,
component: markRaw(BlockActionSeparator),
},
{
priority: 150,
props: {
icon: markRaw(MdiTableRemove),
title: i18n.global.t("editor.menus.table.delete_table"),
action: () => editor.chain().focus().deleteTable().run(),
},
},
],
};
},
getDraggable() {
return {
getRenderContainer({ dom }) {
let container = dom;
while (container && !container.classList.contains("tableWrapper")) {
container = container.parentElement as HTMLElement;
}
return {
el: container,
dragDomOffset: {
x: 20,
y: 20,
},
};
},
handleDrop({ view, event, slice, insertPos }) {
const { state } = view;
const $pos = state.selection.$anchor;
for (let d = $pos.depth; d > 0; d--) {
const node = $pos.node(d);
if (node.type.spec["tableRole"] == "table") {
const eventPos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!eventPos) {
return;
}
if (!slice) {
return;
}
let tr = state.tr;
tr = tr.delete($pos.before(d), $pos.after(d));
const pos = tr.mapping.map(insertPos);
tr = tr.replaceRange(pos, pos, slice).scrollIntoView();
if (tr) {
view.dispatch(tr);
event.preventDefault();
return true;
}
return false;
}
}
},
};
},
};
},
}).configure({ resizable: true });
export default Table;

View File

@ -0,0 +1,194 @@
import { mergeAttributes, Node } from "@/tiptap/vue-3";
import {
Plugin,
PluginKey,
Decoration,
DecorationSet,
addRowAfter,
} from "@/tiptap/pm";
import {
getCellsInColumn,
isRowSelected,
isTableSelected,
selectRow,
selectTable,
} from "./util";
import { Tooltip } from "floating-vue";
import { h } from "vue";
import MdiPlus from "~icons/mdi/plus";
import { render } from "vue";
import { i18n } from "@/locales";
export interface TableCellOptions {
HTMLAttributes: Record<string, any>;
}
const TableCell = Node.create<TableCellOptions>({
name: "tableCell",
content: "block+",
tableRole: "cell",
isolating: true,
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
...this.parent?.(),
colspan: {
default: 1,
parseHTML: (element) => {
const colspan = element.getAttribute("colspan");
const value = colspan ? parseInt(colspan, 10) : 1;
return value;
},
},
rowspan: {
default: 1,
parseHTML: (element) => {
const rowspan = element.getAttribute("rowspan");
const value = rowspan ? parseInt(rowspan, 10) : 1;
return value;
},
},
colwidth: {
default: [100],
parseHTML: (element) => {
const colwidth = element.getAttribute("colwidth");
const value = colwidth ? [parseInt(colwidth, 10)] : null;
return value;
},
},
style: {
default: null,
},
};
},
parseHTML() {
return [{ tag: "td" }];
},
renderHTML({ HTMLAttributes }) {
return [
"td",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addStorage() {
const gripMap = new Map<string, HTMLElement>();
return {
gripMap,
};
},
onDestroy() {
this.storage.gripMap.clear();
},
addProseMirrorPlugins() {
const editor = this.editor;
const storage = this.storage;
return [
new Plugin({
key: new PluginKey("table-cell-control"),
props: {
decorations(state) {
const { doc, selection } = state;
const decorations: Decoration[] = [];
const cells = getCellsInColumn(0)(selection);
if (cells) {
cells.forEach(({ pos }, index) => {
if (index === 0) {
decorations.push(
Decoration.widget(pos + 1, () => {
const key = "table" + index;
let className = "grip-table";
const selected = isTableSelected(selection);
if (selected) {
className += " selected";
}
let grip = storage.gripMap.get(key);
if (!grip) {
grip = document.createElement("a") as HTMLElement;
grip.addEventListener("mousedown", (event: Event) => {
event.preventDefault();
event.stopImmediatePropagation();
editor.view.dispatch(selectTable(editor.state.tr));
});
}
grip.className = className;
storage.gripMap.set(key, grip);
return grip;
})
);
}
decorations.push(
Decoration.widget(pos + 1, () => {
const key = "row" + index;
const rowSelected = isRowSelected(index)(selection);
let className = "grip-row";
if (rowSelected) {
className += " selected";
}
if (index === 0) {
className += " first";
}
if (index === cells.length - 1) {
className += " last";
}
let grip = storage.gripMap.get(key);
if (!grip) {
grip = document.createElement("a");
const instance = h(
Tooltip,
{
triggers: ["hover"],
},
{
default: () => h(MdiPlus, { class: "plus-icon" }),
popper: () =>
i18n.global.t("editor.menus.table.add_row_after"),
}
);
render(instance, grip);
grip.addEventListener(
"mousedown",
(event: Event) => {
event.preventDefault();
event.stopImmediatePropagation();
editor.view.dispatch(
selectRow(index)(editor.state.tr)
);
if (event.target !== grip) {
addRowAfter(editor.state, editor.view.dispatch);
}
},
true
);
}
grip.className = className;
storage.gripMap.set(key, grip);
return grip;
})
);
});
}
return DecorationSet.create(doc, decorations);
},
},
}),
];
},
});
export default TableCell;

View File

@ -0,0 +1,150 @@
import { mergeAttributes, Node } from "@/tiptap/vue-3";
import {
Plugin,
PluginKey,
Decoration,
DecorationSet,
addColumnAfter,
} from "@/tiptap/pm";
import { getCellsInRow, isColumnSelected, selectColumn } from "./util";
import { render } from "vue";
import { Tooltip } from "floating-vue";
import { h } from "vue";
import MdiPlus from "~icons/mdi/plus";
import { i18n } from "@/locales";
export interface TableCellOptions {
HTMLAttributes: Record<string, any>;
}
const TableHeader = Node.create<TableCellOptions>({
name: "tableHeader",
content: "block+",
tableRole: "header_cell",
isolating: true,
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
colspan: {
default: 1,
},
rowspan: {
default: 1,
},
colwidth: {
default: [100],
parseHTML: (element) => {
const colwidth = element.getAttribute("colwidth");
const value = colwidth ? [parseInt(colwidth, 10)] : null;
return value;
},
},
style: {
default: null,
},
};
},
parseHTML() {
return [{ tag: "th" }];
},
renderHTML({ HTMLAttributes }) {
return [
"th",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addStorage() {
const gripMap = new Map<string, HTMLElement>();
return {
gripMap,
};
},
onDestroy() {
this.storage.gripMap.clear();
},
addProseMirrorPlugins() {
const editor = this.editor;
const storage = this.storage;
return [
new Plugin({
key: new PluginKey("table-header-control"),
props: {
decorations(state) {
const { doc, selection } = state;
const decorations: Decoration[] = [];
const cells = getCellsInRow(0)(selection);
if (cells) {
cells.forEach(({ pos }, index) => {
decorations.push(
Decoration.widget(pos + 1, () => {
const key = "column" + index;
const colSelected = isColumnSelected(index)(selection);
let className = "grip-column";
if (colSelected) {
className += " selected";
}
if (index === 0) {
className += " first";
} else if (index === cells.length - 1) {
className += " last";
}
let grip = storage.gripMap.get(key) as HTMLElement;
if (!grip) {
grip = document.createElement("a");
const instance = h(
Tooltip,
{
triggers: ["hover"],
},
{
default: () => h(MdiPlus, { class: "plus-icon" }),
popper: () =>
i18n.global.t(
"editor.menus.table.add_column_after"
),
}
);
render(instance, grip);
grip.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
editor.view.dispatch(
selectColumn(index)(editor.state.tr)
);
if (event.target !== grip) {
addColumnAfter(editor.state, editor.view.dispatch);
}
});
}
grip.className = className;
storage.gripMap.set(key, grip);
return grip;
})
);
});
}
return DecorationSet.create(doc, decorations);
},
},
}),
];
},
});
export default TableHeader;

View File

@ -0,0 +1,7 @@
import { TableRow as BuiltInTableRow } from "@tiptap/extension-table-row";
const TableRow = BuiltInTableRow.extend({
allowGapCursor: false,
});
export default TableRow;

View File

@ -0,0 +1,238 @@
import { findParentNode } from "@/tiptap/vue-3";
import { Node, CellSelection, TableMap } from "@/tiptap/pm";
import type { Selection, Transaction } from "@/tiptap/pm";
export const selectTable = (tr: Transaction) => {
const table = findTable(tr.selection);
if (table) {
const { map } = TableMap.get(table.node);
if (map && map.length) {
const head = table.start + map[0];
const anchor = table.start + map[map.length - 1];
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
return tr.setSelection(new CellSelection($anchor, $head));
}
}
return tr;
};
const select =
(type: "row" | "column") => (index: number) => (tr: Transaction) => {
const table = findTable(tr.selection);
const isRowSelection = type === "row";
if (table) {
const map = TableMap.get(table.node);
if (index >= 0 && index < (isRowSelection ? map.height : map.width)) {
const cells = isRowSelection
? map.cellsInRect({
left: 0,
right: 1,
top: 0,
bottom: map.height,
})
: map.cellsInRect({
left: 0,
right: map.width,
top: 0,
bottom: 1,
});
const startCellRect = map.findCell(cells[index]);
const rect = {
left: isRowSelection ? map.width - 1 : startCellRect.left,
right: isRowSelection ? map.width : startCellRect.right,
top: isRowSelection ? startCellRect.top : map.height - 1,
bottom: isRowSelection ? startCellRect.bottom : map.height,
};
let endCellRect = map.cellsInRect(rect);
while (endCellRect.length === 0) {
if (isRowSelection) {
rect.left -= 1;
} else {
rect.top -= 1;
}
endCellRect = map.cellsInRect(rect);
}
const head = table.start + cells[index];
const anchor = table.start + endCellRect[endCellRect.length - 1];
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
return tr.setSelection(new CellSelection($anchor, $head));
}
}
return tr;
};
export const selectColumn = select("column");
export const selectRow = select("row");
export const getCellsInColumn =
(columnIndex: number | number[]) => (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const indexes = Array.isArray(columnIndex)
? columnIndex
: Array.from([columnIndex]);
return indexes.reduce((acc, index) => {
if (index >= 0 && index <= map.width - 1) {
const cells = map.cellsInRect({
left: index,
right: index + 1,
top: 0,
bottom: map.height,
});
return acc.concat(
cells.map((nodePos: number) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
}) as unknown as {
pos: number;
start: number;
node: Node | null | undefined;
}[]
);
}
return acc;
}, [] as { pos: number; start: number; node: Node | null | undefined }[]);
}
};
export const getCellsInRow =
(rowIndex: number | number[]) => (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const indexes = Array.isArray(rowIndex)
? rowIndex
: Array.from([rowIndex]);
return indexes.reduce((acc, index) => {
if (index >= 0 && index <= map.height - 1) {
const cells = map.cellsInRect({
left: 0,
right: map.width,
top: index,
bottom: index + 1,
});
return acc.concat(
cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
}) as unknown as {
pos: number;
start: number;
node: Node | null | undefined;
}[]
);
}
return acc;
}, [] as { pos: number; start: number; node: Node | null | undefined }[]);
}
};
export const findTable = (selection: Selection) => {
return findParentNode((node) => node.type.spec.tableRole === "table")(
selection
) as
| {
pos: number;
start: number;
depth: number;
node: Node;
}
| undefined;
};
export const isRectSelected = (rect: any) => (selection: CellSelection) => {
const map = TableMap.get(selection.$anchorCell.node(-1));
const start = selection.$anchorCell.start(-1);
const cells = map.cellsInRect(rect);
const selectedCells = map.cellsInRect(
map.rectBetween(
selection.$anchorCell.pos - start,
selection.$headCell.pos - start
)
);
for (let i = 0, count = cells.length; i < count; i++) {
if (selectedCells.indexOf(cells[i]) === -1) {
return false;
}
}
return true;
};
export const isCellSelection = (selection: any) => {
return selection instanceof CellSelection;
};
export const isColumnSelected = (columnIndex: number) => (selection: any) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
const cells = map.cellsInRect({
left: 0,
right: map.width,
top: 0,
bottom: 1,
});
if (columnIndex >= cells.length) {
return false;
}
const startCellRect = map.findCell(cells[columnIndex]);
const isSelect = isRectSelected({
left: startCellRect.left,
right: startCellRect.right,
top: 0,
bottom: map.height,
})(selection);
return isSelect;
}
return false;
};
export const isRowSelected = (rowIndex: number) => (selection: any) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
const cells = map.cellsInRect({
left: 0,
right: 1,
top: 0,
bottom: map.height,
});
if (rowIndex >= cells.length) {
return false;
}
const startCellRect = map.findCell(cells[rowIndex]);
return isRectSelected({
left: 0,
right: map.width,
top: startCellRect.top,
bottom: startCellRect.bottom,
})(selection);
}
return false;
};
export const isTableSelected = (selection: any) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: 0,
right: map.width,
top: 0,
bottom: map.height,
})(selection);
}
return false;
};

View File

@ -0,0 +1,62 @@
import type { Editor, Range } from "@/tiptap/vue-3";
import TiptapTaskList from "@tiptap/extension-task-list";
import type { TaskListOptions } from "@tiptap/extension-task-list";
import ExtensionTaskItem from "@tiptap/extension-task-item";
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
import MdiFormatListCheckbox from "~icons/mdi/format-list-checkbox";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import type { ExtensionOptions } from "@/types";
const TaskList = TiptapTaskList.extend<ExtensionOptions & TaskListOptions>({
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 150,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive("taskList"),
icon: markRaw(MdiFormatListCheckbox),
title: i18n.global.t("editor.common.task_list"),
action: () => editor.chain().focus().toggleTaskList().run(),
},
};
},
getCommandMenuItems() {
return {
priority: 150,
icon: markRaw(MdiFormatListCheckbox),
title: "editor.common.task_list",
keywords: ["tasklist", "renwuliebiao"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
};
},
getDraggable() {
return {
getRenderContainer({ dom }) {
let container = dom;
while (container && !(container.tagName === "LI")) {
container = container.parentElement as HTMLElement;
}
return {
el: container,
dragDomOffset: {
y: -1,
},
};
},
};
},
};
},
addExtensions() {
return [ExtensionTaskItem];
},
});
export default TaskList;

View File

@ -0,0 +1,70 @@
import type { Editor } from "@/tiptap/vue-3";
import TiptapTextAlign from "@tiptap/extension-text-align";
import type { TextAlignOptions } from "@tiptap/extension-text-align";
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
import MdiFormatAlignLeft from "~icons/mdi/format-align-left";
import MdiFormatAlignCenter from "~icons/mdi/format-align-center";
import MdiFormatAlignRight from "~icons/mdi/format-align-right";
import MdiFormatAlignJustify from "~icons/mdi/format-align-justify";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import type { ExtensionOptions } from "@/types";
const TextAlign = TiptapTextAlign.extend<ExtensionOptions & TextAlignOptions>({
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return [
{
priority: 180,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive({ textAlign: "left" }),
icon: markRaw(MdiFormatAlignLeft),
title: i18n.global.t("editor.common.align_left"),
action: () => editor.chain().focus().setTextAlign("left").run(),
},
},
{
priority: 190,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive({ textAlign: "center" }),
icon: markRaw(MdiFormatAlignCenter),
title: i18n.global.t("editor.common.align_center"),
action: () => editor.chain().focus().setTextAlign("center").run(),
},
},
{
priority: 200,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive({ textAlign: "right" }),
icon: markRaw(MdiFormatAlignRight),
title: i18n.global.t("editor.common.align_right"),
action: () => editor.chain().focus().setTextAlign("right").run(),
},
},
{
priority: 210,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive({ textAlign: "justify" }),
icon: markRaw(MdiFormatAlignJustify),
title: i18n.global.t("editor.common.align_justify"),
action: () =>
editor.chain().focus().setTextAlign("justify").run(),
},
},
];
},
};
},
});
export default TextAlign;

View File

@ -0,0 +1,270 @@
import type { ExtensionOptions, NodeBubbleMenu } from "@/types";
import { Text as TiptapText } from "@tiptap/extension-text";
import { markRaw } from "vue";
import ColorBubbleItem from "@/extensions/color/ColorBubbleItem.vue";
import HighlightBubbleItem from "@/extensions/highlight/HighlightBubbleItem.vue";
import LinkBubbleButton from "@/extensions/link/LinkBubbleButton.vue";
import MdiFormatQuoteOpen from "~icons/mdi/format-quote-open";
import MdiCodeTags from "~icons/mdi/code-tags";
import MdiCodeBracesBox from "~icons/mdi/code-braces-box";
import MdiFormatColor from "~icons/mdi/format-color";
import MdiFormatColorHighlight from "~icons/mdi/format-color-highlight";
import MdiFormatItalic from "~icons/mdi/format-italic";
import MdiLinkVariantOff from "~icons/mdi/link-variant-off";
import MdiShare from "~icons/mdi/share";
import MdiFormatStrikethrough from "~icons/mdi/format-strikethrough";
import MdiFormatSubscript from "~icons/mdi/format-subscript";
import MdiFormatSuperscript from "~icons/mdi/format-superscript";
import MdiFormatAlignLeft from "~icons/mdi/format-align-left";
import MdiFormatAlignCenter from "~icons/mdi/format-align-center";
import MdiFormatAlignRight from "~icons/mdi/format-align-right";
import MdiFormatAlignJustify from "~icons/mdi/format-align-justify";
import MdiFormatUnderline from "~icons/mdi/format-underline";
import { isActive, isTextSelection } from "@/tiptap/vue-3";
import type { EditorState } from "@/tiptap/pm";
import MdiFormatBold from "~icons/mdi/format-bold";
import { i18n } from "@/locales";
const OTHER_BUBBLE_MENU_TYPES = [
"audio",
"video",
"image",
"iframe",
"codeBlock",
];
const Text = TiptapText.extend<ExtensionOptions>({
addOptions() {
return {
...this.parent?.(),
getBubbleMenu(): NodeBubbleMenu {
return {
pluginKey: "textBubbleMenu",
shouldShow: ({ state, from, to }) => {
const { doc, selection } = state as EditorState;
const { empty } = selection;
if (empty) {
return false;
}
if (
OTHER_BUBBLE_MENU_TYPES.some((type) =>
isActive(state as EditorState, type)
)
) {
return false;
}
const isEmptyTextBlock =
doc.textBetween(from || 0, to || 0).length === 0;
if (isEmptyTextBlock) {
return false;
}
if (!isTextSelection(selection)) {
return false;
}
return true;
},
tippyOptions: {
fixed: false,
},
defaultAnimation: false,
items: [
{
priority: 10,
props: {
isActive: ({ editor }) => editor.isActive("bold"),
icon: markRaw(MdiFormatBold),
title: i18n.global.t("editor.common.bold"),
action: ({ editor }) => {
editor.chain().focus().toggleBold().run();
},
},
},
{
priority: 20,
props: {
isActive: ({ editor }) => editor.isActive("italic"),
icon: markRaw(MdiFormatItalic),
title: i18n.global.t("editor.common.italic"),
action: ({ editor }) => {
editor.chain().focus().toggleItalic().run();
},
},
},
{
priority: 30,
props: {
isActive: ({ editor }) => editor.isActive("underline"),
icon: markRaw(MdiFormatUnderline),
title: i18n.global.t("editor.common.underline"),
action: ({ editor }) =>
editor.chain().focus().toggleUnderline().run(),
},
},
{
priority: 40,
props: {
isActive: ({ editor }) => editor.isActive("strike"),
icon: markRaw(MdiFormatStrikethrough),
title: i18n.global.t("editor.common.strike"),
action: ({ editor }) =>
editor.chain().focus().toggleStrike().run(),
},
},
{
priority: 50,
component: markRaw(HighlightBubbleItem),
props: {
isActive: ({ editor }) => editor.isActive("highlight"),
icon: markRaw(MdiFormatColorHighlight),
title: i18n.global.t("editor.common.highlight"),
},
},
{
priority: 51,
component: markRaw(ColorBubbleItem),
props: {
isActive: ({ editor }) => editor.isActive("color"),
icon: markRaw(MdiFormatColor),
title: i18n.global.t("editor.common.color"),
},
},
{
priority: 60,
props: {
isActive: ({ editor }) => editor.isActive("blockquote"),
icon: markRaw(MdiFormatQuoteOpen),
title: i18n.global.t("editor.common.quote"),
action: ({ editor }) => {
return editor.commands.toggleBlockquote();
},
},
},
{
priority: 80,
props: {
isActive: ({ editor }) => editor.isActive("code"),
icon: markRaw(MdiCodeTags),
title: i18n.global.t("editor.common.code"),
action: ({ editor }) =>
editor.chain().focus().toggleCode().run(),
},
},
{
priority: 90,
props: {
isActive: ({ editor }) => editor.isActive("codeBlock"),
icon: markRaw(MdiCodeBracesBox),
title: i18n.global.t("editor.common.codeblock"),
action: ({ editor }) =>
editor.chain().focus().toggleCodeBlock().run(),
},
},
{
priority: 100,
props: {
isActive: ({ editor }) => editor.isActive("superscript"),
icon: markRaw(MdiFormatSuperscript),
title: i18n.global.t("editor.common.superscript"),
action: ({ editor }) =>
editor.chain().focus().toggleSuperscript().run(),
},
},
{
priority: 110,
props: {
isActive: ({ editor }) => editor.isActive("subscript"),
icon: markRaw(MdiFormatSubscript),
title: i18n.global.t("editor.common.subscript"),
action: ({ editor }) =>
editor.chain().focus().toggleSubscript().run(),
},
},
{
priority: 120,
props: {
isActive: ({ editor }) =>
editor.isActive({ textAlign: "left" }),
icon: markRaw(MdiFormatAlignLeft),
title: i18n.global.t("editor.common.align_left"),
action: ({ editor }) =>
editor.chain().focus().setTextAlign("left").run(),
},
},
{
priority: 130,
props: {
isActive: ({ editor }) =>
editor.isActive({ textAlign: "center" }),
icon: markRaw(MdiFormatAlignCenter),
title: i18n.global.t("editor.common.align_center"),
action: ({ editor }) =>
editor.chain().focus().setTextAlign("center").run(),
},
},
{
priority: 140,
props: {
isActive: ({ editor }) =>
editor.isActive({ textAlign: "right" }),
icon: markRaw(MdiFormatAlignRight),
title: i18n.global.t("editor.common.align_right"),
action: ({ editor }) =>
editor.chain().focus().setTextAlign("right").run(),
},
},
{
priority: 150,
props: {
isActive: ({ editor }) =>
editor.isActive({ textAlign: "justify" }),
icon: markRaw(MdiFormatAlignJustify),
title: i18n.global.t("editor.common.align_justify"),
action: ({ editor }) =>
editor.chain().focus().setTextAlign("justify").run(),
},
},
{
priority: 160,
component: markRaw(LinkBubbleButton),
props: {
isActive: ({ editor }) => editor.isActive("link"),
},
},
{
priority: 170,
props: {
isActive: () => false,
visible: ({ editor }) => editor.isActive("link"),
icon: markRaw(MdiLinkVariantOff),
title: i18n.global.t("editor.extensions.link.cancel_link"),
action: ({ editor }) => editor.commands.unsetLink(),
},
},
{
priority: 180,
props: {
isActive: () => false,
visible: ({ editor }) => editor.isActive("link"),
icon: markRaw(MdiShare),
title: i18n.global.t("editor.common.tooltip.open_link"),
action: ({ editor }) => {
const attrs = editor.getAttributes("link");
if (attrs?.href) {
window.open(attrs.href, "_blank");
}
},
},
},
],
};
},
};
},
});
export default Text;

View File

@ -0,0 +1,79 @@
import { Extension } from "@/tiptap/vue-3";
import { Plugin, PluginKey } from "@/tiptap/pm";
/**
* @param {object} args Arguments as deconstructable object
* @param {Array | object} args.types possible types
* @param {object} args.node node to check
*/
function nodeEqualsType({ types, node }: { types: any; node: any }) {
return (
(Array.isArray(types) && types.includes(node.type)) || node.type === types
);
}
/**
* Extension based on:
* - https://github.com/ueberdosis/tiptap/tree/main/demos/src/Experiments/TrailingNode
* - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js
* - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts
*/
const TrailingNode = Extension.create({
name: "trailingNode",
addOptions() {
return {
node: "paragraph",
notAfter: ["paragraph"],
};
},
addProseMirrorPlugins() {
const plugin = new PluginKey(this.name);
const disabledNodes = Object.entries(this.editor.schema.nodes)
.map(([, value]) => value)
.filter((node) => this.options.notAfter.includes(node.name));
const isEditable = this.editor.isEditable;
return [
new Plugin({
key: plugin,
appendTransaction: (_, __, state) => {
if (!isEditable) return;
const { doc, tr, schema } = state;
const shouldInsertNodeAtEnd = plugin.getState(state);
const endPosition = doc.content.size;
const type = schema.nodes[this.options.node];
if (!shouldInsertNodeAtEnd) {
return;
}
return tr.insert(endPosition, type.create());
},
state: {
init: (_, state) => {
if (!isEditable) return false;
const lastNode = state.tr.doc.lastChild;
return !nodeEqualsType({ node: lastNode, types: disabledNodes });
},
apply: (tr, value) => {
if (!isEditable) return value;
if (!tr.docChanged) {
return value;
}
const lastNode = tr.doc.lastChild;
return !nodeEqualsType({ node: lastNode, types: disabledNodes });
},
},
}),
];
},
});
export default TrailingNode;

View File

@ -0,0 +1,31 @@
import type { Editor } from "@/tiptap/vue-3";
import TiptapUnderline from "@tiptap/extension-underline";
import type { UnderlineOptions } from "@tiptap/extension-underline";
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
import MdiFormatUnderline from "~icons/mdi/format-underline";
import { markRaw } from "vue";
import { i18n } from "@/locales";
import type { ExtensionOptions } from "@/types";
const Underline = TiptapUnderline.extend<ExtensionOptions & UnderlineOptions>({
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 60,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive("underline"),
icon: markRaw(MdiFormatUnderline),
title: i18n.global.t("editor.common.underline"),
action: () => editor.chain().focus().toggleUnderline().run(),
},
};
},
};
},
});
export default Underline;

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import { i18n } from "@/locales";
import type { Editor } from "@/tiptap/vue-3";
import { computed, type Component } from "vue";
import Video from "./index";
const props = defineProps<{
editor: Editor;
isActive: ({ editor }: { editor: Editor }) => boolean;
visible?: ({ editor }: { editor: Editor }) => boolean;
icon?: Component;
title?: string;
action?: ({ editor }: { editor: Editor }) => void;
}>();
const src = computed({
get: () => {
return props.editor.getAttributes(Video.name).src;
},
set: (src: string) => {
props.editor.chain().updateAttributes(Video.name, { src: src }).run();
},
});
</script>
<template>
<input
v-model.lazy="src"
:placeholder="i18n.global.t('editor.common.placeholder.link_input')"
class="bg-gray-50 rounded-md hover:bg-gray-100 block px-2 w-full py-1.5 text-sm text-gray-900 border border-gray-300 focus:ring-blue-500 focus:border-blue-500"
/>
</template>

View File

@ -0,0 +1,59 @@
<script lang="ts" setup>
import { BlockActionInput } from "@/components";
import type { Editor } from "@/tiptap/vue-3";
import { computed, type Component } from "vue";
import Video from "./index";
import { i18n } from "@/locales";
const props = defineProps<{
editor: Editor;
isActive: ({ editor }: { editor: Editor }) => boolean;
visible?: ({ editor }: { editor: Editor }) => boolean;
icon?: Component;
title?: string;
action?: ({ editor }: { editor: Editor }) => void;
}>();
const width = computed({
get: () => {
return props.editor.getAttributes(Video.name).width;
},
set: (value: string) => {
handleSetSize(value, height.value);
},
});
const height = computed({
get: () => {
return props.editor.getAttributes(Video.name).height;
},
set: (value: string) => {
handleSetSize(width.value, value);
},
});
function handleSetSize(width: string, height: string) {
props.editor
.chain()
.updateAttributes(Video.name, { width, height })
.setNodeSelection(props.editor.state.selection.from)
.focus()
.run();
}
</script>
<template>
<BlockActionInput
v-model.lazy.trim="width"
:tooltip="i18n.global.t('editor.common.tooltip.custom_width_input')"
/>
<BlockActionInput
v-model.lazy.trim="height"
:tooltip="i18n.global.t('editor.common.tooltip.custom_height_input')"
/>
</template>
<style lang="scss">
.editor-block__actions-input {
@apply bg-gray-50 rounded-md hover:bg-gray-100 block px-2 w-32 py-1 text-sm text-gray-900 border border-gray-300 focus:ring-blue-500 focus:border-blue-500;
}
</style>

View File

@ -0,0 +1,86 @@
<script lang="ts" setup>
import type { Node as ProseMirrorNode, Decoration } from "@/tiptap/pm";
import type { Editor, Node } from "@/tiptap/vue-3";
import { NodeViewWrapper } from "@/tiptap/vue-3";
import { computed, onMounted, ref } from "vue";
import { i18n } from "@/locales";
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 src = computed({
get: () => {
return props.node?.attrs.src;
},
set: (src: string) => {
props.updateAttributes({ src: src });
},
});
const controls = computed(() => {
return props.node.attrs.controls;
});
const autoplay = computed(() => {
return props.node.attrs.autoplay;
});
const loop = computed(() => {
return props.node.attrs.loop;
});
function handleSetFocus() {
props.editor.commands.setNodeSelection(props.getPos());
}
const inputRef = ref();
onMounted(() => {
if (!src.value) {
inputRef.value.focus();
}
});
</script>
<template>
<node-view-wrapper as="div" class="inline-block w-full">
<div
class="inline-block overflow-hidden transition-all text-center relative h-full"
:style="{
width: node.attrs.width,
}"
>
<div v-if="!src" class="p-1.5">
<input
ref="inputRef"
v-model.lazy="src"
class="block px-2 w-full py-1.5 text-sm text-gray-900 border border-gray-300 rounded-md bg-gray-50 focus:ring-blue-500 focus:border-blue-500"
:placeholder="i18n.global.t('editor.common.placeholder.link_input')"
tabindex="-1"
@focus="handleSetFocus"
/>
</div>
<video
v-else
:controls="controls"
:autoplay="autoplay"
:loop="loop"
class="rounded-md m-0"
:src="node!.attrs.src"
:style="{
width: node.attrs.width,
height: node.attrs.height,
}"
@mouseenter="handleSetFocus"
></video>
</div>
</node-view-wrapper>
</template>

View File

@ -0,0 +1,468 @@
import type { ExtensionOptions, NodeBubbleMenu } from "@/types";
import {
Editor,
isActive,
mergeAttributes,
Node,
nodeInputRule,
type Range,
VueNodeViewRenderer,
} from "@/tiptap/vue-3";
import type { EditorState } from "@/tiptap/pm";
import { markRaw } from "vue";
import VideoView from "./VideoView.vue";
import MdiVideo from "~icons/mdi/video";
import ToolboxItem from "@/components/toolbox/ToolboxItem.vue";
import { i18n } from "@/locales";
import { BlockActionSeparator } from "@/components";
import BubbleItemVideoSize from "./BubbleItemVideoSize.vue";
import BubbleItemVideoLink from "./BubbleItemVideoLink.vue";
import MdiImageSizeSelectActual from "~icons/mdi/image-size-select-actual";
import MdiImageSizeSelectSmall from "~icons/mdi/image-size-select-small";
import MdiImageSizeSelectLarge from "~icons/mdi/image-size-select-large";
import MdiFormatAlignLeft from "~icons/mdi/format-align-left";
import MdiFormatAlignCenter from "~icons/mdi/format-align-center";
import MdiFormatAlignRight from "~icons/mdi/format-align-right";
import MdiFormatAlignJustify from "~icons/mdi/format-align-justify";
import MdiCogPlay from "~icons/mdi/cog-play";
import MdiCogPlayOutline from "~icons/mdi/cog-play-outline";
import MdiPlayCircle from "~icons/mdi/play-circle";
import MdiPlayCircleOutline from "~icons/mdi/play-circle-outline";
import MdiMotionPlayOutline from "~icons/mdi/motion-play-outline";
import MdiMotionPlay from "~icons/mdi/motion-play";
import MdiLinkVariant from "~icons/mdi/link-variant";
import MdiShare from "~icons/mdi/share";
import { deleteNode } from "@/utils";
import MdiDeleteForeverOutline from "@/components/icon/MdiDeleteForeverOutline.vue";
declare module "@/tiptap" {
interface Commands<ReturnType> {
video: {
setVideo: (options: { src: string }) => ReturnType;
};
}
}
const Video = Node.create<ExtensionOptions>({
name: "video",
inline() {
return true;
},
group() {
return "inline";
},
addAttributes() {
return {
...this.parent?.(),
src: {
default: null,
parseHTML: (element) => {
return element.getAttribute("src");
},
},
width: {
default: "100%",
parseHTML: (element) => {
return element.getAttribute("width");
},
renderHTML(attributes) {
return {
width: attributes.width,
};
},
},
height: {
default: "auto",
parseHTML: (element) => {
return element.getAttribute("height");
},
renderHTML: (attributes) => {
return {
height: attributes.height,
};
},
},
autoplay: {
default: null,
parseHTML: (element) => {
return element.getAttribute("autoplay");
},
renderHTML: (attributes) => {
return {
autoplay: attributes.autoplay,
};
},
},
controls: {
default: null,
parseHTML: (element) => {
return element.getAttribute("controls");
},
renderHTML: (attributes) => {
return {
controls: attributes.controls,
};
},
},
loop: {
default: null,
parseHTML: (element) => {
return element.getAttribute("loop");
},
renderHTML: (attributes) => {
return {
loop: attributes.loop,
};
},
},
textAlign: {
default: null,
parseHTML: (element) => {
return element.getAttribute("text-align");
},
renderHTML: (attributes) => {
return {
"text-align": attributes.textAlign,
};
},
},
};
},
parseHTML() {
return [
{
tag: "video",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["video", mergeAttributes(HTMLAttributes)];
},
addCommands() {
return {
setVideo:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
addInputRules() {
return [
nodeInputRule({
find: /^\$video\$$/,
type: this.type,
getAttributes: () => {
return { width: "100%" };
},
}),
];
},
addNodeView() {
return VueNodeViewRenderer(VideoView);
},
addOptions() {
return {
...this.parent?.(),
getCommandMenuItems() {
return {
priority: 100,
icon: markRaw(MdiVideo),
title: "editor.extensions.commands_menu.video",
keywords: ["video", "shipin"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent([
{ type: "video", attrs: { src: "" } },
{ type: "paragraph", content: "" },
])
.run();
},
};
},
getToolboxItems({ editor }: { editor: Editor }) {
return [
{
priority: 20,
component: markRaw(ToolboxItem),
props: {
editor,
icon: markRaw(MdiVideo),
title: i18n.global.t("editor.extensions.commands_menu.video"),
action: () => {
editor
.chain()
.focus()
.insertContent([{ type: "video", attrs: { src: "" } }])
.run();
},
},
},
];
},
getBubbleMenu({ editor }: { editor: Editor }): NodeBubbleMenu {
return {
pluginKey: "videoBubbleMenu",
shouldShow: ({ state }: { state: EditorState }) => {
return isActive(state, Video.name);
},
items: [
{
priority: 10,
props: {
isActive: () => editor.getAttributes(Video.name).controls,
icon: markRaw(
editor.getAttributes(Video.name).controls
? MdiCogPlay
: MdiCogPlayOutline
),
action: () => {
return editor
.chain()
.updateAttributes(Video.name, {
controls: editor.getAttributes(Video.name).controls
? null
: true,
})
.setNodeSelection(editor.state.selection.from)
.focus()
.run();
},
title: editor.getAttributes(Video.name).controls
? i18n.global.t("editor.extensions.video.disable_controls")
: i18n.global.t("editor.extensions.video.enable_controls"),
},
},
{
priority: 20,
props: {
isActive: () => {
return editor.getAttributes(Video.name).autoplay;
},
icon: markRaw(
editor.getAttributes(Video.name).autoplay
? MdiPlayCircle
: MdiPlayCircleOutline
),
action: () => {
return editor
.chain()
.updateAttributes(Video.name, {
autoplay: editor.getAttributes(Video.name).autoplay
? null
: true,
})
.setNodeSelection(editor.state.selection.from)
.focus()
.run();
},
title: editor.getAttributes(Video.name).autoplay
? i18n.global.t("editor.extensions.video.disable_autoplay")
: i18n.global.t("editor.extensions.video.enable_autoplay"),
},
},
{
priority: 30,
props: {
isActive: () => {
return editor.getAttributes(Video.name).loop;
},
icon: markRaw(
editor.getAttributes(Video.name).loop
? MdiMotionPlay
: MdiMotionPlayOutline
),
action: () => {
editor
.chain()
.updateAttributes(Video.name, {
loop: editor.getAttributes(Video.name).loop ? null : true,
})
.setNodeSelection(editor.state.selection.from)
.focus()
.run();
},
title: editor.getAttributes(Video.name).loop
? i18n.global.t("editor.extensions.video.disable_loop")
: i18n.global.t("editor.extensions.video.enable_loop"),
},
},
{
priority: 40,
component: markRaw(BlockActionSeparator),
},
{
priority: 50,
component: markRaw(BubbleItemVideoSize),
},
{
priority: 60,
component: markRaw(BlockActionSeparator),
},
{
priority: 70,
props: {
isActive: () =>
editor.getAttributes(Video.name).width === "25%",
icon: markRaw(MdiImageSizeSelectSmall),
action: () => handleSetSize(editor, "25%", "auto"),
title: i18n.global.t("editor.extensions.video.small_size"),
},
},
{
priority: 80,
props: {
isActive: () =>
editor.getAttributes(Video.name).width === "50%",
icon: markRaw(MdiImageSizeSelectLarge),
action: () => handleSetSize(editor, "50%", "auto"),
title: i18n.global.t("editor.extensions.video.medium_size"),
},
},
{
priority: 90,
props: {
isActive: () =>
editor.getAttributes(Video.name).width === "100%",
icon: markRaw(MdiImageSizeSelectActual),
action: () => handleSetSize(editor, "100%", "auto"),
title: i18n.global.t("editor.extensions.video.large_size"),
},
},
{
priority: 100,
component: markRaw(BlockActionSeparator),
},
{
priority: 110,
props: {
isActive: () => editor.isActive({ textAlign: "left" }),
icon: markRaw(MdiFormatAlignLeft),
action: () => handleSetTextAlign(editor, "left"),
},
},
{
priority: 120,
props: {
isActive: () => editor.isActive({ textAlign: "center" }),
icon: markRaw(MdiFormatAlignCenter),
action: () => handleSetTextAlign(editor, "center"),
},
},
{
priority: 130,
props: {
isActive: () => editor.isActive({ textAlign: "right" }),
icon: markRaw(MdiFormatAlignRight),
action: () => handleSetTextAlign(editor, "right"),
},
},
{
priority: 140,
props: {
isActive: () => editor.isActive({ textAlign: "justify" }),
icon: markRaw(MdiFormatAlignJustify),
action: () => handleSetTextAlign(editor, "justify"),
},
},
{
priority: 150,
component: markRaw(BlockActionSeparator),
},
{
priority: 160,
props: {
icon: markRaw(MdiLinkVariant),
title: i18n.global.t("editor.common.button.edit_link"),
action: () => {
return markRaw(BubbleItemVideoLink);
},
},
},
{
priority: 170,
props: {
icon: markRaw(MdiShare),
title: i18n.global.t("editor.common.tooltip.open_link"),
action: () => {
window.open(editor.getAttributes(Video.name).src, "_blank");
},
},
},
{
priority: 180,
component: markRaw(BlockActionSeparator),
},
{
priority: 190,
props: {
icon: markRaw(MdiDeleteForeverOutline),
title: i18n.global.t("editor.common.button.delete"),
action: ({ editor }) => {
deleteNode(Video.name, editor);
},
},
},
],
};
},
getDraggable() {
return {
getRenderContainer({ dom, view }) {
let container = dom;
while (container && container.tagName !== "P") {
container = container.parentElement as HTMLElement;
}
if (container) {
container = container.firstElementChild
?.firstElementChild as HTMLElement;
}
let node;
if (container.firstElementChild) {
const pos = view.posAtDOM(container.firstElementChild, 0);
const $pos = view.state.doc.resolve(pos);
node = $pos.node();
}
return {
node: node,
el: container,
};
},
};
},
};
},
});
const handleSetSize = (editor: Editor, width: string, height: string) => {
editor
.chain()
.updateAttributes(Video.name, { width, height })
.setNodeSelection(editor.state.selection.from)
.focus()
.run();
};
const handleSetTextAlign = (
editor: Editor,
align: "left" | "center" | "right" | "justify"
) => {
editor.chain().focus().setTextAlign(align).run();
};
export default Video;

View File

@ -0,0 +1,19 @@
import type { App, Plugin } from "vue";
import { RichTextEditor } from "./components";
import "./styles/index.scss";
import "./styles/tailwind.css";
import "floating-vue/dist/style.css";
import "github-markdown-css/github-markdown-light.css";
import "highlight.js/styles/github-dark.css";
const plugin: Plugin = {
install(app: App) {
app.component("RichTextEditor", RichTextEditor);
},
};
export default plugin;
export * from "./tiptap";
export * from "./extensions";
export * from "./components";

View File

@ -0,0 +1,119 @@
editor:
menus:
undo: Undo
redo: Redo
table:
title: Table
add: Add table
add_column_before: Add column before
add_column_after: Add column after
delete_column: Delete column
add_row_before: Add row before
add_row_after: Add row after
toggle_header_column: Set/Unset First Column Header
toggle_header_row: Set/Unset First Row Header
toggle_header_cell: Set/Unset Current Cell as Header
delete_row: Delete row
merge_cells: Merge cells
split_cell: Split cell
delete_table: Delete table
extensions:
commands_menu:
columns: Column Card
iframe: Iframe
image: image
video: Video
audio: Audio
table: Table
no_results: No results
placeholder: "Enter / to select input type"
link:
add_link: Add link
edit_link: Edit link
placeholder: Link address
open_in_new_window: Open in new window
cancel_link: Cancel link
audio:
disable_autoplay: Disable auto play
enable_autoplay: Enable auto play
disable_loop: Disable loop
enable_loop: Disable loop
iframe:
disable_frameborder: Hide frameborder
enable_frameborder: Show frameborder
phone_size: Mobile phone size
tablet_vertical_size: Tablet portrait size
tablet_horizontal_size: Tablet landscape size
desktop_size: Desktop size
image:
small_size: Small size
medium_size: Medium size
large_size: Large size
restore_size: Restore original size
edit_link: Edit link
edit_alt: Edit alt
edit_href: Edit the image hyperlink
video:
disable_controls: Hide controls
enable_controls: Show controls
disable_autoplay: Disable auto play
enable_autoplay: Enable auto play
disable_loop: Disable loop
enable_loop: Enable loop
small_size: Small size
medium_size: Medium size
large_size: Large size
highlight:
unset: Unset
columns:
add_column_before: Add column before
add_column_after: Add column after
delete_column: Delete column
components:
color_picker:
more_color: More
common:
align_left: Align left
align_center: Align center
align_right: Align right
align_justify: Align justify
bold: Bold
italic: Italic
underline: Underline
strike: Strike
quote: Quote
code: Code
superscript: Super Script
subscript: Sub Script
codeblock: Code block
image: Image
heading:
title: Text type
paragraph: Paragraph
header1: Header 1
header2: Header 2
header3: Header 3
header4: Header 4
header5: Header 5
header6: Header 6
bullet_list: Bullet list
ordered_list: Ordered list
task_list: Task list
highlight: Highlight
color: Color
tooltip:
custom_width_input: Custom width, press Enter to take effect
custom_height_input: Customize height, press Enter to take effect
open_link: Open link
placeholder:
link_input: Enter the link and press enter to confirm.
alt_input: Enter the image alt and press enter to confirm.
alt_href: Enter the image hyperlink and press enter to confirm.
button:
new_line: New line
delete: Delete
edit_link: Edit link
refresh: Refresh
restore_default: Restore default
text:
default: Default

View File

@ -0,0 +1,21 @@
import { createI18n } from "vue-i18n";
// @ts-ignore
import en from "./en.yaml";
// @ts-ignore
import zhCN from "./zh-CN.yaml";
const messages = {
en: en,
zh: zhCN,
"en-US": en,
"zh-CN": zhCN,
};
const i18n = createI18n({
legacy: false,
locale: "en",
fallbackLocale: "zh-CN",
messages,
});
export { i18n };

View File

@ -0,0 +1,119 @@
editor:
menus:
undo: 撤销
redo: 恢复
table:
title: 表格
add: 插入表格
add_column_before: 向前插入列
add_column_after: 向后插入列
delete_column: 删除当前列
add_row_before: 向上插入行
add_row_after: 向下插入行
toggle_header_column: 设置/取消首列表头
toggle_header_row: 设置/取消首行表头
toggle_header_cell: 设置/取消当前单元格为表头
delete_row: 删除当前行
merge_cells: 合并单元格
split_cell: 分割单元格
delete_table: 删除表格
extensions:
commands_menu:
columns: 分栏卡片
iframe: 嵌入网页
image: 图片
video: 视频
audio: 音频
table: 表格
no_results: 没有搜索结果
placeholder: "输入 / 以选择输入类型"
link:
add_link: 添加链接
edit_link: 修改链接
placeholder: 链接地址
open_in_new_window: 在新窗口中打开
cancel_link: 取消链接
audio:
disable_autoplay: 关闭自动播放
enable_autoplay: 开启自动播放
disable_loop: 关闭循环播放
enable_loop: 开启循环播放
iframe:
disable_frameborder: 取消边框
enable_frameborder: 设置边框
phone_size: 手机尺寸
tablet_vertical_size: 平板电脑纵向尺寸
tablet_horizontal_size: 平板电脑横向尺寸
desktop_size: 桌面电脑尺寸
image:
small_size: 小尺寸
medium_size: 中尺寸
large_size: 大尺寸
restore_size: 恢复原始尺寸
edit_link: 修改链接
edit_alt: 修改图片 alt 属性
edit_href: 修改图片跳转链接
video:
disable_controls: 隐藏控制面板
enable_controls: 显示控制面板
disable_autoplay: 关闭自动播放
enable_autoplay: 开启自动播放
disable_loop: 关闭循环播放
enable_loop: 开启循环播放
small_size: 小尺寸
medium_size: 中尺寸
large_size: 大尺寸
highlight:
unset: 移除高亮颜色
columns:
add_column_before: 向前插入列
add_column_after: 向后插入列
delete_column: 删除当前列
components:
color_picker:
more_color: 更多颜色
common:
align_left: 左对齐
align_center: 居中
align_right: 右对齐
align_justify: 两端对齐
bold: 粗体
italic: 斜体
underline: 下划线
strike: 删除线
quote: 引用
code: 行内代码
superscript: 上角标
subscript: 下角标
codeblock: 代码块
image: 图片
heading:
title: 文本类型
paragraph: 普通文本
header1: 一级标题
header2: 二级标题
header3: 三级标题
header4: 四级标题
header5: 五级标题
header6: 六级标题
bullet_list: 无序列表
ordered_list: 有序列表
task_list: 任务列表
highlight: 高亮
color: 字体颜色
tooltip:
custom_width_input: 自定义宽度,按回车键生效
custom_height_input: 自定义高度,按回车键生效
open_link: 打开链接
placeholder:
link_input: 输入链接,按回车确定
alt_input: 输入图片 alt 属性值,按回车确认
alt_href: 输入图片跳转链接,按回车确认
button:
new_line: 换行
delete: 删除
edit_link: 修改链接
refresh: 刷新
restore_default: 恢复为默认
text:
default: 默认

Some files were not shown because too many files have changed in this diff Show More