mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 19:49:54 +02:00
editor: give callout and heading collapse chevrons finger-sized touch targets
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user