Skip to content

feat: add Notion-style block editor drag handle #406

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion ui/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,22 @@ have to force set the collapsed scope block (when contextual zoomed) to be above
it, so that we can drag the block. */
.react-flow__node:has(.scope-block) {
z-index: 1001 !important;
}
}

.global-drag-handle {
position: absolute;

&::after {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1.25rem;
content: "⠿";
font-weight: 700;
cursor: grab;
background: #0d0d0d10;
color: #0d0d0d50;
border-radius: 0.25rem;
}
}
5 changes: 5 additions & 0 deletions ui/src/components/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,11 @@ function CanvasImpl() {
onConnect={onConnect}
onMove={() => {
toggleMoved();
// Hide the Rich node drag handle when moving.
const elems = document.getElementsByClassName("global-drag-handle");
Array.from(elems).forEach((elem) => {
(elem as HTMLElement).style.display = "none";
});
}}
onPaneClick={() => {
toggleClicked();
Expand Down
7 changes: 7 additions & 0 deletions ui/src/components/nodes/Rich.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ import { CodePodSyncExtension } from "./extensions/codepodSync";
import { LinkExtension, LinkToolbar } from "./extensions/link";
import { SlashExtension } from "./extensions/slash";
import { SlashSuggestor } from "./extensions/useSlash";
import { BlockHandleExtension } from "./extensions/blockHandle";

import { NewPodButtons, level2fontsize } from "./utils";
import { RepoContext } from "../../lib/store";
Expand Down Expand Up @@ -242,6 +243,11 @@ const MyStyledWrapper = styled("div")(
.remirror-editor-wrapper {
padding: 0;
}

/* leave some space for the block handle */
.remirror-editor-wrapper .ProseMirror {
padding-left: 24px;
}
`
);

Expand Down Expand Up @@ -349,6 +355,7 @@ const MyEditor = ({
{ name: "slash", char: "/", appendText: " ", matchOffset: 0 },
],
}),
new BlockHandleExtension(),
],
onError: ({ json, invalidContent, transformers }) => {
// Automatically remove all invalid nodes and marks.
Expand Down
170 changes: 170 additions & 0 deletions ui/src/components/nodes/extensions/blockHandle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Code developed based on https://github.com/ueberdosis/tiptap/issues/323#issuecomment-506637799

import { NodeSelection } from "@remirror/pm/state";
// @ts-ignore
import { __serializeForClipboard as serializeForClipboard } from "prosemirror-view";

import { PlainExtension, extension } from "remirror";

function removeNode(node) {
node.parentNode.removeChild(node);
}

function absoluteRect(node) {
const data = node.getBoundingClientRect();

return {
top: data.top,
left: data.left,
width: data.width,
};
}

function blockPosAtCoords(coords, view) {
const pos = view.posAtCoords(coords);
if (!pos) return null;
let node = view.domAtPos(pos.pos);

node = node.node;

if (!node) return;
// Text node is not draggable. Must be a element node.
if (node.nodeType === node.TEXT_NODE) {
node = node.parentNode;
}
// Support bullet list and ordered list.
if (["LI", "UL"].includes(node.parentNode.tagName)) {
node = node.parentNode;
}
// Support task list.
// li[data-task-list-item] > div > p
if (
node.parentNode.parentNode
.getAttributeNames()
.includes("data-task-list-item")
) {
node = node.parentNode.parentNode;
}

if (node && node.nodeType === 1) {
const desc = view.docView.nearestDesc(node, true);

if (!(!desc || desc === view.docView)) {
return desc.posBefore;
}
}
return null;
}

function dragStart(e, view) {
if (!e.dataTransfer) {
return;
}

const coords = { left: e.clientX + 50, top: e.clientY };
const pos = blockPosAtCoords(coords, view);

if (pos != null) {
view.dispatch(
view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos))
);

const slice = view.state.selection.content();
const { dom, text } = serializeForClipboard(view, slice);

e.dataTransfer.clearData();
e.dataTransfer.setData("text/html", dom.innerHTML);
e.dataTransfer.setData("text/plain", text);

const el = document.querySelector(".ProseMirror-selectednode");

e.dataTransfer?.setDragImage(el, 0, 0);
view.dragging = { slice, move: true };
}
}

@extension({})
export class BlockHandleExtension extends PlainExtension {
get name(): string {
return "block-handle";
}

createPlugin() {
let dropElement;
const WIDTH = 28;
return {
view(editorView) {
const element = document.createElement("div");

element.draggable = true;
element.classList.add("global-drag-handle");
element.addEventListener("dragstart", (e) => dragStart(e, editorView));
dropElement = element;
document.body.appendChild(dropElement);

return {
destroy() {
removeNode(dropElement);
dropElement = null;
},
};
},
props: {
handleDOMEvents: {
mousemove(view, event) {
const coords = {
left: event.clientX + WIDTH + 50,
top: event.clientY,
};
const pos = view.posAtCoords(coords);

if (pos) {
let node = view.domAtPos(pos?.pos);

if (node) {
node = node.node;
// Text node is not draggable. Must be a element node.
if (node.nodeType === node.TEXT_NODE) {
node = node.parentNode;
}
// Locate the actual node instead of a <mark>.
while (node.tagName === "MARK") {
node = node.parentNode;
}

if (node instanceof Element) {
const cstyle = window.getComputedStyle(node);
const lineHeight = parseInt(cstyle.lineHeight, 10);
// const top = parseInt(cstyle.marginTop, 10) + parseInt(cstyle.paddingTop, 10)
const top = 0;
const rect = absoluteRect(node);
const win = node.ownerDocument.defaultView;

rect.top += win!.pageYOffset + (lineHeight - 24) / 2 + top;
rect.left += win!.pageXOffset;
rect.width = `${WIDTH}px`;

// The default X offset
let offset = -8;
if (
node.parentNode &&
(node.parentNode as HTMLElement).classList.contains(
"ProseMirror"
)
) {
// The X offset for top-level nodes.
offset = 8;
}

dropElement.style.left = `${-WIDTH + rect.left + offset}px`;
dropElement.style.top = `${rect.top}px`;
dropElement.style.display = "block";
}
}
}
},
},
},
};
}
}