From 7125530e558507b7105886f11b0be2e93c01932b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 17 Jan 2022 17:43:16 +0000 Subject: [PATCH] Added image resizing via drag handles --- TODO | 7 +- resources/js/editor/ProseMirrorView.js | 4 +- resources/js/editor/markdown-serializer.js | 2 +- resources/js/editor/node-views/ImageView.js | 198 ++++++++++++++++++ resources/js/editor/node-views/index.js | 7 + .../js/editor/node-views/node-view-utils.js | 58 +++++ resources/js/editor/schema-nodes.js | 12 +- resources/sass/_editor.scss | 42 ++++ 8 files changed, 324 insertions(+), 6 deletions(-) create mode 100644 resources/js/editor/node-views/ImageView.js create mode 100644 resources/js/editor/node-views/index.js create mode 100644 resources/js/editor/node-views/node-view-utils.js diff --git a/TODO b/TODO index ce7cdbf07..d8d562c66 100644 --- a/TODO +++ b/TODO @@ -1,10 +1,15 @@ +### Next + +// + ### In-Progress +// + ### Features - Tables - Images - - Image Resizing in editor - Drawings - LTR/RTL control - Fullscreen diff --git a/resources/js/editor/ProseMirrorView.js b/resources/js/editor/ProseMirrorView.js index 69177b63a..63a47dc35 100644 --- a/resources/js/editor/ProseMirrorView.js +++ b/resources/js/editor/ProseMirrorView.js @@ -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, }); } diff --git a/resources/js/editor/markdown-serializer.js b/resources/js/editor/markdown-serializer.js index 2edc1ef27..8e7da7d91 100644 --- a/resources/js/editor/markdown-serializer.js +++ b/resources/js/editor/markdown-serializer.js @@ -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 index 000000000..b283d8dd9 --- /dev/null +++ b/resources/js/editor/node-views/ImageView.js @@ -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 index 000000000..997ab0803 --- /dev/null +++ b/resources/js/editor/node-views/index.js @@ -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 index 000000000..dc7ea9401 --- /dev/null +++ b/resources/js/editor/node-views/node-view-utils.js @@ -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 diff --git a/resources/js/editor/schema-nodes.js b/resources/js/editor/schema-nodes.js index 0bc381528..5620ada5b 100644 --- a/resources/js/editor/schema-nodes.js +++ b/resources/js/editor/schema-nodes.js @@ -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}] } }; diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index c2f93d4eb..6a74068b8 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -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