From 2e10f0cb7a3160ed7bd467185d0e6788c8a7e3a0 Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Sun, 9 Aug 2026 19:22:47 +0500 Subject: [PATCH] editor: give callout and heading collapse chevrons finger-sized touch targets --- .../callout/__tests__/callout.test.ts | 71 +++++++++++++++++++ .../editor/src/extensions/callout/callout.ts | 8 ++- .../editor/src/extensions/heading/heading.ts | 31 +++++--- packages/editor/src/utils/prosemirror.ts | 22 +++--- packages/editor/styles/styles.css | 22 +++++- 5 files changed, 132 insertions(+), 22 deletions(-) create mode 100644 packages/editor/src/extensions/callout/__tests__/callout.test.ts diff --git a/packages/editor/src/extensions/callout/__tests__/callout.test.ts b/packages/editor/src/extensions/callout/__tests__/callout.test.ts new file mode 100644 index 000000000..af73fb03d --- /dev/null +++ b/packages/editor/src/extensions/callout/__tests__/callout.test.ts @@ -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 . +*/ + +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: `

INFO

body

`, + 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); + }); +}); diff --git a/packages/editor/src/extensions/callout/callout.ts b/packages/editor/src/extensions/callout/callout.ts index 72f8f79b3..dfcee3fd0 100644 --- a/packages/editor/src/extensions/callout/callout.ts +++ b/packages/editor/src/extensions/callout/callout.ts @@ -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(); diff --git a/packages/editor/src/extensions/heading/heading.ts b/packages/editor/src/extensions/heading/heading.ts index d6fbb112b..ff33f7820 100644 --- a/packages/editor/src/extensions/heading/heading.ts +++ b/packages/editor/src/extensions/heading/heading.ts @@ -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) { diff --git a/packages/editor/src/utils/prosemirror.ts b/packages/editor/src/utils/prosemirror.ts index 5eb2af7b9..25b53087f 100644 --- a/packages/editor/src/utils/prosemirror.ts +++ b/packages/editor/src/utils/prosemirror.ts @@ -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; diff --git a/packages/editor/styles/styles.css b/packages/editor/styles/styles.css index d20fdc0d7..a865facf4 100644 --- a/packages/editor/styles/styles.css +++ b/packages/editor/styles/styles.css @@ -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;