diff --git a/packages/editor/src/extensions/callout/callout.ts b/packages/editor/src/extensions/callout/callout.ts
new file mode 100644
index 000000000..903d5ec5d
--- /dev/null
+++ b/packages/editor/src/extensions/callout/callout.ts
@@ -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 .
+*/
+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 {
+ 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;
+ }
+ };
+ };
+ }
+});
diff --git a/packages/editor/src/extensions/callout/index.ts b/packages/editor/src/extensions/callout/index.ts
new file mode 100644
index 000000000..d287bad29
--- /dev/null
+++ b/packages/editor/src/extensions/callout/index.ts
@@ -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 .
+*/
+
+export * from "./callout";
diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
index 3cd60e3c6..d8e9d4281 100644
--- a/packages/editor/src/index.ts
+++ b/packages/editor/src/index.ts
@@ -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: [
diff --git a/packages/editor/src/toolbar/icons.ts b/packages/editor/src/toolbar/icons.ts
index f18a6b405..c91dffd6a 100644
--- a/packages/editor/src/toolbar/icons.ts
+++ b/packages/editor/src/toolbar/icons.ts
@@ -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,
diff --git a/packages/editor/src/toolbar/tools/block.tsx b/packages/editor/src/toolbar/tools/block.tsx
index fc73933cd..0d371f1e9 100644
--- a/packages/editor/src/toolbar/tools/block.tsx
+++ b/packages/editor/src/toolbar/tools/block.tsx
@@ -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",
diff --git a/packages/editor/styles/styles.css b/packages/editor/styles/styles.css
index 56d307e05..34b500b80 100644
--- a/packages/editor/styles/styles.css
+++ b/packages/editor/styles/styles.css
@@ -652,4 +652,102 @@ p > *::selection {
.ProseMirror ul.simple-checklist > li > div {
margin-top: 2px;
}
-}
\ No newline at end of file
+}
+
+/* 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;
+}