Skip to content
Draft
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
14 changes: 14 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.13",
"@effect/schema": "^0.75.5",
"@floating-ui/dom": "^1.7.6",
"@floating-ui/react": "^0.27.17",
"@handlewithcare/react-prosemirror": "^2.8.4",
"@hypr/api-client": "workspace:*",
"@hypr/changelog": "workspace:^",
"@hypr/codemirror": "workspace:^",
Expand Down Expand Up @@ -131,6 +133,17 @@
"nlcst-to-string": "^4.0.0",
"ollama": "^0.6.3",
"posthog-js": "^1.358.0",
"prosemirror-commands": "^1.7.1",
"prosemirror-dropcursor": "^1.8.2",
"prosemirror-gapcursor": "^1.4.1",
"prosemirror-history": "^1.5.0",
"prosemirror-inputrules": "^1.5.1",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-search": "^1.1.0",
"prosemirror-state": "^1.4.4",
"prosemirror-view": "^1.41.6",
"re-resizable": "^6.11.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
Expand All @@ -145,6 +158,7 @@
"streamdown": "^2.2.0",
"tinybase": "^7.3.2",
"tinytick": "^1.2.8",
"tlds": "^1.261.0",
"unified": "^11.0.5",
"usehooks-ts": "^3.1.1",
"vfile": "^6.0.3",
Expand Down
165 changes: 165 additions & 0 deletions apps/desktop/src/editor/image-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import {
type NodeViewComponentProps,
useEditorEventCallback,
useEditorState,
} from "@handlewithcare/react-prosemirror";
import { forwardRef, useCallback, useRef, useState } from "react";

import {
DEFAULT_EDITOR_WIDTH,
normalizeEditorWidth,
stripEditorWidthFromTitle,
} from "@hypr/tiptap/shared";
import { cn } from "@hypr/utils";

export const ResizableImageView = forwardRef<
HTMLDivElement,
NodeViewComponentProps
>(({ nodeProps, ...htmlAttrs }, ref) => {
const { node, getPos } = nodeProps;
const [isHovered, setIsHovered] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [draftWidth, setDraftWidth] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const updateAttributes = useEditorEventCallback(
(view, attrs: Record<string, unknown>) => {
if (!view) return;
const pos = getPos();
const tr = view.state.tr.setNodeMarkup(pos, undefined, {
...node.attrs,
...attrs,
});
view.dispatch(tr);
},
);

// to detect whether a nodeview is selected:
// see: https://discuss.prosemirror.net/t/is-this-the-right-way-to-determine-if-a-nodeview-is-selected/2208/2
// also: https://github.com/handlewithcarecollective/react-prosemirror/issues/161
const pos = getPos();
const { selection } = useEditorState();
const isSelected =
pos >= selection.from && pos + node.nodeSize <= selection.to;

// we register all resize event handlers during resize start and unregister them on resize end.
// all drag state lives inside this callback scope.
// during a drag, draftWidth is a pixel value for immediate visual feedback.
// once the drag ends, draftWidth resets to null and we calculate and persist the percentage as attributes.
const handleResizeStart = useCallback(
(
direction: "left" | "right",
event: React.PointerEvent<HTMLButtonElement>,
) => {
const containerEl = containerRef.current;
const imageEl = imageRef.current;
if (!containerEl || !imageEl) return;

event.preventDefault();
event.stopPropagation();

const editorEl = containerEl.closest(".ProseMirror");
const maxWidth =
editorEl?.getBoundingClientRect().width ??
containerEl.getBoundingClientRect().width;
const startWidth = imageEl.getBoundingClientRect().width;
const startX = event.clientX;

let currentWidth = startWidth;
setIsResizing(true);
setDraftWidth(startWidth);

const handlePointerMove = (e: PointerEvent) => {
const deltaX = (e.clientX - startX) * (direction === "left" ? -1 : 1);
currentWidth = Math.min(maxWidth, Math.max(120, startWidth + deltaX));
setDraftWidth(currentWidth);
};

const handlePointerUp = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);

updateAttributes({
editorWidth: normalizeEditorWidth((currentWidth / maxWidth) * 100),
});

setIsResizing(false);
setDraftWidth(null);
};

window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
},
[updateAttributes],
);

const showControls = isHovered || isSelected || isResizing;
const editorWidth =
normalizeEditorWidth(node.attrs.editorWidth) ?? DEFAULT_EDITOR_WIDTH;
const imageWidth =
draftWidth !== null ? `${draftWidth}px` : `${editorWidth}%`;

return (
<div
ref={ref}
{...htmlAttrs}
className="relative overflow-visible select-none [&_*::selection]:bg-transparent [&::selection]:bg-transparent"
>
<div
ref={containerRef}
className="relative inline-block w-fit max-w-full overflow-visible"
style={imageWidth ? { width: imageWidth } : undefined}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<img
ref={imageRef}
src={node.attrs.src}
alt={node.attrs.alt || ""}
title={stripEditorWidthFromTitle(node.attrs.title)}
className={cn([
"tiptap-image max-w-full rounded-md bg-white transition-[box-shadow,border-color] select-none",
isSelected
? "ring-2 ring-blue-500 ring-offset-2 ring-offset-white"
: "",
isHovered && !isSelected
? "ring-1 ring-neutral-300 ring-offset-2 ring-offset-white"
: "",
"w-full",
])}
draggable={false}
/>
{showControls && (
<>
<div
aria-hidden="true"
className="absolute top-0 right-0 z-10 h-full w-6"
/>
<div
aria-hidden="true"
className="absolute top-0 left-0 z-10 h-full w-6"
/>
<button
type="button"
aria-label="Resize image from left"
onPointerDown={(event) => handleResizeStart("left", event)}
className="absolute top-1/2 left-1 z-20 flex h-14 w-4 -translate-y-1/2 cursor-ew-resize items-center justify-center rounded-full border border-neutral-300 bg-white/95 shadow-sm backdrop-blur-sm"
>
<span className="h-8 w-1 rounded-full bg-neutral-400" />
</button>
<button
type="button"
aria-label="Resize image from right"
onPointerDown={(event) => handleResizeStart("right", event)}
className="absolute top-1/2 right-1 z-20 flex h-14 w-4 -translate-y-1/2 cursor-ew-resize items-center justify-center rounded-full border border-neutral-300 bg-white/95 shadow-sm backdrop-blur-sm"
>
<span className="h-8 w-1 rounded-full bg-neutral-400" />
</button>
</>
)}
</div>
</div>
);
});

ResizableImageView.displayName = "ResizableImageView";
Loading
Loading