electron/lib/renderer/web-view/web-view.js
Birunthan Mohanathas 2986b7bc4a Allow webview guests to be resized manually
This adds the `disableguestresize` property for webviews to prevent the
webview guest from reacting to size changes of the webview element. This
also partially documents the `webContents.setSize` function in order to
manually control the webview guest size.

These two features can be combined to improve resize performance for
e.g. webviews that span the entire window. This greatly reduces the lag
described in #6905.
2016-11-15 11:00:09 -08:00

466 lines
16 KiB
JavaScript

'use strict'
const {ipcRenderer, remote, webFrame} = require('electron')
const v8Util = process.atomBinding('v8_util')
const guestViewInternal = require('./guest-view-internal')
const webViewConstants = require('./web-view-constants')
const hasProp = {}.hasOwnProperty
// ID generator.
let nextId = 0
const getNextId = function () {
return ++nextId
}
// Represents the internal state of the WebView node.
class WebViewImpl {
constructor (webviewNode) {
this.webviewNode = webviewNode
v8Util.setHiddenValue(this.webviewNode, 'internal', this)
this.attached = false
this.elementAttached = false
this.beforeFirstNavigation = true
// on* Event handlers.
this.on = {}
this.browserPluginNode = this.createBrowserPluginNode()
const shadowRoot = this.webviewNode.createShadowRoot()
shadowRoot.innerHTML = '<!DOCTYPE html><style type="text/css">:host { display: flex; }</style>'
this.setupWebViewAttributes()
this.setupFocusPropagation()
this.viewInstanceId = getNextId()
shadowRoot.appendChild(this.browserPluginNode)
// Subscribe to host's zoom level changes.
this.onZoomLevelChanged = (zoomLevel) => {
this.webviewNode.setZoomLevel(zoomLevel)
}
webFrame.on('zoom-level-changed', this.onZoomLevelChanged)
this.onVisibilityChanged = (event, visibilityState) => {
this.webviewNode.send('ELECTRON_RENDERER_WINDOW_VISIBILITY_CHANGE', visibilityState)
}
ipcRenderer.on('ELECTRON_RENDERER_WINDOW_VISIBILITY_CHANGE', this.onVisibilityChanged)
}
createBrowserPluginNode () {
// We create BrowserPlugin as a custom element in order to observe changes
// to attributes synchronously.
const browserPluginNode = new WebViewImpl.BrowserPlugin()
v8Util.setHiddenValue(browserPluginNode, 'internal', this)
return browserPluginNode
}
// Resets some state upon reattaching <webview> element to the DOM.
reset () {
// Unlisten the zoom-level-changed event.
webFrame.removeListener('zoom-level-changed', this.onZoomLevelChanged)
ipcRenderer.removeListener('ELECTRON_RENDERER_WINDOW_VISIBILITY_CHANGE', this.onVisibilityChanged)
// If guestInstanceId is defined then the <webview> has navigated and has
// already picked up a partition ID. Thus, we need to reset the initialization
// state. However, it may be the case that beforeFirstNavigation is false BUT
// guestInstanceId has yet to be initialized. This means that we have not
// heard back from createGuest yet. We will not reset the flag in this case so
// that we don't end up allocating a second guest.
if (this.guestInstanceId) {
guestViewInternal.destroyGuest(this.guestInstanceId)
this.guestInstanceId = void 0
}
this.webContents = null
this.beforeFirstNavigation = true
this.attributes[webViewConstants.ATTRIBUTE_PARTITION].validPartitionId = true
// Set guestinstance last since this can trigger the attachedCallback to fire
// when moving the webview using element.replaceChild
this.attributes[webViewConstants.ATTRIBUTE_GUESTINSTANCE].setValueIgnoreMutation(undefined)
}
// Sets the <webview>.request property.
setRequestPropertyOnWebViewNode (request) {
Object.defineProperty(this.webviewNode, 'request', {
value: request,
enumerable: true
})
}
setupFocusPropagation () {
if (!this.webviewNode.hasAttribute('tabIndex')) {
// <webview> needs a tabIndex in order to be focusable.
// TODO(fsamuel): It would be nice to avoid exposing a tabIndex attribute
// to allow <webview> to be focusable.
// See http://crbug.com/231664.
this.webviewNode.setAttribute('tabIndex', -1)
}
// Focus the BrowserPlugin when the <webview> takes focus.
this.webviewNode.addEventListener('focus', () => {
this.browserPluginNode.focus()
})
// Blur the BrowserPlugin when the <webview> loses focus.
this.webviewNode.addEventListener('blur', () => {
this.browserPluginNode.blur()
})
}
// This observer monitors mutations to attributes of the <webview> and
// updates the BrowserPlugin properties accordingly. In turn, updating
// a BrowserPlugin property will update the corresponding BrowserPlugin
// attribute, if necessary. See BrowserPlugin::UpdateDOMAttribute for more
// details.
handleWebviewAttributeMutation (attributeName, oldValue, newValue) {
if (!this.attributes[attributeName] || this.attributes[attributeName].ignoreMutation) {
return
}
// Let the changed attribute handle its own mutation
this.attributes[attributeName].handleMutation(oldValue, newValue)
}
handleBrowserPluginAttributeMutation (attributeName, oldValue, newValue) {
if (attributeName === webViewConstants.ATTRIBUTE_INTERNALINSTANCEID && !oldValue && !!newValue) {
this.browserPluginNode.removeAttribute(webViewConstants.ATTRIBUTE_INTERNALINSTANCEID)
this.internalInstanceId = parseInt(newValue)
// Track when the element resizes using the element resize callback.
webFrame.registerElementResizeCallback(this.internalInstanceId, this.onElementResize.bind(this))
if (this.guestInstanceId) {
guestViewInternal.attachGuest(this.internalInstanceId, this.guestInstanceId, this.buildParams())
}
}
}
onSizeChanged (webViewEvent) {
const {newHeight, newWidth} = webViewEvent
const node = this.webviewNode
const width = node.offsetWidth
// Check the current bounds to make sure we do not resize <webview>
// outside of current constraints.
const maxWidth = this.attributes[webViewConstants.ATTRIBUTE_MAXWIDTH].getValue() | width
const maxHeight = this.attributes[webViewConstants.ATTRIBUTE_MAXHEIGHT].getValue() | width
let minWidth = this.attributes[webViewConstants.ATTRIBUTE_MINWIDTH].getValue() | width
let minHeight = this.attributes[webViewConstants.ATTRIBUTE_MINHEIGHT].getValue() | width
minWidth = Math.min(minWidth, maxWidth)
minHeight = Math.min(minHeight, maxHeight)
if (!this.attributes[webViewConstants.ATTRIBUTE_AUTOSIZE].getValue() || (newWidth >= minWidth && newWidth <= maxWidth && newHeight >= minHeight && newHeight <= maxHeight)) {
node.style.width = `${newWidth}px`
node.style.height = `${newHeight}px`
// Only fire the DOM event if the size of the <webview> has actually
// changed.
this.dispatchEvent(webViewEvent)
}
}
onElementResize (newSize) {
// Dispatch the 'resize' event.
const resizeEvent = new Event('resize', {
bubbles: true
})
// Using client size values, because when a webview is transformed `newSize`
// is incorrect
newSize.width = this.webviewNode.clientWidth
newSize.height = this.webviewNode.clientHeight
resizeEvent.newWidth = newSize.width
resizeEvent.newHeight = newSize.height
this.dispatchEvent(resizeEvent)
if (this.guestInstanceId &&
!this.attributes[webViewConstants.ATTRIBUTE_DISABLEGUESTRESIZE].getValue()) {
guestViewInternal.setSize(this.guestInstanceId, {
normal: newSize
})
}
}
createGuest () {
return guestViewInternal.createGuest(this.buildParams(), (event, guestInstanceId) => {
this.attachGuestInstance(guestInstanceId)
})
}
dispatchEvent (webViewEvent) {
this.webviewNode.dispatchEvent(webViewEvent)
}
// Adds an 'on<event>' property on the webview, which can be used to set/unset
// an event handler.
setupEventProperty (eventName) {
const propertyName = `on${eventName.toLowerCase()}`
return Object.defineProperty(this.webviewNode, propertyName, {
get: () => {
return this.on[propertyName]
},
set: (value) => {
if (this.on[propertyName]) {
this.webviewNode.removeEventListener(eventName, this.on[propertyName])
}
this.on[propertyName] = value
if (value) {
return this.webviewNode.addEventListener(eventName, value)
}
},
enumerable: true
})
}
// Updates state upon loadcommit.
onLoadCommit (webViewEvent) {
const oldValue = this.webviewNode.getAttribute(webViewConstants.ATTRIBUTE_SRC)
const newValue = webViewEvent.url
if (webViewEvent.isMainFrame && (oldValue !== newValue)) {
// Touching the src attribute triggers a navigation. To avoid
// triggering a page reload on every guest-initiated navigation,
// we do not handle this mutation.
this.attributes[webViewConstants.ATTRIBUTE_SRC].setValueIgnoreMutation(newValue)
}
}
onAttach (storagePartitionId) {
return this.attributes[webViewConstants.ATTRIBUTE_PARTITION].setValue(storagePartitionId)
}
buildParams () {
const params = {
instanceId: this.viewInstanceId,
userAgentOverride: this.userAgentOverride,
zoomFactor: webFrame.getZoomFactor()
}
for (const attributeName in this.attributes) {
if (hasProp.call(this.attributes, attributeName)) {
params[attributeName] = this.attributes[attributeName].getValue()
}
}
// When the WebView is not participating in layout (display:none)
// then getBoundingClientRect() would report a width and height of 0.
// However, in the case where the WebView has a fixed size we can
// use that value to initially size the guest so as to avoid a relayout of
// the on display:block.
const css = window.getComputedStyle(this.webviewNode, null)
const elementRect = this.webviewNode.getBoundingClientRect()
params.elementWidth = parseInt(elementRect.width) || parseInt(css.getPropertyValue('width'))
params.elementHeight = parseInt(elementRect.height) || parseInt(css.getPropertyValue('height'))
return params
}
attachGuestInstance (guestInstanceId) {
this.guestInstanceId = guestInstanceId
this.attributes[webViewConstants.ATTRIBUTE_GUESTINSTANCE].setValueIgnoreMutation(guestInstanceId)
this.webContents = remote.getGuestWebContents(this.guestInstanceId)
if (!this.internalInstanceId) {
return true
}
return guestViewInternal.attachGuest(this.internalInstanceId, this.guestInstanceId, this.buildParams())
}
}
// Registers browser plugin <object> custom element.
const registerBrowserPluginElement = function () {
const proto = Object.create(HTMLObjectElement.prototype)
proto.createdCallback = function () {
this.setAttribute('type', 'application/browser-plugin')
this.setAttribute('id', `browser-plugin-${getNextId()}`)
// The <object> node fills in the <webview> container.
this.style.flex = '1 1 auto'
}
proto.attributeChangedCallback = function (name, oldValue, newValue) {
var internal
internal = v8Util.getHiddenValue(this, 'internal')
if (internal) {
internal.handleBrowserPluginAttributeMutation(name, oldValue, newValue)
}
}
proto.attachedCallback = function () {
// Load the plugin immediately.
return this.nonExistentAttribute
}
WebViewImpl.BrowserPlugin = webFrame.registerEmbedderCustomElement('browserplugin', {
'extends': 'object',
prototype: proto
})
delete proto.createdCallback
delete proto.attachedCallback
delete proto.detachedCallback
delete proto.attributeChangedCallback
}
// Registers <webview> custom element.
var registerWebViewElement = function () {
const proto = Object.create(HTMLObjectElement.prototype)
proto.createdCallback = function () {
return new WebViewImpl(this)
}
proto.attributeChangedCallback = function (name, oldValue, newValue) {
var internal
internal = v8Util.getHiddenValue(this, 'internal')
if (internal) {
internal.handleWebviewAttributeMutation(name, oldValue, newValue)
}
}
proto.detachedCallback = function () {
var internal
internal = v8Util.getHiddenValue(this, 'internal')
if (!internal) {
return
}
guestViewInternal.deregisterEvents(internal.viewInstanceId)
internal.elementAttached = false
this.internalInstanceId = 0
internal.reset()
}
proto.attachedCallback = function () {
var internal, instance
internal = v8Util.getHiddenValue(this, 'internal')
if (!internal) {
return
}
if (!internal.elementAttached) {
guestViewInternal.registerEvents(internal, internal.viewInstanceId)
internal.elementAttached = true
instance = internal.attributes[webViewConstants.ATTRIBUTE_GUESTINSTANCE].getValue()
if (instance) {
internal.attachGuestInstance(instance)
} else {
internal.attributes[webViewConstants.ATTRIBUTE_SRC].parse()
}
}
}
// Public-facing API methods.
const methods = [
'getURL',
'loadURL',
'getTitle',
'isLoading',
'isLoadingMainFrame',
'isWaitingForResponse',
'stop',
'reload',
'reloadIgnoringCache',
'canGoBack',
'canGoForward',
'canGoToOffset',
'clearHistory',
'goBack',
'goForward',
'goToIndex',
'goToOffset',
'isCrashed',
'setUserAgent',
'getUserAgent',
'openDevTools',
'closeDevTools',
'isDevToolsOpened',
'isDevToolsFocused',
'inspectElement',
'setAudioMuted',
'isAudioMuted',
'undo',
'redo',
'cut',
'copy',
'paste',
'pasteAndMatchStyle',
'delete',
'selectAll',
'unselect',
'replace',
'replaceMisspelling',
'findInPage',
'stopFindInPage',
'getId',
'downloadURL',
'inspectServiceWorker',
'print',
'printToPDF',
'showDefinitionForSelection',
'capturePage'
]
const nonblockMethods = [
'insertCSS',
'insertText',
'send',
'sendInputEvent',
'setZoomFactor',
'setZoomLevel',
'setZoomLevelLimits'
]
// Forward proto.foo* method calls to WebViewImpl.foo*.
const createBlockHandler = function (m) {
return function (...args) {
const internal = v8Util.getHiddenValue(this, 'internal')
if (internal.webContents) {
return internal.webContents[m](...args)
} else {
throw new Error(`Cannot call ${m} because the webContents is unavailable. The WebView must be attached to the DOM and the dom-ready event emitted before this method can be called.`)
}
}
}
for (const method of methods) {
proto[method] = createBlockHandler(method)
}
const createNonBlockHandler = function (m) {
return function (...args) {
const internal = v8Util.getHiddenValue(this, 'internal')
ipcRenderer.send('ELECTRON_BROWSER_ASYNC_CALL_TO_GUEST_VIEW', null, internal.guestInstanceId, m, ...args)
}
}
for (const method of nonblockMethods) {
proto[method] = createNonBlockHandler(method)
}
proto.executeJavaScript = function (code, hasUserGesture, callback) {
const internal = v8Util.getHiddenValue(this, 'internal')
if (typeof hasUserGesture === 'function') {
callback = hasUserGesture
hasUserGesture = false
}
const requestId = getNextId()
ipcRenderer.send('ELECTRON_BROWSER_ASYNC_CALL_TO_GUEST_VIEW', requestId, internal.guestInstanceId, 'executeJavaScript', code, hasUserGesture)
ipcRenderer.once(`ELECTRON_RENDERER_ASYNC_CALL_TO_GUEST_VIEW_RESPONSE_${requestId}`, function (event, result) {
if (callback) callback(result)
})
}
// WebContents associated with this webview.
proto.getWebContents = function () {
return v8Util.getHiddenValue(this, 'internal').webContents
}
window.WebView = webFrame.registerEmbedderCustomElement('webview', {
prototype: proto
})
// Delete the callbacks so developers cannot call them and produce unexpected
// behavior.
delete proto.createdCallback
delete proto.attachedCallback
delete proto.detachedCallback
delete proto.attributeChangedCallback
}
const useCapture = true
const listener = function (event) {
if (document.readyState === 'loading') {
return
}
registerBrowserPluginElement()
registerWebViewElement()
window.removeEventListener(event.type, listener, useCapture)
}
window.addEventListener('readystatechange', listener, true)
module.exports = WebViewImpl