mirror of https://github.com/halo-dev/halo
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
parent
ce5c1f9052
commit
e054e9b8a4
|
@ -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",
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
extends: ["../../.eslintrc.cjs"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
};
|
|
@ -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?
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"git": {
|
||||
"commitMessage": "chore: release v${version}"
|
||||
},
|
||||
"github": {
|
||||
"release": true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,442 @@
|
|||
# 扩展说明
|
||||
|
||||
本文档介绍如何对编辑器的功能进行扩展,包括但不限于扩展工具栏、悬浮工具栏、Slash Command、拖拽功能等。各扩展区域参考下图:
|
||||
|
||||

|
||||
|
||||
目前支持的所有扩展类型 [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 |
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="vite/client" />
|
||||
/// <reference types="unplugin-icons/types/vue" />
|
|
@ -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>
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
plugins: ["../../prettier.config.js"],
|
||||
};
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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";
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 }),
|
||||
});
|
||||
};
|
|
@ -0,0 +1,2 @@
|
|||
export { default as BubbleItem } from "./BubbleItem.vue";
|
||||
export { default as NodeBubbleMenu } from "./BubbleMenu.vue";
|
|
@ -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>
|
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts" setup>
|
||||
import MdiDeleteForeverOutline from "~icons/mdi/delete-forever-outline";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MdiDeleteForeverOutline class="text-red-600" />
|
||||
</template>
|
|
@ -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";
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,2 @@
|
|||
export { default as ToolbarItem } from "./ToolbarItem.vue";
|
||||
export { default as ToolbarSubItem } from "./ToolbarSubItem.vue";
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
|||
export { default as ToolboxItem } from "./ToolboxItem.vue";
|
|
@ -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>
|
|
@ -0,0 +1,6 @@
|
|||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.mount("#app");
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
|
@ -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),
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
|
@ -0,0 +1,2 @@
|
|||
export { default as ExtensionCodeBlock } from "./code-block";
|
||||
export { default as lowlight } from "./lowlight";
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
|||
export { default as ExtensionColumns } from "./columns";
|
||||
export { default as ExtensionColumn } from "./column";
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { default as ExtensionCommands } from "./commands";
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
|
@ -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>
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,7 @@
|
|||
import { TableRow as BuiltInTableRow } from "@tiptap/extension-table-row";
|
||||
|
||||
const TableRow = BuiltInTableRow.extend({
|
||||
allowGapCursor: false,
|
||||
});
|
||||
|
||||
export default TableRow;
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
|
@ -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";
|
|
@ -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
|
|
@ -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 };
|
|
@ -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
Loading…
Reference in New Issue