Skip to content
This repository has been archived by the owner on Nov 29, 2023. It is now read-only.

feat: add an indentation extension to the editor #34

Merged
merged 2 commits into from
Aug 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions example/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
lowlight,
RichTextEditor,
useEditor,
ExtensionIndent,
} from "@halo-dev/richtext-editor";

const content = useLocalStorage("content", "");
Expand Down Expand Up @@ -96,6 +97,7 @@ const editor = useEditor({
ExtensionIframe,
ExtensionColor,
ExtensionFontSize,
ExtensionIndent,
],
onUpdate: () => {
content.value = editor.value?.getHTML() + "";
Expand Down
112 changes: 109 additions & 3 deletions packages/editor/src/extensions/code-block/code-block.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,124 @@
import { Editor, VueNodeViewRenderer, type Range } from "@tiptap/vue-3";
import {
Editor,
VueNodeViewRenderer,
type Range,
type CommandProps,
} from "@tiptap/vue-3";
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 type { ExtensionOptions } from "@/types";
import BubbleItem from "@/components/bubble/BubbleItem.vue";
import ToolboxItem from "@/components/toolbox/ToolboxItem.vue";
import { TextSelection, type Transaction } from "prosemirror-state";

export interface CustomCodeBlockLowlightOptions
extends CodeBlockLowlightOptions {
lowlight: any;
defaultLanguage: string | null | undefined;
}

declare module "@tiptap/core" {
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;
};

export default CodeBlockLowlight.extend<
ExtensionOptions & CodeBlockLowlightOptions
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;
},
"Shift-Tab": () => {
if (this.editor.isActive("codeBlock")) {
return this.editor.chain().focus().codeOutdent().run();
}
return;
},
};
},
addNodeView() {
return VueNodeViewRenderer(CodeBlockViewRenderer);
},
Expand Down
245 changes: 245 additions & 0 deletions packages/editor/src/extensions/indent/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import {
type CommandProps,
type Extensions,
type KeyboardShortcutCommand,
Extension,
isList,
Editor,
} from "@tiptap/core";
import { TextSelection, Transaction } from "prosemirror-state";

declare module "@tiptap/core" {
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");
};

export const getIndent: () => KeyboardShortcutCommand =
() =>
({ editor }) => {
if (isFilterActive(editor)) {
return false;
}
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;
}
if (isFilterActive(editor)) {
return false;
}
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;
Loading