From 51d49685696be2b920e4918a6b61f76f1e770a35 Mon Sep 17 00:00:00 2001 From: jbeen Date: Fri, 20 Aug 2021 12:49:17 +0300 Subject: [PATCH 1/2] fix: copy canvas when ghost resize --- demo/demo.component.ts | 29 +++++++++++++- src/clone-node.ts | 82 ++++++++++++++++++++++++++++++++++++++ src/resizable.directive.ts | 3 +- 3 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 src/clone-node.ts diff --git a/demo/demo.component.ts b/demo/demo.component.ts index 8a8eb3a..ce2ddec 100644 --- a/demo/demo.component.ts +++ b/demo/demo.component.ts @@ -1,5 +1,5 @@ /* tslint:disable:max-inline-declarations */ -import { Component } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core'; import { ResizeEvent } from '../src'; @Component({ @@ -21,6 +21,11 @@ import { ResizeEvent } from '../src'; box-sizing: border-box; // required for the enableGhostResize option to work } + canvas { + width: 150px; + height: 100px; + } + .resize-handle-top, .resize-handle-bottom { position: absolute; @@ -66,6 +71,8 @@ import { ResizeEvent } from '../src'; [resizeSnapGrid]="{ left: 50, right: 50 }" (resizeEnd)="onResizeEnd($event)" > +
HTML text example
+
` }) -export class DemoComponent { +export class DemoComponent implements AfterViewInit { + @ViewChild('canvas') + public canvas: ElementRef; + public style: object = {}; validate(event: ResizeEvent): boolean { @@ -115,4 +125,19 @@ export class DemoComponent { height: `${event.rectangle.height}px` }; } + + drawCanvas(): void { + const ctx = this.canvas.nativeElement.getContext('2d'); + if (ctx) { + ctx.font = '28px serif'; + ctx.fillText('Canvas text example', 50, 50); + ctx.strokeStyle = 'green'; + ctx.lineWidth = 5; + ctx.strokeRect(30, 10, 260, 60); + } + } + + ngAfterViewInit(): void { + this.drawCanvas(); + } } diff --git a/src/clone-node.ts b/src/clone-node.ts new file mode 100644 index 0000000..156c75d --- /dev/null +++ b/src/clone-node.ts @@ -0,0 +1,82 @@ +/** Creates a deep clone of an element. */ +export function deepCloneNode(node: HTMLElement): HTMLElement { + const clone = node.cloneNode(true) as HTMLElement; + const descendantsWithId = clone.querySelectorAll('[id]'); + const nodeName = node.nodeName.toLowerCase(); + + // Remove the `id` to avoid having multiple elements with the same id on the page. + clone.removeAttribute('id'); + + descendantsWithId.forEach(descendant => { + descendant.removeAttribute('id'); + }); + + if (nodeName === 'canvas') { + transferCanvasData(node as HTMLCanvasElement, clone as HTMLCanvasElement); + } else if ( + nodeName === 'input' || + nodeName === 'select' || + nodeName === 'textarea' + ) { + transferInputData(node as HTMLInputElement, clone as HTMLInputElement); + } + + transferData('canvas', node, clone, transferCanvasData); + transferData('input, textarea, select', node, clone, transferInputData); + return clone; +} + +/** Matches elements between an element and its clone and allows for their data to be cloned. */ +function transferData( + selector: string, + node: HTMLElement, + clone: HTMLElement, + callback: (source: T, clone: T) => void +) { + const descendantElements = node.querySelectorAll(selector); + + if (descendantElements.length) { + const cloneElements = clone.querySelectorAll(selector); + + for (let i = 0; i < descendantElements.length; i++) { + callback(descendantElements[i], cloneElements[i]); + } + } +} + +// Counter for unique cloned radio button names. +let cloneUniqueId = 0; + +/** Transfers the data of one input element to another. */ +function transferInputData( + source: Element & { value: string }, + clone: Element & { value: string; name: string; type: string } +) { + // Browsers throw an error when assigning the value of a file input programmatically. + if (clone.type !== 'file') { + clone.value = source.value; + } + + // Radio button `name` attributes must be unique for radio button groups + // otherwise original radio buttons can lose their checked state + // once the clone is inserted in the DOM. + if (clone.type === 'radio' && clone.name) { + clone.name = `mat-clone-${clone.name}-${cloneUniqueId++}`; + } +} + +/** Transfers the data of one canvas element to another. */ +function transferCanvasData( + source: HTMLCanvasElement, + clone: HTMLCanvasElement +) { + const context = clone.getContext('2d'); + + if (context) { + // In some cases `drawImage` can throw (e.g. if the canvas size is 0x0). + // We can't do much about it so just ignore the error. + try { + context.drawImage(source, 0, 0); + } catch {} + } +} diff --git a/src/resizable.directive.ts b/src/resizable.directive.ts index d62cc71..b91d635 100644 --- a/src/resizable.directive.ts +++ b/src/resizable.directive.ts @@ -32,6 +32,7 @@ import { Edges } from './interfaces/edges.interface'; import { BoundingRectangle } from './interfaces/bounding-rectangle.interface'; import { ResizeEvent } from './interfaces/resize-event.interface'; import { IS_TOUCH_DEVICE } from './is-touch-device'; +import { deepCloneNode } from './clone-node'; interface PointerEventCoordinate { clientX: number; @@ -723,7 +724,7 @@ export class ResizableDirective implements OnInit, OnChanges, OnDestroy { this.renderer.setStyle(document.body, 'cursor', cursor); this.setElementClass(this.elm, RESIZE_ACTIVE_CLASS, true); if (this.enableGhostResize) { - currentResize.clonedNode = this.elm.nativeElement.cloneNode(true); + currentResize.clonedNode = deepCloneNode(this.elm.nativeElement); this.elm.nativeElement.parentElement.appendChild( currentResize.clonedNode ); From 9b5202ad27c10f061a5320bfe3dea484081686f8 Mon Sep 17 00:00:00 2001 From: jbeen Date: Fri, 20 Aug 2021 12:49:17 +0300 Subject: [PATCH 2/2] fix: copy canvas when ghost resize --- test/resizable.spec.ts | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/test/resizable.spec.ts b/test/resizable.spec.ts index e8826f4..2956b71 100644 --- a/test/resizable.spec.ts +++ b/test/resizable.spec.ts @@ -1,9 +1,11 @@ /* tslint:disable:max-inline-declarations enforce-component-selector */ -import { Component, ElementRef, ViewChild } from '@angular/core'; -import { ResizableDirective } from '../src/resizable.directive'; +import { Component, DebugElement, ElementRef, ViewChild } from '@angular/core'; +import { + MOUSE_MOVE_THROTTLE_MS, + ResizableDirective +} from '../src/resizable.directive'; import { Edges } from '../src/interfaces/edges.interface'; -import { ResizeEvent, ResizableModule, ResizeHandleDirective } from '../src'; -import { MOUSE_MOVE_THROTTLE_MS } from '../src/resizable.directive'; +import { ResizableModule, ResizeEvent } from '../src'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { expect } from 'chai'; import * as sinon from 'sinon'; @@ -1630,4 +1632,34 @@ describe('resizable directive', () => { } }); }); + + it('should add canvas data to the ghost element', () => { + const template: string = ` +
+ +
+ `; + const fixture: ComponentFixture = createComponent(template); + const div = fixture.debugElement.childNodes[0] as DebugElement; + const canvas = div.nativeElement.children[0]; + const ctx = canvas.getContext('2d'); + ctx.fillText('Canvas text example', 0, 0); + const canvasData = canvas.toDataURL(); + + const elm: any = fixture.componentInstance.resizable.elm.nativeElement; + triggerDomEvent('mousedown', elm, { + clientX: 100, + clientY: 200 + }); + const clonedDiv = elm.nextSibling as HTMLElement; + const clonedCanvas = clonedDiv.children[0] as HTMLCanvasElement; + const actualCanvasData = clonedCanvas.toDataURL(); + expect(actualCanvasData).to.equal(canvasData); + }); });