Skip to content
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
13 changes: 12 additions & 1 deletion .ona/automations/pr-shepherd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ action:

### Duplicate (linked issue already closed)

**Skip this check entirely if the PR has the `ona-user` label.** These PRs are
created via interactive user sessions and may intentionally re-address a
previously-closed issue (e.g., when the first fix was insufficient). The user
knows the issue state — do not second-guess them.

For all other PRs:

Extract the linked issue number from the PR body (`Closes #N` or `Fixes #N`).
If found, check the issue state:
gh issue view <N> --json state,labels --jq '{state: .state, labels: [.labels[].name]}'
Expand All @@ -56,7 +63,9 @@ action:
### Duplicate (multiple open PRs for the same issue)

After processing all PRs above, check if multiple REMAINING open PRs reference the
same `Closes #N`. If so, keep the OLDEST PR (lowest number) and flag the newer ones.
same `Closes #N`. Exclude PRs with the `ona-user` label from this deduplication —
they are user-initiated and take priority. For the remaining PRs, keep the OLDEST
PR (lowest number) and flag the newer ones.

Action on each newer duplicate:
1. Leave a comment:
Expand Down Expand Up @@ -175,3 +184,5 @@ action:
- Attempt to resolve genuine semantic conflicts (both sides changed the same lines
with incompatible intent). Additive and non-overlapping conflicts ARE safe to resolve.
- Rebase a PR more than once — if a [shepherd:conflict] comment exists, skip it.
- Close `ona-user` PRs as duplicates. These are user-initiated and may intentionally
re-address a previously-closed issue when the first fix was insufficient.
177 changes: 83 additions & 94 deletions src/components/editor/draggable-block-plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,8 @@ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext
import {
$getNearestNodeFromDOMNode,
$getNodeByKey,
COMMAND_PRIORITY_HIGH,
DRAGOVER_COMMAND,
DROP_COMMAND,
type LexicalEditor,
} from "lexical";
import { mergeRegister } from "@lexical/utils";
import { GripVertical } from "lucide-react";

const DRAG_DATA_FORMAT = "application/x-memo-drag-block";
Expand Down Expand Up @@ -99,9 +95,12 @@ function getBlockElement(
}
}

// Verify the mouse is within the editor bounds horizontally
// For hover (non-drag), verify the mouse is within the editor bounds
// horizontally. During drag the cursor may be over the handle/padding area
// to the left of the editor — that's valid.
if (
blockElem &&
!useUnboundedSearch &&
(event.clientX < editorBounds.left - 50 ||
event.clientX > editorBounds.right + 50)
) {
Expand Down Expand Up @@ -272,96 +271,86 @@ export function DraggableBlockPlugin({
}
}, []);

// Register drag-over and drop commands on the Lexical editor
// Listen for dragover/drop on the anchorElem (the full-width container
// including the left padding where the drag handle lives). Lexical's
// DRAGOVER_COMMAND / DROP_COMMAND only fire on the contentEditable, so
// dragging straight down from the handle would miss the editor entirely.
const handleDragOver = useCallback(
(event: DragEvent) => {
if (!event.dataTransfer?.types.includes(DRAG_DATA_FORMAT)) return;

event.preventDefault();
event.dataTransfer.dropEffect = "move";

const blockElem = getBlockElement(anchorElem, editor, event, true);
if (!blockElem || !dropIndicatorRef.current) return;

const blockRect = blockElem.getBoundingClientRect();
const isBelow = event.clientY > blockRect.top + blockRect.height / 2;

setDropIndicatorPosition(
dropIndicatorRef.current,
blockElem,
anchorElem,
isBelow
);
},
[anchorElem, editor]
);

const handleDrop = useCallback(
(event: DragEvent) => {
if (!event.dataTransfer?.types.includes(DRAG_DATA_FORMAT)) return;

event.preventDefault();

const nodeKey = event.dataTransfer.getData(DRAG_DATA_FORMAT);
if (!nodeKey) return;

const blockElem = getBlockElement(anchorElem, editor, event, true);
if (!blockElem) return;

const blockRect = blockElem.getBoundingClientRect();
const isBelow = event.clientY > blockRect.top + blockRect.height / 2;

editor.update(() => {
const draggedNode = $getNodeByKey(nodeKey);
if (!draggedNode) return;

const targetNode = $getNearestNodeFromDOMNode(blockElem);
if (!targetNode) return;

// Don't drop on self
if (draggedNode.getKey() === targetNode.getKey()) return;

// Remove from current position
draggedNode.remove();

// Insert at new position
if (isBelow) {
targetNode.insertAfter(draggedNode);
} else {
targetNode.insertBefore(draggedNode);
}
});

if (dropIndicatorRef.current) {
dropIndicatorRef.current.style.opacity = "0";
}

isDraggingRef.current = false;
},
[anchorElem, editor]
);

useEffect(() => {
return mergeRegister(
editor.registerCommand(
DRAGOVER_COMMAND,
(event: DragEvent) => {
if (!event.dataTransfer?.types.includes(DRAG_DATA_FORMAT)) {
return false;
}

event.preventDefault();

const blockElem = getBlockElement(
anchorElem,
editor,
event,
true
);
if (!blockElem || !dropIndicatorRef.current) return true;

const blockRect = blockElem.getBoundingClientRect();
const isBelow = event.clientY > blockRect.top + blockRect.height / 2;

setDropIndicatorPosition(
dropIndicatorRef.current,
blockElem,
anchorElem,
isBelow
);

event.dataTransfer.dropEffect = "move";
return true;
},
COMMAND_PRIORITY_HIGH
),
editor.registerCommand(
DROP_COMMAND,
(event: DragEvent) => {
if (!event.dataTransfer?.types.includes(DRAG_DATA_FORMAT)) {
return false;
}

event.preventDefault();

const nodeKey = event.dataTransfer.getData(DRAG_DATA_FORMAT);
if (!nodeKey) return true;

const blockElem = getBlockElement(
anchorElem,
editor,
event,
true
);
if (!blockElem) return true;

const blockRect = blockElem.getBoundingClientRect();
const isBelow = event.clientY > blockRect.top + blockRect.height / 2;

editor.update(() => {
const draggedNode = $getNodeByKey(nodeKey);
if (!draggedNode) return;

const targetNode = $getNearestNodeFromDOMNode(blockElem);
if (!targetNode) return;

// Don't drop on self
if (draggedNode.getKey() === targetNode.getKey()) return;

// Remove from current position
draggedNode.remove();

// Insert at new position
if (isBelow) {
targetNode.insertAfter(draggedNode);
} else {
targetNode.insertBefore(draggedNode);
}
});

if (dropIndicatorRef.current) {
dropIndicatorRef.current.style.opacity = "0";
}

isDraggingRef.current = false;
return true;
},
COMMAND_PRIORITY_HIGH
)
);
}, [anchorElem, editor]);
anchorElem.addEventListener("dragover", handleDragOver);
anchorElem.addEventListener("drop", handleDrop);
return () => {
anchorElem.removeEventListener("dragover", handleDragOver);
anchorElem.removeEventListener("drop", handleDrop);
};
}, [anchorElem, handleDragOver, handleDrop]);

return createPortal(
<>
Expand Down
Loading