]> BookStack Code Mirror - bookstack/commitdiff
Added image resizing via drag handles
authorDan Brown <redacted>
Mon, 17 Jan 2022 17:43:16 +0000 (17:43 +0000)
committerDan Brown <redacted>
Mon, 17 Jan 2022 17:43:16 +0000 (17:43 +0000)
TODO
resources/js/editor/ProseMirrorView.js
resources/js/editor/markdown-serializer.js
resources/js/editor/node-views/ImageView.js [new file with mode: 0644]
resources/js/editor/node-views/index.js [new file with mode: 0644]
resources/js/editor/node-views/node-view-utils.js [new file with mode: 0644]
resources/js/editor/schema-nodes.js
resources/sass/_editor.scss

diff --git a/TODO b/TODO
index ce7cdbf076e12b0ad279ff5cfb015bcdd40bdc08..d8d562c6642446f28b7cfda5653e3d2294f5d9f3 100644 (file)
--- a/TODO
+++ b/TODO
@@ -1,10 +1,15 @@
+### Next
+
+// 
+
 ### In-Progress
 
+//
+
 ### Features
 
 - Tables
 - Images
-  - Image Resizing in editor
 - Drawings
 - LTR/RTL control
 - Fullscreen
index 69177b63abf38e2746eff29b9c3632123690efd7..63a47dc35b09f07b3e78c0b3cb49df30a996f984 100644 (file)
@@ -6,6 +6,7 @@ import {DOMParser, DOMSerializer} from "prosemirror-model";
 
 import schema from "./schema";
 import menu from "./menu";
+import nodeViews from "./node-views";
 
 class ProseMirrorView {
     constructor(target, content) {
@@ -21,7 +22,8 @@ class ProseMirrorView {
                     ...exampleSetup({schema, menuBar: false}),
                     menu,
                 ]
-            })
+            }),
+            nodeViews,
         });
     }
 
