mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 19:49:54 +02:00
editor: add callout component
This commit is contained in:
297
packages/editor/src/extensions/callout/callout.ts
Normal file
297
packages/editor/src/extensions/callout/callout.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
});
|
||||
20
packages/editor/src/extensions/callout/index.ts
Normal file
20
packages/editor/src/extensions/callout/index.ts
Normal 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";
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user