| /* |
| * Copyright (C) 2017 Apple Inc. All rights reserved. |
| * |
| * Redistribution and use in source and binary forms, with or without |
| * modification, are permitted provided that the following conditions |
| * are met: |
| * 1. Redistributions of source code must retain the above copyright |
| * notice, this list of conditions and the following disclaimer. |
| * 2. Redistributions in binary form must reproduce the above copyright |
| * notice, this list of conditions and the following disclaimer in the |
| * documentation and/or other materials provided with the distribution. |
| * |
| * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' |
| * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, |
| * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR |
| * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS |
| * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR |
| * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF |
| * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS |
| * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN |
| * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) |
| * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF |
| * THE POSSIBILITY OF SUCH DAMAGE. |
| */ |
| |
| WI.RecordingContentView = class RecordingContentView extends WI.ContentView |
| { |
| constructor(representedObject) |
| { |
| console.assert(representedObject instanceof WI.Recording); |
| |
| super(representedObject); |
| |
| this._index = NaN; |
| this._action = null; |
| this._snapshots = []; |
| this._initialContent = null; |
| this._throttler = this.throttle(200); |
| |
| this.element.classList.add("recording", this.representedObject.type); |
| |
| let isCanvas2D = this.representedObject.type === WI.Recording.Type.Canvas2D; |
| let isCanvasWebGL = this.representedObject.type === WI.Recording.Type.CanvasWebGL; |
| if (isCanvas2D || isCanvasWebGL) { |
| if (isCanvas2D && WI.RecordingContentView.supportsCanvasPathDebugging()) { |
| this._pathContext = null; |
| |
| this._showPathButtonNavigationItem = new WI.ActivateButtonNavigationItem("show-path", WI.UIString("Show Path"), WI.UIString("Hide Path"), "Images/Path.svg", 16, 16); |
| this._showPathButtonNavigationItem.addEventListener(WI.ButtonNavigationItem.Event.Clicked, this._showPathButtonClicked, this); |
| this._showPathButtonNavigationItem.activated = !!WI.settings.showCanvasPath.value; |
| } |
| |
| this._showGridButtonNavigationItem = new WI.ActivateButtonNavigationItem("show-grid", WI.UIString("Show Grid"), WI.UIString("Hide Grid"), "Images/NavigationItemCheckers.svg", 13, 13); |
| this._showGridButtonNavigationItem.visibilityPriority = WI.NavigationItem.VisibilityPriority.Low; |
| this._showGridButtonNavigationItem.addEventListener(WI.ButtonNavigationItem.Event.Clicked, this._showGridButtonClicked, this); |
| this._showGridButtonNavigationItem.activated = !!WI.settings.showImageGrid.value; |
| |
| this._exportButtonNavigationItem = new WI.ButtonNavigationItem("export-recording", WI.UIString("Export"), "Images/Export.svg", 15, 15); |
| this._exportButtonNavigationItem.toolTip = WI.UIString("Export recording (%s)").format(WI.saveKeyboardShortcut.displayName); |
| this._exportButtonNavigationItem.buttonStyle = WI.ButtonNavigationItem.Style.ImageAndText; |
| this._exportButtonNavigationItem.visibilityPriority = WI.NavigationItem.VisibilityPriority.High; |
| this._exportButtonNavigationItem.addEventListener(WI.ButtonNavigationItem.Event.Clicked, () => { this._exportRecording(); }); |
| } |
| } |
| |
| // Static |
| |
| static supportsCanvasPathDebugging() |
| { |
| return "currentX" in CanvasRenderingContext2D.prototype && "currentY" in CanvasRenderingContext2D.prototype; |
| } |
| |
| static _actionModifiesPath(recordingAction) |
| { |
| switch (recordingAction.name) { |
| case "arc": |
| case "arcTo": |
| case "beginPath": |
| case "bezierCurveTo": |
| case "closePath": |
| case "ellipse": |
| case "lineTo": |
| case "moveTo": |
| case "quadraticCurveTo": |
| case "rect": |
| return true; |
| } |
| |
| return false; |
| } |
| |
| // Public |
| |
| get navigationItems() |
| { |
| let isCanvas2D = this.representedObject.type === WI.Recording.Type.Canvas2D; |
| let isCanvasWebGL = this.representedObject.type === WI.Recording.Type.CanvasWebGL; |
| if (!isCanvas2D && !isCanvasWebGL) |
| return []; |
| |
| let navigationItems = [this._exportButtonNavigationItem, new WI.DividerNavigationItem]; |
| if (isCanvas2D && WI.RecordingContentView.supportsCanvasPathDebugging()) |
| navigationItems.push(this._showPathButtonNavigationItem); |
| |
| navigationItems.push(this._showGridButtonNavigationItem); |
| return navigationItems; |
| } |
| |
| get supplementalRepresentedObjects() |
| { |
| return this._action ? [this._action] : []; |
| } |
| |
| updateActionIndex(index) |
| { |
| if (!this.representedObject) |
| return; |
| |
| if (this._index === index) |
| return; |
| |
| this.representedObject.actions.then((actions) => { |
| console.assert(index >= 0 && index < actions.length); |
| if (index < 0 || index >= actions.length) |
| return; |
| |
| this._index = index; |
| this._updateSliderValue(); |
| |
| if (this.representedObject.type === WI.Recording.Type.Canvas2D) |
| this._throttler._generateContentCanvas2D(index, actions); |
| else if (this.representedObject.type === WI.Recording.Type.CanvasWebGL) |
| this._throttler._generateContentCanvasWebGL(index, actions); |
| }); |
| } |
| |
| shown() |
| { |
| super.shown(); |
| |
| let isCanvas2D = this.representedObject.type === WI.Recording.Type.Canvas2D; |
| let isCanvasWebGL = this.representedObject.type === WI.Recording.Type.CanvasWebGL; |
| if (isCanvas2D || isCanvasWebGL) { |
| if (isCanvas2D) |
| this._updateCanvasPath(); |
| this._updateImageGrid(); |
| } |
| } |
| |
| hidden() |
| { |
| super.hidden(); |
| |
| this._generateContentCanvas2D.cancelThrottle(); |
| this._generateContentCanvasWebGL.cancelThrottle(); |
| } |
| |
| // Protected |
| |
| get supportsSave() |
| { |
| return true; |
| } |
| |
| get saveData() |
| { |
| return {customSaveHandler: () => { this._exportRecording(); }}; |
| } |
| |
| // Protected |
| |
| initialLayout() |
| { |
| let previewHeader = this.element.appendChild(document.createElement("header")); |
| |
| let sliderContainer = previewHeader.appendChild(document.createElement("div")); |
| sliderContainer.className = "slider-container hidden"; |
| |
| this._previewContainer = this.element.appendChild(document.createElement("div")); |
| this._previewContainer.className = "preview-container"; |
| |
| this._sliderValueElement = sliderContainer.appendChild(document.createElement("div")); |
| this._sliderValueElement.className = "slider-value"; |
| |
| this._sliderElement = sliderContainer.appendChild(document.createElement("input")); |
| this._sliderElement.addEventListener("input", this._sliderChanged.bind(this)); |
| this._sliderElement.type = "range"; |
| this._sliderElement.min = 0; |
| this._sliderElement.max = 0; |
| |
| this.representedObject.actions.then(() => { |
| sliderContainer.classList.remove("hidden"); |
| this._sliderElement.max = this.representedObject.visualActionIndexes.length; |
| this._updateSliderValue(); |
| }); |
| } |
| |
| // Private |
| |
| _exportRecording() |
| { |
| if (!this.representedObject) { |
| InspectorFrontendHost.beep(); |
| return; |
| } |
| |
| let filename = this.representedObject.displayName; |
| let url = "web-inspector:///" + encodeURI(filename) + ".json"; |
| |
| WI.saveDataToFile({ |
| url, |
| content: JSON.stringify(this.representedObject.toJSON()), |
| forceSaveAs: true, |
| }); |
| } |
| |
| async _generateContentCanvas2D(index, actions) |
| { |
| let imageLoad = (event) => { |
| // Loading took too long and the current action index has already changed. |
| if (index !== this._index) |
| return; |
| |
| this._generateContentCanvas2D(index, actions); |
| }; |
| |
| let initialState = this.representedObject.initialState; |
| if (initialState.content && !this._initialContent) { |
| this._initialContent = new Image; |
| this._initialContent.src = initialState.content; |
| this._initialContent.addEventListener("load", imageLoad); |
| return; |
| } |
| |
| let snapshotIndex = Math.floor(index / WI.RecordingContentView.SnapshotInterval); |
| let snapshot = this._snapshots[snapshotIndex]; |
| |
| let showCanvasPath = WI.RecordingContentView.supportsCanvasPathDebugging() && WI.settings.showCanvasPath.value; |
| let indexOfLastBeginPathAction = Infinity; |
| |
| let applyActions = (from, to, callback) => { |
| let saveCount = 0; |
| snapshot.context.save(); |
| |
| if (snapshot.content) { |
| snapshot.context.clearRect(0, 0, snapshot.element.width, snapshot.element.height); |
| snapshot.context.drawImage(snapshot.content, 0, 0); |
| } |
| |
| for (let name in snapshot.state) { |
| if (!(name in snapshot.context)) |
| continue; |
| |
| // Skip internal state used for path debugging. |
| if (name === "currentX" || name === "currentY") |
| continue; |
| |
| try { |
| if (WI.RecordingAction.isFunctionForType(this.representedObject.type, name)) |
| snapshot.context[name](...snapshot.state[name]); |
| else |
| snapshot.context[name] = snapshot.state[name]; |
| } catch { |
| delete snapshot.state[name]; |
| } |
| } |
| |
| let shouldDrawCanvasPath = showCanvasPath && indexOfLastBeginPathAction <= to; |
| if (shouldDrawCanvasPath) { |
| if (!this._pathContext) { |
| let pathCanvas = document.createElement("canvas"); |
| pathCanvas.classList.add("path"); |
| this._pathContext = pathCanvas.getContext("2d"); |
| } |
| |
| this._pathContext.canvas.width = snapshot.element.width; |
| this._pathContext.canvas.height = snapshot.element.height; |
| this._pathContext.clearRect(0, 0, snapshot.element.width, snapshot.element.height); |
| |
| this._pathContext.save(); |
| |
| this._pathContext.fillStyle = "hsla(0, 0%, 100%, 0.75)"; |
| this._pathContext.fillRect(0, 0, snapshot.element.width, snapshot.element.height); |
| } |
| |
| let lastPathPoint = {}; |
| let subPathStartPoint = {}; |
| |
| for (let i = from; i <= to; ++i) { |
| if (actions[i].name === "save") |
| ++saveCount; |
| else if (actions[i].name === "restore") { |
| if (!saveCount) // Only attempt to restore if save has been called. |
| continue; |
| --saveCount; |
| } |
| |
| actions[i].apply(snapshot.context); |
| |
| if (shouldDrawCanvasPath && i >= indexOfLastBeginPathAction && WI.RecordingContentView._actionModifiesPath(actions[i])) { |
| lastPathPoint = {x: this._pathContext.currentX, y: this._pathContext.currentY}; |
| |
| if (i === indexOfLastBeginPathAction) |
| this._pathContext.setTransform(snapshot.context.getTransform()); |
| |
| let isMoveTo = actions[i].name === "moveTo"; |
| this._pathContext.lineWidth = isMoveTo ? 0.5 : 1; |
| this._pathContext.setLineDash(isMoveTo ? [5, 5] : []); |
| this._pathContext.strokeStyle = i === to ? "red" : "black"; |
| |
| this._pathContext.beginPath(); |
| if (!isEmptyObject(lastPathPoint)) |
| this._pathContext.moveTo(lastPathPoint.x, lastPathPoint.y); |
| |
| if (actions[i].name === "closePath" && !isEmptyObject(subPathStartPoint)) { |
| this._pathContext.lineTo(subPathStartPoint.x, subPathStartPoint.y); |
| subPathStartPoint = {}; |
| } else { |
| actions[i].apply(this._pathContext, {nameOverride: isMoveTo ? "lineTo" : null}); |
| if (isMoveTo) |
| subPathStartPoint = {x: this._pathContext.currentX, y: this._pathContext.currentY}; |
| } |
| |
| this._pathContext.stroke(); |
| } |
| } |
| |
| if (shouldDrawCanvasPath) { |
| this._pathContext.restore(); |
| this._previewContainer.appendChild(this._pathContext.canvas); |
| } else if (this._pathContext) |
| this._pathContext.canvas.remove(); |
| |
| let state = { |
| currentX: snapshot.context.currentX, |
| currentY: snapshot.context.currentY, |
| direction: snapshot.context.direction, |
| fillStyle: snapshot.context.fillStyle, |
| font: snapshot.context.font, |
| globalAlpha: snapshot.context.globalAlpha, |
| globalCompositeOperation: snapshot.context.globalCompositeOperation, |
| imageSmoothingEnabled: snapshot.context.imageSmoothingEnabled, |
| imageSmoothingQuality: snapshot.context.imageSmoothingQuality, |
| lineCap: snapshot.context.lineCap, |
| lineDash: snapshot.context.getLineDash(), |
| lineDashOffset: snapshot.context.lineDashOffset, |
| lineJoin: snapshot.context.lineJoin, |
| lineWidth: snapshot.context.lineWidth, |
| miterLimit: snapshot.context.miterLimit, |
| shadowBlur: snapshot.context.shadowBlur, |
| shadowColor: snapshot.context.shadowColor, |
| shadowOffsetX: snapshot.context.shadowOffsetX, |
| shadowOffsetY: snapshot.context.shadowOffsetY, |
| strokeStyle: snapshot.context.strokeStyle, |
| textAlign: snapshot.context.textAlign, |
| textBaseline: snapshot.context.textBaseline, |
| transform: snapshot.context.getTransform(), |
| webkitImageSmoothingEnabled: snapshot.context.webkitImageSmoothingEnabled, |
| webkitLineDash: snapshot.context.webkitLineDash, |
| webkitLineDashOffset: snapshot.context.webkitLineDashOffset, |
| }; |
| |
| if (WI.RecordingContentView.supportsCanvasPathDebugging()) |
| state.setPath = [snapshot.context.getPath()]; |
| |
| snapshot.context.restore(); |
| while (saveCount-- > 0) |
| snapshot.context.restore(); |
| |
| return state; |
| }; |
| |
| if (!snapshot) { |
| snapshot = this._snapshots[snapshotIndex] = {}; |
| snapshot.index = snapshotIndex * WI.RecordingContentView.SnapshotInterval; |
| while (snapshot.index && actions[snapshot.index].name !== "beginPath") |
| --snapshot.index; |
| |
| snapshot.element = document.createElement("canvas"); |
| snapshot.context = snapshot.element.getContext("2d", ...initialState.parameters); |
| if ("width" in initialState.attributes) |
| snapshot.element.width = initialState.attributes.width; |
| if ("height" in initialState.attributes) |
| snapshot.element.height = initialState.attributes.height; |
| |
| let lastSnapshotIndex = snapshotIndex; |
| while (--lastSnapshotIndex >= 0) { |
| if (this._snapshots[lastSnapshotIndex]) |
| break; |
| } |
| |
| let startIndex = 0; |
| if (lastSnapshotIndex < 0) { |
| snapshot.content = this._initialContent; |
| snapshot.state = {}; |
| |
| for (let key in initialState.attributes) { |
| let value = initialState.attributes[key]; |
| |
| switch (key) { |
| case "setTransform": |
| value = [await this.representedObject.swizzle(value, WI.Recording.Swizzle.DOMMatrix)]; |
| break; |
| |
| case "fillStyle": |
| case "strokeStyle": |
| if (Array.isArray(value)) { |
| let canvasStyle = await this.representedObject.swizzle(value[0], WI.Recording.Swizzle.String); |
| if (canvasStyle.includes("gradient")) |
| value = await this.representedObject.swizzle(value, WI.Recording.Swizzle.CanvasGradient); |
| else if (canvasStyle === "pattern") |
| value = await this.representedObject.swizzle(value, WI.Recording.Swizzle.CanvasPattern); |
| } else |
| value = await this.representedObject.swizzle(value, WI.Recording.Swizzle.String); |
| break; |
| |
| case "direction": |
| case "font": |
| case "globalCompositeOperation": |
| case "imageSmoothingEnabled": |
| case "imageSmoothingQuality": |
| case "lineCap": |
| case "lineJoin": |
| case "shadowColor": |
| case "textAlign": |
| case "textBaseline": |
| value = await this.representedObject.swizzle(value, WI.Recording.Swizzle.String); |
| break; |
| |
| case "setPath": |
| value = [await this.representedObject.swizzle(value[0], WI.Recording.Swizzle.Path2D)]; |
| break; |
| } |
| |
| if (value === undefined || (Array.isArray(value) && value.includes(undefined))) |
| continue; |
| |
| snapshot.state[key] = value; |
| } |
| } else { |
| snapshot.content = this._snapshots[lastSnapshotIndex].content; |
| snapshot.state = this._snapshots[lastSnapshotIndex].state; |
| startIndex = this._snapshots[lastSnapshotIndex].index; |
| } |
| |
| snapshot.state = applyActions(startIndex, snapshot.index - 1); |
| |
| snapshot.content = new Image; |
| snapshot.content.src = snapshot.element.toDataURL(); |
| snapshot.content.addEventListener("load", imageLoad); |
| return; |
| } |
| |
| this._previewContainer.removeChildren(); |
| |
| if (showCanvasPath) { |
| indexOfLastBeginPathAction = this._index; |
| while (indexOfLastBeginPathAction > snapshot.index && actions[indexOfLastBeginPathAction].name !== "beginPath") |
| --indexOfLastBeginPathAction; |
| } |
| |
| this._action = actions[this._index]; |
| |
| let state = applyActions(snapshot.index, this._index); |
| console.assert(!this._action.state || Object.shallowEqual(this._action.state, state)); |
| if (!this._action.state) |
| this._action.state = state; |
| |
| this.dispatchEventToListeners(WI.ContentView.Event.SupplementalRepresentedObjectsDidChange); |
| |
| this._previewContainer.appendChild(snapshot.element); |
| this._updateImageGrid(); |
| } |
| |
| async _generateContentCanvasWebGL(index, actions) |
| { |
| let imageLoad = (event) => { |
| // Loading took too long and the current action index has already changed. |
| if (index !== this._index) |
| return; |
| |
| this._generateContentCanvasWebGL(index, actions); |
| }; |
| |
| let initialState = this.representedObject.initialState; |
| if (initialState.content && !this._initialContent) { |
| this._initialContent = new Image; |
| this._initialContent.src = initialState.content; |
| this._initialContent.addEventListener("load", imageLoad); |
| return; |
| } |
| |
| let visualIndex = index; |
| while (!actions[visualIndex].isVisual && !(actions[visualIndex] instanceof WI.RecordingInitialStateAction)) |
| visualIndex--; |
| |
| let snapshot = this._snapshots[visualIndex]; |
| if (!snapshot) { |
| if (actions[visualIndex].snapshot) { |
| snapshot = this._snapshots[visualIndex] = {element: new Image}; |
| snapshot.element.src = actions[visualIndex].snapshot; |
| snapshot.element.addEventListener("load", imageLoad); |
| return; |
| } |
| |
| if (actions[visualIndex] instanceof WI.RecordingInitialStateAction) |
| snapshot = this._snapshots[visualIndex] = {element: this._initialContent}; |
| } |
| |
| if (snapshot) { |
| this._previewContainer.removeChildren(); |
| this._previewContainer.appendChild(snapshot.element); |
| |
| this._updateImageGrid(); |
| } |
| |
| this.dispatchEventToListeners(WI.ContentView.Event.SupplementalRepresentedObjectsDidChange); |
| } |
| |
| _updateCanvasPath() |
| { |
| let activated = WI.settings.showCanvasPath.value; |
| if (this._showPathButtonNavigationItem.activated !== activated) { |
| this.representedObject.actions.then((actions) => { |
| this._generateContentCanvas2D(this._index, actions); |
| }); |
| } |
| |
| this._showPathButtonNavigationItem.activated = activated; |
| } |
| |
| _updateImageGrid() |
| { |
| let activated = WI.settings.showImageGrid.value; |
| this._showGridButtonNavigationItem.activated = activated; |
| |
| let snapshotIndex = Math.floor(this._index / WI.RecordingContentView.SnapshotInterval); |
| if (!isNaN(this._index) && this._snapshots[snapshotIndex]) |
| this._snapshots[snapshotIndex].element.classList.toggle("show-grid", activated); |
| } |
| |
| _updateSliderValue() |
| { |
| if (!this._sliderElement) |
| return; |
| |
| let visualActionIndexes = this.representedObject.visualActionIndexes; |
| let visualActionIndex = 0; |
| if (this._index > 0) { |
| while (visualActionIndex < visualActionIndexes.length && visualActionIndexes[visualActionIndex] <= this._index) |
| visualActionIndex++; |
| } |
| |
| this._sliderElement.value = visualActionIndex; |
| this._sliderValueElement.textContent = WI.UIString("%d of %d").format(visualActionIndex, visualActionIndexes.length); |
| } |
| |
| _showPathButtonClicked(event) |
| { |
| WI.settings.showCanvasPath.value = !this._showPathButtonNavigationItem.activated; |
| |
| this._updateCanvasPath(); |
| } |
| |
| _showGridButtonClicked(event) |
| { |
| WI.settings.showImageGrid.value = !this._showGridButtonNavigationItem.activated; |
| |
| this._updateImageGrid(); |
| } |
| |
| _sliderChanged() |
| { |
| let index = 0; |
| |
| let visualActionIndex = parseInt(this._sliderElement.value) - 1; |
| if (visualActionIndex !== -1) |
| index = this.representedObject.visualActionIndexes[visualActionIndex]; |
| |
| this.updateActionIndex(index); |
| } |
| }; |
| |
| WI.RecordingContentView.SnapshotInterval = 5000; |