diff --git a/packages/editor/src/extensions/heading/heading.ts b/packages/editor/src/extensions/heading/heading.ts index 45ac83846..1d70848bb 100644 --- a/packages/editor/src/extensions/heading/heading.ts +++ b/packages/editor/src/extensions/heading/heading.ts @@ -23,8 +23,11 @@ import { textblockTypeInputRule } from "@tiptap/core"; import { Heading as TiptapHeading } from "@tiptap/extension-heading"; +import { isClickWithinBounds } from "../../utils/prosemirror.js"; import { Plugin, PluginKey, Selection, Transaction } from "@tiptap/pm/state"; import { Node } from "@tiptap/pm/model"; +import { useToolbarStore } from "../../toolbar/stores/toolbar-store.js"; +import { Decoration, DecorationSet } from "prosemirror-view"; const COLLAPSIBLE_BLOCK_TYPES = [ "paragraph", @@ -166,14 +169,6 @@ export const Heading = TiptapHeading.extend({ addNodeView() { return ({ node, getPos, editor, HTMLAttributes }) => { const heading = document.createElement(`h${node.attrs.level}`); - const contentWrapper = document.createElement("div"); - const icon = document.createElement("span"); - - // providing a minWidth so that empty headings show the blinking cursor - contentWrapper.style.minWidth = "1px"; - - icon.className = "heading-collapse-icon"; - icon.contentEditable = "false"; for (const attr in HTMLAttributes) { heading.setAttribute(attr, HTMLAttributes[attr]); @@ -182,46 +177,74 @@ export const Heading = TiptapHeading.extend({ if (node.attrs.collapsed) heading.dataset.collapsed = "true"; else delete heading.dataset.collapsed; - function onIconClick(e: MouseEvent | TouchEvent) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); + if (node.attrs.hidden) heading.dataset.hidden = node.attrs.hidden; + else delete heading.dataset.hidden; - const pos = typeof getPos === "function" ? getPos() : 0; - if (typeof pos !== "number") return; - - const resolvedPos = editor.state.doc.resolve(pos); - const forbiddenParents = ["callout"]; - if ( - findParentNodeClosestToPos(resolvedPos, (node) => - forbiddenParents.includes(node.type.name) - ) - ) { + function onClick(e: MouseEvent | TouchEvent) { + if (e instanceof MouseEvent && e.button !== 0) return; + if (!(e.target instanceof HTMLHeadingElement) || !e.target.lastChild) return; + if (typeof getPos === "boolean") return; + + const pos = getPos(); + const clientX = + e instanceof MouseEvent ? e.clientX : e.touches[0].clientX; + const clientY = + e instanceof MouseEvent ? e.clientY : e.touches[0].clientY; + const isRtl = + e.target.dir === "rtl" || + findParentNodeClosestToPos( + editor.state.doc.resolve(pos), + (node) => !!node.attrs.textDirection + )?.node.attrs.textDirection === "rtl"; + + const range = document.createRange(); + range.selectNodeContents(e.target); + + const hitArea = { height: 40, width: 40 }; + + const rects = range.getClientRects(); + const lines = rectsToLines(rects); + const lastLine = lines[lines.length - 1]; + if (!lastLine) return; + const targetRect = isRtl ? lastLine[0] : lastLine[lastLine.length - 1]; + + const { x, y, width } = targetRect; + + let xStart = clientX >= x + width; + let xEnd = clientX <= x + width + hitArea.width; + const yStart = clientY >= y; + const yEnd = clientY <= y + hitArea.height; + + if (isRtl) { + xStart = clientX >= x - hitArea.width; + xEnd = clientX <= x; } - editor.commands.command(({ tr }) => { - const currentNode = tr.doc.nodeAt(pos); - if (currentNode && currentNode.type.name === "heading") { - const shouldCollapse = !currentNode.attrs.collapsed; - const headingLevel = currentNode.attrs.level; + if (xStart && xEnd && yStart && yEnd) { + e.preventDefault(); + e.stopImmediatePropagation(); - tr.setNodeAttribute(pos, "collapsed", shouldCollapse); - toggleNodesUnderPos(tr, pos, headingLevel, shouldCollapse); - } - return true; - }); + editor.commands.command(({ tr }) => { + const currentNode = tr.doc.nodeAt(pos); + if (currentNode && currentNode.type.name === "heading") { + const shouldCollapse = !currentNode.attrs.collapsed; + const headingLevel = currentNode.attrs.level; + + tr.setNodeAttribute(pos, "collapsed", shouldCollapse); + toggleNodesUnderPos(tr, pos, headingLevel, shouldCollapse); + } + return true; + }); + } } - icon.onmousedown = onIconClick; - icon.ontouchend = onIconClick; - - heading.appendChild(contentWrapper); - heading.appendChild(icon); + heading.onmousedown = onClick; + heading.ontouchstart = onClick; return { dom: heading, - contentDOM: contentWrapper, + contentDOM: heading, update: (updatedNode) => { if (updatedNode.type !== this.type) { return false; @@ -394,3 +417,30 @@ const headingUpdatePlugin = new Plugin({ return modified ? tr : null; } }); + +function rectsToLines(rects: DOMRectList) { + const lines: DOMRect[][] = []; + + outer: for (const rect of rects) { + if (rect.width === 0 || rect.height === 0) continue; + + for (const line of lines) { + const lastRect = line[line.length - 1]; + // Check if rects are on the same line by checking vertical overlap + // This handles cases where text has different font sizes on the same line + const rectBottom = rect.top + rect.height; + const lastRectBottom = lastRect.top + lastRect.height; + const overlapTop = Math.max(rect.top, lastRect.top); + const overlapBottom = Math.min(rectBottom, lastRectBottom); + const hasVerticalOverlap = overlapBottom > overlapTop; + + if (hasVerticalOverlap) { + line.push(rect); + continue outer; + } + } + + lines.push([rect]); + } + return lines; +} diff --git a/packages/editor/styles/styles.css b/packages/editor/styles/styles.css index 85e398942..cf273ff20 100644 --- a/packages/editor/styles/styles.css +++ b/packages/editor/styles/styles.css @@ -895,25 +895,18 @@ del.diffdel { text-decoration: none; } -.ProseMirror h1 , -.ProseMirror h2 , -.ProseMirror h3 , -.ProseMirror h4 , -.ProseMirror h5 , -.ProseMirror h6 { - display: flex; - align-items: center; -} - -.ProseMirror h1 .heading-collapse-icon, -.ProseMirror h2 .heading-collapse-icon, -.ProseMirror h3 .heading-collapse-icon, -.ProseMirror h4 .heading-collapse-icon, -.ProseMirror h5 .heading-collapse-icon, -.ProseMirror h6 .heading-collapse-icon { +.ProseMirror h1::after, +.ProseMirror h2::after, +.ProseMirror h3::after, +.ProseMirror h4::after, +.ProseMirror h5::after, +.ProseMirror h6::after { + display: inline-block; cursor: pointer; - background-size: 18px; - margin-inline-start: 8px; + content: ""; + background-size: var(--icon-size, 18px); + width: var(--icon-size, 18px); + height: var(--icon-size, 18px); background-color: var(--icon); mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9IiM4ODg4ODgiIGQ9Ik03LjQxIDguNThMMTIgMTMuMTdsNC41OS00LjU5TDE4IDEwbC02IDZsLTYtNmwxLjQxLTEuNDJaIi8+PC9zdmc+) @@ -923,101 +916,64 @@ del.diffdel { transform: rotate(0); transition: transform 250ms ease, opacity 200ms ease; opacity: 0; - user-select: none; + vertical-align: middle; } -.ProseMirror h1 .heading-collapse-icon { - margin-top: 3.5px; - width: 18px; - height: 18px; +.ProseMirror h2::after { + --icon-size: 16px; } -.ProseMirror h2 .heading-collapse-icon { - margin-top: 3px; - width: 16px; - height: 16px; +.ProseMirror h3::after { + --icon-size: 14px; } -.ProseMirror h3 .heading-collapse-icon { - margin-top: 2.3px; - width: 15px; - height: 15px; +.ProseMirror h4::after { + --icon-size: 14px; } -.ProseMirror h4 .heading-collapse-icon { - margin-top: 1.8px; - width: 14px; - height: 14px; +.ProseMirror h5::after { + --icon-size: 14px; } -.ProseMirror h5 .heading-collapse-icon { - margin-top: 1.3px; - width: 13px; - height: 13px; +.ProseMirror h6::after { + --icon-size: 11px; } -.ProseMirror h6 .heading-collapse-icon { - margin-top: 0.3px; - width: 12px; - height: 12px; -} - - -.ProseMirror h1[data-collapsed="true"] .heading-collapse-icon, -.ProseMirror h2[data-collapsed="true"] .heading-collapse-icon, -.ProseMirror h3[data-collapsed="true"] .heading-collapse-icon, -.ProseMirror h4[data-collapsed="true"] .heading-collapse-icon, -.ProseMirror h5[data-collapsed="true"] .heading-collapse-icon, -.ProseMirror h6[data-collapsed="true"] .heading-collapse-icon { +.ProseMirror h1[data-collapsed="true"]::after, +.ProseMirror h2[data-collapsed="true"]::after, +.ProseMirror h3[data-collapsed="true"]::after, +.ProseMirror h4[data-collapsed="true"]::after, +.ProseMirror h5[data-collapsed="true"]::after, +.ProseMirror h6[data-collapsed="true"]::after { transform: rotate(-90deg); opacity: 1; } -.ProseMirror h1[data-collapsed="true"][dir="rtl"] .heading-collapse-icon, -.ProseMirror h2[data-collapsed="true"][dir="rtl"] .heading-collapse-icon, -.ProseMirror h3[data-collapsed="true"][dir="rtl"] .heading-collapse-icon, -.ProseMirror h4[data-collapsed="true"][dir="rtl"] .heading-collapse-icon, -.ProseMirror h5[data-collapsed="true"][dir="rtl"] .heading-collapse-icon, -.ProseMirror h6[data-collapsed="true"][dir="rtl"] .heading-collapse-icon { - transform: rotate(90deg); +.ProseMirror h1:hover::after, +.ProseMirror h2:hover::after, +.ProseMirror h3:hover::after, +.ProseMirror h4:hover::after, +.ProseMirror h5:hover::after, +.ProseMirror h6:hover::after { opacity: 1; } -.ProseMirror h1:hover .heading-collapse-icon, -.ProseMirror h2:hover .heading-collapse-icon, -.ProseMirror h3:hover .heading-collapse-icon, -.ProseMirror h4:hover .heading-collapse-icon, -.ProseMirror h5:hover .heading-collapse-icon, -.ProseMirror h6:hover .heading-collapse-icon { - opacity: 1; -} - -.ProseMirror div.callout h1 .heading-collapse-icon, -.ProseMirror div.callout h2 .heading-collapse-icon, -.ProseMirror div.callout h3 .heading-collapse-icon, -.ProseMirror div.callout h4 .heading-collapse-icon, -.ProseMirror div.callout h5 .heading-collapse-icon, -.ProseMirror div.callout h6 .heading-collapse-icon { - display: none; -} - -/* hide collapse icon when heading is empty (only contains trailing break) */ -.ProseMirror h1:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon, -.ProseMirror h2:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon, -.ProseMirror h3:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon, -.ProseMirror h4:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon, -.ProseMirror h5:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon, -.ProseMirror h6:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon { +.ProseMirror h1.is-empty::after, +.ProseMirror h2.is-empty::after, +.ProseMirror h3.is-empty::after, +.ProseMirror h4.is-empty::after, +.ProseMirror h5.is-empty::after, +.ProseMirror h6.is-empty::after { display: none !important; } @media screen and (max-width: 768px) { - .ProseMirror h1 .heading-collapse-icon, - .ProseMirror h2 .heading-collapse-icon, - .ProseMirror h3 .heading-collapse-icon, - .ProseMirror h4 .heading-collapse-icon, - .ProseMirror h5 .heading-collapse-icon, - .ProseMirror h6 .heading-collapse-icon { + .ProseMirror h1::after, + .ProseMirror h2::after, + .ProseMirror h3::after, + .ProseMirror h4::after, + .ProseMirror h5::after, + .ProseMirror h6::after { opacity: 1 !important; } }