editor: add callout component

This commit is contained in:
Abdullah Atta
2024-02-21 22:07:31 +05:00
parent 4d75da5ee0
commit 4848cb9f08
6 changed files with 453 additions and 2 deletions

View File

@@ -0,0 +1,297 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getParentAttributes } from "../../utils/prosemirror";
import {
InputRule,
Node,
findParentNodeClosestToPos,
mergeAttributes
} from "@tiptap/core";
import { Paragraph } from "../paragraph";
import { Heading } from "../heading";
import { TextSelection } from "@tiptap/pm/state";
import { Fragment } from "@tiptap/pm/model";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
callout: {
/**
* Set a code block
*/
setCallout: (attributes: CalloutAttributes) => ReturnType;
};
}
}
const CALLOUT_TYPES = [
"note",
"abstract",
"summary",
"tldr",
"info",
"todo",
"tip",
"hint",
"important",
"success",
"check",
"done",
"question",
"help",
"faq",
"warning",
"warn",
"caution",
"attention",
"failure",
"fail",
"missing",
"danger",
"error",
"bug",
"example",
"quote",
"cite"
] as const;
type CalloutType = (typeof CALLOUT_TYPES)[number];
export type CalloutAttributes = {
type: CalloutType;
};
const CALLOUT_REGEX = />(.+?)(?:\n| (.+)\n$)/g;
export const Callout = Node.create({
name: "callout",
content: "heading block*",
group: "block",
defining: true,
addAttributes() {
return {
type: {
default: "info",
parseHTML: (element) => element.dataset.calloutType,
renderHTML: (attributes) => {
if (!attributes.type) {
return {};
}
return {
"data-callout-type": attributes.type
};
}
},
collapsed: {
default: false,
parseHTML: (element) => element.classList.contains("collapsed"),
renderHTML: (attributes) => {
if (!attributes.collapsed) {
return {};
}
return {
class: "collapsed"
};
}
}
};
},
parseHTML() {
return [
{
tag: "div.callout"
}
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, {
class: "callout"
}),
0
];
},
addCommands() {
return {
setCallout:
(attributes) =>
({ tr, state }) => {
const { selection } = state;
const start = selection.from;
const end = selection.to;
const calloutTitle = attributes.type.toUpperCase();
const content = Fragment.from(
selection.empty
? state.schema.node(Paragraph.name)
: selection.content().content
).addToStart(
state.schema.node(Heading.name, { level: 4 }, [
state.schema.text(calloutTitle)
])
);
const newNode = this.type.create(
{
...getParentAttributes(this.editor),
...attributes
},
content
);
tr.insert(start - 1, newNode).delete(
tr.mapping.map(start),
tr.mapping.map(end)
);
tr.setSelection(
TextSelection.create(tr.doc, tr.selection.anchor - 3)
);
tr.scrollIntoView();
return true;
}
};
},
addInputRules() {
return [
new InputRule({
find: CALLOUT_REGEX,
handler: ({ state, range, match }) => {
if (match.length === 1) return null;
const calloutType = (match[1] || "info") as CalloutType;
const calloutTitle =
match[2] ||
(CALLOUT_TYPES.includes(match[1] as CalloutType)
? match[1].toUpperCase()
: match[1]);
const { tr } = state;
const start = range.from;
const end = range.to;
const newNode = this.type.create({ type: calloutType }, [
state.schema.node(Heading.name, { level: 4 }, [
state.schema.text(calloutTitle)
]),
state.schema.node(Paragraph.name)
]);
tr.insert(start - 1, newNode).delete(
tr.mapping.map(start),
tr.mapping.map(end)
);
tr.setSelection(
TextSelection.create(tr.doc, tr.selection.anchor - 2)
);
tr.scrollIntoView();
}
})
];
},
addNodeView() {
return ({ node, getPos, editor, HTMLAttributes }) => {
const container = document.createElement("div");
container.classList.add("callout");
if (node.attrs.collapsed) container.classList.add("collapsed");
else container.classList.remove("collapsed");
for (const attr in HTMLAttributes) {
container.setAttribute(attr, HTMLAttributes[attr]);
}
function onClick(e: MouseEvent | TouchEvent) {
if (e instanceof MouseEvent && e.button !== 0) return;
if (!(e.target instanceof HTMLHeadingElement)) return;
const pos = typeof getPos === "function" ? getPos() : 0;
if (typeof pos !== "number") return;
const resolvedPos = editor.state.doc.resolve(pos);
const { x, y, width } = e.target.getBoundingClientRect();
const clientX =
e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
const clientY =
e instanceof MouseEvent ? e.clientY : e.touches[0].clientY;
const hitArea = { width: 40, height: 40 };
const isRtl =
e.target.dir === "rtl" ||
findParentNodeClosestToPos(
resolvedPos,
(node) => !!node.attrs.textDirection
)?.node.attrs.textDirection === "rtl";
let xEnd = clientX <= x + width;
let xStart = clientX >= x + width - hitArea.width;
const yStart = clientY >= y;
const yEnd = clientY <= y + hitArea.height;
if (isRtl) {
xStart = clientX >= x;
xEnd = clientX <= x + hitArea.width;
}
if (xStart && xEnd && yStart && yEnd) {
e.preventDefault();
editor.commands.command(({ tr }) => {
tr.setNodeAttribute(
pos,
"collapsed",
!container.classList.contains("collapsed")
);
return true;
});
}
}
container.onmousedown = onClick;
container.ontouchstart = onClick;
return {
dom: container,
contentDOM: container,
update: (updatedNode) => {
if (updatedNode.type !== this.type) {
return false;
}
if (updatedNode.attrs.collapsed) container.classList.add("collapsed");
else container.classList.remove("collapsed");
return true;
}
};
};
}
});

