| /* |
| * 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); |
| |
| let isCanvas2D = this.representedObject.type === WI.Recording.Type.Canvas2D; |
| let isCanvasBitmapRenderer = this.representedObject.type === WI.Recording.Type.CanvasBitmapRenderer; |
| let isCanvasWebGL = this.representedObject.type === WI.Recording.Type.CanvasWebGL; |
| let isCanvasWebGL2 = this.representedObject.type === WI.Recording.Type.CanvasWebGL2; |
| |
| this._index = NaN; |
| this._action = null; |
| this._snapshots = []; |
| this._initialContent = null; |
| this._generateContentThrottler = new Throttler(() => { |
| if (isCanvas2D) |
| this._generateContentCanvas2D(this._index); |
| else if (isCanvasBitmapRenderer || isCanvasWebGL || isCanvasWebGL2) |
| this._generateContentFromSnapshot(this._index); |
| }, 200); |
| |
| this.element.classList.add("recording", this.representedObject.type); |
| |
| if (isCanvas2D || isCanvasBitmapRenderer || isCanvasWebGL || isCanvasWebGL2) { |
| if (isCanvas2D && WI.ImageUtilities.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 transparency grid"), WI.UIString("Hide transparency 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.buttonStyle = WI.ButtonNavigationItem.Style.ImageAndText; |
| this._exportButtonNavigationItem.visibilityPriority = WI.NavigationItem.VisibilityPriority.High; |
| this._exportButtonNavigationItem.addEventListener(WI.ButtonNavigationItem.Event.Clicked, this._handleExportNavigationItemClicked, this); |
| this._updateExportButton(); |
| } |
| } |
| |
| // Public |
| |
| get navigationItems() |
| { |
| let isCanvas2D = this.representedObject.type === WI.Recording.Type.Canvas2D; |
| let isCanvasBitmapRenderer = this.representedObject.type === WI.Recording.Type.CanvasBitmapRenderer; |
| let isCanvasWebGL = this.representedObject.type === WI.Recording.Type.CanvasWebGL; |
| let isCanvasWebGL2 = this.representedObject.type === WI.Recording.Type.CanvasWebGL2; |
| if (!isCanvas2D && !isCanvasBitmapRenderer && !isCanvasWebGL && !isCanvasWebGL2) |
| return []; |
| |
| let navigationItems = [this._exportButtonNavigationItem, new WI.DividerNavigationItem]; |
| if (isCanvas2D && WI.ImageUtilities.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; |
| |
| console.assert(index >= 0 && index < this.representedObject.actions.length); |
| if (index < 0 || index >= this.representedObject.actions.length) |
| return; |
| |
| this._index = index; |
| |
| this._updateSliderValue(); |
| |
| if (this.didInitialLayout) |
| this._generateContentThrottler.fire(); |
| |
| this._action = this.representedObject.actions[this._index]; |
| |
| this.dispatchEventToListeners(WI.ContentView.Event.SupplementalRepresentedObjectsDidChange); |
| } |
| |
| shown() |
| { |
| super.shown(); |
| |
| let isCanvas2D = this.representedObject.type === WI.Recording.Type.Canvas2D; |
| let isCanvasBitmapRenderer = this.representedObject.type === WI.Recording.Type.CanvasBitmapRenderer; |
| let isCanvasWebGL = this.representedObject.type === WI.Recording.Type.CanvasWebGL; |
| let isCanvasWebGL2 = this.representedObject.type === WI.Recording.Type.CanvasWebGL2; |
| if (isCanvas2D || isCanvasBitmapRenderer || isCanvasWebGL || isCanvasWebGL2) { |
| if (isCanvas2D) |
| this._updateCanvasPath(); |
| this._updateImageGrid(); |
| } |
| } |
| |
| hidden() |
| { |
| super.hidden(); |
| |
| this._generateContentThrottler.cancel(); |
| } |
| |
| // Protected |
| |
| get supportsSave() |
| { |
| return true; |
| } |
| |
| get saveData() |
| { |
| return {customSaveHandler: () => { this._exportRecording(); }}; |
| } |
| |
| initialLayout() |
| { |
| let previewHeader = this.element.appendChild(document.createElement("header")); |
| |
| let sliderContainer = previewHeader.appendChild(document.createElement("div")); |
| sliderContainer.className = "slider-container"; |
| |
| 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; |
| |
| if (!this.representedObject.ready) { |
| this.representedObject.addEventListener(WI.Recording.Event.ProcessedAction, this._handleRecordingProcessedAction, this); |
| |
| if (!this.representedObject.processing) |
| this.representedObject.startProcessing(); |
| } |
| |
| this._updateSliderValue(); |
| |
| if (!isNaN(this._index)) |
| this._generateContentThrottler.fire(); |
| } |
| |
| // Private |
| |
| _exportRecording() |
| { |
| let filename = this.representedObject.displayName; |
| |
| WI.FileUtilities.save({ |
| url: WI.FileUtilities.inspectorURLForFilename(filename + ".json"), |
| content: JSON.stringify(this.representedObject.toJSON()), |
| forceSaveAs: true, |
| }); |
| } |
| |
| _exportReduction() |
| { |
| if (!this.representedObject.ready) { |
| InspectorFrontendHost.beep(); |
| return; |
| } |
| |
| let filename = this.representedObject.displayName; |
| |
| WI.FileUtilities.save({ |
| url: WI.FileUtilities.inspectorURLForFilename(filename + ".html"), |
| content: this.representedObject.toHTML(), |
| forceSaveAs: true, |
| }); |
| } |
| |
| _generateContentCanvas2D(index) |
| { |
| let imageLoad = (event) => { |
| // Loading took too long and the current action index has already changed. |
| if (index !== this._index) |
| return; |
| |
| this._generateContentCanvas2D(index); |
| }; |
| |
| 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.ImageUtilities.supportsCanvasPathDebugging() && WI.settings.showCanvasPath.value; |
| let indexOfLastBeginPathAction = Infinity; |
| |
| let actions = this.representedObject.actions; |
| |
| let applyActions = (from, to, callback) => { |
| let saveCount = 0; |
| snapshot.context.save(); |
| |
| for (let attribute in snapshot.attributes) |
| snapshot.element[attribute] = snapshot.attributes[attribute]; |
| |
| if (snapshot.content) { |
| snapshot.context.clearRect(0, 0, snapshot.element.width, snapshot.element.height); |
| snapshot.context.drawImage(snapshot.content, 0, 0); |
| } |
| |
| for (let state of snapshot.states) { |
| state.apply(this.representedObject.type, snapshot.context); |
| |
| ++saveCount; |
| snapshot.context.save(); |
| } |
| |
| 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; |
| } |
| |
| actions[i].apply(snapshot.context); |
| } |
| |
| if (showCanvasPath && indexOfLastBeginPathAction <= to) { |
| 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); |
| |
| function actionModifiesPath(action) { |
| switch (action.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; |
| } |
| |
| for (let i = indexOfLastBeginPathAction; i <= to; ++i) { |
| if (!actionModifiesPath(actions[i])) |
| continue; |
| |
| 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(); |
| } |
| |
| this._pathContext.restore(); |
| this._previewContainer.appendChild(this._pathContext.canvas); |
| } else if (this._pathContext) |
| this._pathContext.canvas.remove(); |
| |
| snapshot.context.restore(); |
| while (saveCount-- > 0) |
| snapshot.context.restore(); |
| }; |
| |
| if (!snapshot) { |
| snapshot = this._snapshots[snapshotIndex] = {}; |
| snapshot.index = snapshotIndex * WI.RecordingContentView.SnapshotInterval; |
| while (snapshot.index && actions[snapshot.index].name !== "beginPath") |
| --snapshot.index; |
| |
| snapshot.context = this.representedObject.createContext(); |
| snapshot.element = snapshot.context.canvas; |
| |
| let lastSnapshotIndex = snapshotIndex; |
| while (--lastSnapshotIndex >= 0) { |
| if (this._snapshots[lastSnapshotIndex]) |
| break; |
| } |
| |
| let startIndex = 0; |
| if (lastSnapshotIndex < 0) { |
| snapshot.content = this._initialContent; |
| snapshot.states = actions[0].states; |
| snapshot.attributes = Object.shallowCopy(initialState.attributes); |
| } else { |
| let lastSnapshot = this._snapshots[lastSnapshotIndex]; |
| snapshot.content = lastSnapshot.content; |
| snapshot.states = lastSnapshot.states; |
| snapshot.attributes = {}; |
| for (let attribute in initialState.attributes) |
| snapshot.attributes[attribute] = lastSnapshot.element[attribute]; |
| |
| startIndex = lastSnapshot.index; |
| } |
| |
| applyActions(startIndex, snapshot.index - 1); |
| if (snapshot.index > 0) |
| snapshot.states = actions[snapshot.index - 1].states; |
| |
| 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; |
| } |
| |
| applyActions(snapshot.index, this._index); |
| |
| this._previewContainer.insertAdjacentElement("afterbegin", snapshot.element); |
| this._updateImageGrid(); |
| } |
| |
| _generateContentFromSnapshot(index) |
| { |
| let imageLoad = (event) => { |
| // Loading took too long and the current action index has already changed. |
| if (index !== this._index) |
| return; |
| |
| this._generateContentFromSnapshot(index); |
| }; |
| |
| 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 actions = this.representedObject.actions; |
| |
| 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(); |
| } |
| } |
| |
| _updateExportButton() |
| { |
| if (this.representedObject.type === WI.Recording.Type.Canvas2D && this.representedObject.ready) |
| this._exportButtonNavigationItem.tooltip = WI.UIString("Export recording (%s)\nShift-click to export a HTML reduction").format(WI.saveKeyboardShortcut.displayName); |
| else |
| this._exportButtonNavigationItem.tooltip = WI.UIString("Export recording (%s)").format(WI.saveKeyboardShortcut.displayName); |
| } |
| |
| _updateCanvasPath() |
| { |
| let activated = WI.settings.showCanvasPath.value; |
| |
| if (this._showPathButtonNavigationItem.activated !== activated) |
| this._generateContentCanvas2D(this._index); |
| |
| this._showPathButtonNavigationItem.activated = activated; |
| } |
| |
| _updateImageGrid() |
| { |
| let activated = WI.settings.showImageGrid.value; |
| this._showGridButtonNavigationItem.activated = activated; |
| |
| if (this.didInitialLayout && !isNaN(this._index)) |
| this._previewContainer.firstElementChild.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._sliderElement.max = visualActionIndexes.length; |
| 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(); |
| } |
| |
| _handleExportNavigationItemClicked(event) |
| { |
| if (event.data.nativeEvent.shiftKey && this.representedObject.type === WI.Recording.Type.Canvas2D && this.representedObject.ready) |
| this._exportReduction(); |
| else |
| this._exportRecording(); |
| } |
| |
| _sliderChanged() |
| { |
| let index = 0; |
| |
| let visualActionIndex = parseInt(this._sliderElement.value) - 1; |
| if (visualActionIndex !== -1) |
| index = this.representedObject.visualActionIndexes[visualActionIndex]; |
| |
| this.updateActionIndex(index); |
| } |
| |
| _handleRecordingProcessedAction(event) |
| { |
| this._updateExportButton(); |
| |
| this._updateSliderValue(); |
| |
| if (this.representedObject.ready) |
| this.representedObject.removeEventListener(null, null, this); |
| } |
| }; |
| |
| WI.RecordingContentView.SnapshotInterval = 5000; |