diff --git a/demo/scripts/controlsV2/demoButtons/createImageEditButtons.ts b/demo/scripts/controlsV2/demoButtons/createImageEditButtons.ts new file mode 100644 index 00000000000..f87e669a6ae --- /dev/null +++ b/demo/scripts/controlsV2/demoButtons/createImageEditButtons.ts @@ -0,0 +1,81 @@ +import { ImageEditor } from 'roosterjs-content-model-types'; +import type { RibbonButton } from '../roosterjsReact/ribbon'; + +/** + * @internal + * "Image Crop" button on the format ribbon + */ +function createImageCropButton(handler: ImageEditor): RibbonButton<'buttonNameCropImage'> { + return { + key: 'buttonNameCropImage', + unlocalizedText: 'Crop Image', + iconName: 'Crop', + isDisabled: formatState => + !formatState.canAddImageAltText || !handler.isOperationAllowed('crop'), + onClick: () => { + handler.cropImage(); + }, + }; +} + +const directions: Record = { + left: 'Left', + right: 'Right', +}; + +/** + * @internal + * "Image Rotate" button on the format ribbon + */ +function createImageRotateButton(handler: ImageEditor): RibbonButton<'buttonNameRotateImage'> { + return { + key: 'buttonNameRotateImage', + unlocalizedText: 'Rotate Image', + iconName: 'Rotate', + dropDownMenu: { + items: directions, + }, + isDisabled: formatState => !formatState.canAddImageAltText, + onClick: (_editor, direction) => { + const rotateDirection = direction as 'left' | 'right'; + const rad = degreeToRad(rotateDirection == 'left' ? 270 : 90); + handler.rotateImage(rad); + }, + }; +} + +const flipDirections: Record = { + horizontal: 'horizontal', + vertical: 'vertical', +}; + +/** + * @internal + * "Image Flip" button on the format ribbon + */ +function createImageFlipButton(handler: ImageEditor): RibbonButton<'buttonNameFlipImage'> { + return { + key: 'buttonNameFlipImage', + unlocalizedText: 'Flip Image', + iconName: 'ImagePixel', + dropDownMenu: { + items: flipDirections, + }, + isDisabled: formatState => !formatState.canAddImageAltText, + onClick: (_editor, flipDirection) => { + handler.flipImage(flipDirection as 'horizontal' | 'vertical'); + }, + }; +} + +export const createImageEditButtons = (handler: ImageEditor) => { + return [ + createImageCropButton(handler), + createImageRotateButton(handler), + createImageFlipButton(handler), + ]; +}; + +const degreeToRad = (degree: number) => { + return degree * (Math.PI / 180); +}; diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index a6923efd2be..64b7b88a527 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -5,13 +5,11 @@ import { ApiPlaygroundPlugin } from '../sidePane/apiPlayground/ApiPlaygroundPlug import { ContentModelPanePlugin } from '../sidePane/contentModel/ContentModelPanePlugin'; import { createEmojiPlugin } from '../roosterjsReact/emoji'; import { createImageEditMenuProvider } from '../roosterjsReact/contextMenu/menus/createImageEditMenuProvider'; -import { createLegacyPlugins } from '../plugins/createLegacyPlugins'; import { createListEditMenuProvider } from '../roosterjsReact/contextMenu/menus/createListEditMenuProvider'; import { createPasteOptionPlugin } from '../roosterjsReact/pasteOptions'; import { createRibbonPlugin, Ribbon, RibbonButton, RibbonPlugin } from '../roosterjsReact/ribbon'; import { darkModeButton } from '../demoButtons/darkModeButton'; import { Editor } from 'roosterjs-content-model-core'; -import { EditorAdapter } from 'roosterjs-editor-adapter'; import { EditorOptionsPlugin } from '../sidePane/editorOptions/EditorOptionsPlugin'; import { EventViewPlugin } from '../sidePane/eventViewer/EventViewPlugin'; import { exportContentButton } from '../demoButtons/exportContentButton'; @@ -54,6 +52,7 @@ import { CustomReplacePlugin, EditPlugin, HyperlinkPlugin, + ImageEditPlugin, MarkdownPlugin, PastePlugin, ShortcutPlugin, @@ -100,6 +99,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { private formatPainterPlugin: FormatPainterPlugin; private samplePickerPlugin: SamplePickerPlugin; private snapshots: Snapshots; + private imageEditPlugin: ImageEditPlugin; protected sidePane = React.createRef(); protected updateContentPlugin: UpdateContentPlugin; @@ -137,6 +137,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { this.ribbonPlugin = createRibbonPlugin(); this.formatPainterPlugin = new FormatPainterPlugin(); this.samplePickerPlugin = new SamplePickerPlugin(); + this.imageEditPlugin = new ImageEditPlugin(); this.state = { showSidePane: window.location.hash != '', @@ -288,7 +289,11 @@ export class MainPane extends React.Component<{}, MainPaneState> { private renderRibbon() { return ( @@ -310,14 +315,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { private resetEditor() { this.setState({ editorCreator: (div: HTMLDivElement, options: EditorOptions) => { - const legacyPluginList = createLegacyPlugins(this.state.initState); - - return legacyPluginList.length > 0 - ? new EditorAdapter(div, { - ...options, - legacyPlugins: legacyPluginList, - }) - : new Editor(div, options); + return new Editor(div, options); }, }); } @@ -505,13 +503,16 @@ export class MainPane extends React.Component<{}, MainPaneState> { pluginList.tableEdit && new TableEditPlugin(), pluginList.watermark && new WatermarkPlugin(watermarkText), pluginList.markdown && new MarkdownPlugin(markdownOptions), + pluginList.imageEditPlugin && this.imageEditPlugin, pluginList.emoji && createEmojiPlugin(), pluginList.pasteOption && createPasteOptionPlugin(), pluginList.sampleEntity && new SampleEntityPlugin(), pluginList.contextMenu && createContextMenuPlugin(), pluginList.contextMenu && listMenu && createListEditMenuProvider(), pluginList.contextMenu && tableMenu && createTableEditMenuProvider(), - pluginList.contextMenu && imageMenu && createImageEditMenuProvider(), + pluginList.contextMenu && + imageMenu && + createImageEditMenuProvider(this.imageEditPlugin), pluginList.hyperlink && new HyperlinkPlugin( linkTitle?.indexOf(UrlPlaceholder) >= 0 diff --git a/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts b/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts deleted file mode 100644 index a4fdd37cc88..00000000000 --- a/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { EditorPlugin as LegacyEditorPlugin } from 'roosterjs-editor-types'; -import { ImageEdit } from 'roosterjs-editor-plugins'; -import { LegacyPluginList, OptionState } from '../sidePane/editorOptions/OptionState'; - -export function createLegacyPlugins(initState: OptionState): LegacyEditorPlugin[] { - const { pluginList } = initState; - - const plugins: Record = { - imageEdit: pluginList.imageEdit - ? new ImageEdit({ - preserveRatio: initState.forcePreserveRatio, - applyChangesOnMouseUp: initState.applyChangesOnMouseUp, - }) - : null, - }; - - return Object.values(plugins).filter(x => !!x); -} diff --git a/demo/scripts/controlsV2/roosterjsReact/contextMenu/menus/createImageEditMenuProvider.tsx b/demo/scripts/controlsV2/roosterjsReact/contextMenu/menus/createImageEditMenuProvider.tsx index 3f56d9e07a6..01faeba620f 100644 --- a/demo/scripts/controlsV2/roosterjsReact/contextMenu/menus/createImageEditMenuProvider.tsx +++ b/demo/scripts/controlsV2/roosterjsReact/contextMenu/menus/createImageEditMenuProvider.tsx @@ -91,7 +91,7 @@ const ImageRotateMenuItem: ContextMenuItem { + onClick: (key, _editor, _node, _strings, _uiUtilities, imageEdit) => { switch (key) { case 'menuNameImageRotateLeft': imageEdit?.rotateImage(-Math.PI / 2); @@ -116,7 +116,7 @@ const ImageFlipMenuItem: ContextMenuItem { + onClick: (key, _editor, _node, _strings, _uiUtilities, imageEdit) => { switch (key) { case 'menuNameImageRotateFlipHorizontally': imageEdit?.flipImage('horizontal'); @@ -137,7 +137,7 @@ const ImageCropMenuItem: ContextMenuItem { + onClick: (_, _editor, _node, _strings, _uiUtilities, imageEdit) => { imageEdit?.cropImage(); }, }; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 321ac281c8e..53df2b411aa 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -18,11 +18,9 @@ const initialState: OptionState = { pasteOption: true, sampleEntity: true, markdown: true, + imageEditPlugin: true, hyperlink: true, customReplace: true, - - // Legacy plugins - imageEdit: false, }, defaultFormat: { fontFamily: 'Calibri', @@ -32,7 +30,6 @@ const initialState: OptionState = { linkTitle: 'Ctrl+Click to follow the link:' + UrlPlaceholder, watermarkText: 'Type content here ...', forcePreserveRatio: false, - applyChangesOnMouseUp: false, isRtl: false, disableCache: false, tableFeaturesContainerSelector: '#' + 'EditorContainer', diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index d2eb00a73e7..95dc798c720 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -2,10 +2,6 @@ import { AutoFormatOptions, CustomReplace, MarkdownOptions } from 'roosterjs-con import type { SidePaneElementProps } from '../SidePaneElement'; import type { ContentModelSegmentFormat, ExperimentalFeature } from 'roosterjs-content-model-types'; -export interface LegacyPluginList { - imageEdit: boolean; -} - export interface NewPluginList { autoFormat: boolean; edit: boolean; @@ -19,10 +15,11 @@ export interface NewPluginList { sampleEntity: boolean; markdown: boolean; hyperlink: boolean; + imageEditPlugin: boolean; customReplace: boolean; } -export interface BuildInPluginList extends LegacyPluginList, NewPluginList {} +export interface BuildInPluginList extends NewPluginList {} export interface OptionState { pluginList: BuildInPluginList; @@ -46,7 +43,6 @@ export interface OptionState { // Editor options isRtl: boolean; disableCache: boolean; - applyChangesOnMouseUp: boolean; experimentalFeatures: Set; } diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx index 369bce6fb77..6e775fa3a14 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx @@ -3,9 +3,9 @@ import { Code } from './Code'; import { DefaultFormatPane } from './DefaultFormatPane'; import { EditorCode } from './codes/EditorCode'; import { ExperimentalFeatures } from './ExperimentalFeatures'; -import { LegacyPlugins, Plugins } from './Plugins'; import { MainPane } from '../../mainPane/MainPane'; import { OptionPaneProps, OptionState } from './OptionState'; +import { Plugins } from './Plugins'; const htmlStart = '\n' + @@ -23,8 +23,6 @@ const htmlButtons = '\n'; '\n'; const jsCode = '\n'; -const legacyJsCode = - '\n\n'; const htmlEnd = '\n' + ''; export class OptionsPane extends React.Component { @@ -39,7 +37,7 @@ export class OptionsPane extends React.Component { } render() { const editorCode = new EditorCode(this.state); - const html = this.getHtml(editorCode.requireLegacyCode()); + const html = this.getHtml(); return (
@@ -58,12 +56,6 @@ export class OptionsPane extends React.Component { -
- - Legacy Plugins - - -
Experimental features @@ -136,7 +128,7 @@ export class OptionsPane extends React.Component { pluginList: { ...this.state.pluginList }, defaultFormat: { ...this.state.defaultFormat }, forcePreserveRatio: this.state.forcePreserveRatio, - applyChangesOnMouseUp: this.state.applyChangesOnMouseUp, + isRtl: this.state.isRtl, disableCache: this.state.disableCache, tableFeaturesContainerSelector: this.state.tableFeaturesContainerSelector, @@ -165,7 +157,7 @@ export class OptionsPane extends React.Component { let code = editor.getCode(); let json = { title: 'RoosterJs', - html: this.getHtml(editor.requireLegacyCode()), + html: this.getHtml(), head: '', js: code, js_pre_processor: 'typescript', @@ -188,9 +180,7 @@ export class OptionsPane extends React.Component { }, true); }; - private getHtml(requireLegacyCode: boolean) { - return `${htmlStart}${htmlButtons}${jsCode}${ - requireLegacyCode ? legacyJsCode : '' - }${htmlEnd}`; + private getHtml() { + return `${htmlStart}${htmlButtons}${jsCode}${htmlEnd}`; } } diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx index 383000d54cd..86f7f7f9f17 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx @@ -1,11 +1,6 @@ import * as React from 'react'; import { UrlPlaceholder } from './OptionState'; -import type { - BuildInPluginList, - LegacyPluginList, - NewPluginList, - OptionState, -} from './OptionState'; +import type { BuildInPluginList, NewPluginList, OptionState } from './OptionState'; const styles = require('./OptionsPane.scss'); @@ -101,29 +96,6 @@ abstract class PluginsBase extends Re }; } -export class LegacyPlugins extends PluginsBase { - private forcePreserveRatio = React.createRef(); - - render() { - return ( - - - {this.renderPluginItem( - 'imageEdit', - 'Image Edit Plugin', - this.renderCheckBox( - 'Force preserve ratio', - this.forcePreserveRatio, - this.props.state.forcePreserveRatio, - (state, value) => (state.forcePreserveRatio = value) - ) - )} - -
- ); - } -} - export class Plugins extends PluginsBase { private allowExcelNoBorderTable = React.createRef(); private listMenu = React.createRef(); @@ -292,6 +264,7 @@ export class Plugins extends PluginsBase { ) )} {this.renderPluginItem('customReplace', 'Custom Replace')} + {this.renderPluginItem('imageEditPlugin', 'ImageEditPlugin')} ); diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/EditorCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/EditorCode.ts index c103657ae82..aaa36c8c080 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/EditorCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/EditorCode.ts @@ -2,12 +2,11 @@ import { ButtonsCode } from './ButtonsCode'; import { CodeElement } from './CodeElement'; import { DarkModeCode } from './DarkModeCode'; import { DefaultFormatCode } from './DefaultFormatCode'; -import { LegacyPluginCode, PluginsCode } from './PluginsCode'; +import { PluginsCode } from './PluginsCode'; import type { OptionState } from '../OptionState'; export class EditorCode extends CodeElement { private plugins: PluginsCode; - private legacyPlugins: LegacyPluginCode; private defaultFormat: DefaultFormatCode; private buttons: ButtonsCode; private darkMode: DarkModeCode; @@ -16,35 +15,23 @@ export class EditorCode extends CodeElement { super(); this.plugins = new PluginsCode(state); - this.legacyPlugins = new LegacyPluginCode(state); this.defaultFormat = new DefaultFormatCode(state.defaultFormat); this.buttons = new ButtonsCode(); this.darkMode = new DarkModeCode(); } - requireLegacyCode() { - return this.legacyPlugins.getPluginCount() > 0; - } - getCode() { let defaultFormat = this.defaultFormat.getCode(); let code = "let contentDiv = document.getElementById('contentDiv');\n"; let darkMode = this.darkMode.getCode(); - const hasLegacyPlugin = this.legacyPlugins.getPluginCount() > 0; - code += `let plugins = ${this.plugins.getCode()};\n`; - code += hasLegacyPlugin ? `let legacyPlugins = ${this.legacyPlugins.getCode()};\n` : ''; code += defaultFormat ? `let defaultSegmentFormat = ${defaultFormat};\n` : ''; code += 'let options = {\n'; code += this.indent('plugins: plugins,\n'); - code += hasLegacyPlugin ? this.indent('legacyPlugins: legacyPlugins,\n') : ''; code += defaultFormat ? this.indent('defaultSegmentFormat: defaultSegmentFormat,\n') : ''; code += this.indent(`getDarkColor: ${darkMode},\n`); code += '};\n'; - code += `let editor = new ${ - hasLegacyPlugin ? 'roosterjsAdapter.EditorAdapter' : 'roosterjs.Editor' - }(contentDiv, options);\n`; code += this.buttons ? this.buttons.getCode() : ''; return code; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts index 1912151d5e7..63cbde5f675 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts @@ -1,16 +1,15 @@ import { AutoFormatCode } from './AutoFormatCode'; import { CodeElement } from './CodeElement'; -import { HyperLinkCode } from './HyperLinkCode'; import { MarkdownCode } from './MarkdownCode'; import { OptionState } from '../OptionState'; import { WatermarkCode } from './WatermarkCode'; import { EditPluginCode, - ImageEditCode, PastePluginCode, TableEditPluginCode, ShortcutPluginCode, + ImageEditPluginCode, } from './SimplePluginCode'; export class PluginsCodeBase extends CodeElement { @@ -45,17 +44,7 @@ export class PluginsCode extends PluginsCodeBase { pluginList.shortcut && new ShortcutPluginCode(), pluginList.watermark && new WatermarkCode(state.watermarkText), pluginList.markdown && new MarkdownCode(state.markdownOptions), - pluginList.hyperlink && new HyperLinkCode(state.linkTitle), + pluginList.imageEditPlugin && new ImageEditPluginCode(), ]); } } - -export class LegacyPluginCode extends PluginsCodeBase { - constructor(state: OptionState) { - const pluginList = state.pluginList; - - const plugins: CodeElement[] = [pluginList.imageEdit && new ImageEditCode()]; - - super(plugins); - } -} diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts index b910a90da7a..b078ab59a2a 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts @@ -34,8 +34,8 @@ export class TableEditPluginCode extends SimplePluginCode { } } -export class ImageEditCode extends SimplePluginCode { +export class ImageEditPluginCode extends SimplePluginCode { constructor() { - super('ImageEdit', 'roosterjsLegacy'); + super('ImageEditPlugin'); } } diff --git a/demo/scripts/controlsV2/tabs/ribbonButtons.ts b/demo/scripts/controlsV2/tabs/ribbonButtons.ts index c2879edc086..292b80b3875 100644 --- a/demo/scripts/controlsV2/tabs/ribbonButtons.ts +++ b/demo/scripts/controlsV2/tabs/ribbonButtons.ts @@ -10,6 +10,7 @@ import { changeImageButton } from '../demoButtons/changeImageButton'; import { clearFormatButton } from '../roosterjsReact/ribbon/buttons/clearFormatButton'; import { codeButton } from '../roosterjsReact/ribbon/buttons/codeButton'; import { createFormatPainterButton } from '../demoButtons/formatPainterButton'; +import { createImageEditButtons } from '../demoButtons/createImageEditButtons'; import { decreaseFontSizeButton } from '../roosterjsReact/ribbon/buttons/decreaseFontSizeButton'; import { decreaseIndentButton } from '../roosterjsReact/ribbon/buttons/decreaseIndentButton'; import { fontButton } from '../roosterjsReact/ribbon/buttons/fontButton'; @@ -21,6 +22,7 @@ import { imageBorderRemoveButton } from '../demoButtons/imageBorderRemoveButton' import { imageBorderStyleButton } from '../demoButtons/imageBorderStyleButton'; import { imageBorderWidthButton } from '../demoButtons/imageBorderWidthButton'; import { imageBoxShadowButton } from '../demoButtons/imageBoxShadowButton'; +import { ImageEditor } from 'roosterjs-content-model-types'; import { increaseFontSizeButton } from '../roosterjsReact/ribbon/buttons/increaseFontSizeButton'; import { increaseIndentButton } from '../roosterjsReact/ribbon/buttons/increaseIndentButton'; import { insertImageButton } from '../roosterjsReact/ribbon/buttons/insertImageButton'; @@ -191,7 +193,11 @@ const allButtons: RibbonButton[] = [ spaceAfterButton, pasteButton, ]; -export function getButtons(id: tabNames, formatPlainerPlugin?: FormatPainterPlugin) { +export function getButtons( + id: tabNames, + formatPlainerPlugin?: FormatPainterPlugin, + imageEditor?: ImageEditor +) { switch (id) { case 'text': return [createFormatPainterButton(formatPlainerPlugin), ...textButtons]; @@ -200,7 +206,9 @@ export function getButtons(id: tabNames, formatPlainerPlugin?: FormatPainterPlug case 'insert': return insertButtons; case 'image': - return imageButtons; + return imageEditor + ? [...imageButtons, ...createImageEditButtons(imageEditor)] + : imageButtons; case 'table': return tableButtons; case 'all': diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts index c35a0e516ca..2f9780cb1eb 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts @@ -19,11 +19,11 @@ import type { } from 'roosterjs-content-model-types'; /** - * Format content model at a given insert point with a callback function + * Invoke a callback to format the content in a specific position using Content Model * @param editor The editor object - * @param insertPoint The insert point to format - * @param callback The callback function to format the content model - * @param options Options to control the behavior of the formatting + * @param insertPoint The insert position. + * @param callback The callback to insert the format. + * @param options More options, @see FormatContentModelOptions */ export function formatInsertPointWithContentModel( editor: IEditor, diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/ensureImageHasSpanParent.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/ensureImageHasSpanParent.ts new file mode 100644 index 00000000000..7ede477d9a8 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/ensureImageHasSpanParent.ts @@ -0,0 +1,24 @@ +import { isElementOfType, isNodeOfType, wrap } from 'roosterjs-content-model-dom'; + +/** + * @internal + * Ensure image is wrapped by a span element + * @param image + * @returns the image + */ +export function ensureImageHasSpanParent(image: HTMLImageElement): HTMLImageElement { + const parent = image.parentElement; + + if ( + parent && + isNodeOfType(parent, 'ELEMENT_NODE') && + isElementOfType(parent, 'span') && + parent.firstChild == image && + parent.lastChild == image + ) { + return image; + } + + wrap(image.ownerDocument, image, 'span'); + return image; +} diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts index 44411024aa2..b547f8107f6 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts @@ -1,14 +1,9 @@ import { addRangeToSelection } from './addRangeToSelection'; +import { ensureImageHasSpanParent } from './ensureImageHasSpanParent'; import { ensureUniqueId } from '../setEditorStyle/ensureUniqueId'; import { findLastedCoInMergedCell } from './findLastedCoInMergedCell'; import { findTableCellElement } from './findTableCellElement'; -import { - isElementOfType, - isNodeOfType, - parseTableCells, - toArray, - wrap, -} from 'roosterjs-content-model-dom'; +import { isNodeOfType, parseTableCells, toArray } from 'roosterjs-content-model-dom'; import type { ParsedTable, SelectionChangedEvent, @@ -56,7 +51,9 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC core.api.setEditorStyle( core, DOM_SELECTION_CSS_KEY, - `outline-style:auto!important; outline-color:${imageSelectionColor}!important;`, + `outline-style:solid!important; outline-color:${imageSelectionColor}!important;display: ${ + core.environment.isSafari ? '-webkit-inline-flex' : 'inline-flex' + };`, [`span:has(>img#${ensureUniqueId(image, IMAGE_ID)})`] ); core.api.setEditorStyle( @@ -244,20 +241,3 @@ function setRangeSelection(doc: Document, element: HTMLElement | undefined, coll addRangeToSelection(doc, range, isReverted); } } - -function ensureImageHasSpanParent(image: HTMLImageElement): HTMLImageElement { - const parent = image.parentElement; - - if ( - parent && - isNodeOfType(parent, 'ELEMENT_NODE') && - isElementOfType(parent, 'span') && - parent.firstChild == image && - parent.lastChild == image - ) { - return image; - } - - wrap(image.ownerDocument, image, 'span'); - return image; -} diff --git a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts index 644455473f4..0a8fab6898d 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts @@ -66,6 +66,9 @@ describe('setDOMSelection', () => { lifecycle: { isDarkMode: false, }, + environment: { + isSafari: false, + }, } as any; }); @@ -307,7 +310,7 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', - 'outline-style:auto!important; outline-color:#DB626C!important;', + 'outline-style:solid!important; outline-color:#DB626C!important;display: inline-flex;', ['span:has(>img#image_0)'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( @@ -367,7 +370,7 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', - 'outline-style:auto!important; outline-color:red!important;', + 'outline-style:solid!important; outline-color:red!important;display: inline-flex;', ['span:has(>img#image_0)'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( @@ -434,7 +437,7 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( coreValue, '_DOMSelection', - 'outline-style:auto!important; outline-color:DarkColorMock-red!important;', + 'outline-style:solid!important; outline-color:DarkColorMock-red!important;display: inline-flex;', ['span:has(>img#image_0)'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( @@ -495,7 +498,7 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', - 'outline-style:auto!important; outline-color:#DB626C!important;', + 'outline-style:solid!important; outline-color:#DB626C!important;display: inline-flex;', ['span:has(>img#image_0)'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( @@ -556,7 +559,7 @@ describe('setDOMSelection', () => { expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', - 'outline-style:auto!important; outline-color:#DB626C!important;', + 'outline-style:solid!important; outline-color:#DB626C!important;display: inline-flex;', ['span:has(>img#image_0_0)'] ); expect(setEditorStyleSpy).toHaveBeenCalledWith( diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/unwrap.ts b/packages/roosterjs-content-model-dom/lib/domUtils/unwrap.ts index 93237574061..05a7e0532f5 100644 --- a/packages/roosterjs-content-model-dom/lib/domUtils/unwrap.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/unwrap.ts @@ -1,5 +1,4 @@ /** - * @internal * Removes the node and keep all children in place, return the parentNode where the children are attached * @param node the node to remove */ diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index f10c7245898..9a1f9e338c6 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -22,6 +22,7 @@ export { getObjectKeys } from './domUtils/getObjectKeys'; export { toArray } from './domUtils/toArray'; export { moveChildNodes, wrapAllChildNodes } from './domUtils/moveChildNodes'; export { wrap } from './domUtils/wrap'; +export { unwrap } from './domUtils/unwrap'; export { isEntityElement, findClosestEntityWrapper, diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateImageMetadata.ts b/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateImageMetadata.ts index bf5ca0bb4d0..e4af5626e99 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateImageMetadata.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateImageMetadata.ts @@ -1,5 +1,6 @@ import { getMetadata, updateMetadata } from './updateMetadata'; import { + createBooleanDefinition, createNumberDefinition, createObjectDefinition, createStringDefinition, @@ -10,8 +11,13 @@ import type { ReadonlyContentModelImage, } from 'roosterjs-content-model-types'; -const NumberDefinition = createNumberDefinition(); +const NumberDefinition = createNumberDefinition(true); +const BooleanDefinition = createBooleanDefinition(true); +/** + * @internal + * Definition of ImageMetadataFormat + */ const ImageMetadataFormatDefinition = createObjectDefinition>({ widthPx: NumberDefinition, heightPx: NumberDefinition, @@ -23,6 +29,8 @@ const ImageMetadataFormatDefinition = createObjectDefinition { + const cropper = createElement(data, doc); + if ( + cropper && + isNodeOfType(cropper, 'ELEMENT_NODE') && + isElementOfType(cropper, 'div') + ) { + return cropper; + } + }) + .filter(cropper => !!cropper) as HTMLDivElement[]; + return cropper; +} + +/** + * @internal + * Get HTML for crop elements, including 4 overlays (to show dark shadow), 1 container and 4 crop handles + */ +export function getCropHTML(): CreateElementData[] { + const overlayHTML: CreateElementData = { + tag: 'div', + style: 'position:absolute;background-color:rgb(0,0,0,0.5);pointer-events:none', + className: ImageEditElementClass.CropOverlay, + }; + const containerHTML: CreateElementData = { + tag: 'div', + style: 'position:absolute;overflow:hidden;inset:0px;', + className: ImageEditElementClass.CropContainer, + children: [], + }; + + if (containerHTML) { + XS_CROP.forEach(x => + YS_CROP.forEach(y => containerHTML.children?.push(getCropHTMLInternal(x, y))) + ); + } + return [containerHTML, overlayHTML, overlayHTML, overlayHTML, overlayHTML]; +} + +function getCropHTMLInternal(x: DNDDirectionX, y: DnDDirectionY): CreateElementData { + const leftOrRight = x == 'w' ? 'left' : 'right'; + const topOrBottom = y == 'n' ? 'top' : 'bottom'; + const rotation = ROTATION[y + x]; + + return { + tag: 'div', + className: ImageEditElementClass.CropHandle, + style: `position:absolute;pointer-events:auto;cursor:${y}${x}-resize;${leftOrRight}:0;${topOrBottom}:0;width:${CROP_HANDLE_SIZE}px;height:${CROP_HANDLE_SIZE}px;transform:rotate(${rotation}deg)`, + dataset: { x, y }, + children: getCropHandleHTML(), + }; +} + +function getCropHandleHTML(): CreateElementData[] { + const result: CreateElementData[] = []; + [0, 1].forEach(layer => + [0, 1].forEach(dir => { + result.push(getCropHandleHTMLInternal(layer, dir)); + }) + ); + return result; +} + +function getCropHandleHTMLInternal(layer: number, dir: number): CreateElementData { + const position = + dir == 0 + ? `right:${layer}px;height:${CROP_HANDLE_WIDTH - layer * 2}px;` + : `top:${layer}px;width:${CROP_HANDLE_WIDTH - layer * 2}px;`; + const bgColor = layer == 0 ? 'white' : 'black'; + + return { + tag: 'div', + style: `position:absolute;left:${layer}px;bottom:${layer}px;${position};background-color:${bgColor}`, + }; +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/Cropper/cropperContext.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/Cropper/cropperContext.ts new file mode 100644 index 00000000000..0013a869cb2 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/Cropper/cropperContext.ts @@ -0,0 +1,93 @@ +import { rotateCoordinate } from '../utils/imageEditUtils'; +import type { DragAndDropContext } from '../types/DragAndDropContext'; +import type { DragAndDropHandler } from '../../pluginUtils/DragAndDrop/DragAndDropHandler'; +import type { ImageCropMetadataFormat } from 'roosterjs-content-model-types'; + +/** + * @internal + * Crop handle for DragAndDropHelper + */ +export const Cropper: DragAndDropHandler = { + onDragStart: ({ editInfo }) => ({ ...editInfo }), + onDragging: ({ editInfo, x, y, options }, e, base, dx, dy) => { + [dx, dy] = rotateCoordinate(dx, dy, editInfo.angleRad ?? 0); + + const { + widthPx, + heightPx, + leftPercent, + rightPercent, + topPercent, + bottomPercent, + } = editInfo; + + if ( + leftPercent === undefined || + rightPercent === undefined || + topPercent === undefined || + bottomPercent === undefined || + base.leftPercent === undefined || + base.rightPercent === undefined || + base.topPercent === undefined || + base.bottomPercent === undefined || + widthPx === undefined || + heightPx === undefined + ) { + return false; + } + + const { minWidth, minHeight } = options; + const widthPercent = 1 - leftPercent - rightPercent; + const heightPercent = 1 - topPercent - bottomPercent; + + if ( + widthPercent > 0 && + heightPercent > 0 && + minWidth !== undefined && + minHeight !== undefined + ) { + const fullWidth = widthPx / widthPercent; + const fullHeight = heightPx / heightPercent; + const newLeft = + x != 'e' + ? crop(base.leftPercent, dx, fullWidth, rightPercent, minWidth) + : leftPercent; + const newRight = + x != 'w' + ? crop(base.rightPercent, -dx, fullWidth, leftPercent, minWidth) + : rightPercent; + const newTop = + y != 's' + ? crop(base.topPercent, dy, fullHeight, bottomPercent, minHeight) + : topPercent; + const newBottom = + y != 'n' + ? crop(base.bottomPercent, -dy, fullHeight, topPercent, minHeight) + : bottomPercent; + + editInfo.leftPercent = newLeft; + editInfo.rightPercent = newRight; + editInfo.topPercent = newTop; + editInfo.bottomPercent = newBottom; + editInfo.widthPx = fullWidth * (1 - newLeft - newRight); + editInfo.heightPx = fullHeight * (1 - newTop - newBottom); + + return true; + } else { + return false; + } + }, +}; + +function crop( + basePercentage: number, + deltaValue: number, + fullValue: number, + currentPercentage: number, + minValue: number +): number { + const maxValue = fullValue * (1 - currentPercentage) - minValue; + const newValue = fullValue * basePercentage + deltaValue; + const validValue = Math.max(Math.min(newValue, maxValue), 0); + return validValue / fullValue; +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts new file mode 100644 index 00000000000..6cccb46a8e4 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -0,0 +1,521 @@ +import { applyChange } from './utils/applyChange'; +import { canRegenerateImage } from './utils/canRegenerateImage'; +import { checkIfImageWasResized, isASmallImage } from './utils/imageEditUtils'; +import { createImageWrapper } from './utils/createImageWrapper'; +import { Cropper } from './Cropper/cropperContext'; +import { getDropAndDragHelpers } from './utils/getDropAndDragHelpers'; +import { getHTMLImageOptions } from './utils/getHTMLImageOptions'; +import { getSelectedImageMetadata } from './utils/updateImageEditInfo'; +import { ImageEditElementClass } from './types/ImageEditElementClass'; +import { Resizer } from './Resizer/resizerContext'; +import { Rotator } from './Rotator/rotatorContext'; +import { updateRotateHandle } from './Rotator/updateRotateHandle'; +import { updateWrapper } from './utils/updateWrapper'; +import { + getSelectedSegmentsAndParagraphs, + isElementOfType, + isNodeOfType, + mutateSegment, + unwrap, +} from 'roosterjs-content-model-dom'; +import type { DragAndDropHelper } from '../pluginUtils/DragAndDrop/DragAndDropHelper'; +import type { DragAndDropContext } from './types/DragAndDropContext'; +import type { ImageHtmlOptions } from './types/ImageHtmlOptions'; +import type { ImageEditOptions } from './types/ImageEditOptions'; +import type { + EditorPlugin, + IEditor, + ImageEditOperation, + ImageEditor, + ImageMetadataFormat, + PluginEvent, +} from 'roosterjs-content-model-types'; + +const DefaultOptions: Partial = { + borderColor: '#DB626C', + minWidth: 10, + minHeight: 10, + preserveRatio: true, + disableRotate: false, + disableSideResize: false, + onSelectState: 'resize', +}; + +const IMAGE_EDIT_CHANGE_SOURCE = 'ImageEdit'; + +/** + * ImageEdit plugin handles the following image editing features: + * - Resize image + * - Crop image + * - Rotate image + * - Flip image + */ +export class ImageEditPlugin implements ImageEditor, EditorPlugin { + protected editor: IEditor | null = null; + private shadowSpan: HTMLSpanElement | null = null; + private selectedImage: HTMLImageElement | null = null; + public wrapper: HTMLSpanElement | null = null; + private imageEditInfo: ImageMetadataFormat | null = null; + private imageHTMLOptions: ImageHtmlOptions | null = null; + private dndHelpers: DragAndDropHelper[] = []; + private clonedImage: HTMLImageElement | null = null; + private lastSrc: string | null = null; + private wasImageResized: boolean = false; + private isCropMode: boolean = false; + private resizers: HTMLDivElement[] = []; + private rotators: HTMLDivElement[] = []; + private croppers: HTMLDivElement[] = []; + private zoomScale: number = 1; + private disposer: (() => void) | null = null; + + constructor(protected options: ImageEditOptions = DefaultOptions) {} + + /** + * Get name of this plugin + */ + getName() { + return 'ImageEdit'; + } + + /** + * The first method that editor will call to a plugin when editor is initializing. + * It will pass in the editor instance, plugin should take this chance to save the + * editor reference so that it can call to any editor method or format API later. + * @param editor The editor object + */ + initialize(editor: IEditor) { + this.editor = editor; + this.disposer = editor.attachDomEvent({ + blur: { + beforeDispatch: () => { + this.formatImageWithContentModel( + editor, + true /* shouldSelectImage */, + true /* shouldSelectAsImageSelection*/ + ); + }, + }, + }); + } + + /** + * The last method that editor will call to a plugin before it is disposed. + * Plugin can take this chance to clear the reference to editor. After this method is + * called, plugin should not call to any editor method since it will result in error. + */ + dispose() { + this.editor = null; + this.cleanInfo(); + if (this.disposer) { + this.disposer(); + this.disposer = null; + } + } + + /** + * Core method for a plugin. Once an event happens in editor, editor will call this + * method of each plugin to handle the event as long as the event is not handled + * exclusively by another plugin. + * @param event The event to handle: + */ + onPluginEvent(_event: PluginEvent) {} + + private startEditing( + editor: IEditor, + image: HTMLImageElement, + apiOperation?: ImageEditOperation + ) { + const imageSpan = image.parentElement; + if (!imageSpan || (imageSpan && !isElementOfType(imageSpan, 'span'))) { + return; + } + this.imageEditInfo = getSelectedImageMetadata(editor, image); + this.lastSrc = image.getAttribute('src'); + this.imageHTMLOptions = getHTMLImageOptions(editor, this.options, this.imageEditInfo); + const { + resizers, + rotators, + wrapper, + shadowSpan, + imageClone, + croppers, + } = createImageWrapper( + editor, + image, + imageSpan, + this.options, + this.imageEditInfo, + this.imageHTMLOptions, + apiOperation || this.options.onSelectState + ); + this.shadowSpan = shadowSpan; + this.selectedImage = image; + this.wrapper = wrapper; + this.clonedImage = imageClone; + this.wasImageResized = checkIfImageWasResized(image); + this.resizers = resizers; + this.rotators = rotators; + this.croppers = croppers; + this.zoomScale = editor.getDOMHelper().calculateZoomScale(); + + editor.setEditorStyle('imageEdit', `outline-style:none!important;`, [ + `span:has(>img#${this.selectedImage.id})`, + ]); + } + + public startRotateAndResize( + editor: IEditor, + image: HTMLImageElement, + apiOperation?: 'resize' | 'rotate' + ) { + if (this.wrapper && this.selectedImage && this.shadowSpan) { + this.removeImageWrapper(); + } + this.startEditing(editor, image, apiOperation); + if (this.selectedImage && this.imageEditInfo && this.wrapper && this.clonedImage) { + this.dndHelpers = [ + ...getDropAndDragHelpers( + this.wrapper, + this.imageEditInfo, + this.options, + ImageEditElementClass.ResizeHandle, + Resizer, + () => { + if ( + this.imageEditInfo && + this.selectedImage && + this.wrapper && + this.clonedImage + ) { + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + this.resizers + ); + this.wasImageResized = true; + } + }, + this.zoomScale + ), + ...getDropAndDragHelpers( + this.wrapper, + this.imageEditInfo, + this.options, + ImageEditElementClass.RotateHandle, + Rotator, + () => { + if ( + this.imageEditInfo && + this.selectedImage && + this.wrapper && + this.clonedImage + ) { + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + this.rotators + ); + this.updateRotateHandleState( + editor, + this.selectedImage, + this.wrapper, + this.rotators, + this.imageEditInfo?.angleRad + ); + } + }, + this.zoomScale + ), + ]; + + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + this.resizers + ); + + this.updateRotateHandleState( + editor, + this.selectedImage, + this.wrapper, + this.rotators, + this.imageEditInfo?.angleRad + ); + } + } + + private updateRotateHandleState( + editor: IEditor, + image: HTMLImageElement, + wrapper: HTMLSpanElement, + rotators: HTMLDivElement[], + angleRad: number | undefined + ) { + const viewport = editor.getVisibleViewport(); + const smallImage = isASmallImage(image.width, image.height); + if (viewport && rotators && rotators.length > 0) { + const rotator = rotators[0]; + const rotatorHandle = rotator.firstElementChild; + if ( + isNodeOfType(rotatorHandle, 'ELEMENT_NODE') && + isElementOfType(rotatorHandle, 'div') + ) { + updateRotateHandle( + viewport, + angleRad ?? 0, + wrapper, + rotator, + rotatorHandle, + smallImage + ); + } + } + } + + public isOperationAllowed(operation: ImageEditOperation): boolean { + return operation === 'resize' || operation === 'rotate' || operation === 'flip'; + } + + public canRegenerateImage(image: HTMLImageElement): boolean { + return canRegenerateImage(image); + } + + public cropImage() { + const selection = this.editor?.getDOMSelection(); + if (!this.editor || !selection || selection.type !== 'image') { + return; + } + let image = selection.image; + if (this.wrapper && this.selectedImage && this.shadowSpan) { + image = this.removeImageWrapper() ?? image; + } + + this.startEditing(this.editor, image, 'crop'); + if (!this.selectedImage || !this.imageEditInfo || !this.wrapper || !this.clonedImage) { + return; + } + this.dndHelpers = [ + ...getDropAndDragHelpers( + this.wrapper, + this.imageEditInfo, + this.options, + ImageEditElementClass.CropHandle, + Cropper, + () => { + if ( + this.imageEditInfo && + this.selectedImage && + this.wrapper && + this.clonedImage + ) { + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + undefined, + this.croppers + ); + this.isCropMode = true; + } + }, + this.zoomScale + ), + ]; + + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper, + undefined, + this.croppers + ); + } + + private editImage( + editor: IEditor, + image: HTMLImageElement, + apiOperation: ImageEditOperation, + operation: (imageEditInfo: ImageMetadataFormat) => void + ) { + if (this.wrapper && this.selectedImage && this.shadowSpan) { + image = this.removeImageWrapper() ?? image; + } + this.startEditing(editor, image, apiOperation); + if (!this.selectedImage || !this.imageEditInfo || !this.wrapper || !this.clonedImage) { + return; + } + + operation(this.imageEditInfo); + + updateWrapper( + this.imageEditInfo, + this.options, + this.selectedImage, + this.clonedImage, + this.wrapper + ); + + this.formatImageWithContentModel( + editor, + true /* shouldSelect*/, + true /* shouldSelectAsImageSelection*/ + ); + } + + private cleanInfo() { + this.editor?.setEditorStyle('imageEdit', null); + this.selectedImage = null; + this.shadowSpan = null; + this.wrapper = null; + this.imageEditInfo = null; + this.imageHTMLOptions = null; + this.dndHelpers.forEach(helper => helper.dispose()); + this.dndHelpers = []; + this.clonedImage = null; + this.lastSrc = null; + this.wasImageResized = false; + this.isCropMode = false; + this.resizers = []; + this.rotators = []; + this.croppers = []; + } + + private formatImageWithContentModel( + editor: IEditor, + shouldSelectImage: boolean, + shouldSelectAsImageSelection: boolean + ) { + if ( + this.lastSrc && + this.selectedImage && + this.imageEditInfo && + this.clonedImage && + this.shadowSpan + ) { + editor.formatContentModel( + model => { + const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs( + model, + false + ); + if (!selectedSegmentsAndParagraphs[0]) { + return false; + } + + const segment = selectedSegmentsAndParagraphs[0][0]; + const paragraph = selectedSegmentsAndParagraphs[0][1]; + + if (paragraph && segment.segmentType == 'Image') { + mutateSegment(paragraph, segment, image => { + if ( + this.lastSrc && + this.selectedImage && + this.imageEditInfo && + this.clonedImage + ) { + applyChange( + editor, + this.selectedImage, + image, + this.imageEditInfo, + this.lastSrc, + this.wasImageResized || this.isCropMode, + this.clonedImage + ); + image.isSelected = shouldSelectImage; + image.isSelectedAsImageSelection = shouldSelectAsImageSelection; + } + }); + return true; + } + + return false; + }, + { + changeSource: IMAGE_EDIT_CHANGE_SOURCE, + onNodeCreated: () => { + this.cleanInfo(); + }, + } + ); + } + } + + private removeImageWrapper() { + let image: HTMLImageElement | null = null; + if (this.shadowSpan && this.shadowSpan.parentElement) { + if ( + this.shadowSpan.firstElementChild && + isNodeOfType(this.shadowSpan.firstElementChild, 'ELEMENT_NODE') && + isElementOfType(this.shadowSpan.firstElementChild, 'img') + ) { + image = this.shadowSpan.firstElementChild; + } + unwrap(this.shadowSpan); + this.shadowSpan = null; + this.wrapper = null; + } + + return image; + } + + public flipImage(direction: 'horizontal' | 'vertical') { + const selection = this.editor?.getDOMSelection(); + if (!this.editor || !selection || selection.type !== 'image') { + return; + } + const image = selection.image; + if (this.editor) { + this.editImage(this.editor, image, 'flip', imageEditInfo => { + const angleRad = imageEditInfo.angleRad || 0; + const isInVerticalPostion = + (angleRad >= Math.PI / 2 && angleRad < (3 * Math.PI) / 4) || + (angleRad <= -Math.PI / 2 && angleRad > (-3 * Math.PI) / 4); + if (isInVerticalPostion) { + if (direction === 'horizontal') { + imageEditInfo.flippedVertical = !imageEditInfo.flippedVertical; + } else { + imageEditInfo.flippedHorizontal = !imageEditInfo.flippedHorizontal; + } + } else { + if (direction === 'vertical') { + imageEditInfo.flippedVertical = !imageEditInfo.flippedVertical; + } else { + imageEditInfo.flippedHorizontal = !imageEditInfo.flippedHorizontal; + } + } + }); + } + } + + public rotateImage(angleRad: number) { + const selection = this.editor?.getDOMSelection(); + if (!this.editor || !selection || selection.type !== 'image') { + return; + } + const image = selection.image; + if (this.editor) { + this.editImage(this.editor, image, 'rotate', imageEditInfo => { + imageEditInfo.angleRad = (imageEditInfo.angleRad || 0) + angleRad; + }); + } + } + + //EXPOSED FOR TEST ONLY + public getWrapper() { + return this.wrapper; + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/Resizer/createImageResizer.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/Resizer/createImageResizer.ts new file mode 100644 index 00000000000..165685839b6 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/Resizer/createImageResizer.ts @@ -0,0 +1,102 @@ +import { createElement } from '../../pluginUtils/CreateElement/createElement'; +import { ImageEditElementClass } from '../types/ImageEditElementClass'; +import { isElementOfType, isNodeOfType } from 'roosterjs-content-model-dom'; +import { Xs, Ys } from '../constants/constants'; +import type { CreateElementData } from '../../pluginUtils/CreateElement/CreateElementData'; +import type { DNDDirectionX, DnDDirectionY } from '../types/DragAndDropContext'; +/** + * @internal + */ +export interface OnShowResizeHandle { + (elementData: CreateElementData, x: DNDDirectionX, y: DnDDirectionY): void; +} + +const RESIZE_HANDLE_MARGIN = 6; +const RESIZE_HANDLE_SIZE = 10; + +/** + * @internal + */ +export function createImageResizer( + doc: Document, + onShowResizeHandle?: OnShowResizeHandle +): HTMLDivElement[] { + const cornerElements = getCornerResizeHTML(onShowResizeHandle); + const sideElements = getSideResizeHTML(onShowResizeHandle); + const handles = [...cornerElements, ...sideElements] + .map(element => { + const handle = createElement(element, doc); + if (isNodeOfType(handle, 'ELEMENT_NODE') && isElementOfType(handle, 'div')) { + return handle; + } + }) + .filter(element => !!element) as HTMLDivElement[]; + return handles; +} + +/** + * @internal + * Get HTML for resize handles at the corners + */ +function getCornerResizeHTML(onShowResizeHandle?: OnShowResizeHandle): CreateElementData[] { + const result: CreateElementData[] = []; + + Xs.forEach(x => + Ys.forEach(y => { + const elementData = (x == '') == (y == '') ? getResizeHandleHTML(x, y) : null; + if (onShowResizeHandle && elementData) { + onShowResizeHandle(elementData, x, y); + } + if (elementData) { + result.push(elementData); + } + }) + ); + return result; +} + +/** + * @internal + * Get HTML for resize handles on the sides + */ +function getSideResizeHTML(onShowResizeHandle?: OnShowResizeHandle): CreateElementData[] { + const result: CreateElementData[] = []; + Xs.forEach(x => + Ys.forEach(y => { + const elementData = (x == '') != (y == '') ? getResizeHandleHTML(x, y) : null; + if (onShowResizeHandle && elementData) { + onShowResizeHandle(elementData, x, y); + } + if (elementData) { + result.push(elementData); + } + }) + ); + return result; +} + +const createHandleStyle = (direction: string, topOrBottom: string, leftOrRight: string) => { + return `position:relative;width:${RESIZE_HANDLE_SIZE}px;height:${RESIZE_HANDLE_SIZE}px;background-color: #FFFFFF;cursor:${direction}-resize;${topOrBottom}:-${RESIZE_HANDLE_MARGIN}px;${leftOrRight}:-${RESIZE_HANDLE_MARGIN}px;border-radius:100%;border: 2px solid #bfbfbf;box-shadow: 0px 0.36316px 1.36185px rgba(100, 100, 100, 0.25);`; +}; + +function getResizeHandleHTML(x: DNDDirectionX, y: DnDDirectionY): CreateElementData | null { + const leftOrRight = x == 'w' ? 'left' : 'right'; + const topOrBottom = y == 'n' ? 'top' : 'bottom'; + const leftOrRightValue = x == '' ? '50%' : '0px'; + const topOrBottomValue = y == '' ? '50%' : '0px'; + const direction = y + x; + return x == '' && y == '' + ? null + : { + tag: 'div', + style: `position:absolute;${leftOrRight}:${leftOrRightValue};${topOrBottom}:${topOrBottomValue}`, + children: [ + { + tag: 'div', + style: createHandleStyle(direction, topOrBottom, leftOrRight), + className: ImageEditElementClass.ResizeHandle, + dataset: { x, y }, + }, + ], + }; +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/Resizer/resizerContext.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/Resizer/resizerContext.ts new file mode 100644 index 00000000000..3a42761ed2b --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/Resizer/resizerContext.ts @@ -0,0 +1,58 @@ +import { rotateCoordinate } from '../utils/imageEditUtils'; +import type { DragAndDropHandler } from '../../pluginUtils/DragAndDrop/DragAndDropHandler'; +import type { ImageResizeMetadataFormat } from 'roosterjs-content-model-types'; +import type { DragAndDropContext } from '../types/DragAndDropContext'; + +/** + * @internal + * The resize drag and drop handler + */ +export const Resizer: DragAndDropHandler = { + onDragStart: ({ editInfo }) => ({ ...editInfo }), + onDragging: ({ x, y, editInfo, options }, e, base, deltaX, deltaY) => { + if ( + base.heightPx && + base.widthPx && + options.minWidth !== undefined && + options.minHeight !== undefined + ) { + const ratio = + base.widthPx > 0 && base.heightPx > 0 ? (base.widthPx * 1.0) / base.heightPx : 0; + [deltaX, deltaY] = rotateCoordinate(deltaX, deltaY, editInfo.angleRad ?? 0); + const horizontalOnly = x == ''; + const verticalOnly = y == ''; + const shouldPreserveRatio = + !(horizontalOnly || verticalOnly) && (options.preserveRatio || e.shiftKey); + let newWidth = horizontalOnly + ? base.widthPx + : Math.max(base.widthPx + deltaX * (x == 'w' ? -1 : 1), options.minWidth); + let newHeight = verticalOnly + ? base.heightPx + : Math.max(base.heightPx + deltaY * (y == 'n' ? -1 : 1), options.minHeight); + + if (shouldPreserveRatio && ratio > 0) { + if (ratio > 1) { + // first sure newHeight is right,calculate newWidth + newWidth = newHeight * ratio; + if (newWidth < options.minWidth) { + newWidth = options.minWidth; + newHeight = newWidth / ratio; + } + } else { + // first sure newWidth is right,calculate newHeight + newHeight = newWidth / ratio; + if (newHeight < options.minHeight) { + newHeight = options.minHeight; + newWidth = newHeight * ratio; + } + } + } + + editInfo.widthPx = newWidth; + editInfo.heightPx = newHeight; + return true; + } else { + return false; + } + }, +}; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/Resizer/updateSideHandlesVisibility.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/Resizer/updateSideHandlesVisibility.ts new file mode 100644 index 00000000000..29a4e824bd7 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/Resizer/updateSideHandlesVisibility.ts @@ -0,0 +1,12 @@ +/** + * @internal + */ +export function updateSideHandlesVisibility(handles: HTMLDivElement[], isSmall: boolean) { + handles.forEach(handle => { + const { y, x } = handle.dataset; + const coordinate = (y ?? '') + (x ?? ''); + const directions = ['n', 's', 'e', 'w']; + const isSideHandle = directions.indexOf(coordinate) > -1; + handle.style.display = isSideHandle && isSmall ? 'none' : ''; + }); +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/Rotator/createImageRotator.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/Rotator/createImageRotator.ts new file mode 100644 index 00000000000..fa9bfb7b52d --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/Rotator/createImageRotator.ts @@ -0,0 +1,84 @@ +import { createElement } from '../../pluginUtils/CreateElement/createElement'; +import { ImageEditElementClass } from '../types/ImageEditElementClass'; +import { isElementOfType, isNodeOfType } from 'roosterjs-content-model-dom'; +import type { CreateElementData } from '../../pluginUtils/CreateElement/CreateElementData'; +import type { ImageHtmlOptions } from '../types/ImageHtmlOptions'; +import { + ROTATE_GAP, + ROTATE_HANDLE_TOP, + ROTATE_ICON_MARGIN, + ROTATE_SIZE, + ROTATE_WIDTH, +} from '../constants/constants'; + +/** + * @internal + * Get HTML for rotate elements, including the rotate handle with icon, and a line between the handle and the image + */ +export function createImageRotator(doc: Document, htmlOptions: ImageHtmlOptions) { + return getRotateHTML(htmlOptions) + .map(element => { + const rotator = createElement(element, doc); + if (isNodeOfType(rotator, 'ELEMENT_NODE') && isElementOfType(rotator, 'div')) { + return rotator; + } + }) + .filter(rotator => !!rotator) as HTMLDivElement[]; +} + +/** + * @internal + * Get HTML for rotate elements, including the rotate handle with icon, and a line between the handle and the image + * + */ +function getRotateHTML({ + borderColor, + rotateHandleBackColor, +}: ImageHtmlOptions): CreateElementData[] { + const handleLeft = ROTATE_SIZE / 2; + return [ + { + tag: 'div', + className: ImageEditElementClass.RotateCenter, + style: `position:absolute;left:50%;width:1px;background-color:${borderColor};top:${-ROTATE_HANDLE_TOP}px;height:${ROTATE_GAP}px;margin-left:${-ROTATE_WIDTH}px;`, + children: [ + { + tag: 'div', + className: ImageEditElementClass.RotateHandle, + style: `position:absolute;background-color:${rotateHandleBackColor};border:solid 1px ${borderColor};border-radius:50%;width:${ROTATE_SIZE}px;height:${ROTATE_SIZE}px;left:-${ + handleLeft + ROTATE_WIDTH + }px;cursor:move;top:${-ROTATE_SIZE}px;line-height: 0px;`, + children: [getRotateIconHTML(borderColor)], + }, + ], + }, + ]; +} + +function getRotateIconHTML(borderColor: string): CreateElementData { + return { + tag: 'svg', + namespace: 'http://www.w3.org/2000/svg', + style: `width:16px;height:16px;margin: ${ROTATE_ICON_MARGIN}px ${ROTATE_ICON_MARGIN}px`, + children: [ + { + tag: 'path', + namespace: 'http://www.w3.org/2000/svg', + attributes: { + d: 'M 10.5,10.0 A 3.8,3.8 0 1 1 6.7,6.3', + transform: 'matrix(1.1 1.1 -1.1 1.1 11.6 -10.8)', + ['fill-opacity']: '0', + stroke: borderColor, + }, + }, + { + tag: 'path', + namespace: 'http://www.w3.org/2000/svg', + attributes: { + d: 'M12.0 3.648l.884-.884.53 2.298-2.298-.53z', + stroke: borderColor, + }, + }, + ], + }; +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/Rotator/rotatorContext.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/Rotator/rotatorContext.ts new file mode 100644 index 00000000000..4908e2bc10c --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/Rotator/rotatorContext.ts @@ -0,0 +1,33 @@ +import { DEFAULT_ROTATE_HANDLE_HEIGHT, DEG_PER_RAD } from '../constants/constants'; +import type { ImageRotateMetadataFormat } from 'roosterjs-content-model-types'; +import type { DragAndDropHandler } from '../../pluginUtils/DragAndDrop/DragAndDropHandler'; +import type { DragAndDropContext } from '../types/DragAndDropContext'; + +/** + * @internal + * The rotate drag and drop handler + */ +export const Rotator: DragAndDropHandler = { + onDragStart: ({ editInfo }) => ({ ...editInfo }), + onDragging: ({ editInfo, options }, e, base, deltaX, deltaY) => { + if (editInfo.heightPx) { + const distance = editInfo.heightPx / 2 + DEFAULT_ROTATE_HANDLE_HEIGHT; + const newX = distance * Math.sin(base.angleRad ?? 0) + deltaX; + const newY = distance * Math.cos(base.angleRad ?? 0) - deltaY; + let angleInRad = Math.atan2(newX, newY); + + if (!e.altKey && options && options.minRotateDeg !== undefined) { + const angleInDeg = angleInRad * DEG_PER_RAD; + const adjustedAngleInDeg = + Math.round(angleInDeg / options.minRotateDeg) * options.minRotateDeg; + angleInRad = adjustedAngleInDeg / DEG_PER_RAD; + } + + if (editInfo.angleRad != angleInRad) { + editInfo.angleRad = angleInRad; + return true; + } + } + return false; + }, +}; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/Rotator/updateRotateHandle.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/Rotator/updateRotateHandle.ts new file mode 100644 index 00000000000..058b4d021e2 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/Rotator/updateRotateHandle.ts @@ -0,0 +1,63 @@ +import { DEG_PER_RAD, RESIZE_HANDLE_MARGIN, ROTATE_GAP, ROTATE_SIZE } from '../constants/constants'; +import type { Rect } from 'roosterjs-content-model-types'; + +/** + * @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 updateRotateHandle( + editorRect: Rect, + angleRad: number, + wrapper: HTMLElement, + rotateCenter: HTMLElement, + rotateHandle: HTMLElement, + isSmallImage: boolean +) { + if (isSmallImage) { + rotateCenter.style.display = 'none'; + rotateHandle.style.display = 'none'; + return; + } else { + rotateCenter.style.display = ''; + rotateHandle.style.display = ''; + const rotateCenterRect = rotateCenter.getBoundingClientRect(); + const wrapperRect = wrapper.getBoundingClientRect(); + const ROTATOR_HEIGHT = ROTATE_SIZE + ROTATE_GAP + RESIZE_HANDLE_MARGIN; + if (rotateCenterRect && wrapperRect) { + let adjustedDistance = Number.MAX_SAFE_INTEGER; + const angle = angleRad * DEG_PER_RAD; + + if (angle < 45 && angle > -45 && wrapperRect.top - editorRect.top < ROTATOR_HEIGHT) { + const top = rotateCenterRect.top - editorRect.top; + adjustedDistance = top; + } else if ( + angle <= -80 && + angle >= -100 && + wrapperRect.left - editorRect.left < ROTATOR_HEIGHT + ) { + const left = rotateCenterRect.left - editorRect.left; + adjustedDistance = left; + } else if ( + angle >= 80 && + angle <= 100 && + editorRect.right - wrapperRect.right < ROTATOR_HEIGHT + ) { + const right = rotateCenterRect.right - editorRect.right; + adjustedDistance = Math.min(editorRect.right - wrapperRect.right, right); + } else if ( + (angle <= -160 || angle >= 160) && + editorRect.bottom - wrapperRect.bottom < ROTATOR_HEIGHT + ) { + const bottom = rotateCenterRect.bottom - editorRect.bottom; + adjustedDistance = Math.min(editorRect.bottom - wrapperRect.bottom, bottom); + } + + 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 - RESIZE_HANDLE_MARGIN + 'px'; + rotateCenter.style.height = rotateGap + 'px'; + rotateHandle.style.top = -rotateTop + 'px'; + } + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/constants/constants.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/constants/constants.ts new file mode 100644 index 00000000000..e189922c499 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/constants/constants.ts @@ -0,0 +1,96 @@ +import type { DNDDirectionX, DnDDirectionY } from '../types/DragAndDropContext'; + +/** + * @internal + */ +export const RESIZE_HANDLE_SIZE = 10; + +/** + * @internal + */ +export const RESIZE_HANDLE_MARGIN = 6; + +/** + * @internal + */ +export const ROTATE_SIZE = 32; + +/** + * @internal + */ +export const ROTATE_GAP = 15; + +/** + * @internal + */ +export const DEG_PER_RAD = 180 / Math.PI; + +/** + * @internal + */ +export const DEFAULT_ROTATE_HANDLE_HEIGHT = ROTATE_SIZE / 2 + ROTATE_GAP; + +/** + * @internal + */ +export const ROTATE_ICON_MARGIN = 8; + +/** + * @internal + */ +export const ROTATION: Record = { + sw: 0, + nw: 90, + ne: 180, + se: 270, +}; + +/** + * @internal + */ +export const Xs: DNDDirectionX[] = ['w', '', 'e']; + +/** + * @internal + */ +export const Ys: DnDDirectionY[] = ['s', '', 'n']; + +/** + * @internal + */ +export const ROTATE_WIDTH = 1; + +/** + * @internal + */ +export const ROTATE_HANDLE_TOP = ROTATE_GAP + RESIZE_HANDLE_MARGIN; + +/** + * @internal + */ +export const CROP_HANDLE_SIZE = 22; + +/** + * @internal + */ +export const CROP_HANDLE_WIDTH = 7; + +/** + * @internal + */ +export const XS_CROP: DNDDirectionX[] = ['w', 'e']; + +/** + * @internal + */ +export const YS_CROP: DnDDirectionY[] = ['s', 'n']; + +/** + * @internal + */ +export const MIN_HEIGHT_WIDTH = 3 * RESIZE_HANDLE_SIZE + 2 * RESIZE_HANDLE_MARGIN; + +/** + * @internal + */ +export const RESIZE_IMAGE = 'resizeImage'; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/types/DragAndDropContext.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/DragAndDropContext.ts new file mode 100644 index 00000000000..6fe1f1f2363 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/DragAndDropContext.ts @@ -0,0 +1,46 @@ +import type { ImageEditElementClass } from './ImageEditElementClass'; +import type { ImageEditOptions } from './ImageEditOptions'; +import type { ImageMetadataFormat } from 'roosterjs-content-model-types'; + +/** + * @internal + * Horizontal direction types for image edit + */ +export type DNDDirectionX = 'w' | '' | 'e'; + +/** + * @internal + * Vertical direction types for image edit + */ +export type DnDDirectionY = 'n' | '' | 's'; + +/** + * @internal + * Context object of image editing for DragAndDropHelper + */ +export interface DragAndDropContext { + /** + * The CSS class name of this editing element + */ + elementClass: ImageEditElementClass; + + /** + * Edit info of current image, can be modified by handlers + */ + editInfo: ImageMetadataFormat; + + /** + * Horizontal direction + */ + x: DNDDirectionX; + + /** + * Vertical direction + */ + y: DnDDirectionY; + + /** + * Edit options + */ + options: ImageEditOptions; +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/types/GeneratedImageSize.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/GeneratedImageSize.ts new file mode 100644 index 00000000000..da03397f782 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/GeneratedImageSize.ts @@ -0,0 +1,38 @@ +/** + * @internal The result structure for getGeneratedImageSize() + */ +export interface GeneratedImageSize { + /** + * Final image width after rotate and crop + */ + targetWidth: number; + + /** + * Final image height after rotate and crop + */ + targetHeight: number; + + /** + * Original width of image before rotate and crop + */ + originalWidth: number; + + /** + * Original height of image before rotate and crop + */ + originalHeight: number; + + /** + * Visible width of image at current state + * Depends on if beforeCrop is true passed into getGeneratedImageSize(), + * the value can be before or after crop + */ + visibleWidth: number; + + /** + * Visible height of image at current state + * Depends on if beforeCrop is true passed into getGeneratedImageSize(), + * the value can be before or after crop + */ + visibleHeight: number; +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageEditElementClass.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageEditElementClass.ts new file mode 100644 index 00000000000..55966bd35d1 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageEditElementClass.ts @@ -0,0 +1,35 @@ +/** + * @internal + * CSS class names for image editing elements + */ +export enum ImageEditElementClass { + /** + * CSS class name for resize handle + */ + ResizeHandle = 'r_resizeH', + + /** + * CSS class name for rotate handle + */ + RotateHandle = 'r_rotateH', + + /** + * CSS class name for the container of rotate handle + */ + RotateCenter = 'r_rotateC', + + /** + * CSS class name for crop overlay + */ + CropOverlay = 'r_cropO', + + /** + * CSS class name for container of crop handle + */ + CropContainer = 'r_cropC', + + /** + * CSS class name for crop handle + */ + CropHandle = 'r_cropH', +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageEditOptions.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageEditOptions.ts new file mode 100644 index 00000000000..9aec93b20a1 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageEditOptions.ts @@ -0,0 +1,65 @@ +import type { ImageEditOperation } from 'roosterjs-content-model-types'; + +/** + * Options for customize ImageEdit plugin + */ +export interface ImageEditOptions { + /** + * Color of resize/rotate border, handle and icon + * @default #DB626C + */ + borderColor?: string; + + /** + * Minimum resize/crop width + * @default 10 + */ + minWidth?: number; + + /** + * Minimum resize/crop height + * @default 10 + */ + minHeight?: number; + + /** + * Whether preserve width/height ratio when resize + * Pressing SHIFT key when resize will for preserve ratio even this value is set to false + * @default false + */ + preserveRatio?: boolean; + + /** + * Minimum degree increase/decrease when rotate image. + * Pressing SHIFT key when rotate will ignore this value and rotate by any degree with mouse moving + * @default 5 + */ + minRotateDeg?: number; + + /** + * Selector of the image that allows editing + * @default img + */ + imageSelector?: string; + + /** + * Whether side resizing (single direction resizing) is disabled. @default false + */ + disableSideResize?: boolean; + + /** + * Whether image rotate is disabled. @default false + */ + disableRotate?: boolean; + + /** + * Whether image crop is disabled. @default false + */ + disableCrop?: boolean; + + /** + * Which operations will be executed when image is selected + * @default resizeAndRotate + */ + onSelectState?: ImageEditOperation; +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageHtmlOptions.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageHtmlOptions.ts new file mode 100644 index 00000000000..4eb5566defb --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/types/ImageHtmlOptions.ts @@ -0,0 +1,20 @@ +/** + * @internal + * Options for retrieve HTML string for image editing + */ +export interface ImageHtmlOptions { + /** + * Border and handle color of resize and rotate handle + */ + borderColor: string; + + /** + * Background color of the rotate handle + */ + rotateHandleBackColor: string; + + /** + * Verify if the area of the image is less than 10000px, if yes, don't insert the side handles + */ + isSmallImage: boolean; +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts new file mode 100644 index 00000000000..af259f85fdb --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/applyChange.ts @@ -0,0 +1,92 @@ +import { checkEditInfoState } from './checkEditInfoState'; +import { generateDataURL } from './generateDataURL'; +import { getGeneratedImageSize } from './generateImageSize'; +import { getSelectedImageMetadata, updateImageEditInfo } from './updateImageEditInfo'; +import type { + ContentModelImage, + IEditor, + ImageMetadataFormat, +} from 'roosterjs-content-model-types'; + +/** + * @internal + * Apply changes from the edit info of an image, write result to the image + * @param editor The editor object that contains the image + * @param image The image to apply the change + * @param editInfo Edit info that contains the changed information of the image + * @param previousSrc Last src value of the image before the change was made + * @param wasResizedOrCropped if the image was resized or cropped apply the new image dimensions + * @param editingImage (optional) Image in editing state + */ +export function applyChange( + editor: IEditor, + image: HTMLImageElement, + contentModelImage: ContentModelImage, + editInfo: ImageMetadataFormat, + previousSrc: string, + wasResizedOrCropped: boolean, + editingImage?: HTMLImageElement +) { + let newSrc = ''; + const initEditInfo = getSelectedImageMetadata(editor, editingImage ?? image) ?? undefined; + const state = checkEditInfoState(editInfo, initEditInfo); + + switch (state) { + case 'ResizeOnly': + // For resize only case, no need to generate a new image, just reuse the original one + newSrc = editInfo.src || ''; + break; + case 'SameWithLast': + // For SameWithLast case, image may be resized but the content is still the same with last one, + // so no need to create a new image, but just reuse last one + newSrc = previousSrc; + break; + case 'FullyChanged': + // For other cases (cropped, rotated, ...) we need to create a new image to reflect the change + newSrc = generateDataURL(editingImage ?? image, editInfo); + break; + } + + const srcChanged = newSrc != previousSrc; + + if (srcChanged) { + // If the src is changed, fire an EditImage event so that plugins knows that a new image is used, and can + // replace the new src with some other string and it will be used and set to the image + const event = editor.triggerEvent('editImage', { + image: image, + originalSrc: editInfo.src || image.src, + previousSrc, + newSrc, + }); + newSrc = event.newSrc; + } + + if (newSrc == editInfo.src) { + // If newSrc is the same with original one, it means there is only size change, but no rotation, no cropping, + // so we don't need to keep edit info, we can delete it + updateImageEditInfo(contentModelImage, null); + } else { + // Otherwise, save the new edit info to the image so that next time when we edit the same image, we know + // the edit info + updateImageEditInfo(contentModelImage, editInfo); + } + + // Write back the change to image, and set its new size + const generatedImageSize = getGeneratedImageSize(editInfo); + if (!generatedImageSize) { + return; + } + + contentModelImage.src = newSrc; + + if (wasResizedOrCropped || state == 'FullyChanged') { + contentModelImage.format.width = generatedImageSize.targetWidth + 'px'; + contentModelImage.format.height = generatedImageSize.targetHeight + 'px'; + + // Remove width/height style so that it won't affect the image size, since style width/height has higher priority + image.style.removeProperty('width'); + image.style.removeProperty('height'); + image.style.removeProperty('max-width'); + image.style.removeProperty('max-height'); + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/canRegenerateImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/canRegenerateImage.ts new file mode 100644 index 00000000000..4e679b9a8db --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/canRegenerateImage.ts @@ -0,0 +1,28 @@ +/** + * @internal + * Check if we can regenerate edited image from the source image. + * An image can't regenerate result when there is CORS issue of the source content. + * @param img The image element to test + * @returns True when we can regenerate the edited image, otherwise false + */ +export function canRegenerateImage(img: HTMLImageElement | null): boolean { + if (!img) { + return false; + } + + try { + const canvas = img.ownerDocument.createElement('canvas'); + canvas.width = 10; + canvas.height = 10; + const context = canvas.getContext('2d'); + if (context) { + context.drawImage(img, 0, 0); + context.getImageData(0, 0, 1, 1); + return true; + } + + return false; + } catch { + return false; + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/checkEditInfoState.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/checkEditInfoState.ts new file mode 100644 index 00000000000..ea463ec76a3 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/checkEditInfoState.ts @@ -0,0 +1,98 @@ +import type { + ImageCropMetadataFormat, + ImageMetadataFormat, + ImageResizeMetadataFormat, + ImageRotateMetadataFormat, +} from 'roosterjs-content-model-types'; + +const RESIZE_KEYS: (keyof ImageResizeMetadataFormat)[] = ['widthPx', 'heightPx']; +const ROTATE_KEYS: (keyof ImageRotateMetadataFormat)[] = ['angleRad']; +const CROP_KEYS: (keyof ImageCropMetadataFormat)[] = [ + 'leftPercent', + 'rightPercent', + 'topPercent', + 'bottomPercent', +]; +const ROTATE_CROP_KEYS: (keyof ImageRotateMetadataFormat | keyof ImageCropMetadataFormat)[] = [ + ...ROTATE_KEYS, + ...CROP_KEYS, +]; +const ALL_KEYS = [...ROTATE_CROP_KEYS, ...RESIZE_KEYS]; + +/** + * @internal + * State of an edit info object for image editing. + * It is returned by checkEditInfoState() function + */ +export type ImageEditInfoState = + /** + * Invalid edit info. It means the given edit info object is either null, + * or not all its member are of correct type + */ + | 'Invalid' + + /** + * The edit info shows that it is only potentially edited by resizing action. + * Image is not rotated or cropped, or event not changed at all. + */ + | 'ResizeOnly' + + /** + * When compare with another edit info, this value can be returned when both current + * edit info and the other one are not been rotated, and they have same cropping + * percentages. So that they can share the same image src, only width and height + * need to be adjusted. + */ + | 'SameWithLast' + + /** + * When this value is returned, it means the image is edited by either cropping or + * rotation, or both. Image source can't be reused, need to generate a new image src + * data uri. + */ + | 'FullyChanged'; + +/** + * @internal + * Check the state of an edit info + * @param editInfo The edit info to check + * @param compareTo An optional edit info to compare to + * @returns If the source edit info is not valid (wrong type, missing field, ...), returns Invalid. + * If the source edit info doesn't contain any rotation or cropping, returns ResizeOnly + * If the compare edit info exists, and both of them don't contain rotation, and the have same cropping values, + * returns SameWithLast. Otherwise, returns FullyChanged + */ +export function checkEditInfoState( + editInfo: ImageMetadataFormat, + compareTo?: ImageMetadataFormat +): ImageEditInfoState { + if (!editInfo || !editInfo.src || ALL_KEYS.some(key => !isNumber(editInfo[key]))) { + return 'Invalid'; + } else if ( + ROTATE_CROP_KEYS.every(key => areSameNumber(editInfo[key], 0)) && + !editInfo.flippedHorizontal && + !editInfo.flippedVertical && + (!compareTo || (compareTo && editInfo.angleRad === compareTo.angleRad)) + ) { + return 'ResizeOnly'; + } else if ( + compareTo && + ROTATE_KEYS.every(key => areSameNumber(editInfo[key], 0)) && + ROTATE_KEYS.every(key => areSameNumber(compareTo[key], 0)) && + CROP_KEYS.every(key => areSameNumber(editInfo[key], compareTo[key])) && + compareTo.flippedHorizontal === editInfo.flippedHorizontal && + compareTo.flippedVertical === editInfo.flippedVertical + ) { + return 'SameWithLast'; + } else { + return 'FullyChanged'; + } +} + +function isNumber(o: any): o is number { + return typeof o === 'number'; +} + +function areSameNumber(n1?: number, n2?: number) { + return n1 != undefined && n2 != undefined && Math.abs(n1 - n2) < 1e-3; +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts new file mode 100644 index 00000000000..9a11e44565f --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts @@ -0,0 +1,148 @@ +import { createImageCropper } from '../Cropper/createImageCropper'; +import { createImageResizer } from '../Resizer/createImageResizer'; +import { createImageRotator } from '../Rotator/createImageRotator'; + +import type { + IEditor, + ImageEditOperation, + ImageMetadataFormat, +} from 'roosterjs-content-model-types'; +import type { ImageEditOptions } from '../types/ImageEditOptions'; +import type { ImageHtmlOptions } from '../types/ImageHtmlOptions'; + +/** + * @internal + */ +export interface WrapperElements { + wrapper: HTMLSpanElement; + shadowSpan: HTMLElement; + imageClone: HTMLImageElement; + resizers: HTMLDivElement[]; + rotators: HTMLDivElement[]; + croppers: HTMLDivElement[]; +} + +/** + * @internal + */ +export function createImageWrapper( + editor: IEditor, + image: HTMLImageElement, + imageSpan: HTMLSpanElement, + options: ImageEditOptions, + editInfo: ImageMetadataFormat, + htmlOptions: ImageHtmlOptions, + operation?: ImageEditOperation +): WrapperElements { + const imageClone = cloneImage(image, editInfo); + const doc = editor.getDocument(); + + let rotators: HTMLDivElement[] = []; + if (!options.disableRotate && operation === 'rotate') { + rotators = createImageRotator(doc, htmlOptions); + } + let resizers: HTMLDivElement[] = []; + if (operation === 'resize') { + resizers = createImageResizer(doc); + } + + let croppers: HTMLDivElement[] = []; + if (operation === 'crop') { + croppers = createImageCropper(doc); + } + + const wrapper = createWrapper( + editor, + imageClone, + options, + editInfo, + resizers, + rotators, + croppers + ); + const shadowSpan = createShadowSpan(wrapper, imageSpan); + return { wrapper, shadowSpan, imageClone, resizers, rotators, croppers }; +} + +const createShadowSpan = (wrapper: HTMLElement, imageSpan: HTMLSpanElement) => { + const shadowRoot = imageSpan.attachShadow({ + mode: 'open', + }); + imageSpan.style.verticalAlign = 'bottom'; + shadowRoot.appendChild(wrapper); + return imageSpan; +}; + +const createWrapper = ( + editor: IEditor, + image: HTMLImageElement, + options: ImageEditOptions, + editInfo: ImageMetadataFormat, + resizers?: HTMLDivElement[], + rotators?: HTMLDivElement[], + cropper?: HTMLDivElement[] +) => { + const doc = editor.getDocument(); + const wrapper = doc.createElement('span'); + const imageBox = doc.createElement('div'); + + imageBox.setAttribute( + `style`, + `position:relative;width:100%;height:100%;overflow:hidden;transform:scale(1);` + ); + imageBox.appendChild(image); + wrapper.setAttribute( + 'style', + `max-width: 100%; position: relative; display: inline-flex; font-size: 24px; margin: 0px; transform: rotate(${ + editInfo.angleRad ?? 0 + }rad); text-align: left;` + ); + wrapper.style.display = editor.getEnvironment().isSafari ? 'inline-block' : 'inline-flex'; + + const border = createBorder(editor, options.borderColor); + wrapper.appendChild(imageBox); + wrapper.appendChild(border); + wrapper.style.userSelect = 'none'; + + if (resizers && resizers?.length > 0) { + resizers.forEach(resizer => { + wrapper.appendChild(resizer); + }); + } + if (rotators && rotators.length > 0) { + rotators.forEach(r => { + wrapper.appendChild(r); + }); + } + if (cropper && cropper.length > 0) { + cropper.forEach(c => { + wrapper.appendChild(c); + }); + } + + return wrapper; +}; + +const createBorder = (editor: IEditor, borderColor?: string) => { + const doc = editor.getDocument(); + const resizeBorder = doc.createElement('div'); + resizeBorder.setAttribute( + `style`, + `position:absolute;left:0;right:0;top:0;bottom:0;border:solid 2px ${borderColor};pointer-events:none;` + ); + return resizeBorder; +}; + +const cloneImage = (image: HTMLImageElement, editInfo: ImageMetadataFormat) => { + const imageClone = image.cloneNode(true) as HTMLImageElement; + imageClone.style.removeProperty('transform'); + if (editInfo.src) { + imageClone.src = editInfo.src; + imageClone.removeAttribute('id'); + imageClone.style.removeProperty('max-width'); + imageClone.style.removeProperty('max-height'); + imageClone.style.width = editInfo.widthPx + 'px'; + imageClone.style.height = editInfo.heightPx + 'px'; + } + return imageClone; +}; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/doubleCheckResize.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/doubleCheckResize.ts new file mode 100644 index 00000000000..5d3ff50e1a1 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/doubleCheckResize.ts @@ -0,0 +1,40 @@ +import type { ImageMetadataFormat } from 'roosterjs-content-model-types'; + +/** + * @internal + * Double check if the changed size can satisfy current width of container. + * When resize an image and preserve ratio, its size can be limited by the size of container. + * So we need to check the actual size and calculate the size again + * @param editInfo Edit info of the image + * @param preserveRatio Whether w/h ratio need to be preserved + * @param actualWidth Actual width of the image after resize + * @param actualHeight Actual height of the image after resize + */ +export function doubleCheckResize( + editInfo: ImageMetadataFormat, + preserveRatio: boolean, + actualWidth: number, + actualHeight: number +) { + let { widthPx, heightPx } = editInfo; + if (widthPx == undefined || heightPx == undefined) { + return; + } + const ratio = heightPx > 0 ? widthPx / heightPx : 0; + + actualWidth = Math.floor(actualWidth); + actualHeight = Math.floor(actualHeight); + widthPx = Math.floor(widthPx); + heightPx = Math.floor(heightPx); + + editInfo.widthPx = actualWidth; + editInfo.heightPx = actualHeight; + + if (preserveRatio && ratio > 0 && (widthPx !== actualWidth || heightPx !== actualHeight)) { + if (actualWidth < widthPx) { + editInfo.heightPx = actualWidth / ratio; + } else { + editInfo.widthPx = actualHeight * ratio; + } + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/generateDataURL.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/generateDataURL.ts new file mode 100644 index 00000000000..13d6cbeb532 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/generateDataURL.ts @@ -0,0 +1,72 @@ +import { getGeneratedImageSize } from './generateImageSize'; +import type { ImageMetadataFormat } from 'roosterjs-content-model-types'; + +/** + * @internal + * Generate new dataURL from an image and edit info + * @param image The image to generate data URL from. It is supposed to have original src loaded + * @param editInfo Edit info of the image + * @returns A BASE64 encoded string with image prefix that represents the content of the generated image. + * If there are rotate/crop/resize info in the edit info, the generated image will also reflect the result. + * It is possible to throw exception since the original image may not be able to read its content from + * the code, so better check canRegenerateImage() of the image first. + * @throws Exception when fail to generate dataURL from canvas + */ +export function generateDataURL(image: HTMLImageElement, editInfo: ImageMetadataFormat): string { + const generatedImageSize = getGeneratedImageSize(editInfo); + if (!generatedImageSize) { + return ''; + } + + const { + angleRad, + widthPx, + heightPx, + bottomPercent, + leftPercent, + rightPercent, + topPercent, + naturalWidth, + naturalHeight, + } = editInfo; + const angle = angleRad || 0; + const left = leftPercent || 0; + const right = rightPercent || 0; + const top = topPercent || 0; + const bottom = bottomPercent || 0; + const nHeight = naturalHeight || image.naturalHeight; + const nWidth = naturalWidth || image.naturalHeight; + const width = widthPx || image.clientWidth; + const height = heightPx || image.clientHeight; + + const imageWidth = nWidth * (1 - left - right); + const imageHeight = nHeight * (1 - top - bottom); + + // Adjust the canvas size and scaling for high display resolution + const devicePixelRatio = window.devicePixelRatio || 1; + const canvas = document.createElement('canvas'); + const { targetWidth, targetHeight } = generatedImageSize; + canvas.width = targetWidth * devicePixelRatio; + canvas.height = targetHeight * devicePixelRatio; + + const context = canvas.getContext('2d'); + if (context) { + context.scale(devicePixelRatio, devicePixelRatio); + context.translate(targetWidth / 2, targetHeight / 2); + context.rotate(angle); + context.scale(editInfo.flippedHorizontal ? -1 : 1, editInfo.flippedVertical ? -1 : 1); + context.drawImage( + image, + nWidth * left, + nHeight * top, + imageWidth, + imageHeight, + -width / 2, + -height / 2, + width, + height + ); + } + + return canvas.toDataURL('image/png', 1.0); +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/generateImageSize.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/generateImageSize.ts new file mode 100644 index 00000000000..b38b87b0ecc --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/generateImageSize.ts @@ -0,0 +1,65 @@ +import type { ImageMetadataFormat } from 'roosterjs-content-model-types'; +import type { GeneratedImageSize } from '../types/GeneratedImageSize'; + +/** + * @internal + * Calculate the target size of an image. + * For image that is not rotated, target size is the same with resizing/cropping size. + * For image that is rotated, target size is calculated from resizing/cropping size and its rotate angle + * Say an image is resized to 100w*100h, cropped 25% on each side, then rotated 45deg, so that cropped size + * will be (both height and width) 100*(1-0.25-0,25) = 50px, then final image size will be 50*sqrt(2) = 71px + * @param editInfo The edit info to calculate size from + * @param beforeCrop True to calculate the full size of original image before crop, false to calculate the size + * after crop + * @returns A GeneratedImageSize object which contains original, visible and target target width and height of the image + */ +export function getGeneratedImageSize( + editInfo: ImageMetadataFormat, + beforeCrop?: boolean +): GeneratedImageSize | undefined { + const { + widthPx: width, + heightPx: height, + angleRad, + leftPercent: left, + rightPercent: right, + topPercent: top, + bottomPercent: bottom, + } = editInfo; + + if ( + height == undefined || + width == undefined || + left == undefined || + right == undefined || + top == undefined || + bottom == undefined + ) { + return; + } + + const angle = angleRad ?? 0; + + // Original image size before crop and rotate + const originalWidth = width / (1 - left - right); + const originalHeight = height / (1 - top - bottom); + + // Visible size + const visibleWidth = beforeCrop ? originalWidth : width; + const visibleHeight = beforeCrop ? originalHeight : height; + + // Target size after crop and rotate + const targetWidth = + Math.abs(visibleWidth * Math.cos(angle)) + Math.abs(visibleHeight * Math.sin(angle)); + const targetHeight = + Math.abs(visibleWidth * Math.sin(angle)) + Math.abs(visibleHeight * Math.cos(angle)); + + return { + targetWidth, + targetHeight, + originalWidth, + originalHeight, + visibleWidth, + visibleHeight, + }; +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getDropAndDragHelpers.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getDropAndDragHelpers.ts new file mode 100644 index 00000000000..4e99265fba1 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getDropAndDragHelpers.ts @@ -0,0 +1,41 @@ +import { DragAndDropHelper } from '../../pluginUtils/DragAndDrop/DragAndDropHelper'; +import { toArray } from 'roosterjs-content-model-dom'; +import type { ImageEditElementClass } from '../types/ImageEditElementClass'; +import type { ImageMetadataFormat } from 'roosterjs-content-model-types'; +import type { ImageEditOptions } from '../types/ImageEditOptions'; +import type { DragAndDropHandler } from '../../pluginUtils/DragAndDrop/DragAndDropHandler'; +import type { DragAndDropContext, DNDDirectionX, DnDDirectionY } from '../types/DragAndDropContext'; + +/** + * @internal + */ +export function getDropAndDragHelpers( + wrapper: HTMLElement, + editInfo: ImageMetadataFormat, + options: ImageEditOptions, + elementClass: ImageEditElementClass, + helper: DragAndDropHandler, + updateWrapper: (context: DragAndDropContext, _handle: HTMLElement) => void, + zoomScale: number +): DragAndDropHelper[] { + return getEditElements(wrapper, elementClass).map( + element => + new DragAndDropHelper( + element, + { + editInfo: editInfo, + options: options, + elementClass, + x: element.dataset.x as DNDDirectionX, + y: element.dataset.y as DnDDirectionY, + }, + updateWrapper, + helper, + zoomScale + ) + ); +} + +function getEditElements(wrapper: HTMLElement, elementClass: ImageEditElementClass): HTMLElement[] { + return toArray(wrapper.querySelectorAll('.' + elementClass)) as HTMLElement[]; +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getHTMLImageOptions.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getHTMLImageOptions.ts new file mode 100644 index 00000000000..9bd644bb9f4 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getHTMLImageOptions.ts @@ -0,0 +1,26 @@ +import { isASmallImage } from './imageEditUtils'; +import type { IEditor, ImageMetadataFormat } from 'roosterjs-content-model-types'; +import type { ImageEditOptions } from '../types/ImageEditOptions'; +import type { ImageHtmlOptions } from '../types/ImageHtmlOptions'; + +/** + * Default background colors for rotate handle + */ +const LIGHT_MODE_BGCOLOR = 'white'; +const DARK_MODE_BGCOLOR = '#333'; + +/** + * @internal + */ +export const getHTMLImageOptions = ( + editor: IEditor, + options: ImageEditOptions, + editInfo: ImageMetadataFormat +): ImageHtmlOptions => { + return { + borderColor: + options.borderColor || (editor.isDarkMode() ? DARK_MODE_BGCOLOR : LIGHT_MODE_BGCOLOR), + rotateHandleBackColor: editor.isDarkMode() ? DARK_MODE_BGCOLOR : LIGHT_MODE_BGCOLOR, + isSmallImage: isASmallImage(editInfo.widthPx ?? 0, editInfo.heightPx ?? 0), + }; +}; diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedContentModelImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedContentModelImage.ts new file mode 100644 index 00000000000..3d9085f8778 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/getSelectedContentModelImage.ts @@ -0,0 +1,19 @@ +import { getSelectedSegments } from 'roosterjs-content-model-dom'; +import type { + ReadonlyContentModelImage, + ShallowMutableContentModelDocument, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function getSelectedContentModelImage( + model: ShallowMutableContentModelDocument +): ReadonlyContentModelImage | null { + const selectedSegments = getSelectedSegments(model, false /*includeFormatHolder*/); + if (selectedSegments.length == 1 && selectedSegments[0].segmentType == 'Image') { + return selectedSegments[0]; + } + + return null; +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/imageEditUtils.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/imageEditUtils.ts new file mode 100644 index 00000000000..50db9e114fe --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/imageEditUtils.ts @@ -0,0 +1,120 @@ +import { MIN_HEIGHT_WIDTH } from '../constants/constants'; + +/** + * @internal + */ +export function getPx(value: number): string { + return value + 'px'; +} + +/** + * @internal + */ +export function isASmallImage(widthPx: number, heightPx: number): boolean { + return widthPx && heightPx && (widthPx < MIN_HEIGHT_WIDTH || heightPx < MIN_HEIGHT_WIDTH) + ? true + : false; +} + +/** + * @internal Calculate the rotated x and y distance for mouse moving + * @param x Original x distance + * @param y Original y distance + * @param angle Rotated angle, in radian + * @returns rotated x and y distances + */ +export function rotateCoordinate(x: number, y: number, angle: number): [number, number] { + if (x == 0 && y == 0) { + return [0, 0]; + } + const hypotenuse = Math.sqrt(x * x + y * y); + angle = Math.atan2(y, x) - angle; + return [hypotenuse * Math.cos(angle), hypotenuse * Math.sin(angle)]; +} + +/** + * @internal + */ +export function setFlipped( + element: HTMLElement | null, + flippedHorizontally?: boolean, + flippedVertically?: boolean +) { + if (element) { + element.style.transform = `scale(${flippedHorizontally ? -1 : 1}, ${ + flippedVertically ? -1 : 1 + })`; + } +} + +/** + * @internal + */ +export function setWrapperSizeDimensions( + wrapper: HTMLElement, + image: HTMLImageElement, + width: number, + height: number +) { + const hasBorder = image.style.borderStyle; + if (hasBorder) { + const borderWidth = image.style.borderWidth ? 2 * parseInt(image.style.borderWidth) : 2; + wrapper.style.width = getPx(width + borderWidth); + wrapper.style.height = getPx(height + borderWidth); + return; + } + wrapper.style.width = getPx(width); + wrapper.style.height = getPx(height); +} + +/** + * @internal + */ +export function setSize( + element: HTMLElement, + left: number | undefined, + top: number | undefined, + right: number | undefined, + bottom: number | undefined, + width: number | undefined, + height: number | undefined +) { + element.style.left = left !== undefined ? getPx(left) : element.style.left; + element.style.top = top !== undefined ? getPx(top) : element.style.top; + element.style.right = right !== undefined ? getPx(right) : element.style.right; + element.style.bottom = bottom !== undefined ? getPx(bottom) : element.style.bottom; + element.style.width = width !== undefined ? getPx(width) : element.style.width; + element.style.height = height !== undefined ? getPx(height) : element.style.height; +} + +/** + * @internal + * Check if the current image was resized by the user + * @param image the current image + * @returns if the user resized the image, returns true, otherwise, returns false + */ +export function checkIfImageWasResized(image: HTMLImageElement): boolean { + const { style } = image; + const isMaxWidthInitial = + style.maxWidth === '' || style.maxWidth === 'initial' || style.maxWidth === 'auto'; + if ( + isMaxWidthInitial && + (isFixedNumberValue(style.height) || isFixedNumberValue(style.width)) + ) { + return true; + } else { + return false; + } +} + +/** + * @internal + */ +export function isRTL(image: HTMLImageElement): boolean { + return window.getComputedStyle(image).direction === 'rtl'; +} + +function isFixedNumberValue(value: string | number) { + const numberValue = typeof value === 'string' ? parseInt(value) : value; + return !isNaN(numberValue); +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateHandleCursor.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateHandleCursor.ts new file mode 100644 index 00000000000..eedbba30011 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateHandleCursor.ts @@ -0,0 +1,29 @@ +const PI = Math.PI; +const DIRECTIONS = 8; +const DirectionRad = (PI * 2) / DIRECTIONS; +const DirectionOrder = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']; + +function handleRadIndexCalculator(angleRad: number): number { + const idx = Math.round(angleRad / DirectionRad) % DIRECTIONS; + return idx < 0 ? idx + DIRECTIONS : idx; +} + +function rotateHandles(angleRad: number, y: string = '', x: string = ''): string { + const radIndex = handleRadIndexCalculator(angleRad); + const originalDirection = y + x; + const originalIndex = DirectionOrder.indexOf(originalDirection); + const rotatedIndex = originalIndex >= 0 && originalIndex + radIndex; + return rotatedIndex ? DirectionOrder[rotatedIndex % DIRECTIONS] : ''; +} +/** + * @internal + * Rotate the resizer and cropper handles according to the image position. + * @param handles The resizer handles. + * @param angleRad The angle that the image was rotated. + */ +export function updateHandleCursor(handles: HTMLElement[], angleRad: number) { + handles.forEach(handle => { + const { y, x } = handle.dataset; + handle.style.cursor = `${rotateHandles(angleRad, y, x)}-resize`; + }); +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts new file mode 100644 index 00000000000..7edf511774d --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateImageEditInfo.ts @@ -0,0 +1,60 @@ +import { getSelectedContentModelImage } from './getSelectedContentModelImage'; +import { updateImageMetadata } from 'roosterjs-content-model-dom'; +import type { + ContentModelImage, + IEditor, + ImageMetadataFormat, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function updateImageEditInfo( + contentModelImage: ContentModelImage, + newImageMetadata?: ImageMetadataFormat | null +) { + updateImageMetadata( + contentModelImage, + newImageMetadata !== undefined + ? format => { + format = newImageMetadata; + return format; + } + : undefined + ); +} + +function getInitialEditInfo(image: HTMLImageElement): ImageMetadataFormat { + return { + src: image.getAttribute('src') || '', + widthPx: image.clientWidth, + heightPx: image.clientHeight, + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight, + leftPercent: 0, + rightPercent: 0, + topPercent: 0, + bottomPercent: 0, + angleRad: 0, + }; +} + +/** + * @internal + * @returns + */ +export function getSelectedImageMetadata( + editor: IEditor, + image: HTMLImageElement +): ImageMetadataFormat { + let imageMetadata: ImageMetadataFormat = getInitialEditInfo(image); + editor.formatContentModel(model => { + const selectedImage = getSelectedContentModelImage(model); + if (selectedImage) { + imageMetadata = { ...imageMetadata, ...selectedImage.dataset }; + } + return false; + }); + + return imageMetadata; +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateWrapper.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateWrapper.ts new file mode 100644 index 00000000000..43a8b58980d --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/updateWrapper.ts @@ -0,0 +1,141 @@ +import { doubleCheckResize } from './doubleCheckResize'; +import { getGeneratedImageSize } from './generateImageSize'; +import { ImageEditElementClass } from '../types/ImageEditElementClass'; +import { isElementOfType, isNodeOfType } from 'roosterjs-content-model-dom'; +import { updateHandleCursor } from './updateHandleCursor'; +import { updateSideHandlesVisibility } from '../Resizer/updateSideHandlesVisibility'; +import type { ImageEditOptions } from '../types/ImageEditOptions'; +import type { ImageMetadataFormat } from 'roosterjs-content-model-types'; +import { + getPx, + isASmallImage, + isRTL, + setFlipped, + setSize, + setWrapperSizeDimensions, +} from './imageEditUtils'; + +/** + * @internal + */ +export function updateWrapper( + editInfo: ImageMetadataFormat, + options: ImageEditOptions, + image: HTMLImageElement, + clonedImage: HTMLImageElement, + wrapper: HTMLSpanElement, + resizers?: HTMLDivElement[], + croppers?: HTMLDivElement[] +) { + const { + angleRad, + bottomPercent, + leftPercent, + rightPercent, + topPercent, + flippedHorizontal, + flippedVertical, + } = editInfo; + + const generateImageSize = getGeneratedImageSize(editInfo, croppers && croppers?.length > 0); + if (!generateImageSize) { + return; + } + const { + targetWidth, + targetHeight, + originalWidth, + originalHeight, + visibleWidth, + visibleHeight, + } = generateImageSize; + + const marginHorizontal = (targetWidth - visibleWidth) / 2; + const marginVertical = (targetHeight - visibleHeight) / 2; + const cropLeftPx = originalWidth * (leftPercent || 0); + const cropRightPx = originalWidth * (rightPercent || 0); + const cropTopPx = originalHeight * (topPercent || 0); + const cropBottomPx = originalHeight * (bottomPercent || 0); + + // Update size and margin of the wrapper + wrapper.style.margin = `${marginVertical}px ${marginHorizontal}px`; + wrapper.style.transform = `rotate(${angleRad}rad)`; + setWrapperSizeDimensions(wrapper, image, visibleWidth, 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 + if (isRTL(clonedImage)) { + wrapper.style.textAlign = 'right'; + if (!croppers) { + clonedImage.style.left = getPx(cropLeftPx); + clonedImage.style.right = getPx(-cropRightPx); + } + } else { + wrapper.style.textAlign = 'left'; + } + + // Update size of the image + clonedImage.style.width = getPx(originalWidth); + clonedImage.style.height = getPx(originalHeight); + clonedImage.style.verticalAlign = 'bottom'; + clonedImage.style.position = 'absolute'; + + //Update flip direction + setFlipped(clonedImage.parentElement, flippedHorizontal, flippedVertical); + const smallImage = isASmallImage(visibleWidth, visibleWidth); + + if (!croppers) { + // For rotate/resize, set the margin of the image so that cropped part won't be visible + clonedImage.style.margin = `${-cropTopPx}px 0 0 ${-cropLeftPx}px`; + } + + if (croppers && croppers.length > 0) { + const cropContainer = croppers[0]; + const cropOverlays = croppers.filter( + cropper => cropper.className === ImageEditElementClass.CropOverlay + ); + + setSize( + cropContainer, + cropLeftPx, + cropTopPx, + cropRightPx, + cropBottomPx, + undefined, + undefined + ); + setSize(cropOverlays[0], 0, 0, cropRightPx, undefined, undefined, cropTopPx); + 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); + + if (angleRad) { + updateHandleCursor(croppers, angleRad); + } + } + + if (resizers) { + const clientWidth = wrapper.clientWidth; + const clientHeight = wrapper.clientHeight; + + doubleCheckResize(editInfo, options.preserveRatio || false, clientWidth, clientHeight); + + const resizeHandles = resizers + .map(resizer => { + const resizeHandle = resizer.firstElementChild; + if ( + isNodeOfType(resizeHandle, 'ELEMENT_NODE') && + isElementOfType(resizeHandle, 'div') + ) { + return resizeHandle; + } + }) + .filter(handle => !!handle) as HTMLDivElement[]; + + if (angleRad) { + updateHandleCursor(resizeHandles, angleRad); + } + + updateSideHandlesVisibility(resizeHandles, smallImage); + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index fd29faee83f..6ae637a1d56 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -34,5 +34,7 @@ export { PickerPlugin } from './picker/PickerPlugin'; export { PickerHelper } from './picker/PickerHelper'; export { PickerSelectionChangMode, PickerDirection, PickerHandler } from './picker/PickerHandler'; export { CustomReplacePlugin, CustomReplace } from './customReplace/CustomReplacePlugin'; +export { ImageEditPlugin } from './imageEdit/ImageEditPlugin'; +export { ImageEditOptions } from './imageEdit/types/ImageEditOptions'; export { getDOMInsertPointRect } from './pluginUtils/Rect/getDOMInsertPointRect'; diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/Cropper/createImageCropperTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/Cropper/createImageCropperTest.ts new file mode 100644 index 00000000000..820270013c9 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/Cropper/createImageCropperTest.ts @@ -0,0 +1,48 @@ +import { createImageCropper } from '../../../lib/imageEdit/Cropper/createImageCropper'; + +const cropperCenterHTML = + '
'; +const cropTopLeftHTML = + '
'; +const cropTopRightHTML = + '
'; +const cropBottomLeftHTML = + '
'; +const cropBottomRightHTML = + '
'; + +describe('createImageCropper', () => { + it('should create the croppers', () => { + const croppers = createImageCropper(document); + const cropCenterDiv = document.createElement('div'); + const cropOverlayTopLeftDiv = document.createElement('div'); + const cropOverlayTopRightDiv = document.createElement('div'); + const cropOverlayBottomLeftDiv = document.createElement('div'); + const cropOverlayBottomRightDiv = document.createElement('div'); + document.body.appendChild(cropCenterDiv); + document.body.appendChild(cropOverlayTopLeftDiv); + document.body.appendChild(cropOverlayTopRightDiv); + document.body.appendChild(cropOverlayBottomLeftDiv); + document.body.appendChild(cropOverlayBottomRightDiv); + cropCenterDiv.innerHTML = cropperCenterHTML; + cropOverlayTopLeftDiv.innerHTML = cropTopLeftHTML; + cropOverlayTopRightDiv.innerHTML = cropTopRightHTML; + cropOverlayBottomLeftDiv.innerHTML = cropBottomLeftHTML; + cropOverlayBottomRightDiv.innerHTML = cropBottomRightHTML; + const cropCenter = cropCenterDiv.firstElementChild!; + const cropOverlayTopRight = cropOverlayTopRightDiv.firstElementChild!; + const cropOverlayTopLeft = cropOverlayTopLeftDiv.firstElementChild!; + const cropOverlayBottomLeft = cropOverlayBottomLeftDiv.firstElementChild!; + const cropOverlayBottomRight = cropOverlayBottomRightDiv.firstElementChild!; + + const expectedCropper = [ + cropCenter, + cropOverlayTopLeft, + cropOverlayTopRight, + cropOverlayBottomLeft, + cropOverlayBottomRight, + ] as HTMLDivElement[]; + + expect(JSON.stringify(croppers)).toEqual(JSON.stringify(expectedCropper)); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/Cropper/cropperTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/Cropper/cropperTest.ts new file mode 100644 index 00000000000..0d8001a43af --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/Cropper/cropperTest.ts @@ -0,0 +1,134 @@ +import { Cropper } from '../../../lib/imageEdit/Cropper/cropperContext'; +import { + DNDDirectionX, + DnDDirectionY, + DragAndDropContext, +} from '../../../lib/imageEdit/types/DragAndDropContext'; +import { ImageEditOptions } from '../../../lib/imageEdit/types/ImageEditOptions'; +import type { ImageMetadataFormat } from 'roosterjs-content-model-types'; + +describe('Cropper: crop only', () => { + const options: ImageEditOptions = { + minWidth: 10, + minHeight: 10, + }; + + const initValue = { + leftPercent: 0, + rightPercent: 0, + topPercent: 0, + bottomPercent: 0, + }; + const mouseEvent: MouseEvent = {} as any; + const Xs: DNDDirectionX[] = ['w', '', 'e']; + const Ys: DnDDirectionY[] = ['n', '', 's']; + + function getInitEditInfo(): ImageMetadataFormat { + return { + src: '', + naturalWidth: 100, + naturalHeight: 200, + leftPercent: 0, + topPercent: 0, + rightPercent: 0, + bottomPercent: 0, + widthPx: 100, + heightPx: 200, + angleRad: 0, + }; + } + + function runTest( + e: MouseEvent, + getEditInfo: () => ImageMetadataFormat, + expectedResult: { width: number; height: number } + ) { + let actualResult: { width: number; height: number } = { width: 0, height: 0 }; + Xs.forEach(x => { + Ys.forEach(y => { + const editInfo = getEditInfo(); + const context: DragAndDropContext = { + elementClass: '', + x, + y, + editInfo, + options, + }; + + Cropper.onDragging?.(context, e, initValue, 20, 20); + actualResult = { + width: Math.floor(editInfo.widthPx || 0), + height: Math.floor(editInfo.heightPx || 0), + }; + }); + }); + + expect(actualResult).toEqual(expectedResult); + } + + it('Crop right', () => { + runTest( + mouseEvent, + () => { + const editInfo = getInitEditInfo(); + editInfo.rightPercent = -0.1; + return editInfo; + }, + { width: 90, height: 200 } + ); + }); + + it('Crop top', () => { + runTest( + mouseEvent, + () => { + const editInfo = getInitEditInfo(); + editInfo.topPercent = 0.5; + return editInfo; + }, + { width: 100, height: 200 } + ); + }); + + it('Crop top and bottom', () => { + runTest( + mouseEvent, + () => { + const editInfo = getInitEditInfo(); + editInfo.topPercent = 0.1; + editInfo.bottomPercent = -0.1; + return editInfo; + }, + { width: 100, height: 180 } + ); + }); + + it('Crop left and right', () => { + runTest( + mouseEvent, + () => { + const editInfo = getInitEditInfo(); + editInfo.leftPercent = 0.1; + editInfo.rightPercent = -0.1; + return editInfo; + }, + { width: 90, height: 200 } + ); + }); + + it('Crop all', () => { + runTest( + mouseEvent, + () => { + const editInfo = getInitEditInfo(); + + editInfo.leftPercent = 0.1; + editInfo.rightPercent = -0.1; + editInfo.topPercent = 0.1; + editInfo.bottomPercent = -0.1; + return editInfo; + }, + { width: 90, height: 180 } + ); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts new file mode 100644 index 00000000000..4b6c7dba2f5 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -0,0 +1,65 @@ +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { getSelectedImageMetadata } from '../../lib/imageEdit/utils/updateImageEditInfo'; +import { ImageEditPlugin } from '../../lib/imageEdit/ImageEditPlugin'; +import { initEditor } from '../TestHelper'; + +const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, +}; + +describe('ImageEditPlugin', () => { + const plugin = new ImageEditPlugin(); + const editor = initEditor('image_edit', [plugin], model); + + it('flip', () => { + const image = new Image(); + image.src = 'test'; + plugin.initialize(editor); + plugin.flipImage('horizontal'); + const dataset = getSelectedImageMetadata(editor, image); + expect(dataset).toBeTruthy(); + plugin.dispose(); + }); + + it('rotate', () => { + const image = new Image(); + image.src = 'test'; + plugin.initialize(editor); + plugin.rotateImage(Math.PI / 2); + const dataset = getSelectedImageMetadata(editor, image); + expect(dataset).toBeTruthy(); + plugin.dispose(); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/Resizer/ResizerTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/Resizer/ResizerTest.ts new file mode 100644 index 00000000000..c9cb2ecdb5c --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/Resizer/ResizerTest.ts @@ -0,0 +1,162 @@ +import { ImageEditOptions } from '../../../lib/imageEdit/types/ImageEditOptions'; +import { Resizer } from '../../../lib/imageEdit/Resizer/resizerContext'; + +import { + DNDDirectionX, + DnDDirectionY, + DragAndDropContext, +} from '../../../lib/imageEdit/types/DragAndDropContext'; +import type { ImageMetadataFormat, ImageResizeMetadataFormat } from 'roosterjs-content-model-types'; + +describe('Resizer: resize only', () => { + const options: ImageEditOptions = { + minWidth: 10, + minHeight: 10, + }; + + const initValue: ImageResizeMetadataFormat = { widthPx: 100, heightPx: 200 }; + const mouseEvent: MouseEvent = {} as any; + const mouseEventShift: MouseEvent = { shiftKey: true } as any; + const Xs: DNDDirectionX[] = ['w', '', 'e']; + const Ys: DnDDirectionY[] = ['n', '', 's']; + + function getInitEditInfo(): ImageMetadataFormat { + return { + src: '', + naturalWidth: 100, + naturalHeight: 200, + leftPercent: 0, + topPercent: 0, + rightPercent: 0, + bottomPercent: 0, + widthPx: 100, + heightPx: 200, + angleRad: 0, + }; + } + + function runTest( + e: MouseEvent, + getEditInfo: () => ImageMetadataFormat, + expectedResult: Record> + ) { + const actualResult: { [key: string]: { [key: string]: [number, number] } } = {}; + Xs.forEach(x => { + actualResult[x] = {}; + Ys.forEach(y => { + const editInfo = getEditInfo(); + const context: DragAndDropContext = { + elementClass: '', + x, + y, + editInfo, + options, + }; + + Resizer.onDragging?.(context, e, initValue, 20, 20); + actualResult[x][y] = [ + Math.floor(editInfo.widthPx || 0), + Math.floor(editInfo.heightPx || 0), + ]; + }); + }); + + expect(actualResult).toEqual(expectedResult); + } + + it('Not shift key', () => { + runTest(mouseEvent, getInitEditInfo, { + w: { + n: [80, 180], + '': [80, 200], + s: [80, 220], + }, + '': { + n: [100, 180], + '': [100, 200], + s: [100, 220], + }, + e: { + n: [120, 180], + '': [120, 200], + s: [120, 220], + }, + }); + }); + + it('With shift key', () => { + runTest(mouseEventShift, getInitEditInfo, { + w: { + n: [80, 160], + '': [80, 200], + s: [80, 160], + }, + '': { + n: [100, 180], + '': [100, 200], + s: [100, 220], + }, + e: { + n: [120, 240], + '': [120, 200], + s: [120, 240], + }, + }); + }); + + it('With rotation', () => { + runTest( + mouseEvent, + () => { + const editInfo = getInitEditInfo(); + editInfo.angleRad = Math.PI / 6; + return editInfo; + }, + { + w: { + n: [72, 192], + '': [72, 200], + s: [72, 207], + }, + '': { + n: [100, 192], + '': [100, 200], + s: [100, 207], + }, + e: { + n: [127, 192], + '': [127, 200], + s: [127, 207], + }, + } + ); + }); + + it('With rotation and SHIFT key', () => { + runTest( + mouseEventShift, + () => { + const editInfo = getInitEditInfo(); + editInfo.angleRad = Math.PI / 6; + return editInfo; + }, + { + w: { + n: [72, 145], + '': [72, 200], + s: [72, 145], + }, + '': { + n: [100, 192], + '': [100, 200], + s: [100, 207], + }, + e: { + n: [127, 254], + '': [127, 200], + s: [127, 254], + }, + } + ); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/Resizer/createImageResizerTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/Resizer/createImageResizerTest.ts new file mode 100644 index 00000000000..590b69ef19f --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/Resizer/createImageResizerTest.ts @@ -0,0 +1,69 @@ +import { createImageResizer } from '../../../lib/imageEdit/Resizer/createImageResizer'; +import { DNDDirectionX, DnDDirectionY } from '../../../lib/imageEdit/types/DragAndDropContext'; +import { + RESIZE_HANDLE_MARGIN, + RESIZE_HANDLE_SIZE, + Xs, + Ys, +} from '../../../lib/imageEdit/constants/constants'; + +describe('createImageResizer', () => { + it('should create the croppers', () => { + const result = createImageResizer(document); + const resizers = [...createCorners(), ...createSides()].filter(element => !!element); + expect(result).toEqual(resizers); + }); +}); + +const createCorners = () => { + let corners: HTMLDivElement[] = []; + Xs.forEach(x => + Ys.forEach(y => { + const handle = (x == '') == (y == '') ? createResizeHandle(x, y) : null; + if (handle) { + corners.push(handle); + } + }) + ); + return corners; +}; + +const createSides = () => { + let sides: HTMLDivElement[] = []; + Xs.forEach(x => + Ys.forEach(y => { + const handle = (x == '') != (y == '') ? createResizeHandle(x, y) : null; + if (handle) { + sides.push(handle); + } + }) + ); + return sides; +}; + +const createHandleStyle = (direction: string, topOrBottom: string, leftOrRight: string) => { + return `position:relative;width:${RESIZE_HANDLE_SIZE}px;height:${RESIZE_HANDLE_SIZE}px;background-color: #FFFFFF;cursor:${direction}-resize;${topOrBottom}:-${RESIZE_HANDLE_MARGIN}px;${leftOrRight}:-${RESIZE_HANDLE_MARGIN}px;border-radius:100%;border: 2px solid #bfbfbf;box-shadow: 0px 0.36316px 1.36185px rgba(100, 100, 100, 0.25);`; +}; + +function createResizeHandle(x: DNDDirectionX, y: DnDDirectionY) { + if (x == '' && y == '') { + return undefined; + } + const leftOrRight = x == 'w' ? 'left' : 'right'; + const topOrBottom = y == 'n' ? 'top' : 'bottom'; + const leftOrRightValue = x == '' ? '50%' : '0px'; + const topOrBottomValue = y == '' ? '50%' : '0px'; + const direction = y + x; + const resizer = document.createElement('div'); + resizer.setAttribute( + 'style', + `position:absolute;${leftOrRight}:${leftOrRightValue};${topOrBottom}:${topOrBottomValue}` + ); + const handle = document.createElement('div'); + handle.setAttribute('style', createHandleStyle(direction, topOrBottom, leftOrRight)); + handle.className = 'r_resizeH'; + handle.dataset['x'] = x; + handle.dataset['y'] = y; + resizer.appendChild(handle); + return resizer; +} diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/Resizer/updateSideHandlesVisibilityTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/Resizer/updateSideHandlesVisibilityTest.ts new file mode 100644 index 00000000000..ee0815b8368 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/Resizer/updateSideHandlesVisibilityTest.ts @@ -0,0 +1,27 @@ +import { updateSideHandlesVisibility } from '../../../lib/imageEdit/Resizer/updateSideHandlesVisibility'; + +describe('updateSideHandlesVisibility', () => { + it('should hide handle ', () => { + const handle1 = document.createElement('div'); + handle1.dataset['y'] = 'n'; + handle1.dataset['x'] = ''; + updateSideHandlesVisibility([handle1], true); + expect(handle1.style.display).toBe('none'); + }); + + it('should not side hide handle ', () => { + const handle1 = document.createElement('div'); + handle1.dataset['y'] = 'n'; + handle1.dataset['x'] = ''; + updateSideHandlesVisibility([handle1], false); + expect(handle1.style.display).toBe(''); + }); + + it('should not hide corner handle ', () => { + const handle1 = document.createElement('div'); + handle1.dataset['y'] = 'n'; + handle1.dataset['x'] = 'w'; + updateSideHandlesVisibility([handle1], true); + expect(handle1.style.display).toBe(''); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/createImageRotatorTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/createImageRotatorTest.ts new file mode 100644 index 00000000000..c707a5f6d0e --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/createImageRotatorTest.ts @@ -0,0 +1,20 @@ +import { createImageRotator } from '../../../lib/imageEdit/Rotator/createImageRotator'; + +const rotatorOuterHTML = + '
'; + +describe('createImageRotator', () => { + it('should create the croppers', () => { + const result = createImageRotator(document, { + borderColor: '#DB626C', + rotateHandleBackColor: '#DB626C', + } as any); + const div = document.createElement('div'); + document.body.appendChild(div); + div.innerHTML = rotatorOuterHTML; + const expectedRotator = div.firstElementChild! as HTMLDivElement; + + expect(result).toEqual([expectedRotator]); + document.body.removeChild(div); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/rotatorTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/rotatorTest.ts new file mode 100644 index 00000000000..99e30485e27 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/rotatorTest.ts @@ -0,0 +1,104 @@ +import { ImageEditOptions } from '../../../lib/imageEdit/types/ImageEditOptions'; +import { Rotator } from '../../../lib/imageEdit/Rotator/rotatorContext'; + +import { + DNDDirectionX, + DnDDirectionY, + DragAndDropContext, +} from '../../../lib/imageEdit/types/DragAndDropContext'; +import type { ImageMetadataFormat, ImageRotateMetadataFormat } from 'roosterjs-content-model-types'; + +const ROTATE_SIZE = 32; +const ROTATE_GAP = 15; +const DEG_PER_RAD = 180 / Math.PI; +const DEFAULT_ROTATE_HANDLE_HEIGHT = ROTATE_SIZE / 2 + ROTATE_GAP; + +describe('Rotate: rotate only', () => { + const options: ImageEditOptions = { + minRotateDeg: 10, + }; + + const initValue: ImageRotateMetadataFormat = { angleRad: 0 }; + const mouseEvent: MouseEvent = {} as any; + const mouseEventAltKey: MouseEvent = { altkey: true } as any; + const Xs: DNDDirectionX[] = ['w', '', 'e']; + const Ys: DnDDirectionY[] = ['n', '', 's']; + + function getInitEditInfo(): ImageMetadataFormat { + return { + src: '', + naturalWidth: 100, + naturalHeight: 200, + leftPercent: 0, + topPercent: 0, + rightPercent: 0, + bottomPercent: 0, + widthPx: 100, + heightPx: 200, + angleRad: 0, + }; + } + + function runTest( + e: MouseEvent, + getEditInfo: () => ImageMetadataFormat, + expectedResult: number + ) { + let angle = 0; + Xs.forEach(x => { + Ys.forEach(y => { + const editInfo = getEditInfo(); + const context: DragAndDropContext = { + elementClass: '', + x, + y, + editInfo, + options, + }; + Rotator.onDragging?.(context, e, initValue, 20, 20); + angle = editInfo.angleRad || 0; + }); + }); + + expect(angle).toEqual(expectedResult); + } + + it('Rotate alt key', () => { + runTest( + mouseEventAltKey, + () => { + const editInfo = getInitEditInfo(); + editInfo.heightPx = 100; + return editInfo; + }, + calculateAngle(100, mouseEventAltKey) + ); + }); + + it('Rotate no alt key', () => { + runTest( + mouseEvent, + () => { + const editInfo = getInitEditInfo(); + editInfo.heightPx = 180; + return editInfo; + }, + calculateAngle(180, mouseEvent) + ); + }); +}); + +function calculateAngle(heightPx: number, mouseInfo: MouseEvent) { + const distance = heightPx / 2 + DEFAULT_ROTATE_HANDLE_HEIGHT; + const newX = distance * Math.sin(0) + 20; + const newY = distance * Math.cos(0) - 20; + let angleInRad = Math.atan2(newX, newY); + + if (!mouseInfo.altKey) { + const angleInDeg = angleInRad * DEG_PER_RAD; + const adjustedAngleInDeg = Math.round(angleInDeg / 10) * 10; + angleInRad = adjustedAngleInDeg / DEG_PER_RAD; + } + + return angleInRad; +} diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/updateRotateHandleTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/updateRotateHandleTest.ts new file mode 100644 index 00000000000..98cbfaa7155 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/Rotator/updateRotateHandleTest.ts @@ -0,0 +1,245 @@ +import * as TestHelper from '../../TestHelper'; +import { createImageWrapper } from '../../../lib/imageEdit/utils/createImageWrapper'; +import { ImageEditPlugin } from '../../../lib/imageEdit/ImageEditPlugin'; +import { ImageHtmlOptions } from '../../../lib/imageEdit/types/ImageHtmlOptions'; +import { updateRotateHandle } from '../../../lib/imageEdit/Rotator/updateRotateHandle'; + +import type { IEditor, Rect } from 'roosterjs-content-model-types'; + +const DEG_PER_RAD = 180 / Math.PI; + +//this tests are not consistent +xdescribe('updateRotateHandlePosition', () => { + let editor: IEditor; + const TEST_ID = 'imageEditTest_rotateHandlePosition'; + let plugin: ImageEditPlugin; + let editorGetVisibleViewport: any; + beforeEach(() => { + plugin = new ImageEditPlugin(); + editor = TestHelper.initEditor(TEST_ID, [plugin]); + editorGetVisibleViewport = spyOn(editor, 'getVisibleViewport'); + }); + + afterEach(() => { + let element = document.getElementById(TEST_ID); + if (element) { + element.parentElement?.removeChild(element); + } + editor.dispose(); + }); + const options: ImageHtmlOptions = { + borderColor: 'blue', + rotateHandleBackColor: 'blue', + isSmallImage: false, + }; + + function runTest( + rotatePosition: DOMRect, + rotateCenterTop: string, + rotateCenterHeight: string, + rotateHandleTop: string, + wrapperPosition: DOMRect, + angle: number + ) { + const imageSpan = document.createElement('span'); + const image = document.createElement('img'); + imageSpan.appendChild(image); + document.body.appendChild(imageSpan); + const imageInfo = { + src: image.getAttribute('src') || '', + widthPx: image.clientWidth, + heightPx: image.clientHeight, + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight, + leftPercent: 0, + rightPercent: 0, + topPercent: 0, + bottomPercent: 0, + angleRad: 0, + }; + const { wrapper } = createImageWrapper( + editor, + image, + imageSpan, + {}, + imageInfo, + options, + 'rotate' + ); + const rotateCenter = wrapper.querySelector('.r_rotateC')! as HTMLElement; + const rotateHandle = wrapper.querySelector('.r_rotateH')! as HTMLElement; + spyOn(rotateHandle, 'getBoundingClientRect').and.returnValues(rotatePosition); + spyOn(wrapper, 'getBoundingClientRect').and.returnValues(wrapperPosition); + const viewport: Rect = { + top: 1, + bottom: 200, + left: 1, + right: 200, + }; + editorGetVisibleViewport.and.returnValue(viewport); + const angleRad = angle / DEG_PER_RAD; + + updateRotateHandle(viewport, angleRad, wrapper, rotateCenter, rotateHandle, false); + + expect(rotateCenter.style.top).toBe(rotateCenterTop); + expect(rotateCenter.style.height).toBe(rotateCenterHeight); + expect(rotateHandle.style.top).toBe(rotateHandleTop); + + document.body.removeChild(imageSpan); + } + + xit('adjust rotate handle - ROTATOR HIDDEN ON TOP', () => { + runTest( + { + top: 0, + bottom: 3, + left: 3, + right: 5, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + '-21px', + '15px', + '7px', + { + top: 2, + bottom: 3, + left: 2, + right: 5, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + 0 + ); + }); + + xit('adjust rotate handle - ROTATOR NOT HIDDEN', () => { + runTest( + { + top: 2, + bottom: 3, + left: 3, + right: 5, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + '-21px', + '15px', + '-32px', + { + top: 0, + bottom: 20, + left: 3, + right: 5, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + 50 + ); + }); + + it('adjust rotate handle - ROTATOR HIDDEN ON LEFT', () => { + runTest( + { + top: 2, + bottom: 3, + left: 2, + right: 5, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + '-12px', + '6px', + '0px', + { + top: 2, + bottom: 3, + left: 2, + right: 5, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + -90 + ); + }); + + xit('adjust rotate handle - ROTATOR HIDDEN ON BOTTOM', () => { + runTest( + { + top: 2, + bottom: 200, + left: 1, + right: 5, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + '-16px', + '10px', + '0px', + { + top: 0, + bottom: 190, + left: 3, + right: 190, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + 180 + ); + }); + + xit('adjust rotate handle - ROTATOR HIDDEN ON RIGHT', () => { + runTest( + { + top: 2, + bottom: 3, + left: 1, + right: 200, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + '-6px', + '0px', + '0px', + { + top: 0, + bottom: 190, + left: 3, + right: 190, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + 90 + ); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/applyChangeTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/applyChangeTest.ts new file mode 100644 index 00000000000..fdcd0feefa0 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/applyChangeTest.ts @@ -0,0 +1,435 @@ +import { applyChange } from '../../../lib/imageEdit/utils/applyChange'; +import { createImage } from 'roosterjs-content-model-dom'; +import { formatInsertPointWithContentModel } from 'roosterjs-content-model-api'; +import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; +import type { + ContentModelDocument, + IEditor, + ImageMetadataFormat, + PluginEventType, +} from 'roosterjs-content-model-types'; + +const IMG_SRC = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAKCAYAAAC0VX7mAAAALUlEQVQ4EWNgYGD4T2U8lAz8TyZACzKEl8k0Dz0OhpKBaGGB7hVi+QgvD0oDATe/bqDDw39VAAAAAElFTkSuQmCC'; +const WIDTH = 20; +const HEIGHT = 10; +const contentModelImage = createImage(IMG_SRC); +const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: IMG_SRC, + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, +}; + +//disabled because this test fails on Linux + +xdescribe('applyChange', () => { + let img: HTMLImageElement; + let editor: IEditor; + let triggerEvent: jasmine.Spy; + + beforeEach(async () => { + img = await loadImage(IMG_SRC); + document.body.appendChild(img); + triggerEvent = jasmine.createSpy('triggerEvent'); + editor = ({ + focus: () => {}, + triggerEvent: (type: PluginEventType, obj: any) => { + triggerEvent(); + return { + eventType: type, + ...obj, + }; + }, + }); + }); + + afterEach(() => { + img?.parentNode?.removeChild(img); + }); + + function runTest(input: ContentModelDocument, callback: () => boolean) { + const formatWithContentModelSpy = jasmine + .createSpy('formatWithContentModel') + .and.callFake((callback, options) => { + return callback(input, { + newEntities: [], + deletedEntities: [], + newImages: [], + canUndoByBackspace: true, + }); + }); + formatInsertPointWithContentModel( + { + formatContentModel: formatWithContentModelSpy, + focus: () => {}, + triggerEvent: (type: PluginEventType, obj: any) => { + triggerEvent(); + return { + eventType: type, + ...obj, + }; + }, + } as any, + {} as any, + callback, + { + selectionOverride: { + type: 'image', + image: img, + }, + } + ); + } + + itChromeOnly('Write back with no change', () => { + const editInfo = getEditInfoFromImage(img); + + applyChange(editor, img, contentModelImage, editInfo, IMG_SRC, false); + + expect(img.outerHTML).toBe(``); + }); + + itChromeOnly('Write back with resize only', () => { + const editInfo = getEditInfoFromImage(img); + editInfo.widthPx = 100; + applyChange(editor, img, contentModelImage, editInfo, IMG_SRC, true); + expect(img.outerHTML).toBe(``); + }); + + itChromeOnly('Write back with rotate only', () => { + runTest(model, () => { + const editInfo = getEditInfoFromImage(img); + editInfo.angleRad = Math.PI / 2; + applyChange(editor, img, contentModelImage, editInfo, IMG_SRC, false); + const newSrc = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAUCAYAAAC07qxWAAAAAXNSR0IArs4c6QAAACxJREFUOE9jZGBg+M9ABGCkrcL//1FdwcgIshACUKweVQgOktHgoW16xJcjAK3PPgHKCkLrAAAAAElFTkSuQmCC'; + + const metadata: ImageMetadataFormat = JSON.parse( + contentModelImage.dataset['editingInfo'] + ); + expect(metadata).toEqual({ + src: IMG_SRC, + widthPx: WIDTH, + heightPx: HEIGHT, + naturalWidth: WIDTH, + naturalHeight: HEIGHT, + leftPercent: 0, + rightPercent: 0, + topPercent: 0, + bottomPercent: 0, + angleRad: 1.5707963267948966, + }); + expect(contentModelImage.src).toBe(newSrc); + expect(triggerEvent).toHaveBeenCalled(); + return true; + }); + }); + + itChromeOnly('Write back with crop only', () => { + runTest(model, () => { + const editInfo = getEditInfoFromImage(img); + editInfo.leftPercent = 0.1; + editInfo.rightPercent = 0.2; + editInfo.topPercent = 0.3; + editInfo.bottomPercent = 0.4; + applyChange(editor, img, contentModelImage, editInfo, IMG_SRC, false); + const newSrc = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAKCAYAAAC0VX7mAAAAAXNSR0IArs4c6QAAADpJREFUOE9jZGBg+O/v788QEBDAQA3ACDKwvLycoaKighrmMYANrK+vZ2hoaBg1kLwQGA1D8sINWRcASiEmkzo9vZYAAAAASUVORK5CYII='; + + expect(triggerEvent).toHaveBeenCalled(); + const metadata: ImageMetadataFormat = JSON.parse( + contentModelImage.dataset['editingInfo'] + ); + expect(metadata).toEqual({ + src: IMG_SRC, + widthPx: WIDTH, + heightPx: HEIGHT, + naturalWidth: WIDTH, + naturalHeight: HEIGHT, + leftPercent: 0.1, + rightPercent: 0.2, + topPercent: 0.3, + bottomPercent: 0.4, + angleRad: 0, + }); + expect(contentModelImage.src).toBe(newSrc); + return true; + }); + }); + + itChromeOnly('Write back with rotate and crop', () => { + runTest(model, () => { + const editInfo = getEditInfoFromImage(img); + editInfo.leftPercent = 0.1; + editInfo.rightPercent = 0.2; + editInfo.topPercent = 0.3; + editInfo.bottomPercent = 0.4; + editInfo.angleRad = Math.PI / 4; + applyChange(editor, img, contentModelImage, editInfo, IMG_SRC, false); + const newSrc = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABUAAAAVCAYAAACpF6WWAAAAAXNSR0IArs4c6QAAAQ9JREFUOE+t07FthDAYBeDnAWCJVPS3QoqUkRgjEjnJUlJgIHKTVEG6ZW6ANJiePZCuuO5PjERkwAZs4ZafT/bzM4N7vQB4AHBembF+Yo4fNHhJ0xRJktRSSi/Yhg4g5xxZlqHrOrRtW+d5vhueozkAqcGiKBBFEfq+R9M0XrCJVgBKExyj8YVH1AmGwBrdBOewUupLCPHuasWAcs7LMcOt+oxRKKU+hBB6Q4s1HP9vsIrjuNwCLTu2wv8XRURDDEfAk0odBS/KfwRsfaZE9AngLTQK19sHEX0DeA2BnajGQuFVNAC+AnjeRD3gHwCPjLH7LnQHrMEnxthNz+5GV+AJ6I1a4AUYhBrwyTyyWb1fIePUTmYAkbMAAAAASUVORK5CYII='; + + expect(triggerEvent).toHaveBeenCalled(); + const metadata: ImageMetadataFormat = JSON.parse( + contentModelImage.dataset['editingInfo'] + ); + expect(metadata).toEqual({ + src: IMG_SRC, + widthPx: WIDTH, + heightPx: HEIGHT, + naturalWidth: WIDTH, + naturalHeight: HEIGHT, + leftPercent: 0.1, + rightPercent: 0.2, + topPercent: 0.3, + bottomPercent: 0.4, + angleRad: 0.7853981633974483, + }); + expect(contentModelImage.src).toBe(newSrc); + return true; + }); + }); + + itChromeOnly('Write back with triggerEvent', () => { + runTest(model, () => { + const editInfo = getEditInfoFromImage(img); + editInfo.angleRad = Math.PI / 2; + + const newSrc = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABUAAAAVCAYAAACpF6WWAAAAlElEQVQ4je3MsQ3DIBBG4eszH5MgOiqgxAPReRcGAN0CfyqncAQckMJFXvukj6idJqLY+dNpIoJSCtban8CaiGCMQc4ZKSV477fgD8jMAIBSyhb8BV6twk3wqtY6BQ/BFThKwDvsnDu6KjNHkTgLA3gWHEL4w3L4WIDPLroAnwBeQ3QCloNCeB4cwOtgA94Hb3ATfAMzcgdiCyJ6YgAAAABJRU5ErkJggg=='; + editor.triggerEvent = (() => { + return { newSrc }; + }); + applyChange(editor, img, contentModelImage, editInfo, IMG_SRC, false); + + const metadata: ImageMetadataFormat = JSON.parse( + contentModelImage.dataset['editingInfo'] + ); + expect(metadata).toEqual({ + src: IMG_SRC, + widthPx: WIDTH, + heightPx: HEIGHT, + naturalWidth: WIDTH, + naturalHeight: HEIGHT, + leftPercent: 0, + rightPercent: 0, + topPercent: 0, + bottomPercent: 0, + angleRad: 1.5707963267948966, + }); + expect(contentModelImage.src).toBe(newSrc); + return true; + }); + }); + + itChromeOnly('Resize then rotate', () => { + runTest(model, () => { + let editInfo = getEditInfoFromImage(img); + editInfo.widthPx = editInfo.widthPx * 2; + editInfo.angleRad = Math.PI / 4; + applyChange(editor, img, contentModelImage, editInfo, IMG_SRC, true); + + const newSrc = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAjCAYAAAAe2bNZAAAAAXNSR0IArs4c6QAAAZBJREFUWEfF1r1OwzAQwPF/n6GPwDN0jsTCQhaWpuqA0pkBJhiQoEKqYCoSezslyjuwpAtbJV6jQ4VUMcCGLNUoTep81edmtZP8fHf2uYP5uQJOgJuSOVaHOoavKcjbduzVFWgfJgvRViegPOYeeDJESxyUxTwCDxVFIArSmDoQ8ZQpTCnE9302mw2LxSIbNJEIlWIUZDgc0u/3GY1GzOdzUZAxTVmIFkiDjAXseR5pmhbqWRKU39o79ROGIbPZzBlo36F3NJCpHRwFZMKo1LQFvQB3bTpoGeYQ0Hi7mEamKoxTUB2MM1BdjBNQE4wCPQO3uhAanEO1aqgpRjmmwLUEqA1GDNQWIwI6BGMddCjGKsgGxhrIFqY2aDAYkCRJtk38b3ubmErQarUiiiImkwnr9boAso0xgjQkjmOWy+W+BjqWwBRAQRDQ6/Uogah3fqQwBVC3282nJhudX8CXxBRAhsvNN3ABvEtjqkAKcgZ8qIkuMCbQDsQlJg/6As51RHT6XEVG/09dPy6BU+AzX0N/QcPh4EzchP8AAAAASUVORK5CYII='; + + const metadata: ImageMetadataFormat = JSON.parse( + contentModelImage.dataset['editingInfo'] + ); + expect(metadata).toEqual({ + src: IMG_SRC, + widthPx: WIDTH * 2, + heightPx: HEIGHT, + naturalWidth: WIDTH, + naturalHeight: HEIGHT, + leftPercent: 0, + rightPercent: 0, + topPercent: 0, + bottomPercent: 0, + angleRad: 0.7853981633974483, + }); + expect(contentModelImage.src).toBe(newSrc); + return true; + }); + }); + + itChromeOnly('Rotate then resize', () => { + runTest(model, () => { + let editInfo = getEditInfoFromImage(img); + editInfo.angleRad = Math.PI / 4; + editInfo.widthPx = editInfo.widthPx * 2; + applyChange(editor, img, contentModelImage, editInfo, IMG_SRC, true); + + const newSrc = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAjCAYAAAAe2bNZAAAAAXNSR0IArs4c6QAAAZBJREFUWEfF1r1OwzAQwPF/n6GPwDN0jsTCQhaWpuqA0pkBJhiQoEKqYCoSezslyjuwpAtbJV6jQ4VUMcCGLNUoTep81edmtZP8fHf2uYP5uQJOgJuSOVaHOoavKcjbduzVFWgfJgvRViegPOYeeDJESxyUxTwCDxVFIArSmDoQ8ZQpTCnE9302mw2LxSIbNJEIlWIUZDgc0u/3GY1GzOdzUZAxTVmIFkiDjAXseR5pmhbqWRKU39o79ROGIbPZzBlo36F3NJCpHRwFZMKo1LQFvQB3bTpoGeYQ0Hi7mEamKoxTUB2MM1BdjBNQE4wCPQO3uhAanEO1aqgpRjmmwLUEqA1GDNQWIwI6BGMddCjGKsgGxhrIFqY2aDAYkCRJtk38b3ubmErQarUiiiImkwnr9boAso0xgjQkjmOWy+W+BjqWwBRAQRDQ6/Uogah3fqQwBVC3282nJhudX8CXxBRAhsvNN3ABvEtjqkAKcgZ8qIkuMCbQDsQlJg/6As51RHT6XEVG/09dPy6BU+AzX0N/QcPh4EzchP8AAAAASUVORK5CYII='; + + const metadata: ImageMetadataFormat = JSON.parse( + contentModelImage.dataset['editingInfo'] + ); + expect(metadata).toEqual({ + src: IMG_SRC, + widthPx: WIDTH * 2, + heightPx: HEIGHT, + naturalWidth: WIDTH, + naturalHeight: HEIGHT, + leftPercent: 0, + rightPercent: 0, + topPercent: 0, + bottomPercent: 0, + angleRad: 0.7853981633974483, + }); + expect(contentModelImage.src).toBe(newSrc); + return true; + }); + }); + + itChromeOnly('Crop then resize', () => { + runTest(model, () => { + let editInfo = getEditInfoFromImage(img); + editInfo.leftPercent = 0.5; + editInfo.widthPx *= 2; + applyChange(editor, img, contentModelImage, editInfo, IMG_SRC, true); + + const newSrc = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAKCAYAAADGmhxQAAAAAXNSR0IArs4c6QAAAEBJREFUOE9jZGBg+M8wiAHjqAMpjB3G////0yWKHzx4wHDgwAE4fvjwIVFOH3UgLJiGbwiO5mKisgJuRYO+HAQAjrZGAckZDUoAAAAASUVORK5CYII='; + + const metadata: ImageMetadataFormat = JSON.parse( + contentModelImage.dataset['editingInfo'] + ); + expect(metadata).toEqual({ + src: IMG_SRC, + widthPx: WIDTH * 2, + heightPx: HEIGHT, + naturalWidth: WIDTH, + naturalHeight: HEIGHT, + leftPercent: 0.5, + rightPercent: 0, + topPercent: 0, + bottomPercent: 0, + angleRad: 0, + }); + expect(contentModelImage.src).toBe(newSrc); + return true; + }); + }); + + itChromeOnly('Rotate then crop', () => { + runTest(model, () => { + let editInfo = getEditInfoFromImage(img); + editInfo.angleRad = Math.PI / 4; + editInfo.leftPercent = 0.5; + applyChange(editor, img, contentModelImage, editInfo, IMG_SRC, false); + + const newSrc = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABUAAAAVCAYAAACpF6WWAAAAAXNSR0IArs4c6QAAAShJREFUOE+t0zFugzAUxvF/Vq5RiRP0Ch2ygHoEe87QTs1QKYm6tFMrRWKEjQMw9QDduQUnqFA3t44AGRsn4JTV9o/vPfut8H8b4AZ4PLNncmnlOaDBY7f2sRR2UKXURkp5LIrC/N8ieIQqpZ6BF61JKQmFB1QptQd2ZrxQuEf3QohdnudOi0NgjQ4JhRDMhN+Are9VjFC9aQF86AI59lC+2c9rYfP2Rxd1DWy/03+BpyZqFpymKVVVmf0ceuwb01fgqT9ht6Kua8qyJMsy2rZ1YB+qN74DDzbcgxptmmbqVR3OoQ6cJAlxHJ9SesDTTy6hDhxFkV2ymfYTuJ+DOrBnkr6Au79J+5mLXoI1uAa+55ZvBhtdXrcwAkNQO7EDhqI9fGuWbJbzC88EiJ0xyRQjAAAAAElFTkSuQmCC'; + + const metadata: ImageMetadataFormat = JSON.parse( + contentModelImage.dataset['editingInfo'] + ); + expect(metadata).toEqual({ + src: IMG_SRC, + widthPx: WIDTH, + heightPx: HEIGHT, + naturalWidth: WIDTH, + naturalHeight: HEIGHT, + leftPercent: 0.5, + rightPercent: 0, + topPercent: 0, + bottomPercent: 0, + angleRad: 0.7853981633974483, + }); + + expect(contentModelImage.src).toBe(newSrc); + return true; + }); + }); + + itChromeOnly('Crop then rotate', () => { + runTest(model, () => { + let editInfo = getEditInfoFromImage(img); + editInfo.leftPercent = 0.5; + editInfo.angleRad = Math.PI / 4; + applyChange(editor, img, contentModelImage, editInfo, IMG_SRC, false); + + const newSrc = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABUAAAAVCAYAAACpF6WWAAAAAXNSR0IArs4c6QAAAShJREFUOE+t0zFugzAUxvF/Vq5RiRP0Ch2ygHoEe87QTs1QKYm6tFMrRWKEjQMw9QDduQUnqFA3t44AGRsn4JTV9o/vPfut8H8b4AZ4PLNncmnlOaDBY7f2sRR2UKXURkp5LIrC/N8ieIQqpZ6BF61JKQmFB1QptQd2ZrxQuEf3QohdnudOi0NgjQ4JhRDMhN+Are9VjFC9aQF86AI59lC+2c9rYfP2Rxd1DWy/03+BpyZqFpymKVVVmf0ceuwb01fgqT9ht6Kua8qyJMsy2rZ1YB+qN74DDzbcgxptmmbqVR3OoQ6cJAlxHJ9SesDTTy6hDhxFkV2ymfYTuJ+DOrBnkr6Au79J+5mLXoI1uAa+55ZvBhtdXrcwAkNQO7EDhqI9fGuWbJbzC88EiJ0xyRQjAAAAAElFTkSuQmCC'; + + const metadata: ImageMetadataFormat = JSON.parse( + contentModelImage.dataset['editingInfo'] + ); + expect(metadata).toEqual({ + src: IMG_SRC, + widthPx: WIDTH, + heightPx: HEIGHT, + naturalWidth: WIDTH, + naturalHeight: HEIGHT, + leftPercent: 0.5, + rightPercent: 0, + topPercent: 0, + bottomPercent: 0, + angleRad: 0.7853981633974483, + }); + + expect(contentModelImage.src).toBe(newSrc); + return true; + }); + }); +}); + +function loadImage(src: string): Promise { + return new Promise(resolve => { + const img = document.createElement('img'); + const load = () => { + img.onload = null; + img.onerror = null; + resolve(img); + }; + img.onload = load; + img.onerror = load; + img.src = src; + }); +} + +function getEditInfoFromImage(img: HTMLImageElement) { + return { + src: img.getAttribute('src') || '', + widthPx: img.clientWidth, + heightPx: img.clientHeight, + naturalWidth: img.naturalWidth, + naturalHeight: img.naturalHeight, + leftPercent: 0, + rightPercent: 0, + topPercent: 0, + bottomPercent: 0, + angleRad: 0, + }; +} diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/canRegenerateImageTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/canRegenerateImageTest.ts new file mode 100644 index 00000000000..57e3cc5f21d --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/canRegenerateImageTest.ts @@ -0,0 +1,36 @@ +import { canRegenerateImage } from '../../../lib/imageEdit/utils/canRegenerateImage'; + +const IMG_SRC = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAKCAYAAAC0VX7mAAAALUlEQVQ4EWNgYGD4T2U8lAz8TyZACzKEl8k0Dz0OhpKBaGGB7hVi+QgvD0oDATe/bqDDw39VAAAAAElFTkSuQmCC'; + +describe('canRegenerateImage', () => { + function runTest(element: HTMLImageElement, canRegenerate: boolean) { + const result = canRegenerateImage(element); + expect(result).toBe(canRegenerate); + } + + it('should not regenerate', () => { + runTest(null!, false); + }); + + it('should regenerate', async () => { + const img = await loadImage(IMG_SRC); + img.width = 100; + img.height = 100; + runTest(img, true); + }); +}); + +function loadImage(src: string): Promise { + return new Promise(resolve => { + const img = document.createElement('img'); + const result = () => { + img.onload = null; + img.onerror = null; + resolve(img); + }; + img.onload = result; + img.onerror = result; + img.src = src; + }); +} diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/checkEditInfoStateTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/checkEditInfoStateTest.ts new file mode 100644 index 00000000000..488d0670955 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/checkEditInfoStateTest.ts @@ -0,0 +1,107 @@ +import { checkEditInfoState } from '../../../lib/imageEdit/utils/checkEditInfoState'; +import { ImageMetadataFormat } from 'roosterjs-content-model-types'; + +describe('checkEditInfoState', () => { + function runTest( + editInfo: ImageMetadataFormat, + expectResult: string, + compareTo?: ImageMetadataFormat + ) { + const result = checkEditInfoState(editInfo, compareTo); + expect(result).toBe(expectResult); + } + + it('should return invalid', () => { + runTest({}, 'Invalid'); + }); + + it('should return ResizeOnly', () => { + runTest( + { + src: 'test', + widthPx: 20, + heightPx: 20, + naturalWidth: 10, + naturalHeight: 10, + leftPercent: 0, + rightPercent: 0, + topPercent: 0, + bottomPercent: 0, + angleRad: 0, + }, + 'ResizeOnly', + { + src: 'test', + widthPx: 10, + heightPx: 10, + naturalWidth: 10, + naturalHeight: 10, + leftPercent: 0, + rightPercent: 0, + topPercent: 0, + bottomPercent: 0, + angleRad: 0, + } + ); + }); + + it('should return SameWithLast', () => { + runTest( + { + src: 'test', + widthPx: 20, + heightPx: 20, + naturalWidth: 10, + naturalHeight: 10, + leftPercent: 0, + rightPercent: 0, + topPercent: 0.1, + bottomPercent: 0, + angleRad: 0, + }, + 'SameWithLast', + { + src: 'test', + widthPx: 20, + heightPx: 20, + naturalWidth: 10, + naturalHeight: 10, + leftPercent: 0, + rightPercent: 0, + topPercent: 0.1, + bottomPercent: 0, + angleRad: 0, + } + ); + }); + + it('should return FullyChanged', () => { + runTest( + { + src: 'test', + widthPx: 20, + heightPx: 20, + naturalWidth: 10, + naturalHeight: 10, + leftPercent: 0, + rightPercent: 0.1, + topPercent: 0, + bottomPercent: 0, + angleRad: 30, + }, + 'FullyChanged', + { + src: 'test', + widthPx: 20, + heightPx: 20, + naturalWidth: 10, + naturalHeight: 10, + leftPercent: 0, + rightPercent: 0, + topPercent: 0, + bottomPercent: 0, + angleRad: 0, + } + ); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts new file mode 100644 index 00000000000..b2cf97173e9 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts @@ -0,0 +1,268 @@ +import { createImageCropper } from '../../../lib/imageEdit/Cropper/createImageCropper'; +import { createImageResizer } from '../../../lib/imageEdit/Resizer/createImageResizer'; +import { createImageRotator } from '../../../lib/imageEdit/Rotator/createImageRotator'; +import { IEditor, ImageEditOperation, ImageMetadataFormat } from 'roosterjs-content-model-types'; +import { ImageEditOptions } from '../../../lib/imageEdit/types/ImageEditOptions'; +import { ImageHtmlOptions } from '../../../lib/imageEdit/types/ImageHtmlOptions'; +import { initEditor } from '../../TestHelper'; +import { + WrapperElements, + createImageWrapper, +} from '../../../lib/imageEdit/utils/createImageWrapper'; + +describe('createImageWrapper', () => { + const editor = initEditor('editor_test'); + function runTest( + image: HTMLImageElement, + imageSpan: HTMLSpanElement, + options: ImageEditOptions, + editInfo: ImageMetadataFormat, + htmlOptions: ImageHtmlOptions, + operation: ImageEditOperation | undefined, + expectResult: WrapperElements + ) { + const result = createImageWrapper( + editor, + image, + imageSpan, + options, + editInfo, + htmlOptions, + operation + ); + expect(JSON.stringify(result)).toEqual(JSON.stringify(expectResult)); + } + + it('resizer', () => { + const image = document.createElement('img'); + const imageSpan = document.createElement('span'); + imageSpan.append(image); + document.body.appendChild(imageSpan); + const options: ImageEditOptions = { + borderColor: '#DB626C', + minWidth: 10, + minHeight: 10, + preserveRatio: true, + disableRotate: false, + disableSideResize: false, + onSelectState: 'resize', + }; + const editInfo = { + src: 'test', + widthPx: 20, + heightPx: 20, + naturalWidth: 10, + naturalHeight: 10, + leftPercent: 0, + rightPercent: 0, + topPercent: 0.1, + bottomPercent: 0, + angleRad: 0, + }; + const htmlOptions = { + borderColor: '#DB626C', + rotateHandleBackColor: 'white', + isSmallImage: false, + }; + const resizers = createImageResizer(document); + const wrapper = createWrapper(editor, image, options, editInfo, resizers); + const shadowSpan = createShadowSpan(wrapper); + const imageClone = cloneImage(image, editInfo); + + runTest(image, imageSpan, options, editInfo, htmlOptions, 'resize', { + wrapper, + shadowSpan, + imageClone, + resizers, + rotators: [], + croppers: [], + }); + document.body.removeChild(imageSpan); + }); + + it('rotate', () => { + const image = document.createElement('img'); + const imageSpan = document.createElement('span'); + imageSpan.append(image); + document.body.appendChild(imageSpan); + const options: ImageEditOptions = { + borderColor: '#DB626C', + minWidth: 10, + minHeight: 10, + preserveRatio: true, + disableRotate: false, + disableSideResize: false, + onSelectState: 'rotate', + }; + const editInfo = { + src: 'test', + widthPx: 20, + heightPx: 20, + naturalWidth: 10, + naturalHeight: 10, + leftPercent: 0, + rightPercent: 0, + topPercent: 0.1, + bottomPercent: 0, + angleRad: 0, + }; + const htmlOptions = { + borderColor: '#DB626C', + rotateHandleBackColor: 'white', + isSmallImage: false, + }; + const rotator = createImageRotator(document, htmlOptions); + const wrapper = createWrapper(editor, image, options, editInfo, undefined, rotator); + const shadowSpan = createShadowSpan(wrapper); + const imageClone = cloneImage(image, editInfo); + + runTest(image, imageSpan, options, editInfo, htmlOptions, 'rotate', { + wrapper: wrapper, + shadowSpan: shadowSpan, + imageClone: imageClone, + resizers: [], + rotators: rotator, + croppers: [], + }); + document.body.removeChild(imageSpan); + }); + + it('crop', () => { + const image = document.createElement('img'); + const imageSpan = document.createElement('span'); + imageSpan.append(image); + document.body.appendChild(imageSpan); + const options: ImageEditOptions = { + borderColor: '#DB626C', + minWidth: 10, + minHeight: 10, + preserveRatio: true, + disableRotate: false, + disableSideResize: false, + onSelectState: 'resize', + }; + const editInfo = { + src: 'test', + widthPx: 20, + heightPx: 20, + naturalWidth: 10, + naturalHeight: 10, + leftPercent: 0, + rightPercent: 0, + topPercent: 0.1, + bottomPercent: 0, + angleRad: 0, + }; + const htmlOptions = { + borderColor: '#DB626C', + rotateHandleBackColor: 'white', + isSmallImage: false, + }; + const cropper = createImageCropper(document); + const wrapper = createWrapper( + editor, + image, + options, + editInfo, + undefined, + undefined, + cropper + ); + const shadowSpan = createShadowSpan(wrapper); + const imageClone = cloneImage(image, editInfo); + + runTest(image, imageSpan, options, editInfo, htmlOptions, 'crop', { + wrapper, + shadowSpan, + imageClone, + resizers: [], + rotators: [], + croppers: cropper, + }); + document.body.removeChild(imageSpan); + }); +}); + +const cloneImage = (image: HTMLImageElement, editInfo: ImageMetadataFormat) => { + const imageClone = image.cloneNode(true) as HTMLImageElement; + imageClone.style.removeProperty('transform'); + if (editInfo.src) { + imageClone.src = editInfo.src; + imageClone.removeAttribute('id'); + imageClone.style.removeProperty('max-width'); + imageClone.style.removeProperty('max-height'); + imageClone.style.width = editInfo.widthPx + 'px'; + imageClone.style.height = editInfo.heightPx + 'px'; + } + return imageClone; +}; + +const createShadowSpan = (wrapper: HTMLSpanElement) => { + const span = document.createElement('span'); + const shadowRoot = span.attachShadow({ + mode: 'open', + }); + span.style.verticalAlign = 'bottom'; + shadowRoot.append(wrapper); + return span; +}; + +const createWrapper = ( + editor: IEditor, + image: HTMLImageElement, + options: ImageEditOptions, + editInfo: ImageMetadataFormat, + resizers?: HTMLDivElement[], + rotators?: HTMLDivElement[], + cropper?: HTMLDivElement[] +) => { + const doc = document; + const wrapper = doc.createElement('span'); + const imageBox = doc.createElement('div'); + + imageBox.setAttribute( + `style`, + `position:relative;width:100%;height:100%;overflow:hidden;transform:scale(1);` + ); + imageBox.append(image); + wrapper.setAttribute( + 'style', + `max-width: 100%; position: relative; display: inline-flex; font-size: 24px; margin: 0px; transform: rotate(${ + editInfo.angleRad ?? 0 + }rad); text-align: left;` + ); + wrapper.style.display = editor.getEnvironment().isSafari ? 'inline-block' : 'inline-flex'; + + const border = createBorder(options.borderColor); + wrapper.append(imageBox); + wrapper.append(border); + wrapper.style.userSelect = 'none'; + + if (resizers && resizers?.length > 0) { + resizers.forEach(resizer => { + wrapper.append(resizer); + }); + } + if (rotators && rotators.length > 0) { + rotators.forEach(r => { + wrapper.append(r); + }); + } + if (cropper && cropper.length > 0) { + cropper.forEach(c => { + wrapper.append(c); + }); + } + + return wrapper; +}; + +const createBorder = (borderColor?: string) => { + const doc = document; + const resizeBorder = doc.createElement('div'); + resizeBorder.setAttribute( + `style`, + `position:absolute;left:0;right:0;top:0;bottom:0;border:solid 2px ${borderColor};pointer-events:none;` + ); + return resizeBorder; +}; diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/doubleCheckResizeTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/doubleCheckResizeTest.ts new file mode 100644 index 00000000000..bbb5e0cc6af --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/doubleCheckResizeTest.ts @@ -0,0 +1,45 @@ +import { doubleCheckResize } from '../../../lib/imageEdit/utils/doubleCheckResize'; + +describe('doubleCheckResize', () => { + const editInfo = { + src: 'test', + widthPx: 20, + heightPx: 20, + naturalWidth: 10, + naturalHeight: 10, + leftPercent: 0, + rightPercent: 0, + topPercent: 0.1, + bottomPercent: 0, + angleRad: 0, + }; + + function runTest(preserveRatio: boolean, actualWidth: number, actualHeight: number) { + const { heightPx, widthPx } = editInfo; + const ratio = widthPx / heightPx; + doubleCheckResize(editInfo, preserveRatio, actualWidth, actualHeight); + + if (preserveRatio) { + if (actualWidth < widthPx) { + expect(editInfo.heightPx).toBe(actualWidth / ratio); + } else { + expect(editInfo.widthPx).toBe(actualHeight * ratio); + } + } else { + expect(editInfo.heightPx).toBe(actualHeight); + expect(editInfo.widthPx).toBe(actualWidth); + } + } + + it('should preserve ratio | adjust height', () => { + runTest(true, 10, 10); + }); + + it('should preserve ratio | adjust width', () => { + runTest(true, 30, 30); + }); + + it('should not preserve ratio', () => { + runTest(false, 30, 30); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/generateDataURLTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/generateDataURLTest.ts new file mode 100644 index 00000000000..b80750523f7 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/generateDataURLTest.ts @@ -0,0 +1,25 @@ +import { generateDataURL } from '../../../lib/imageEdit/utils/generateDataURL'; +import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; + +describe('generateDataURL', () => { + itChromeOnly('generate image url', () => { + const editInfo = { + src: 'test', + widthPx: 20, + heightPx: 20, + naturalWidth: 10, + naturalHeight: 10, + leftPercent: 0, + rightPercent: 0, + topPercent: 0.1, + bottomPercent: 0, + angleRad: 0, + }; + const image = document.createElement('img'); + image.src = 'https://th.bing.com/th/id/OIP.kJCCjl_yUweRlj94AdU-egHaFK?rs=1&pid=ImgDetMain'; + const url = generateDataURL(image, editInfo); + expect(url).toBe( + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAAXNSR0IArs4c6QAAAChJREFUOE9jZKAyYKSyeQyjBlIeoqNhOBqGZITAaLIhI9DQtIzAMAQASMYAFTvklLAAAAAASUVORK5CYII=' + ); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/generateImageSizeTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/generateImageSizeTest.ts new file mode 100644 index 00000000000..15f98cc86a6 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/generateImageSizeTest.ts @@ -0,0 +1,51 @@ +import { getGeneratedImageSize } from '../../../lib/imageEdit/utils/generateImageSize'; + +describe('generateImageSize', () => { + it('beforeCrop false', () => { + const editInfo = { + src: 'test', + widthPx: 20, + heightPx: 20, + naturalWidth: 10, + naturalHeight: 10, + leftPercent: 0, + rightPercent: 0, + topPercent: 0.1, + bottomPercent: 0, + angleRad: 0, + }; + const result = getGeneratedImageSize(editInfo, true); + expect(result).toEqual({ + targetHeight: 22.22222222222222, + targetWidth: 20, + visibleHeight: 22.22222222222222, + visibleWidth: 20, + originalHeight: 22.22222222222222, + originalWidth: 20, + }); + }); + + it('beforeCrop true', () => { + const editInfo = { + src: 'test', + widthPx: 20, + heightPx: 20, + naturalWidth: 10, + naturalHeight: 10, + leftPercent: 0, + rightPercent: 0, + topPercent: 0.1, + bottomPercent: 0, + angleRad: 0, + }; + const result = getGeneratedImageSize(editInfo, true); + expect(result).toEqual({ + targetHeight: 22.22222222222222, + targetWidth: 20, + visibleHeight: 22.22222222222222, + visibleWidth: 20, + originalHeight: 22.22222222222222, + originalWidth: 20, + }); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getDropAndDragHelpersTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getDropAndDragHelpersTest.ts new file mode 100644 index 00000000000..74f61b59aec --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getDropAndDragHelpersTest.ts @@ -0,0 +1,115 @@ +import { Cropper } from '../../../lib/imageEdit/Cropper/cropperContext'; +import { DragAndDropHandler } from '../../../lib/pluginUtils/DragAndDrop/DragAndDropHandler'; +import { DragAndDropHelper } from '../../../lib/pluginUtils/DragAndDrop/DragAndDropHelper'; +import { getDropAndDragHelpers } from '../../../lib/imageEdit/utils/getDropAndDragHelpers'; +import { ImageEditElementClass } from '../../../lib/imageEdit/types/ImageEditElementClass'; +import { ImageEditOptions } from '../../../lib/imageEdit/types/ImageEditOptions'; +import { Resizer } from '../../../lib/imageEdit/Resizer/resizerContext'; +import { Rotator } from '../../../lib/imageEdit/Rotator/rotatorContext'; +import { + DNDDirectionX, + DnDDirectionY, + DragAndDropContext, +} from '../../../lib/imageEdit/types/DragAndDropContext'; + +describe('getDropAndDragHelpers', () => { + const image = document.createElement('img'); + const imageSpan = document.createElement('span'); + imageSpan.append(image); + const options: ImageEditOptions = { + borderColor: '#DB626C', + minWidth: 10, + minHeight: 10, + preserveRatio: true, + disableRotate: false, + disableSideResize: false, + onSelectState: 'resize', + }; + const editInfo = { + src: 'test', + widthPx: 20, + heightPx: 20, + naturalWidth: 10, + naturalHeight: 10, + leftPercent: 0, + rightPercent: 0, + topPercent: 0.1, + bottomPercent: 0, + angleRad: 0, + }; + const imageWrapper = document.createElement('div'); + const element = document.createElement('div'); + imageWrapper.appendChild(element); + + function runTest( + elementClass: ImageEditElementClass, + helper: DragAndDropHandler, + expectResult: DragAndDropHelper[] + ) { + element.className = elementClass; + const result = getDropAndDragHelpers( + imageWrapper, + editInfo, + options, + elementClass, + helper, + () => {}, + 1 + ); + expect(JSON.stringify(result)).toEqual(JSON.stringify(expectResult)); + } + + it('create resizer helper', () => { + runTest(ImageEditElementClass.ResizeHandle, Resizer, [ + new DragAndDropHelper( + element, + { + editInfo: editInfo, + options: options, + elementClass: ImageEditElementClass.ResizeHandle, + x: element.dataset.x as DNDDirectionX, + y: element.dataset.y as DnDDirectionY, + }, + () => {}, + Resizer, + 1 + ), + ]); + }); + + it('create rotate helper', () => { + runTest(ImageEditElementClass.RotateHandle, Rotator, [ + new DragAndDropHelper( + element, + { + editInfo: editInfo, + options: options, + elementClass: ImageEditElementClass.RotateHandle, + x: element.dataset.x as DNDDirectionX, + y: element.dataset.y as DnDDirectionY, + }, + () => {}, + Rotator, + 1 + ), + ]); + }); + + it('create cropper helper', () => { + runTest(ImageEditElementClass.CropHandle, Cropper, [ + new DragAndDropHelper( + element, + { + editInfo: editInfo, + options: options, + elementClass: ImageEditElementClass.CropHandle, + x: element.dataset.x as DNDDirectionX, + y: element.dataset.y as DnDDirectionY, + }, + () => {}, + Cropper, + 1 + ), + ]); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getHTMLImageOptionsTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getHTMLImageOptionsTest.ts new file mode 100644 index 00000000000..55381e09dac --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getHTMLImageOptionsTest.ts @@ -0,0 +1,85 @@ +import { getHTMLImageOptions } from '../../../lib/imageEdit/utils/getHTMLImageOptions'; +import { IEditor, ImageMetadataFormat } from 'roosterjs-content-model-types'; +import { ImageEditOptions } from '../../../lib/imageEdit/types/ImageEditOptions'; +import { ImageHtmlOptions } from '../../../lib/imageEdit/types/ImageHtmlOptions'; + +describe('getHTMLImageOptions', () => { + const createEditor = (darkMode: boolean) => { + return { isDarkMode: () => darkMode } as IEditor; + }; + + function runTest( + darkMode: boolean, + options: ImageEditOptions, + editInfo: ImageMetadataFormat, + expectResult: ImageHtmlOptions + ) { + const editor = createEditor(darkMode); + const result = getHTMLImageOptions(editor, options, editInfo); + expect(result).toEqual(expectResult); + } + + it('Light mode and not small', () => { + runTest( + false, + { + borderColor: '#DB626C', + minWidth: 10, + minHeight: 10, + preserveRatio: true, + disableRotate: false, + disableSideResize: false, + onSelectState: 'resize', + }, + { + src: 'test', + widthPx: 200, + heightPx: 200, + naturalWidth: 10, + naturalHeight: 10, + leftPercent: 0, + rightPercent: 0, + topPercent: 0.1, + bottomPercent: 0, + angleRad: 0, + }, + { + borderColor: '#DB626C', + rotateHandleBackColor: 'white', + isSmallImage: false, + } + ); + }); + + it('Light mode and not small', () => { + runTest( + true, + { + borderColor: '#DB626C', + minWidth: 10, + minHeight: 10, + preserveRatio: true, + disableRotate: false, + disableSideResize: false, + onSelectState: 'resize', + }, + { + src: 'test', + widthPx: 10, + heightPx: 10, + naturalWidth: 10, + naturalHeight: 10, + leftPercent: 0, + rightPercent: 0, + topPercent: 0.1, + bottomPercent: 0, + angleRad: 0, + }, + { + borderColor: '#DB626C', + rotateHandleBackColor: '#333', + isSmallImage: true, + } + ); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getSelectedContentModelImageTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getSelectedContentModelImageTest.ts new file mode 100644 index 00000000000..c4c152e3ca5 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/getSelectedContentModelImageTest.ts @@ -0,0 +1,97 @@ +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { getSelectedContentModelImage } from '../../../lib/imageEdit/utils/getSelectedContentModelImage'; + +describe('getSelectedContentModelImage', () => { + it('should return image model', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const result = getSelectedContentModelImage(model); + expect(result).toEqual({ + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }); + }); + + it('should not return image model', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: false, + isSelected: false, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const result = getSelectedContentModelImage(model); + expect(result).toEqual(null); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/imageEditUtilsTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/imageEditUtilsTest.ts new file mode 100644 index 00000000000..48036afa758 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/imageEditUtilsTest.ts @@ -0,0 +1,116 @@ +import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; +import { + checkIfImageWasResized, + getPx, + isASmallImage, + isRTL, + rotateCoordinate, + setFlipped, + setSize, + setWrapperSizeDimensions, +} from '../../../lib/imageEdit/utils/imageEditUtils'; + +describe('imageEditUtils', () => { + describe('getPx', () => { + it('should return in px', () => { + const result = getPx(30); + expect(result).toBe('30px'); + }); + }); + + describe('isASmallImage', () => { + it('is small', () => { + const result = isASmallImage(10, 10); + expect(result).toBeTruthy(); + }); + + it('is not small', () => { + const result = isASmallImage(100, 100); + expect(result).toBeFalsy(); + }); + }); + + describe('rotateCoordinate', () => { + it('should calculate rotation ', () => { + const result = rotateCoordinate(10, 10, Math.PI); + expect(result).toEqual([-10, -10.000000000000002]); + }); + }); + + describe('setFlipped', () => { + it('should flip horizontally ', () => { + const element = document.createElement('div'); + setFlipped(element, true, false); + expect(element.style.transform).toBe('scale(-1, 1)'); + }); + + it('should flip vertically ', () => { + const element = document.createElement('div'); + setFlipped(element, false, true); + expect(element.style.transform).toBe('scale(1, -1)'); + }); + + itChromeOnly('should flip horizontally/vertically ', () => { + const element = document.createElement('div'); + setFlipped(element, true, true); + expect(element.style.transform).toBe('scale(-1, -1)'); + }); + }); + + describe('setWrapperSizeDimensions', () => { + it('with border style', () => { + const wrapper = document.createElement('span'); + const image = document.createElement('img'); + image.style.borderStyle = 'dotted'; + image.style.borderWidth = '1px'; + setWrapperSizeDimensions(wrapper, image, 10, 10); + expect(wrapper.style.width).toBe('12px'); + expect(wrapper.style.height).toBe('12px'); + }); + + it('without border style', () => { + const wrapper = document.createElement('span'); + const image = document.createElement('img'); + setWrapperSizeDimensions(wrapper, image, 10, 10); + expect(wrapper.style.width).toBe('10px'); + expect(wrapper.style.height).toBe('10px'); + }); + }); + + describe('setSize', () => { + it('should set size', () => { + const element = document.createElement('div'); + setSize(element, 10, 10, 10, 10, 10, 10); + expect(element.style.left).toBe('10px'); + expect(element.style.top).toBe('10px'); + expect(element.style.right).toBe('10px'); + expect(element.style.bottom).toBe('10px'); + expect(element.style.width).toBe('10px'); + expect(element.style.height).toBe('10px'); + }); + }); + + describe('checkIfImageWasResized', () => { + it('was resized', () => { + const image = document.createElement('img'); + image.style.width = '10px'; + image.style.height = '10px'; + const result = checkIfImageWasResized(image); + expect(result).toBeTruthy(); + }); + + it('was resized', () => { + const image = document.createElement('img'); + const result = checkIfImageWasResized(image); + expect(result).toBeFalsy(); + }); + }); + + describe('isRTL', () => { + it(' not isRTL', () => { + const image = document.createElement('img'); + const result = isRTL(image); + expect(result).toBeFalsy(); + }); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateHandleCursorTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateHandleCursorTest.ts new file mode 100644 index 00000000000..4af8ff4c00d --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateHandleCursorTest.ts @@ -0,0 +1,12 @@ +import { updateHandleCursor } from '../../../lib/imageEdit/utils/updateHandleCursor'; +describe('updateHandleCursor', () => { + it('should set cursor', () => { + const handle1 = document.createElement('div'); + + handle1.dataset['x'] = 'e'; + handle1.dataset['y'] = 'n'; + + updateHandleCursor([handle1], 0); + expect(handle1.style.cursor).toBe('ne-resize'); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateImageEditInfoTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateImageEditInfoTest.ts new file mode 100644 index 00000000000..90ce33e4961 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateImageEditInfoTest.ts @@ -0,0 +1,82 @@ +import * as updateImageMetadata from 'roosterjs-content-model-dom/lib/modelApi/metadata/updateImageMetadata'; +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { createImage } from 'roosterjs-content-model-dom'; +import { initEditor } from '../../TestHelper'; +import { + getSelectedImageMetadata, + updateImageEditInfo, +} from '../../../lib/imageEdit/utils/updateImageEditInfo'; + +const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: { + editingInfo: JSON.stringify({ + src: 'test', + }), + }, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, +}; + +describe('updateImageEditInfo', () => { + it('update image edit info', () => { + const updateImageMetadataSpy = spyOn(updateImageMetadata, 'updateImageMetadata'); + const contentModelImage = createImage('test'); + updateImageEditInfo(contentModelImage, { + widthPx: 10, + heightPx: 10, + }); + expect(updateImageMetadataSpy).toHaveBeenCalled(); + }); +}); + +describe('getSelectedImageMetadata', () => { + it('get image edit info', () => { + const editor = initEditor('updateImageEditInfo', [], model); + const image = new Image(10, 10); + const metadata = getSelectedImageMetadata(editor, image); + const expected = { + src: '', + widthPx: 0, + heightPx: 0, + naturalWidth: 0, + naturalHeight: 0, + leftPercent: 0, + rightPercent: 0, + topPercent: 0, + bottomPercent: 0, + angleRad: 0, + editingInfo: '{"src":"test"}', + }; + expect(metadata).toEqual(expected); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts new file mode 100644 index 00000000000..df74a226e3b --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/updateWrapperTest.ts @@ -0,0 +1,63 @@ +import { createImageWrapper } from '../../../lib/imageEdit/utils/createImageWrapper'; +import { ImageEditOptions } from '../../../lib/imageEdit/types/ImageEditOptions'; +import { initEditor } from '../../TestHelper'; +import { updateWrapper } from '../../../lib/imageEdit/utils/updateWrapper'; + +describe('updateWrapper', () => { + const editor = initEditor('wrapper_test'); + const options: ImageEditOptions = { + borderColor: '#DB626C', + minWidth: 10, + minHeight: 10, + preserveRatio: true, + disableRotate: false, + disableSideResize: false, + onSelectState: 'resize', + }; + const editInfo = { + src: 'test', + widthPx: 20, + heightPx: 20, + naturalWidth: 10, + naturalHeight: 10, + leftPercent: 0, + rightPercent: 0, + topPercent: 0.1, + bottomPercent: 0, + angleRad: 0, + }; + const htmlOptions = { + borderColor: '#DB626C', + rotateHandleBackColor: 'white', + isSmallImage: false, + }; + const image = document.createElement('img'); + const imageSpan = document.createElement('span'); + imageSpan.appendChild(image); + document.body.appendChild(imageSpan); + + it('should update size', () => { + const { wrapper, imageClone, resizers } = createImageWrapper( + editor, + image, + imageSpan, + options, + editInfo, + htmlOptions, + 'resize' + ); + editInfo.heightPx = 12; + updateWrapper(editInfo, options, image, imageClone, wrapper, resizers); + + expect(wrapper.style.margin).toBe('0px'); + expect(wrapper.style.transform).toBe(`rotate(0rad)`); + + expect(wrapper.style.width).toBe('20px'); + expect(wrapper.style.height).toBe('12px'); + + expect(imageClone.style.width).toBe('20px'); + expect(imageClone.style.height).toBe('13.3333px'); + expect(imageClone.style.verticalAlign).toBe('bottom'); + expect(imageClone.style.position).toBe('absolute'); + }); +}); diff --git a/packages/roosterjs-content-model-types/lib/contentModel/format/metadata/ImageMetadataFormat.ts b/packages/roosterjs-content-model-types/lib/contentModel/format/metadata/ImageMetadataFormat.ts index b336e6eb29e..8a7a8b2945d 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/format/metadata/ImageMetadataFormat.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/format/metadata/ImageMetadataFormat.ts @@ -19,6 +19,20 @@ export type ImageResizeMetadataFormat = { heightPx?: number; }; +/** + * Metadata for inline image flip + */ +export interface ImageFlipMetadataFormat { + /** + * If true, the image was flipped. + */ + flippedVertical?: boolean; + /** + * If true, the image was flipped. + */ + flippedHorizontal?: boolean; +} + /** * Metadata for inline image crop */ @@ -64,7 +78,8 @@ export type ImageRotateMetadataFormat = { */ export type ImageMetadataFormat = ImageResizeMetadataFormat & ImageCropMetadataFormat & - ImageRotateMetadataFormat & { + ImageRotateMetadataFormat & + ImageFlipMetadataFormat & { /** * Original src of the image. This value will not be changed when edit image. We can always use it * to get the original image so that all editing operation will be on top of the original image. diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index f236306d258..2500705786f 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -65,6 +65,7 @@ export { ImageCropMetadataFormat, ImageMetadataFormat, ImageRotateMetadataFormat, + ImageFlipMetadataFormat, } from './contentModel/format/metadata/ImageMetadataFormat'; export { TableCellMetadataFormat } from './contentModel/format/metadata/TableCellMetadataFormat'; diff --git a/packages/roosterjs-content-model-types/lib/parameter/ImageEditor.ts b/packages/roosterjs-content-model-types/lib/parameter/ImageEditor.ts index d1b93b8a8ef..127127c849d 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/ImageEditor.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/ImageEditor.ts @@ -15,7 +15,12 @@ export type ImageEditOperation = /** * Crop an image */ - | 'crop'; + | 'crop' + + /** + * Flip an image + */ + | 'flip'; /** * Define the common operation of an image editor