View File

@@ -0,0 +1,20 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export * from "./callout";

View File

@@ -85,6 +85,7 @@ import { Quirks } from "./extensions/quirks";
import { LIST_NODE_TYPES } from "./utils/node-types";
import CheckList from "./extensions/check-list";
import CheckListItem from "./extensions/check-list-item";
import { Callout } from "./extensions/callout";
declare global {
// eslint-disable-next-line no-var
@@ -288,6 +289,8 @@ const useTiptap = (
nested: true
}),
Callout,
// Quirks handlers
Quirks.configure({
irremovableNodesOnBackspace: [

View File

@@ -120,7 +120,8 @@ import {
mdiPencil,
mdiCheckboxMultipleBlankOutline,
mdiCheckboxMultipleMarked,
mdiFormatFloatLeft
mdiFormatFloatLeft,
mdiMessageOutline
} from "@mdi/js";
export const Icons = {
@@ -226,6 +227,7 @@ export const Icons = {
tableSettings: mdiTableCog,
math: mdiFunctionVariant,
mathBlock: mdiMathIntegral,
callout: mdiMessageOutline,
outlineList: mdiFileTreeOutline,
fontFamily: mdiFormatFont,
fontSize: mdiFormatFontSizeIncrease,

View File

@@ -45,6 +45,7 @@ export function InsertBlock(props: ToolProps) {
horizontalRule(editor),
codeblock(editor),
mathblock(editor),
callout(editor),
blockquote(editor),
image(editor, isMobile),
attachment(editor),
@@ -139,6 +140,36 @@ const mathblock = (editor: Editor): MenuItem => ({
modifier: "Mod-Shift-M"
});
const callout = (editor: Editor): MenuItem => ({
key: "callout",
type: "button",
title: "Callout",
icon: Icons.callout,
menu: {
items: [
"Abstract",
"Hint",
"Info",
"Success",
"Warn",
"Error",
"Example",
"Quote"
].map((type) => ({
title: type,
key: type,
type: "button",
isChecked: editor?.isActive("callout", { type: type.toLowerCase() }),
onClick: () =>
editor.current
?.chain()
.focus()
.setCallout({ type: type.toLowerCase() as any })
.run()
}))
}
});
const image = (editor: Editor, isMobile: boolean): MenuItem => ({
key: "image",
type: "button",

View File

@@ -652,4 +652,102 @@ p > *::selection {
.ProseMirror ul.simple-checklist > li > div {
margin-top: 2px;
}
}
}
/* Callout */
.ProseMirror div.callout {
padding: 15px;
border-radius: 10px;
background-color: rgba(var(--callout-color), 0.1);
}
.ProseMirror div.callout > :first-child {
margin: 0px;
color: rgb(var(--callout-color));
position: relative;
}
.ProseMirror div.callout > :first-child::after {
position: absolute;
top: 3px;
right: 0px;
cursor: pointer;
content: "";
background-size: 18px;
width: 18px;
height: 18px;
background-color: rgb(var(--callout-color));
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9IiM4ODg4ODgiIGQ9Ik03LjQxIDguNThMMTIgMTMuMTdsNC41OS00LjU5TDE4IDEwbC02IDZsLTYtNmwxLjQxLTEuNDJaIi8+PC9zdmc+)
no-repeat 50% 50%;
mask-size: cover;
border: 1px solid var(--background);
transform: rotate(0);
transition: transform 250ms ease;
}
.ProseMirror div.callout > :first-child[dir="rtl"]::after {
right: unset;
left: 0px;
}
.ProseMirror div.callout.collapsed > :first-child::after {
transform: rotate(-90deg);
}
.ProseMirror div.callout.collapsed :not(:first-child) {
display: none;
}
.ProseMirror div.callout[data-callout-type="abstract"],
.ProseMirror div.callout[data-callout-type="tldr"],
.ProseMirror div.callout[data-callout-type="summary"],
.ProseMirror div.callout[data-callout-type="tip"],
.ProseMirror div.callout[data-callout-type="hint"],
.ProseMirror div.callout[data-callout-type="important"] {
--callout-color: 0, 191, 188;
}
.ProseMirror div.callout[data-callout-type="info"],
.ProseMirror div.callout[data-callout-type="todo"] {
--callout-color: 8, 109, 221;
}
.ProseMirror div.callout[data-callout-type="success"],
.ProseMirror div.callout[data-callout-type="check"],
.ProseMirror div.callout[data-callout-type="done"] {
--callout-color: 8, 185, 78;
}
.ProseMirror div.callout[data-callout-type="help"],
.ProseMirror div.callout[data-callout-type="faq"],
.ProseMirror div.callout[data-callout-type="question"],
.ProseMirror div.callout[data-callout-type="warn"],
.ProseMirror div.callout[data-callout-type="warning"],
.ProseMirror div.callout[data-callout-type="caution"],
.ProseMirror div.callout[data-callout-type="attention"] {
--callout-color: 236, 117, 0;
}
.ProseMirror div.callout[data-callout-type="failure"],
.ProseMirror div.callout[data-callout-type="fail"],
.ProseMirror div.callout[data-callout-type="missing"],
.ProseMirror div.callout[data-callout-type="danger"],
.ProseMirror div.callout[data-callout-type="error"],
.ProseMirror div.callout[data-callout-type="bug"] {
--callout-color: 233, 49, 71;
}
.ProseMirror div.callout[data-callout-type="example"] {
--callout-color: 120, 82, 238;
}
.ProseMirror div.callout[data-callout-type="quote"],
.ProseMirror div.callout[data-callout-type="cite"] {
--callout-color: 158, 158, 158;
}