Compare commits

...

5 Commits

Author SHA1 Message Date
Tom Moor 37cabc5440 Merge main 2024-06-08 19:54:17 -04:00
Tom Moor 0106d7152c Move block trigger to same setup 2023-09-13 08:53:33 -04:00
Tom Moor 28c81d3a47 Style improves 2023-09-12 22:45:34 -04:00
Tom Moor 857878701a Remove old hovering logic 2023-09-12 22:23:38 -04:00
Tom Moor cca4e256e0 wip 2023-09-12 22:23:38 -04:00
7 changed files with 305 additions and 269 deletions
+172
View File
@@ -0,0 +1,172 @@
import { NodeSelection, Plugin } from "prosemirror-state";
import { EditorView, __serializeForClipboard } from "prosemirror-view";
import Extension from "@shared/editor/lib/Extension";
import {
absoluteRect,
nodeDOMAtCoords,
nodePosAtDOM,
} from "@shared/editor/lib/position";
export type DragHandleOptions = {
/**
* The width of the drag handle
*/
dragHandleWidth: number;
};
function DragHandle(options: DragHandleOptions) {
let dragHandleElement: HTMLElement | null = null;
function handleDragStart(event: DragEvent, view: EditorView) {
view.focus();
if (!event.dataTransfer) {
return;
}
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) {
return;
}
const nodePos = nodePosAtDOM(node, view);
if (nodePos === undefined || nodePos < 0) {
return;
}
view.dispatch(
view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos))
);
const slice = view.state.selection.content();
const { dom, text } = __serializeForClipboard(view, slice);
event.dataTransfer.clearData();
event.dataTransfer.setData("text/html", dom.innerHTML);
event.dataTransfer.setData("text/plain", text);
event.dataTransfer.effectAllowed = "copyMove";
event.dataTransfer.setDragImage(node, 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
function handleClick(event: MouseEvent, view: EditorView) {
view.focus();
view.dom.classList.remove("dragging");
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) {
return;
}
const nodePos = nodePosAtDOM(node, view);
if (!nodePos) {
return;
}
view.dispatch(
view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos))
);
}
function hideDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.add("hidden");
}
}
function showDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.remove("hidden");
}
}
return new Plugin({
view: (view) => {
dragHandleElement = document.createElement("div");
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = "";
dragHandleElement.classList.add("drag-handle");
dragHandleElement.addEventListener("dragstart", (e) =>
handleDragStart(e, view)
);
dragHandleElement.addEventListener("click", (e) => handleClick(e, view));
hideDragHandle();
view.dom.parentElement?.appendChild(dragHandleElement);
return {
destroy: () => {
dragHandleElement?.remove();
dragHandleElement = null;
},
};
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
if (!view.editable) {
return;
}
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) {
hideDragHandle();
return;
}
const style = window.getComputedStyle(node);
const lineHeight = parseInt(style.lineHeight, 10);
const paddingTop = parseInt(style.paddingTop, 10);
const rect = absoluteRect(node);
rect.top += (lineHeight - 24) / 2;
rect.top += paddingTop;
if (node.matches("ul:not(.checkbox_list) li, ol li")) {
rect.left -= options.dragHandleWidth;
}
rect.width = options.dragHandleWidth;
if (!dragHandleElement) {
return;
}
dragHandleElement.style.left = `${rect.left - rect.width}px`;
dragHandleElement.style.top = `${rect.top}px`;
showDragHandle();
},
keydown: hideDragHandle,
mousewheel: hideDragHandle,
dragstart: (view) => {
view.dom.classList.add("dragging");
},
drop: (view) => {
view.dom.classList.remove("dragging");
},
dragend: (view) => {
view.dom.classList.remove("dragging");
},
},
},
});
}
export default class DragAndDrop extends Extension {
get plugins() {
return [DragHandle({ dragHandleWidth: 24 })];
}
}
@@ -13,6 +13,7 @@ import Editor, { Props as EditorProps } from "~/components/Editor";
import Flex from "~/components/Flex";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
import DragAndDrop from "~/editor/extensions/DragAndDrop";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
@@ -45,6 +46,7 @@ const extensions = [
MentionMenuExtension,
FindAndReplaceExtension,
HoverPreviewsExtension,
DragAndDrop,
// Order these default key handlers last
PreventTab,
Keys,
+8
View File
@@ -11,4 +11,12 @@ declare module "prosemirror-view" {
plainText: boolean,
$context: ResolvedPos
);
export function __serializeForClipboard(
view: EditorView,
slice: Slice
): {
dom: Element;
text: string;
};
}
+76 -93
View File
@@ -1,6 +1,7 @@
/* eslint-disable no-irregular-whitespace */
import { lighten, transparentize } from "polished";
import styled, { DefaultTheme, css, keyframes } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import { videoStyle } from "./Video";
@@ -257,6 +258,65 @@ const findAndReplaceStyle = () => css`
}
`;
const dragHandleStyle = (props: Props) => css`
.drag-handle,
.block-menu-trigger {
display: none;
pointer-events: none;
position: fixed;
opacity: 1;
transition: opacity ease-in 100ms;
border-radius: 4px;
color: ${props.theme.textSecondary};
background-color: ${props.theme.background};
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJjdXJyZW50Q29sb3IiPgo8cmVjdCB4PSI4IiB5PSI3IiB3aWR0aD0iMyIgaGVpZ2h0PSIyIiByeD0iMSIvPgo8cmVjdCB4PSI4IiB5PSIxMSIgd2lkdGg9IjMiIGhlaWdodD0iMiIgcng9IjEiLz4KPHJlY3QgeD0iOCIgeT0iMTUiIHdpZHRoPSIzIiBoZWlnaHQ9IjIiIHJ4PSIxIi8+CjxyZWN0IHg9IjEzIiB5PSI3IiB3aWR0aD0iMyIgaGVpZ2h0PSIyIiByeD0iMSIvPgo8cmVjdCB4PSIxMyIgeT0iMTEiIHdpZHRoPSIzIiBoZWlnaHQ9IjIiIHJ4PSIxIi8+CjxyZWN0IHg9IjEzIiB5PSIxNSIgd2lkdGg9IjMiIGhlaWdodD0iMiIgcng9IjEiLz4KPC9zdmc+);
background-repeat: no-repeat;
background-position: 0 0;
width: 24px;
height: 24px;
z-index: 50;
cursor: grab;
&:hover,
&:focus,
&:active {
color: ${props.theme.text};
transition: background-color 0.2s;
}
&.hidden {
opacity: 0;
pointer-events: none;
transition: none;
}
@media print {
display: none;
}
${breakpoint("tablet")`
display: block;
pointer-events: auto;
`}
}
.block-menu-trigger {
background: none;
outline: none;
border: 0;
padding: 0;
margin: 0;
cursor: var(--pointer);
background: ${props.theme.background};
&:hover,
&:focus {
color: ${props.theme.text};
background: ${props.theme.secondaryBackground};
}
}
`;
const emailStyle = (props: Props) => css`
.attachment {
display: block;
@@ -613,9 +673,16 @@ iframe.embed {
caret-color: transparent;
}
.ProseMirror-selectednode {
outline: 2px solid
${props.readOnly ? "transparent" : props.theme.selected};
.ProseMirror:not(.dragging) .ProseMirror-selectednode {
outline: 4px solid ${
props.readOnly ? "transparent" : transparentize(0.9, props.theme.accent)
};
border-radius: 4px;
background-color: ${
props.readOnly ? "transparent" : transparentize(0.9, props.theme.accent)
};
transition: background-color 100ms;
box-shadow: none;
@media print {
outline: none;
@@ -628,17 +695,6 @@ li.ProseMirror-selectednode {
outline: none;
}
li.ProseMirror-selectednode:after {
content: "";
position: absolute;
left: ${props.rtl ? "-2px" : "-32px"};
right: ${props.rtl ? "-32px" : "-2px"};
top: -2px;
bottom: -2px;
border: 2px solid ${props.theme.selected};
pointer-events: none;
}
img.ProseMirror-separator {
display: inline;
border: none !important;
@@ -772,7 +828,7 @@ h6:not(.placeholder):before {
opacity: 0;
user-select: none;
background: ${props.theme.background};
margin-${props.rtl ? "right" : "left"}: -26px;
margin: 0 4px;
flex-direction: ${props.rtl ? "row-reverse" : "row"};
display: none;
position: relative;
@@ -1021,8 +1077,8 @@ a:hover {
ul,
ol {
margin: ${props.rtl ? "0 -26px 0 0.1em" : "0 0.1em 0 -26px"};
padding: ${props.rtl ? "0 48px 0 0" : "0 0 0 48px"};
margin: 0;
padding: ${props.rtl ? "0 22px 0 0" : "0 0 0 22px"};
}
ol ol {
@@ -1035,8 +1091,7 @@ ol ol ol {
ul.checkbox_list {
padding: 0;
margin-left: ${props.rtl ? "0" : "-24px"};
margin-right: ${props.rtl ? "-24px" : "0"};
margin: 0;
}
ul li,
@@ -1056,52 +1111,12 @@ ol li {
ul.checkbox_list > li {
display: flex;
list-style: none;
padding-${props.rtl ? "right" : "left"}: 24px;
}
ul.checkbox_list > li.checked > div > p {
color: ${props.theme.textTertiary};
}
ul li::before,
ol li::before {
background: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3QgeD0iOCIgeT0iNyIgd2lkdGg9IjMiIGhlaWdodD0iMiIgcng9IjEiIGZpbGw9IiM0RTVDNkUiLz4KPHJlY3QgeD0iOCIgeT0iMTEiIHdpZHRoPSIzIiBoZWlnaHQ9IjIiIHJ4PSIxIiBmaWxsPSIjNEU1QzZFIi8+CjxyZWN0IHg9IjgiIHk9IjE1IiB3aWR0aD0iMyIgaGVpZ2h0PSIyIiByeD0iMSIgZmlsbD0iIzRFNUM2RSIvPgo8cmVjdCB4PSIxMyIgeT0iNyIgd2lkdGg9IjMiIGhlaWdodD0iMiIgcng9IjEiIGZpbGw9IiM0RTVDNkUiLz4KPHJlY3QgeD0iMTMiIHk9IjExIiB3aWR0aD0iMyIgaGVpZ2h0PSIyIiByeD0iMSIgZmlsbD0iIzRFNUM2RSIvPgo8cmVjdCB4PSIxMyIgeT0iMTUiIHdpZHRoPSIzIiBoZWlnaHQ9IjIiIHJ4PSIxIiBmaWxsPSIjNEU1QzZFIi8+Cjwvc3ZnPgo=") no-repeat;
background-position: 0 2px;
content: "";
display: ${props.readOnly ? "none" : "inline-block"};
cursor: grab;
width: 24px;
height: 24px;
position: absolute;
${props.rtl ? "right" : "left"}: -40px;
opacity: 0;
transition: opacity 200ms ease-in-out;
}
ul li[draggable=true]::before,
ol li[draggable=true]::before {
cursor: grabbing;
}
ul > li.counter-2::before,
ol li.counter-2::before {
${props.rtl ? "right" : "left"}: -50px;
}
ul > li.hovering::before,
ol li.hovering::before {
opacity: 0.5;
}
ul li.ProseMirror-selectednode::after,
ol li.ProseMirror-selectednode::after {
display: none;
}
ul.checkbox_list > li::before {
${props.rtl ? "right" : "left"}: 0;
}
ul.checkbox_list li .checkbox {
display: inline-block;
cursor: var(--pointer);
@@ -1664,40 +1679,6 @@ table {
border-right: ${EditorStyleHelper.padding}px solid ${props.theme.background};
}
.block-menu-trigger {
opacity: 0;
pointer-events: none;
display: ${props.readOnly ? "none" : "inline"};
width: 24px;
height: 24px;
color: ${props.theme.textSecondary};
background: none;
position: absolute;
transition: color 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
opacity 150ms ease-in-out;
outline: none;
border: 0;
padding: 0;
margin-top: 1px;
margin-${props.rtl ? "right" : "left"}: -28px;
border-radius: 4px;
&:hover,
&:focus {
cursor: var(--pointer);
color: ${props.theme.text};
background: ${props.theme.secondaryBackground};
}
}
.ProseMirror[contenteditable="true"]:focus-within,
.ProseMirror-focused .block-menu-trigger,
.block-menu-trigger:active,
.block-menu-trigger:focus {
opacity: 1;
pointer-events: initial;
}
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
@@ -1755,6 +1736,7 @@ del[data-operation-index] {
.placeholder:before,
.block-menu-trigger,
.heading-actions,
.drag-handle,
button.show-source-button,
h1:not(.placeholder):before,
h2:not(.placeholder):before,
@@ -1796,6 +1778,7 @@ const EditorContainer = styled.div<Props>`
${codeMarkCursor}
${codeBlockStyle}
${findAndReplaceStyle}
${dragHandleStyle}
${emailStyle}
`;
+38
View File
@@ -0,0 +1,38 @@
import { EditorView } from "prosemirror-view";
export function absoluteRect(node: Element) {
const data = node.getBoundingClientRect();
return {
top: data.top,
left: data.left,
width: data.width,
};
}
export function nodeDOMAtCoords(coords: { x: number; y: number }) {
return document
.elementsFromPoint(coords.x, coords.y)
.find(
(elem: Element) =>
elem.parentElement?.matches?.(".ProseMirror") ||
elem.matches(
[
"li",
"p:not(:first-child)",
"pre",
"blockquote",
"h1, h2, h3, h4, h5, h6",
].join(", ")
)
);
}
export function nodePosAtDOM(node: Element, view: EditorView) {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 1,
top: boundingRect.top + 1,
})?.inside;
}
+8 -7
View File
@@ -77,6 +77,14 @@ export default class Heading extends Node {
return [
`h${node.attrs.level + (this.options.offset || 0)}`,
[
"span",
{
class: "heading-content",
},
0,
],
[
"span",
{
@@ -87,13 +95,6 @@ export default class Heading extends Node {
},
...(anchor ? [anchor, fold] : []),
],
[
"span",
{
class: "heading-content",
},
0,
],
];
},
};
+1 -169
View File
@@ -4,19 +4,10 @@ import {
sinkListItem,
liftListItem,
} from "prosemirror-schema-list";
import {
Transaction,
EditorState,
Plugin,
TextSelection,
Command,
} from "prosemirror-state";
import { DecorationSet, Decoration } from "prosemirror-view";
import { TextSelection, Command } from "prosemirror-state";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { findParentNodeClosestToPos } from "../queries/findParentNode";
import getParentListItem from "../queries/getParentListItem";
import isInList from "../queries/isInList";
import isList from "../queries/isList";
import Node from "./Node";
export default class ListItem extends Node {
@@ -34,165 +25,6 @@ export default class ListItem extends Node {
};
}
get plugins() {
return [
new Plugin({
state: {
init() {
return DecorationSet.empty;
},
apply: (
tr: Transaction,
set: DecorationSet,
oldState: EditorState,
newState: EditorState
) => {
const action = tr.getMeta("li");
if (!action && !tr.docChanged) {
return set;
}
// Adjust decoration positions to changes made by the transaction
set = set.map(tr.mapping, tr.doc);
switch (action?.event) {
case "mouseover": {
const result = findParentNodeClosestToPos(
newState.doc.resolve(action.pos),
(node) =>
node.type.name === this.name ||
node.type.name === "checkbox_item"
);
if (!result) {
return set;
}
const list = findParentNodeClosestToPos(
newState.doc.resolve(action.pos),
(node) => isList(node, this.editor.schema)
);
if (!list) {
return set;
}
const start = list.node.attrs.order || 1;
let listItemNumber = 0;
list.node.content.forEach((li, _, index) => {
if (li === result.node) {
listItemNumber = index;
}
});
const counterLength = String(start + listItemNumber).length;
return set.add(tr.doc, [
Decoration.node(
result.pos,
result.pos + result.node.nodeSize,
{
class: `hovering`,
},
{
hover: true,
}
),
Decoration.node(
result.pos,
result.pos + result.node.nodeSize,
{
class: `counter-${counterLength}`,
}
),
]);
}
case "mouseout": {
const result = findParentNodeClosestToPos(
newState.doc.resolve(action.pos),
(node) =>
node.type.name === this.name ||
node.type.name === "checkbox_item"
);
if (!result) {
return set;
}
return set.remove(
set.find(
result.pos,
result.pos + result.node.nodeSize,
(spec) => spec.hover
)
);
}
default:
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
handleDOMEvents: {
mouseover: (view, event) => {
const { state, dispatch } = view;
const target = event.target as HTMLElement;
const li = target?.closest("li");
if (!li) {
return false;
}
if (!view.dom.contains(li)) {
return false;
}
const pos = view.posAtDOM(li, 0);
if (!pos) {
return false;
}
dispatch(
state.tr.setMeta("li", {
event: "mouseover",
pos,
})
);
return false;
},
mouseout: (view, event) => {
const { state, dispatch } = view;
const target = event.target as HTMLElement;
const li = target?.closest("li");
if (!li) {
return false;
}
if (!view.dom.contains(li)) {
return false;
}
const pos = view.posAtDOM(li, 0);
if (!pos) {
return false;
}
dispatch(
state.tr.setMeta("li", {
event: "mouseout",
pos,
})
);
return false;
},
},
},
}),
];
}
commands({ type }: { type: NodeType }) {
return {
indentList: () => sinkListItem(type),