From 10c9ef174be25a821900185e762ab1cc3e48df18 Mon Sep 17 00:00:00 2001 From: Noma Date: Fri, 21 Mar 2025 15:21:32 -0400 Subject: feat: mesh transform for images --- .../imageEditor/imageMeshTool/ImageMeshTool.ts | 0 .../nodes/imageEditor/imageMeshTool/imageMesh.scss | 24 +++++ .../nodes/imageEditor/imageMeshTool/imageMesh.tsx | 109 +++++++++++++++++++++ .../imageMeshTool/imageMeshToolButton.scss | 21 ++++ .../imageMeshTool/imageMeshToolButton.tsx | 90 +++++++++++++++++ 5 files changed, 244 insertions(+) create mode 100644 src/client/views/nodes/imageEditor/imageMeshTool/ImageMeshTool.ts create mode 100644 src/client/views/nodes/imageEditor/imageMeshTool/imageMesh.scss create mode 100644 src/client/views/nodes/imageEditor/imageMeshTool/imageMesh.tsx create mode 100644 src/client/views/nodes/imageEditor/imageMeshTool/imageMeshToolButton.scss create mode 100644 src/client/views/nodes/imageEditor/imageMeshTool/imageMeshToolButton.tsx (limited to 'src/client/views/nodes/imageEditor/imageMeshTool') diff --git a/src/client/views/nodes/imageEditor/imageMeshTool/ImageMeshTool.ts b/src/client/views/nodes/imageEditor/imageMeshTool/ImageMeshTool.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/client/views/nodes/imageEditor/imageMeshTool/imageMesh.scss b/src/client/views/nodes/imageEditor/imageMeshTool/imageMesh.scss new file mode 100644 index 000000000..253f48f77 --- /dev/null +++ b/src/client/views/nodes/imageEditor/imageMeshTool/imageMesh.scss @@ -0,0 +1,24 @@ +/* MeshTransformGrid.scss */ +.meshTransformGrid { + position: relative; + width: 100%; + height: 100%; + pointer-events: none; /* Prevents interaction with the grid itself */ + opacity: 5%; +} + +.grid-line { + position: absolute; + background-color: rgba(255, 255, 255, 0.6); /* Light grid lines */ +} + +.control-point { + position: absolute; + width: 12px; + height: 12px; + background-color: rgba(255, 255, 255, 1); /* White control points */ + border-radius: 50%; + cursor: pointer; + z-index: 10; + pointer-events: auto; /* Allows dragging of control points */ +} diff --git a/src/client/views/nodes/imageEditor/imageMeshTool/imageMesh.tsx b/src/client/views/nodes/imageEditor/imageMeshTool/imageMesh.tsx new file mode 100644 index 000000000..ee5c597e9 --- /dev/null +++ b/src/client/views/nodes/imageEditor/imageMeshTool/imageMesh.tsx @@ -0,0 +1,109 @@ +import React, { useState, useEffect } from 'react'; +import './MeshTransformGrid.scss'; + +interface MeshTransformGridProps { + imageRef: React.RefObject; // Reference to the image element + gridXSize: number; // Number of X subdivisions + gridYSize: number; // Number of Y subdivisions + isInteractive: boolean; // Whether control points are interactive (can be dragged) +} + +const MeshTransformGrid: React.FC = ({ imageRef, gridXSize, gridYSize, isInteractive }) => { + const [controlPoints, setControlPoints] = useState([]); + + // Set up control points based on image size and grid sizes + useEffect(() => { + if (imageRef.current) { + const { width, height, left, top } = imageRef.current.getBoundingClientRect(); + const newControlPoints = []; + + for (let i = 0; i <= gridYSize; i++) { + for (let j = 0; j <= gridXSize; j++) { + newControlPoints.push({ + id: `${i}-${j}`, + x: (j * width) / gridXSize + left, + y: (i * height) / gridYSize + top, + }); + } + } + + setControlPoints(newControlPoints); + } + }, [imageRef, gridXSize, gridYSize]); + + // Handle dragging of control points + const handleDrag = (e: React.MouseEvent, pointId: string) => { + if (!isInteractive) return; // Prevent dragging if grid is not interactive + + const { clientX, clientY } = e; + const updatedPoints = controlPoints.map((point) => { + if (point.id === pointId) { + return { ...point, x: clientX, y: clientY }; + } + return point; + }); + setControlPoints(updatedPoints); + }; + + // Render grid lines between control points + const renderGridLines = () => { + const lines = []; + for (let i = 0; i < controlPoints.length; i++) { + const point = controlPoints[i]; + const nextPoint = controlPoints[i + 1]; + + // Horizontal lines + if (nextPoint && i % (gridXSize + 1) !== gridXSize) { + lines.push({ + start: { x: point.x, y: point.y }, + end: { x: nextPoint.x, y: nextPoint.y }, + }); + } + + // Vertical lines + if (i + gridXSize + 1 < controlPoints.length) { + const downPoint = controlPoints[i + gridXSize + 1]; + lines.push({ + start: { x: point.x, y: point.y }, + end: { x: downPoint.x, y: downPoint.y }, + }); + } + } + return lines.map((line, index) => ( +
+ )); + }; + + return ( +
+ {renderGridLines()} + + {controlPoints.map((point) => ( +
handleDrag(e, point.id)} + /> + ))} +
+ ); +}; + +export default MeshTransformGrid; diff --git a/src/client/views/nodes/imageEditor/imageMeshTool/imageMeshToolButton.scss b/src/client/views/nodes/imageEditor/imageMeshTool/imageMeshToolButton.scss new file mode 100644 index 000000000..a72b2de6a --- /dev/null +++ b/src/client/views/nodes/imageEditor/imageMeshTool/imageMeshToolButton.scss @@ -0,0 +1,21 @@ +/* MeshTransformButton.scss */ +.meshTransformBtnContainer { + position: relative; + width: 100%; + height: 100%; +} + +.grid-line { + position: absolute; + background-color: rgba(255, 255, 255, 0.6); +} + +.control-point { + position: absolute; + width: 12px; + height: 12px; + background-color: rgba(255, 255, 255, 1); + border-radius: 50%; + cursor: pointer; + z-index: 10; +} diff --git a/src/client/views/nodes/imageEditor/imageMeshTool/imageMeshToolButton.tsx b/src/client/views/nodes/imageEditor/imageMeshTool/imageMeshToolButton.tsx new file mode 100644 index 000000000..eb68410b0 --- /dev/null +++ b/src/client/views/nodes/imageEditor/imageMeshTool/imageMeshToolButton.tsx @@ -0,0 +1,90 @@ +import './MeshTransformButton.scss'; +import * as React from 'react'; +import ReactLoading from 'react-loading'; +import { Button, IconButton, Type } from '@dash/components'; +import { AiOutlineInfo } from 'react-icons/ai'; +import { SettingsManager } from '../../../../util/SettingsManager'; +import MeshTransformGrid from './imageMesh'; + +interface ButtonContainerProps { + onClick: () => Promise; + loading: boolean; + onReset: () => void; + btnText: string; + imageWidth: number; + imageHeight: number; + gridXSize: number; // X subdivisions + gridYSize: number; // Y subdivisions +} + +export function MeshTransformButton({ + loading, + onClick: startMeshTransform, + onReset, + btnText, + imageWidth, + imageHeight, + gridXSize, + gridYSize +}: ButtonContainerProps) { + const [showGrid, setShowGrid] = React.useState(false); + const [isGridInteractive, setIsGridInteractive] = React.useState(false); // Controls the dragging of control points + const imageRef = React.useRef(null); // Reference to the image element + + const handleGridToggle = () => { + if (showGrid) { + setShowGrid(false); // Hide the grid + setIsGridInteractive(false); // Disable control points manipulation + } else { + setShowGrid(true); // Show the grid + setIsGridInteractive(true); // Enable control points manipulation + } + }; + + return ( +
+
+ ); +} -- cgit v1.2.3-70-g09d2 From 6aa7649ea9195cf3fc53cf70e126095a9045e057 Mon Sep 17 00:00:00 2001 From: bobzel Date: Tue, 22 Apr 2025 10:58:03 -0400 Subject: from last --- src/client/views/nodes/LinkBox.tsx | 1 + .../views/nodes/imageEditor/imageMeshTool/imageMeshToolButton.tsx | 2 +- src/client/views/nodes/scrapbook/ScrapbookBox.tsx | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) (limited to 'src/client/views/nodes/imageEditor/imageMeshTool') diff --git a/src/client/views/nodes/LinkBox.tsx b/src/client/views/nodes/LinkBox.tsx index 8bf65b637..78c8a686c 100644 --- a/src/client/views/nodes/LinkBox.tsx +++ b/src/client/views/nodes/LinkBox.tsx @@ -128,6 +128,7 @@ export class LinkBox extends ViewBoxBaseComponent() { const getAnchor = (field: FieldResult): Element[] => { const docField = DocCast(field); const doc = docField?.layout_unrendered ? DocCast(docField.annotationOn, docField) : docField; + if (!doc) return []; const ele = document.getElementById(DocumentView.UniquifyId(DocumentView.LightboxContains(this.DocumentView?.()), doc[Id])); if (ele?.className === 'linkBox-label') foundParent = true; if (ele?.getBoundingClientRect().width) return [ele]; diff --git a/src/client/views/nodes/imageEditor/imageMeshTool/imageMeshToolButton.tsx b/src/client/views/nodes/imageEditor/imageMeshTool/imageMeshToolButton.tsx index eb68410b0..c02a1eb94 100644 --- a/src/client/views/nodes/imageEditor/imageMeshTool/imageMeshToolButton.tsx +++ b/src/client/views/nodes/imageEditor/imageMeshTool/imageMeshToolButton.tsx @@ -19,7 +19,7 @@ interface ButtonContainerProps { export function MeshTransformButton({ loading, - onClick: startMeshTransform, + onClick, onReset, btnText, imageWidth, diff --git a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx index b02976067..24946f4d2 100644 --- a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx +++ b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx @@ -60,7 +60,7 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent() } return false; }; - rejectDrop = (draggedDoc: Doc[] | undefined, subView?: DocumentView) => { + rejectDrop = (draggedDoc: Doc[] | undefined /* , subView?: DocumentView */) => { if (draggedDoc?.length === 1 && draggedDoc[0].$type !== DocumentType.IMG) { return true; } -- cgit v1.2.3-70-g09d2 From 0694277e33c4a5c4f66b1cebef38f9d86c38cf34 Mon Sep 17 00:00:00 2001 From: bobzel Date: Tue, 29 Apr 2025 12:29:02 -0400 Subject: clean up outpaint code. fix image sizing when outpainting. --- src/client/views/DocumentDecorations.tsx | 25 ++++++------- src/client/views/nodes/ImageBox.scss | 42 +++++++++++----------- src/client/views/nodes/ImageBox.tsx | 24 ++++++------- .../views/nodes/formattedText/FormattedTextBox.tsx | 1 - .../imageMeshTool/imageMeshToolButton.tsx | 15 ++------ 5 files changed, 46 insertions(+), 61 deletions(-) (limited to 'src/client/views/nodes/imageEditor/imageMeshTool') diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 2d39b827d..ab665e984 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -448,8 +448,8 @@ export class DocumentDecorations extends ObservableReactComponent e.shiftKey && dv.ComponentView instanceof ImageBox) .forEach(dv => { - dv.Document._outpaintingOriginalWidth = NumCast(dv.Document._width); - dv.Document._outpaintingOriginalHeight = NumCast(dv.Document._height); + dv.Document[dv.ComponentView!.fieldKey + '_outpaintOriginalWidth'] = NumCast(dv.Document._width); + dv.Document[dv.ComponentView!.fieldKey + '_outpaintOriginalHeight'] = NumCast(dv.Document._height); }); setupMoveUpEvents(this, e, this.onPointerMove, this.onPointerUp, emptyFunction); e.stopPropagation(); @@ -502,14 +502,15 @@ export class DocumentDecorations extends ObservableReactComponent !e.shiftKey || !(dv.ComponentView instanceof ImageBox)); - // Special handling for shift-drag resize (outpainting of Images) - DocumentView.Selected() - .filter(dv => !notOutpainted.includes(dv)) - .forEach(dv => this.resizeViewForOutpainting(dv, refPt, scale, { dragHdl, shiftKey: e.shiftKey })); // Adjust only the document dimensions without scaling internal content + const outpainted = e.shiftKey ? DocumentView.Selected().filter(dv => dv.ComponentView instanceof ImageBox) : []; + const notOutpainted = e.shiftKey ? DocumentView.Selected().filter(dv => !outpainted.includes(dv)) : DocumentView.Selected(); - // Regular resize behavior for docs not being outpainted + // Special handling for shift-drag resize (outpainting of Images by resizing without scaling content - fill in with firefly GAI) + e.shiftKey && outpainted.forEach(dv => this.resizeViewForOutpainting(dv, refPt, scale, { dragHdl, shiftKey: e.shiftKey })); + + // Special handling for not outpainted Docs when ctrl-resizing (setup native dimesions for modification) e.ctrlKey && notOutpainted.forEach(docView => !Doc.NativeHeight(docView.Document) && docView.toggleNativeDimensions()); + const hasFixedAspect = notOutpainted.map(dv => dv.Document).some(this.hasFixedAspect); const scaleAspect = { x: scale.x === 1 && hasFixedAspect ? scale.y : scale.x, y: scale.x !== 1 && hasFixedAspect ? scale.x : scale.y }; notOutpainted.forEach(docView => this.resizeView(docView, refPt, scaleAspect, { dragHdl, freezeNativeDims: e.ctrlKey })); @@ -546,20 +547,14 @@ export class DocumentDecorations extends ObservableReactComponent { + onPointerUp = () => { SnappingManager.SetIsResizing(undefined); SnappingManager.clearSnapLines(); - // Check if any outpainting needs to be processed - DocumentView.Selected() - .filter(dv => e.shiftKey && dv.ComponentView instanceof ImageBox) - .forEach(view => (view.ComponentView as ImageBox).processOutpainting()); - this._resizeHdlId = ''; this._resizeUndo?.end(); diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss index 9fc20ffd4..ac1a6ece9 100644 --- a/src/client/views/nodes/ImageBox.scss +++ b/src/client/views/nodes/ImageBox.scss @@ -106,7 +106,7 @@ height: 100%; img { object-fit: contain; - height: 100%; + height: fit-content; } .imageBox-fadeBlocker, @@ -249,27 +249,29 @@ background: white; padding: 20px; border-radius: 8px; - box-shadow: 0 4px 12px rgba(0,0,0,0.2); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); z-index: 10000; - - h3 { margin-top: 0; } - + + h3 { + margin-top: 0; + } + input { - width: 300px; - padding: 8px; - margin-bottom: 10px; + width: 300px; + padding: 8px; + margin-bottom: 10px; } - + .buttons { - display: flex; - justify-content: flex-end; - gap: 10px; - - .generate-btn { - background: #0078d4; - color: white; - border: none; - padding: 8px 16px; - } + display: flex; + justify-content: flex-end; + gap: 10px; + + .generate-btn { + background: #0078d4; + color: white; + border: none; + padding: 8px 16px; + } } - } \ No newline at end of file +} diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index 010028af7..31a135fa7 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -170,6 +170,11 @@ export class ImageBox extends ViewBoxAnnotatableComponent() { }, { fireImmediately: true } ); + this._disposers.outpaint = reaction( + () => this.Document[this.fieldKey + '_outpaintOriginalWidth'] !== undefined && !SnappingManager.ShiftKey, + complete => complete && this.openOutpaintPrompt(), + { fireImmediately: true } + ); } componentWillUnmount() { @@ -372,13 +377,8 @@ export class ImageBox extends ViewBoxAnnotatableComponent() { const field = Cast(this.dataDoc[this.fieldKey], ImageField); if (!field) return; - const origWidth = NumCast(this.Document._outpaintingOriginalWidth); - const origHeight = NumCast(this.Document._outpaintingOriginalHeight); - - if (!origWidth || !origHeight) { - console.error('Original dimensions (_outpaintingOriginalWidth/_outpaintingOriginalHeight) not set. Ensure resizeViewForOutpainting was called first.'); - return; - } + const origWidth = NumCast(this.Document[this.fieldKey + '_outpaintOriginalWidth']); + const origHeight = NumCast(this.Document[this.fieldKey + '_outpaintOriginalHeight']); // Set flag that outpainting is in progress this._outpaintingInProgress = true; @@ -444,8 +444,8 @@ export class ImageBox extends ViewBoxAnnotatableComponent() { this.Document.$ai = true; this.Document.$ai_outpainted = true; this.Document.$ai_outpaint_prompt = customPrompt; - this.Document._outpaintingOriginalWidth = undefined; - this.Document._outpaintingOriginalHeight = undefined; + this.Document[this.fieldKey + '_outpaintOriginalWidth'] = undefined; + this.Document[this.fieldKey + '_outpaintOriginalHeight'] = undefined; } else { this.Document._width = origWidth; this.Document._height = origHeight; @@ -465,11 +465,9 @@ export class ImageBox extends ViewBoxAnnotatableComponent() { } }; - processOutpainting = () => this.openOutpaintPrompt(); - componentUI = () => !this._showOutpaintPrompt ? null : ( -
+

Outpaint Image

() { ref={action((r: HTMLImageElement | null) => (this.imageRef = r))} key="paths" src={srcpath} - style={{ transform, transformOrigin, height: 'fit-content' }} + style={{ transform, transformOrigin, height: this.Document[this.fieldKey + '_outpaintOriginalWidth'] !== undefined ? '100%' : undefined }} onError={action(e => (this._error = e.toString()))} draggable={false} width={nativeWidth} diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index 98e461a52..d6fa3172d 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -1349,7 +1349,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent { const html = data?.getData('text/html'); - const text = data?.getData('text/plain'); const pdfAnchorId = data?.getData('dash/pdfAnchor'); if (html && !pdfAnchorId) { const replaceDivsWithParagraphs = (expr: string) => { diff --git a/src/client/views/nodes/imageEditor/imageMeshTool/imageMeshToolButton.tsx b/src/client/views/nodes/imageEditor/imageMeshTool/imageMeshToolButton.tsx index c02a1eb94..e580c7070 100644 --- a/src/client/views/nodes/imageEditor/imageMeshTool/imageMeshToolButton.tsx +++ b/src/client/views/nodes/imageEditor/imageMeshTool/imageMeshToolButton.tsx @@ -13,20 +13,11 @@ interface ButtonContainerProps { btnText: string; imageWidth: number; imageHeight: number; - gridXSize: number; // X subdivisions - gridYSize: number; // Y subdivisions + gridXSize: number; // X subdivisions + gridYSize: number; // Y subdivisions } -export function MeshTransformButton({ - loading, - onClick, - onReset, - btnText, - imageWidth, - imageHeight, - gridXSize, - gridYSize -}: ButtonContainerProps) { +export function MeshTransformButton({ loading, onClick, onReset, btnText, imageWidth, imageHeight, gridXSize, gridYSize }: ButtonContainerProps) { const [showGrid, setShowGrid] = React.useState(false); const [isGridInteractive, setIsGridInteractive] = React.useState(false); // Controls the dragging of control points const imageRef = React.useRef(null); // Reference to the image element -- cgit v1.2.3-70-g09d2