Skip to content

Commit

Permalink
Merge pull request #1520 from microsoft/u/juliaroldi/shadow-dom-image…
Browse files Browse the repository at this point in the history
…-wrapper

Shadow dom image wrapper
  • Loading branch information
juliaroldi authored Jan 23, 2023
2 parents 7103ea7 + 82779a5 commit af3d4ea
Show file tree
Hide file tree
Showing 3 changed files with 89 additions and 97 deletions.
2 changes: 1 addition & 1 deletion packages/roosterjs-editor-dom/lib/utils/createElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const KnownCreateElementData: Record<KnownCreateElementDataIndex, CreateE
},
[KnownCreateElementDataIndex.ImageEditWrapper]: {
tag: 'span',
style: 'max-width:100%;position:fixed',
style: 'max-width:100%;vertical-align:bottom',
children: [
{
tag: 'div',
Expand Down
153 changes: 59 additions & 94 deletions packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,22 @@ import DragAndDropContext, { DNDDirectionX, DnDDirectionY } from './types/DragAn
import DragAndDropHandler from '../../pluginUtils/DragAndDropHandler';
import DragAndDropHelper from '../../pluginUtils/DragAndDropHelper';
import getGeneratedImageSize from './editInfoUtils/getGeneratedImageSize';
import getLatestZIndex from './editInfoUtils/getLastZIndex';
import ImageEditInfo from './types/ImageEditInfo';
import ImageHtmlOptions from './types/ImageHtmlOptions';
import { Cropper, getCropHTML } from './imageEditors/Cropper';
import { deleteEditInfo, getEditInfoFromImage } from './editInfoUtils/editInfo';
import { getRotateHTML, Rotator } from './imageEditors/Rotator';
import { getRotateHTML, Rotator, updateRotateHandlePosition } from './imageEditors/Rotator';
import { ImageEditElementClass } from './types/ImageEditElementClass';
import {
arrayPush,
Browser,
createElement,
getComputedStyle,
getObjectKeys,
removeGlobalCssStyle,
safeInstanceOf,
setGlobalCssStyles,
toArray,
unwrap,
wrap,
} from 'roosterjs-editor-dom';
import {
Resizer,
Expand Down Expand Up @@ -126,9 +125,14 @@ export default class ImageEdit implements EditorPlugin {
private wasResized: boolean;

/**
* Editor zoom scale
* The span element that wraps the image and opens shadow dom
*/
private zoomWrapper: HTMLElement;
private shadowSpan: HTMLSpanElement;

/**
* The span element that wraps the image and opens shadow dom
*/
private isCropping: boolean;

/**
* Create a new instance of ImageEdit
Expand Down Expand Up @@ -163,7 +167,14 @@ export default class ImageEdit implements EditorPlugin {
*/
initialize(editor: IEditor) {
this.editor = editor;
this.disposer = editor.addDomEventHandler('blur', this.onBlur);
this.disposer = editor.addDomEventHandler({
blur: () => this.onBlur,
dragstart: e => {
if (this.image) {
e.preventDefault();
}
},
});
}

/**
Expand Down Expand Up @@ -191,7 +202,16 @@ export default class ImageEdit implements EditorPlugin {
}
break;
case PluginEventType.MouseDown:
this.setEditingImage(null);
// When left click in a image that already in editing mode, do not quit edit mode
const mouseTarget = e.rawEvent.target;
const button = e.rawEvent.button;
if (
this.shadowSpan !== mouseTarget ||
(this.shadowSpan === mouseTarget && button !== 0) ||
this.isCropping
) {
this.setEditingImage(null);
}
break;
case PluginEventType.KeyDown:
this.setEditingImage(null);
Expand All @@ -207,9 +227,8 @@ export default class ImageEdit implements EditorPlugin {
deleteEditInfo(img as HTMLImageElement);
});
break;

case PluginEventType.Scroll:
this.setEditingImage(null);
case PluginEventType.BeforeDispose:
this.removeWrapper();
break;
}
}
Expand Down Expand Up @@ -277,6 +296,7 @@ export default class ImageEdit implements EditorPlugin {
this.editInfo = null;
this.lastSrc = null;
this.clonedImage = null;
this.isCropping = false;
}

if (!this.image && image?.isContentEditable) {
Expand Down Expand Up @@ -307,7 +327,6 @@ export default class ImageEdit implements EditorPlugin {
];

this.editor.select(this.image);
this.toggleImageVisibility(this.image, false /** showImage */);
}
}

Expand All @@ -317,7 +336,6 @@ export default class ImageEdit implements EditorPlugin {
private onBlur = () => {
this.setEditingImage(null, true);
};

/**
* Create editing wrapper for the image
*/
Expand All @@ -330,13 +348,6 @@ export default class ImageEdit implements EditorPlugin {
this.image.ownerDocument
) as HTMLSpanElement;
this.wrapper.firstChild.appendChild(this.clonedImage);

// keep the same vertical align
const originalVerticalAlign = getStylePropertyValue(this.image, 'vertical-align');
if (originalVerticalAlign) {
this.wrapper.style.verticalAlign = originalVerticalAlign;
}

this.wrapper.style.display = Browser.isSafari ? 'inline-block' : 'inline-flex';

// Cache current src so that we can compare it after edit see if src is changed
Expand All @@ -345,7 +356,6 @@ export default class ImageEdit implements EditorPlugin {
// Set image src to original src to help show editing UI, also it will be used when regenerate image dataURL after editing
this.clonedImage.src = this.editInfo.src;
this.clonedImage.style.position = 'absolute';
this.clonedImage.style.maxWidth = null;

// Get HTML for all edit elements (resize handle, rotate handle, crop handle and overlay, ...) and create HTML element
const options: ImageHtmlOptions = {
Expand Down Expand Up @@ -373,44 +383,29 @@ export default class ImageEdit implements EditorPlugin {
this.wrapper.appendChild(element);
}
});

this.insertImageWrapper(this.editor, this.image, this.wrapper, this.editor.getZoomScale());
this.insertImageWrapper(this.wrapper);
}

private toggleImageVisibility(image: HTMLImageElement, showImage: boolean) {
const editorId = this.editor.getEditorDomAttribute('id');
const doc = this.editor.getDocument();
const editingId = 'editingId' + editorId;
if (showImage) {
removeGlobalCssStyle(doc, editingId);
} else {
const cssRule = `#${editorId} #${image.id} {visibility: hidden}`;
setGlobalCssStyles(doc, cssRule, editingId);
}
}
private insertImageWrapper(wrapper: HTMLSpanElement) {
this.shadowSpan = wrap(this.image, 'span');
const shadowRoot = this.shadowSpan.attachShadow({
mode: 'open',
});

private insertImageWrapper(
editor: IEditor,
image: HTMLImageElement,
wrapper: HTMLSpanElement,
scale: number
) {
this.zoomWrapper = copyElementRect(image, createZoomWrapper(editor, wrapper, scale));
this.zoomWrapper.style.zIndex = `${getLatestZIndex(editor.getScrollContainer()) + 1}`;
this.editor.getDocument().body.appendChild(this.zoomWrapper);
this.shadowSpan.style.verticalAlign = 'bottom';

shadowRoot.appendChild(wrapper);
}

/**
* Remove the temp wrapper of the image
*/
private removeWrapper = () => {
const doc = this.editor.getDocument();
if (this.zoomWrapper && doc.body?.contains(this.zoomWrapper)) {
doc.body?.removeChild(this.zoomWrapper);
this.toggleImageVisibility(this.image, true /** showImage */);
if (this.editor.contains(this.image) && this.wrapper) {
unwrap(this.image.parentNode);
}
this.wrapper = null;
this.zoomWrapper = null;
this.shadowSpan = null;
};

/**
Expand All @@ -424,10 +419,12 @@ export default class ImageEdit implements EditorPlugin {
const cropContainers = getEditElements(wrapper, ImageEditElementClass.CropContainer);
const cropOverlays = getEditElements(wrapper, ImageEditElementClass.CropOverlay);
const resizeHandles = getEditElements(wrapper, ImageEditElementClass.ResizeHandle);
const rotateCenter = getEditElements(wrapper, ImageEditElementClass.RotateCenter)[0];
const rotateHandle = getEditElements(wrapper, ImageEditElementClass.RotateHandle)[0];
const cropHandles = getEditElements(wrapper, ImageEditElementClass.CropHandle);

// Cropping and resizing will show different UI, so check if it is cropping here first
const isCropping = cropContainers.length == 1 && cropOverlays.length == 4;
this.isCropping = cropContainers.length == 1 && cropOverlays.length == 4;
const {
angleRad,
bottomPercent,
Expand All @@ -444,7 +441,7 @@ export default class ImageEdit implements EditorPlugin {
originalHeight,
visibleWidth,
visibleHeight,
} = getGeneratedImageSize(this.editInfo, isCropping);
} = getGeneratedImageSize(this.editInfo, this.isCropping);
const marginHorizontal = (targetWidth - visibleWidth) / 2;
const marginVertical = (targetHeight - visibleHeight) / 2;
const cropLeftPx = originalWidth * leftPercent;
Expand All @@ -457,9 +454,8 @@ export default class ImageEdit implements EditorPlugin {
wrapper.style.height = getPx(visibleHeight);
wrapper.style.margin = `${marginVertical}px ${marginHorizontal}px`;
wrapper.style.transform = `rotate(${angleRad}rad)`;
this.zoomWrapper.style.width = getPx(visibleWidth);
this.zoomWrapper.style.height = getPx(visibleHeight);
fitImageContainer(this.editor, this.zoomWrapper, angleRad);
this.wrapper.style.width = getPx(visibleWidth);
this.wrapper.style.height = getPx(visibleHeight);

// Update the text-alignment to avoid the image to overflow if the parent element have align center or right
// or if the direction is Right To Left
Expand All @@ -469,7 +465,7 @@ export default class ImageEdit implements EditorPlugin {
this.clonedImage.style.width = getPx(originalWidth);
this.clonedImage.style.height = getPx(originalHeight);

if (isCropping) {
if (this.isCropping) {
// For crop, we also need to set position of the overlays
setSize(
cropContainers[0],
Expand All @@ -484,6 +480,7 @@ export default class ImageEdit implements EditorPlugin {
setSize(cropOverlays[1], undefined, 0, 0, cropBottomPx, cropRightPx, undefined);
setSize(cropOverlays[2], cropLeftPx, undefined, 0, 0, undefined, cropBottomPx);
setSize(cropOverlays[3], 0, cropTopPx, undefined, 0, cropLeftPx, undefined);

updateHandleCursor(cropHandles, angleRad);
} else {
// For rotate/resize, set the margin of the image so that cropped part won't be visible
Expand All @@ -504,6 +501,14 @@ export default class ImageEdit implements EditorPlugin {
this.updateWrapper();
}

updateRotateHandlePosition(
this.editInfo,
this.editor.getVisibleViewport(),
marginVertical,
rotateCenter,
rotateHandle
);

updateHandleCursor(resizeHandles, angleRad);
}
}
Expand Down Expand Up @@ -645,43 +650,3 @@ function getColorString(color: string | ModeIndependentColor, isDarkMode: boolea
}
return isDarkMode ? color.darkModeColor.trim() : color.lightModeColor.trim();
}

function fitImageContainer(editor: IEditor, zoomWrapper: HTMLElement, angle: number) {
const angleIndex = handleRadIndexCalculator(angle);
const isVertical = (angleIndex >= 2 && angleIndex < 4) || angleIndex >= 6;
const editorTop = editor.getScrollContainer()?.getBoundingClientRect()?.top;
const { top, width, height } = zoomWrapper?.getBoundingClientRect();
if (editorTop > top) {
const rotatePercent = 100 * Math.abs(angle);
const zoomWrapperHeight = editorTop - top;
const zoomWrapperHeightPercent = isVertical
? rotatePercent * (zoomWrapperHeight / width)
: 100 * (zoomWrapperHeight / height);

zoomWrapper.style.clipPath = `polygon(0 ${zoomWrapperHeightPercent}%, 100% ${zoomWrapperHeightPercent}%, 100% ${
isVertical ? rotatePercent : '100'
}%, 0 ${isVertical ? rotatePercent : '100'}%)`;
}
}

function copyElementRect(originalElement: HTMLElement, element: HTMLElement) {
const { top, left, right, bottom } = originalElement.getBoundingClientRect();
element.style.top = `${top}px`;
element.style.bottom = `${bottom}px`;
element.style.right = `${right}px`;
element.style.left = `${left}px`;
return element;
}

function createZoomWrapper(editor: IEditor, wrapper: HTMLSpanElement, scale: number) {
const zoomWrapper = editor.getDocument().createElement('div');
zoomWrapper.style.transform = `scale(${scale || 1})`;
zoomWrapper.style.transformOrigin = 'top left';
zoomWrapper.style.position = 'fixed';
zoomWrapper.appendChild(wrapper);
return zoomWrapper;
}

function getStylePropertyValue(element: HTMLElement, property: string): string {
return element.ownerDocument.defaultView.getComputedStyle(element).getPropertyValue(property);
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import DragAndDropContext from '../types/DragAndDropContext';
import DragAndDropHandler from '../../../pluginUtils/DragAndDropHandler';
import ImageEditInfo, { RotateInfo } from '../types/ImageEditInfo';
import ImageHtmlOptions from '../types/ImageHtmlOptions';
import { CreateElementData } from 'roosterjs-editor-types';
import { CreateElementData, Rect } from 'roosterjs-editor-types';
import { ImageEditElementClass } from '../types/ImageEditElementClass';
import { RotateInfo } from '../types/ImageEditInfo';

const ROTATE_SIZE = 32;
const ROTATE_GAP = 15;
Expand Down Expand Up @@ -39,6 +39,33 @@ export const Rotator: DragAndDropHandler<DragAndDropContext, RotateInfo> = {
},
};

/**
* @internal
* Move rotate handle. When image is very close to the border of editor, rotate handle may not be visible.
* Fix it by reduce the distance from image to rotate handle
*/
export function updateRotateHandlePosition(
editInfo: ImageEditInfo,
editorRect: Rect,
marginVertical: number,
rotateCenter: HTMLElement,
rotateHandle: HTMLElement
) {
const top = rotateHandle.getBoundingClientRect()?.top - editorRect?.top;
const { angleRad, heightPx } = editInfo;
const cosAngle = Math.cos(angleRad);
const adjustedDistance =
cosAngle <= 0
? Number.MAX_SAFE_INTEGER
: (top + heightPx / 2 + marginVertical) / cosAngle - heightPx / 2;

const rotateGap = Math.max(Math.min(ROTATE_GAP, adjustedDistance), 0);
const rotateTop = Math.max(Math.min(ROTATE_SIZE, adjustedDistance - rotateGap), 0);
rotateCenter.style.top = -rotateGap + 'px';
rotateCenter.style.height = rotateGap + 'px';
rotateHandle.style.top = -rotateTop + 'px';
}

/**
* @internal
* Get HTML for rotate elements, including the rotate handle with icon, and a line between the handle and the image
Expand Down

0 comments on commit af3d4ea

Please sign in to comment.