editor: give callout and heading collapse chevrons finger-sized touch targets

This commit is contained in:
Ammar Ahmed
2026-08-09 19:22:47 +05:00
parent 7de9440d88
commit 2e10f0cb7a
5 changed files with 132 additions and 22 deletions

View File

@@ -0,0 +1,71 @@
/*
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 { describe, expect, test } from "vitest";
import { createEditor } from "../../../../test-utils/index.js";
import { Callout } from "../callout.js";
import { Heading } from "../../heading/heading.js";
import { Paragraph } from "../../paragraph/paragraph.js";
describe("callout", () => {
test("tapping the chevron corner of a callout toggles it collapsed", () => {
const { editor } = createEditor({
initialContent: `<div class="callout" data-callout-type="info"><h4>INFO</h4><p>body</p></div>`,
extensions: {
callout: Callout,
heading: Heading,
paragraph: Paragraph
}
});
const dom = editor.view.dom as HTMLElement;
const heading = dom.querySelector(".callout > h4") as HTMLElement;
expect(heading).toBeTruthy();
const collapsed = () => {
let value: boolean | undefined;
editor.state.doc.descendants((node) => {
if (node.type.name === Callout.name) value = node.attrs.collapsed;
});
return value;
};
const tapAt = (clientX: number) =>
heading.dispatchEvent(
new MouseEvent("mousedown", {
button: 0,
clientX,
clientY: 5,
bubbles: true,
cancelable: true
})
);
expect(collapsed()).toBe(false);
tapAt(-10);
expect(collapsed()).toBe(true);
tapAt(-10);
expect(collapsed()).toBe(false);
tapAt(8);
expect(collapsed()).toBe(true);
tapAt(8);
expect(collapsed()).toBe(false);
});
});

View File

@@ -240,7 +240,13 @@ export const Callout = Node.create({
if (typeof pos !== "number") return;
const resolvedPos = editor.state.doc.resolve(pos);
if (isClickWithinBounds(e, resolvedPos, "right")) {
if (
isClickWithinBounds(e, resolvedPos, "right", {
width: 40,
height: 40,
offset: 11
})
) {
e.preventDefault();
e.stopImmediatePropagation();

View File

@@ -34,6 +34,7 @@ import {
AttributeUpdate,
BatchAttributeStep
} from "../../utils/batch-attribute-step.js";
import { useToolbarStore } from "../../toolbar/stores/toolbar-store.js";
const COLLAPSIBLE_BLOCK_TYPES = [
"paragraph",
@@ -223,24 +224,34 @@ export const Heading = TiptapHeading.extend({
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;
const { x, y, width, height } = targetRect;
let xStart = clientX >= x + width;
let xEnd = clientX <= x + width + hitArea.width;
const yStart = clientY >= y;
const yEnd = clientY <= y + hitArea.height;
const isMobile = useToolbarStore.getState().isMobile;
if (isRtl) {
xStart = clientX >= x - hitArea.width;
xEnd = clientX <= x;
let xStart: boolean, xEnd: boolean, yStart: boolean, yEnd: boolean;
if (isMobile) {
const hitWidth = 40;
const gap = 10;
xStart = isRtl
? clientX >= x - gap - hitWidth
: clientX >= x + width + gap;
xEnd = isRtl ? clientX <= x - gap : clientX <= x + width + gap + hitWidth;
yStart = clientY >= y;
yEnd = clientY <= y + height;
} else {
const size = 15;
const centerX = isRtl ? x - 20 : x + width + 20;
const centerY = y + height / 2;
xStart = clientX >= centerX - size / 2;
xEnd = clientX <= centerX + size / 2;
yStart = clientY >= centerY - size / 2;
yEnd = clientY <= centerY + size / 2;
}
if (xStart && xEnd && yStart && yEnd) {

View File

@@ -353,12 +353,16 @@ export function isClickWithinBounds(
e: MouseEvent | TouchEvent,
pos: ResolvedPos,
hitPosition: "left" | "right",
hitArea: { width: number; height: number } = { width: 40, height: 40 }
hitArea: { width: number; height: number; offset?: number } = {
width: 40,
height: 40
}
) {
const { target } = e;
if (!(target instanceof HTMLElement)) return false;
const { x, y, right, width } = target.getBoundingClientRect();
const offset = hitArea.offset ?? 0;
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY;
const isRtl =
@@ -368,27 +372,27 @@ export function isClickWithinBounds(
switch (hitPosition) {
case "left": {
let xStart = clientX >= x - hitArea.width;
let xEnd = clientX <= x;
let xStart = clientX >= x - hitArea.width - offset;
let xEnd = clientX <= x - offset;
const yStart = clientY >= y;
const yEnd = clientY <= y + hitArea.height;
if (isRtl) {
xEnd = clientX <= right + hitArea.width;
xStart = clientX >= right;
xEnd = clientX <= right + hitArea.width + offset;
xStart = clientX >= right + offset;
}
return xStart && xEnd && yStart && yEnd;
}
case "right": {
let xEnd = clientX <= x + width;
let xStart = clientX >= x + width - hitArea.width;
let xEnd = clientX <= x + width + offset;
let xStart = clientX >= x + width - hitArea.width + offset;
const yStart = clientY >= y;
const yEnd = clientY <= y + hitArea.height;
if (isRtl) {
xStart = clientX >= x;
xEnd = clientX <= x + hitArea.width;
xStart = clientX >= x - offset;
xEnd = clientX <= x + hitArea.width - offset;
}
return xStart && xEnd && yStart && yEnd;

View File

@@ -819,6 +819,7 @@ p > *::selection {
position: absolute;
top: calc((1lh - 18px) / 2);
right: 0px;
margin: 0;
cursor: pointer;
content: "";
background-size: 18px;
@@ -841,6 +842,22 @@ p > *::selection {
left: 0px;
}
.ProseMirror div.callout > :first-child::before {
content: "";
position: absolute;
top: 0;
right: -11px;
width: 40px;
height: 40px;
cursor: pointer;
z-index: 1;
}
.ProseMirror div.callout > :first-child[dir="rtl"]::before {
right: unset;
left: -11px;
}
.ProseMirror div.callout.collapsed > :first-child::after {
transform: rotate(-90deg);
}
@@ -930,13 +947,14 @@ del.diffdel {
cursor: pointer;
content: "";
background-size: var(--icon-size, 18px);
width: var(--icon-size, 18px);
width: 40px;
height: var(--icon-size, 18px);
margin-inline-end: calc(var(--icon-size, 18px) - 40px);
background-color: var(--icon);
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9IiM4ODg4ODgiIGQ9Ik03LjQxIDguNThMMTIgMTMuMTdsNC41OS00LjU5TDE4IDEwbC02IDZsLTYtNmwxLjQxLTEuNDJaIi8+PC9zdmc+)
no-repeat 50% 50%;
mask-size: cover;
mask-size: var(--icon-size, 18px);
transform: rotate(0);
transition: transform 250ms ease, opacity 200ms ease;