blob: f0ea4653f2e54400cbc7a6fab07d02bcfec74ad6 [file] [log] [blame]
/*
* 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;