index 2edc1ef27606ea4547cdd0979115ce031f9d60ca..8e7da7d91855abefe6db3307fa62e5b58d9f65e7 100644 (file)
@@ -92,7 +92,7 @@ function writeNodeAsHtml(state, node) {
 // formatting or content.
 for (const [nodeType, serializerFunction] of Object.entries(nodes)) {
     nodes[nodeType] = function (state, node, parent, index) {
-        if (node.attrs.align) {
+        if (node.attrs.align || node.attrs.height || node.attrs.width) {
             writeNodeAsHtml(state, node);
         } else {
             serializerFunction(state, node, parent, index);
diff --git a/resources/js/editor/node-views/ImageView.js b/resources/js/editor/node-views/ImageView.js
new file mode 100644 (file)
index 0000000..b283d8d
--- /dev/null
@@ -0,0 +1,198 @@
+import {positionHandlesAtCorners, removeHandles, renderHandlesAtCorners} from "./node-view-utils";
+import {NodeSelection} from "prosemirror-state";
+
+class ImageView {
+    /**
+     * @param {PmNode} node
+     * @param {PmView} view
+     * @param {(function(): number)} getPos
+     */
+    constructor(node, view, getPos) {
+        this.dom = document.createElement('div');
+        this.dom.classList.add('ProseMirror-imagewrap');
+
+        this.image = document.createElement("img");
+        this.image.src = node.attrs.src;
+        this.image.alt = node.attrs.alt;
+        if (node.attrs.width) {
+            this.image.width = node.attrs.width;
+        }
+        if (node.attrs.height) {
+            this.image.height = node.attrs.height;
+        }
+
+        this.dom.appendChild(this.image);
+
+        this.handles = [];
+        this.handleDragStartInfo = null;
+        this.handleDragMoveDimensions = null;
+        this.removeHandlesListener = this.removeHandlesListener.bind(this);
+        this.handleMouseMove = this.handleMouseMove.bind(this);
+        this.handleMouseUp = this.handleMouseUp.bind(this);
+        this.handleMouseDown = this.handleMouseDown.bind(this);
+
+        this.dom.addEventListener("click", event => {
+            this.showHandles();
+        });
+
+        // Show handles if selected
+        if (view.state.selection.node === node) {
+            window.setTimeout(() => {
+                this.showHandles();
+            }, 10);
+        }
+
+        this.updateImageDimensions = function (width, height) {
+            const attrs = Object.assign({}, node.attrs, {width, height});
+            let tr = view.state.tr;
+            const position = getPos();
+            tr = tr.setNodeMarkup(position, null, attrs)
+            tr = tr.setSelection(NodeSelection.create(tr.doc, position));
+            view.dispatch(tr);
+        };
+
+    }
+
+    showHandles() {
+        if (this.handles.length === 0) {
+            this.image.dataset.showHandles = 'true';
+            window.addEventListener('click', this.removeHandlesListener);
+            this.handles = renderHandlesAtCorners(this.image);
+            for (const handle of this.handles) {
+                handle.addEventListener('mousedown', this.handleMouseDown);
+            }
+        }
+    }
+
+    removeHandlesListener(event) {
+        console.log(this.dom.contains(event.target), event.target);
+        if (!this.dom.contains(event.target)) {
+            this.removeHandles();
+            this.handles = [];
+        }
+    }
+
+    removeHandles() {
+        removeHandles(this.handles);
+        window.removeEventListener('click', this.removeHandlesListener);
+        delete this.image.dataset.showHandles;
+    }
+
+    stopEvent() {
+        return false;
+    }
+
+    /**
+     * @param {MouseEvent} event
+     */
+    handleMouseDown(event) {
+        event.preventDefault();
+
+        const imageBounds = this.image.getBoundingClientRect();
+        const handle = event.target;
+        this.handleDragStartInfo = {
+            x: event.screenX,
+            y: event.screenY,
+            ratio: imageBounds.width / imageBounds.height,
+            bounds: imageBounds,
+            handleX: handle.dataset.x,
+            handleY: handle.dataset.y,
+        };
+
+        this.createDragDummy(imageBounds);
+        this.dom.appendChild(this.dragDummy);
+
+        window.addEventListener('mousemove', this.handleMouseMove);
+        window.addEventListener('mouseup', this.handleMouseUp);
+    }
+
+    /**
+     * @param {DOMRect} bounds
+     */
+    createDragDummy(bounds) {
+        this.dragDummy = this.image.cloneNode();
+        this.dragDummy.style.opacity = '0.5';
+        this.dragDummy.classList.add('ProseMirror-dragdummy');
+        this.dragDummy.style.width = bounds.width + 'px';
+        this.dragDummy.style.height = bounds.height + 'px';
+    }
+
+    /**
+     * @param {MouseEvent} event
+     */
+    handleMouseUp(event) {
+        if (this.handleDragMoveDimensions) {
+            const {width, height} = this.handleDragMoveDimensions;
+            this.updateImageDimensions(String(width), String(height));
+        }
+
+        window.removeEventListener('mousemove', this.handleMouseMove);
+        window.removeEventListener('mouseup', this.handleMouseUp);
+        this.handleDragStartInfo = null;
+        this.handleDragMoveDimensions = null;
+        this.dragDummy.remove();
+        positionHandlesAtCorners(this.image, this.handles);
+    }
+
+    /**
+     * @param {MouseEvent} event
+     */
+    handleMouseMove(event) {
+        const originalBounds = this.handleDragStartInfo.bounds;
+
+        // Calculate change in x & y, flip amounts depending on handle
+        let xChange = event.screenX - this.handleDragStartInfo.x;
+        if (this.handleDragStartInfo.handleX === 'left') {
+            xChange = -xChange;
+        }
+        let yChange = event.screenY - this.handleDragStartInfo.y;
+        if (this.handleDragStartInfo.handleY === 'top') {
+            yChange = -yChange;
+        }
+
+        // Prevent images going too small or into negative bounds
+        if (originalBounds.width + xChange < 10) {
+            xChange = -originalBounds.width + 10;
+        }
+        if (originalBounds.height + yChange < 10) {
+            yChange = -originalBounds.height + 10;
+        }
+
+        // Choose the larger dimension change and align the other to keep
+        // image aspect ratio, aligning growth/reduction direction
+        if (Math.abs(xChange) > Math.abs(yChange)) {
+            yChange = Math.floor(xChange * this.handleDragStartInfo.ratio);
+            if (yChange * xChange < 0) {
+                yChange = -yChange;
+            }
+        } else {
+            xChange = Math.floor(yChange / this.handleDragStartInfo.ratio);
+            if (xChange * yChange < 0) {
+                xChange = -xChange;
+            }
+        }
+
+        // Calculate our new sizes
+        const newWidth = originalBounds.width + xChange;
+        const newHeight = originalBounds.height + yChange;
+
+        // Apply the sizes and positioning to our ghost dummy
+        this.dragDummy.style.width = `${newWidth}px`;
+        if (this.handleDragStartInfo.handleX === 'left') {
+            this.dragDummy.style.left = `${-xChange}px`;
+        }
+        this.dragDummy.style.height = `${newHeight}px`;
+        if (this.handleDragStartInfo.handleY === 'top') {
+            this.dragDummy.style.top = `${-yChange}px`;
+        }
+
+        // Update corners and track dimension changes for later application
+        positionHandlesAtCorners(this.dragDummy, this.handles);
+        this.handleDragMoveDimensions = {
+            width: newWidth,
+            height: newHeight,
+        }
+    }
+}
+
+export default ImageView;
\ No newline at end of file
diff --git a/resources/js/editor/node-views/index.js b/resources/js/editor/node-views/index.js
new file mode 100644 (file)
index 0000000..997ab08
--- /dev/null
@@ -0,0 +1,7 @@
+import ImageView from "./ImageView";
+
+const views = {
+    image: (node, view, getPos) => new ImageView(node, view, getPos),
+};
+
+export default views;
\ No newline at end of file
diff --git a/resources/js/editor/node-views/node-view-utils.js b/resources/js/editor/node-views/node-view-utils.js
new file mode 100644 (file)
index 0000000..dc7ea94
--- /dev/null
@@ -0,0 +1,58 @@
+import crel from "crelt";
+
+/**
+ * Render grab handles at the corners of the given element.
+ * @param {Element} elem
+ * @return {Element[]}
+ */
+export function renderHandlesAtCorners(elem) {
+    const handles = [];
+    const baseClass = 'ProseMirror-grabhandle';
+
+    for (let i = 0; i < 4; i++) {
+        const y = (i < 2) ? 'top' : 'bottom';
+        const x = (i === 0 || i === 3) ? 'left' : 'right';
+        const handle = crel('div', {
+            class: `${baseClass} ${baseClass}-${x}-${y}`,
+        });
+        handle.dataset.y = y;
+        handle.dataset.x = x;
+        handles.push(handle);
+        elem.parentNode.appendChild(handle);
+    }
+
+    positionHandlesAtCorners(elem, handles);
+    return handles;
+}
+
+/**
+ * @param {Element[]} handles
+ */
+export function removeHandles(handles) {
+    for (const handle of handles) {
+        handle.remove();
+    }
+}
+
+/**
+ *
+ * @param {Element} element
+ * @param {[Element, Element, Element, Element]}handles
+ */
+export function positionHandlesAtCorners(element, handles) {
+    const bounds = element.getBoundingClientRect();
+    const parentBounds = element.parentElement.getBoundingClientRect();
+    const positions = [
+        {x: bounds.left - parentBounds.left, y: bounds.top - parentBounds.top},
+        {x: bounds.right - parentBounds.left, y: bounds.top - parentBounds.top},
+        {x: bounds.right - parentBounds.left, y: bounds.bottom - parentBounds.top},
+        {x: bounds.left - parentBounds.left, y: bounds.bottom - parentBounds.top},
+    ];
+
+    for (let i = 0; i < 4; i++) {
+        const {x, y} = positions[i];
+        const handle = handles[i];
+        handle.style.left = (x - 6) + 'px';
+        handle.style.top = (y - 6) + 'px';
+    }
+}
\ No newline at end of file
index 0bc381528ca04d357005306621a65e11788ac46e..5620ada5bc15cd3caa1770f7d024f2d82525c45c 100644 (file)
@@ -139,7 +139,9 @@ const image = {
     attrs: {
         src: {},
         alt: {default: null},
-        title: {default: null}
+        title: {default: null},
+        height: {default: null},
+        width: {default: null},
     },
     group: "inline",
     draggable: true,
@@ -148,7 +150,9 @@ const image = {
             return {
                 src: dom.getAttribute("src"),
                 title: dom.getAttribute("title"),
-                alt: dom.getAttribute("alt")
+                alt: dom.getAttribute("alt"),
+                height: dom.getAttribute("height"),
+                width: dom.getAttribute("width"),
             }
         }
     }],
@@ -157,7 +161,9 @@ const image = {
         const src = ref.src;
         const alt = ref.alt;
         const title = ref.title;
-        return ["img", {src: src, alt: alt, title: title}]
+        const width = ref.width;
+        const height = ref.height;
+        return ["img", {src, alt, title, width, height}]
     }
 };
 
index c2f93d4eb698b30804b54f328a815c48b70d7eab..6a74068b81b9ce1886cf7e03175e5a37de8f3be5 100644 (file)
@@ -452,4 +452,46 @@ img.ProseMirror-separator {
   input {
     margin: 0 $-s;
   }
+}
+
+.ProseMirror-imagewrap {
+  display: inline-block;
+  line-height: 0;
+  font-size: 0;
+  position: relative;
+}
+.ProseMirror-imagewrap.ProseMirror-selectednode {
+  outline: 0;
+}
+
+.ProseMirror img[data-show-handles] {
+  outline: 4px solid #000;
+}
+.ProseMirror-dragdummy {
+  position: absolute;
+  z-index: 2;
+  left: 0;
+  top: 0;
+  max-width: none !important;
+  max-height: none !important;
+}
+.ProseMirror-grabhandle {
+  width: 12px;
+  height: 12px;
+  border: 2px solid #000;
+  z-index: 4;
+  position: absolute;
+  background-color: #FFF;
+}
+.ProseMirror-grabhandle-left-top {
+  cursor: nw-resize;
+}
+.ProseMirror-grabhandle-right-top {
+  cursor: ne-resize;
+}
+.ProseMirror-grabhandle-right-bottom {
+  cursor: se-resize;
+}
+.ProseMirror-grabhandle-left-bottom {
+  cursor: sw-resize;
 }
\ No newline at end of file