mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-02-23 19:49:56 +01:00
editor: fix heading align (#8996)
this reverts all the custom layout based on divs & spans and moves back to using pseudo element for the expand/collapse icon. There were a few problems with the previous logic: 1. We had to write custom logic for text alignment (not a huge deal but it could cause bugs down the line) 2. Aligning the icon properly was hit or miss. We were using flex which meant for multi line headings, the icon appeared way to the left instead of right next to the end of the heading. 3. The styling css for the previous logic was MASSIVE and handled lots of cases which meant more maintenance burden for us. The new logic is simpler: 1. Use `::after` pseudo element for the expand/collapse icon. This works very nicely on Android. 2. Use selection range on the heading to compute bounding rects for each line. It's a bit hacky but works for both LTR & RTL headings. --------- Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> Co-authored-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user