From 4848cb9f08cbd0086a8bbabb6580431316c06703 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Wed, 21 Feb 2024 22:07:31 +0500 Subject: [PATCH] editor: add callout component --- .../editor/src/extensions/callout/callout.ts | 297 ++++++++++++++++++ .../editor/src/extensions/callout/index.ts | 20 ++ packages/editor/src/index.ts | 3 + packages/editor/src/toolbar/icons.ts | 4 +- packages/editor/src/toolbar/tools/block.tsx | 31 ++ packages/editor/styles/styles.css | 100 +++++- 6 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 packages/editor/src/extensions/callout/callout.ts create mode 100644 packages/editor/src/extensions/callout/index.ts 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; +}