Skip to content

Commit

Permalink
feat(extension-iframe): make iframe resizable (remirror#999)
Browse files Browse the repository at this point in the history
  • Loading branch information
ocavue committed Jul 24, 2021
1 parent 98da3e0 commit c48193a
Show file tree
Hide file tree
Showing 33 changed files with 1,783 additions and 272 deletions.
10 changes: 10 additions & 0 deletions .changeset/wild-turkeys-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'prosemirror-resizable-view': major
'@remirror/core-utils': minor
'@remirror/extension-embed': minor
'@remirror/extension-image': minor
---

- Refactor of `ResizableNodeView`
- Add resizable ability to iframe by extending `ResizableNodeView`
- Add `setStyle` to `core-utils`
1 change: 1 addition & 0 deletions packages/prosemirror-resizable-view/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"dependencies": {
"@babel/runtime": "^7.13.10",
"@remirror/core-helpers": "^1.0.1",
"@remirror/core-utils": "^1.0.1",
"prosemirror-model": "^1.14.2",
"prosemirror-view": "^1.18.10"
},
Expand Down
312 changes: 199 additions & 113 deletions packages/prosemirror-resizable-view/src/prosemirror-resizable-view.ts
Original file line number Diff line number Diff line change
@@ -1,172 +1,258 @@
import { Node as ProsemirrorNode } from 'prosemirror-model';
import { EditorView, NodeView } from 'prosemirror-view';
import { isNumber, isString, throttle } from '@remirror/core-helpers';
import { throttle } from '@remirror/core-helpers';
import { setStyle } from '@remirror/core-utils';

type CreateElement = (props: {
node: ProsemirrorNode;
view: EditorView;
getPos: () => number;
}) => HTMLElement;
import { ResizableHandle, ResizableHandleType } from './resizable-view-handle';

export enum ResizableRatioType {
Fixed,
Flexible,
}

interface OptionShape {
[key: string]: any;
}
/**
* ResizableNodeView is a base NodeView for resizable element. You can resize the
* DOM element by dragging the handle over the image.
* ResizableNodeView serves as a base NodeView for resizable element,
* and cannot be directly instantiated.
* With this base NodeView, you can resize the DOM element by dragging the handle over the image.
*
* @param node - the node which uses this nodeView. Must have `width` and `height` in the attrs.
* @param view - the editor view used by this nodeView.
* @param getPos - a utility method to get the absolute cursor position of the node
* @param createElement - a function to get the inner DOM element for this prosemirror node
* @param getPos - a utility method to get the absolute cursor position of the node.
* @param aspectRatio? - to determine which type of aspect ratio should be used.
* @param options? - extra options to pass to `createElement` method.
* @param initialSize? - initial view size.
*/
export class ResizableNodeView implements NodeView {
export abstract class ResizableNodeView implements NodeView {
dom: HTMLElement;
readonly inner: HTMLElement;
node: ProsemirrorNode;
#element: HTMLElement;
#node: ProsemirrorNode;
#destroyList: Array<() => void> = [];
readonly aspectRatio: ResizableRatioType;

// cache the current element's size so that we can compare with new node's
// size when `update` method is called.
width = '';
#width = '';
#height = '';

constructor({
node,
view,
getPos,
createElement,
aspectRatio = ResizableRatioType.Fixed,
options,
initialSize,
}: {
node: ProsemirrorNode;
view: EditorView;
getPos: () => number;
createElement: CreateElement;
aspectRatio?: ResizableRatioType;
options?: OptionShape;
initialSize?: { width: number; height: number };
}) {
const outer = document.createElement('div');
outer.style.position = 'relative';
outer.style.width = node.attrs.width;
outer.style.maxWidth = '100%';
outer.style.minWidth = '50px';
outer.style.display = 'inline-block';
outer.style.lineHeight = '0'; // necessary so the bottom right handle is aligned nicely
outer.style.transition = 'width 0.15s ease-out, height 0.15s ease-out'; // make sure transition time is larger then mousemove event's throttle time

const inner = createElement({ node, view, getPos });

const handle = document.createElement('div');
handle.style.position = 'absolute';
handle.style.bottom = '0px';
handle.style.right = '0px';
handle.style.width = '16px';
handle.style.height = '16px';
handle.style.borderBottom = handle.style.borderRight = '4px solid #000';
handle.style.display = 'none';
handle.style.zIndex = '100';
handle.style.cursor = 'nwse-resize';

const setHandleDisplay = (visible?: true | string) => {
visible = visible || handle.dataset.mouseover || handle.dataset.dragging;
handle.style.display = visible ? '' : 'none';
};

outer.addEventListener('mouseover', () => {
handle.dataset.mouseover = 'true';
setHandleDisplay(true);
});
const outer = this.createWrapper(node, initialSize);
const element = this.createElement({ node, view, getPos, options });

/**
* Only initialize `handleBottom`, `handleBottomRight`, and `handleBottomLeft` in
* `ResizableRatioType.Flexible` mode
*/
const types =
aspectRatio === ResizableRatioType.Flexible
? [
ResizableHandleType.Right,
ResizableHandleType.Left,
ResizableHandleType.Bottom,
ResizableHandleType.BottomRight,
ResizableHandleType.BottomLeft,
]
: [ResizableHandleType.Right, ResizableHandleType.Left];

const handles = types.map((type) => new ResizableHandle(type));

for (const handle of handles) {
const handler = (e: MouseEvent) => {
this.resizeHandler(e, view, getPos, handle);
};
handle.dom.addEventListener('mousedown', handler);
this.#destroyList.push(() => handle.dom.removeEventListener('mousedown', handler));
outer.append(handle.dom);
}

outer.addEventListener('mouseout', () => {
handle.dataset.mouseover = '';
setHandleDisplay();
});
const setHandleVisibe = () => {
handles.forEach((handle) => handle.setHandleVisibility(true));
};
const setHandleInvisible = () => {
handles.forEach((handle) => handle.setHandleVisibility(false));
};

handle.addEventListener('mousedown', (e: MouseEvent) => {
e.preventDefault();
outer.addEventListener('mouseover', setHandleVisibe);
outer.addEventListener('mouseout', setHandleInvisible);
this.#destroyList.push(
() => outer.removeEventListener('mouseover', setHandleVisibe),
() => outer.removeEventListener('mouseout', setHandleInvisible),
);

handle.dataset.dragging = 'true';
outer.append(element);

const startX = e.pageX;
this.dom = outer;
this.#node = node;
this.#element = element;
this.aspectRatio = aspectRatio;
}

const startWidth = getWidthFromNode(this.node) || getSizeFromDom(inner)[0];
/**
* `createElement` - a method to produce the element DOM element for this prosemirror node.
* The subclasses have to implement this abstract method.
*/
abstract createElement(props: {
node: ProsemirrorNode;
view: EditorView;
getPos: () => number;
options?: OptionShape;
}): HTMLElement;

const onMouseMove = throttle(100, false, (e: MouseEvent) => {
const currentX = e.pageX;
createWrapper(
node: ProsemirrorNode,
initialSize?: { width: number; height: number },
): HTMLElement {
const outer = document.createElement('div');
outer.classList.add('remirror-resizable-view');
outer.style.position = 'relative';

const diffX = currentX - startX;
outer.style.width = `${startWidth + diffX}px`;
if (initialSize) {
setStyle(outer, {
width: `${initialSize.width}px`,
height: `${initialSize.height}px`,
});
} else {
setStyle(outer, {
width: node.attrs.width,
height: node.attrs.height,
});
}

const onMouseUp = (e: MouseEvent) => {
e.preventDefault();

handle.dataset.dragging = '';
setHandleDisplay();

document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
setStyle(outer, {
maxWidth: '100%',
minWidth: '50px',
display: 'inline-block',
lineHeight: '0', // necessary so the bottom right handle is aligned nicely
transition: 'width 0.15s ease-out, height 0.15s ease-out', // make sure transition time is larger then mousemove event's throttle time
});

const transaction = view.state.tr.setNodeMarkup(getPos(), undefined, {
src: node.attrs.src,
width: outer.style.width,
});
return outer;
}

this.width = outer.style.width;
resizeHandler(
e: MouseEvent,
view: EditorView,
getPos: () => number,
handle: ResizableHandle,
): void {
e.preventDefault();
handle.dataSetDragging(true);
this.#element.style.pointerEvents = 'none';

const startX = e.pageX;
const startY = e.pageY;
const startWidth = this.#element?.getBoundingClientRect().width || 0;
const startHeight = this.#element?.getBoundingClientRect().height || 0;

const onMouseMove = throttle(100, false, (e: MouseEvent) => {
const currentX = e.pageX;
const currentY = e.pageY;
const diffX = currentX - startX;
const diffY = currentY - startY;

switch (handle.type) {
case ResizableHandleType.Right:
this.dom.style.width = `${startWidth + diffX}px`;
break;
case ResizableHandleType.Left:
this.dom.style.width = `${startWidth - diffX}px`;
break;
case ResizableHandleType.Bottom:
this.dom.style.height = `${startHeight + diffY}px`;
break;
case ResizableHandleType.BottomRight:
this.dom.style.width = `${startWidth + diffX}px`;
this.dom.style.height = `${startHeight + diffY}px`;

break;
case ResizableHandleType.BottomLeft:
this.dom.style.width = `${startWidth - diffX}px`;
this.dom.style.height = `${startHeight + diffY}px`;
break;
}
});

view.dispatch(transaction);
};
const onMouseUp = (e: MouseEvent) => {
e.preventDefault();
handle.dataSetDragging(false);
handle.setHandleVisibility(false);
this.#element.style.pointerEvents = 'auto';

document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
const transaction = view.state.tr.setNodeMarkup(getPos(), undefined, {
src: this.#node.attrs.src,
width: this.dom.style.width,
height: this.dom.style.height,
});

document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
this.#width = this.dom.style.width;
this.#height = this.dom.style.height;

outer.append(handle);
outer.append(inner);
view.dispatch(transaction);
};

this.dom = outer;
this.inner = inner;
this.node = node;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}

/**
* `update` will be called by Prosemirror, when the view is updating itself.
*/
update(node: ProsemirrorNode): boolean {
if (node.type !== this.node.type) {
if (node.type !== this.#node.type) {
return false;
}

if (node.attrs.width && node.attrs.width !== this.width) {
if (
this.aspectRatio === ResizableRatioType.Fixed &&
node.attrs.width &&
node.attrs.width !== this.#width
) {
return false;
}

if (!isEqualWithoutAttrs(this.node, node, ['width'])) {
if (
this.aspectRatio === ResizableRatioType.Flexible &&
node.attrs.width &&
node.attrs.height &&
node.attrs.width !== this.#width &&
node.attrs.height !== this.#height
) {
return false;
}

node.attrs.width = this.node = node;
this.width = node.attrs.width;

this.inner.style.width = node.attrs.width;
return true;
}
}

function getWidthFromNode(node: ProsemirrorNode): number {
const width = node.attrs.width;
if (!isEqualWithoutAttrs(this.#node, node, ['width', 'height'])) {
return false;
}

if (isNumber(width)) {
return width;
}
this.#node = node;

if (isString(width)) {
const w = width.match(/(\d+)px/)?.[0];
this.#width = node.attrs.width;
this.#height = node.attrs.height;

if (w) {
return Number.parseFloat(w);
}
return true;
}

return 0;
}

function getSizeFromDom(element: HTMLElement | null): [number, number] {
if (!element) {
return [0, 0];
destroy(): void {
this.#destroyList.forEach((removeEventListener) => removeEventListener());
}

const rect = element.getBoundingClientRect();
return [rect.width, rect.height];
}

// Check if two nodes are equal by ignore some attributes
Expand Down
Loading

0 comments on commit c48193a

Please sign in to comment.