Skip to content

Commit

Permalink
Merge pull request #50 from marp-team/webkit-feature-detection
Browse files Browse the repository at this point in the history
Add feature detection for WebKit
  • Loading branch information
yhatt authored Feb 10, 2023
2 parents a9751dd + 26c5919 commit a7ff40a
Show file tree
Hide file tree
Showing 8 changed files with 435 additions and 44 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

## [Unreleased]

### Added

- Feature detection for WebKit ([#50](https://github.com/marp-team/marpit-svg-polyfill/pull/50))
- A WebKit polyfill will not apply if enabled [Layer-based SVG engine](https://blogs.igalia.com/nzimmermann/posts/2021-10-29-layer-based-svg-engine/)

### Changed

- The `polyfills()` function now returns a [thenable](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables) array, for getting the correct result of feature detection when calling together with `await` expression ([#50](https://github.com/marp-team/marpit-svg-polyfill/pull/50))
- Upgrade development Node and dependent packages to the latest version ([#51](https://github.com/marp-team/marpit-svg-polyfill/pull/51))

## v2.0.0 - 2022-04-12
Expand Down
40 changes: 30 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The polyfill for [the inline SVG slide][inline-svg] rendered by [Marpit].

### Supported browser

- [WebKit](#webkit) based browser: Safari and iOS browsers (included Chrome and Firefox)
- [WebKit](#webkit) based browser: Safari and iOS browsers (including iOS Chrome, iOS Firefox, iOS Edge, and so on...)

## Usage

Expand All @@ -36,13 +36,22 @@ The polyfill for [the inline SVG slide][inline-svg] rendered by [Marpit].

[Marpit]'s [inline SVG slide][inline-svg] has a lot of advantages: No requires JavaScript, gives better performance for scaling, and has predicatable DOM structure.

But unfortunately, WebKit browser has not scaled the wrapped HTML correctly. It is caused from a long standing [bug 23113](https://bugs.webkit.org/show_bug.cgi?id=23113), and it does not resolved in the last 10 years.
But unfortunately, WebKit browser has not scaled the wrapped HTML correctly. It is caused from a long standing [bug 23113](https://bugs.webkit.org/show_bug.cgi?id=23113), and it does not resolved in the last 15 years.

![](https://raw.githubusercontent.com/marp-team/marpit-svg-polyfill/main/docs/webkit-bug.png)

Through inspector, we have not confirmed that there is a wrong layout in SVG itself and around. Thus, the problem has in a rendering of the parent SVG.

Actually, the nested SVG seems to be scaled correctly (e.g. `<!--fit-->` keyword in [Marp Core](https://github.com/marp-team/marp-core)).
> **Note**
> A brand-new SVG engine for WebKit called as [**"Layer-based SVG engine (LBSE)"**](https://blogs.igalia.com/nzimmermann/posts/2021-10-29-layer-based-svg-engine/) is currently under development, and it will finally bring glitch-free scaling without JS. (See also: [Status of the new SVG engine in WebKit](https://wpewebkit.org/blog/05-new-svg-engine.html))
>
> You can test LBSE in [Safari Technology Preview](https://developer.apple.com/safari/technology-preview/) by following these steps:
>
> 1. Install Safari Technology Preview
> 1. Run `defaults write com.apple.SafariTechnologyPreview IncludeInternalDebugMenu 1` in terminal
> 1. Open Safari Technology Preview
> 1. Turn on **"Layer-based SVG engine (LBSE)"** from "Debug" menu → "WebKit Internal Features"
> 1. Restart app
>
> marpit-svg-polyfill v2.1.0 and later will try to detect whether or not enabled LBSE, and does not apply polyfill if LBSE was available.
## Solutions

Expand All @@ -55,23 +64,32 @@ We try to simulate scaling and centering by applying `transform` / `transform-or
```html
<svg viewBox="0 0 1280 960">
<foreignObject width="1280" height="960">
<section
style="transform-origin:0 0;transform:translate(123px,456px) scale(0.36666);"
>
<section style="transform-origin:0 0;transform:matrix(......);">
...
</section>
</foreignObject>
</svg>
```

We have to get the computed size of SVG element, so the polyfill would make a sacrifice of zero-JS feature.
marpit-svg-polyfill uses the result of `getScreenCTM()` method, so the polyfill will sacrifice "zero-JS slide", the key feature of inline SVG.

#### Repainting

WebKit browser would not trigger repainting even if modified the contents of slide. It becomes a problem when supporting the live preview feature in [Marp Web](https://web.marp.app/).
WebKit browser would not trigger repainting even if modified the contents of slide. It becomes a problem when supporting the live preview feature in Marp tools.

Fortunately, [a genius already resolved this problem only in CSS!](https://stackoverflow.com/a/21947628) `transform:translateZ(0)` would trigger re-painting immidiately when modified contents.

#### Animation GIF

People like to put GIF animation in the slide. However, GIF in polyfilled slides have glitches. GIF updates only a cropped part somewhere.

Applying `transform:translateZ(0.0001px)` to each `<section>` elements within SVG is a magic to resolve that. 🪄

> **Warning**
> This style brings slightly blurred contents too. Our polyfill prefers to render animated contents correctly.
<!--
## Advanced
### Apply polyfill manually
Expand All @@ -97,6 +115,8 @@ We have confirmed a similar rendering bug to WebKit in a few Blink based browser
We are not applied polyfill for Blink browsers because [they are working toward to resolve this.](https://bugs.chromium.org/p/chromium/issues/detail?id=467484) But you may apply `webkit()` manually if you required.
-->

## Contributing

We are following [the contributing guideline of marp-team projects](https://github.com/marp-team/.github/blob/master/CONTRIBUTING.md). Please read these guidelines this before starting work in this repository.
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@
"preset": "ts-jest",
"restoreMocks": true,
"testEnvironment": "jest-environment-jsdom",
"testEnvironmentOptions": {
"resources": "usable"
},
"testMatch": [
"<rootDir>/test/**/!(@(.|_))*.[jt]s"
]
Expand Down Expand Up @@ -81,6 +84,7 @@
"@types/node": "^18.11.19",
"@typescript-eslint/eslint-plugin": "^5.50.0",
"@typescript-eslint/parser": "^5.50.0",
"canvas": "^2.11.0",
"eslint": "^8.33.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-import": "^2.27.5",
Expand Down
102 changes: 89 additions & 13 deletions src/polyfill.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
import { isRequiredPolyfill } from './utils/feature-detection'

const msgPrefix = 'marpitSVGPolyfill:setZoomFactor,'

export type PolyfillOption = { target?: ParentNode }
type PolyfillsRawArray = Array<(opts: PolyfillOption) => void>
type PolyfillsArray = PolyfillsRawArray & PromiseLike<PolyfillsRawArray>

export type PolyfillOption = {
/** The parent node to observe. It's useful for observing Marpit slides inside shadow DOM. */
target?: ParentNode
}

export const observerSymbol = Symbol()
export const zoomFactorRecieverSymbol = Symbol()

/**
* Start observing DOM to apply polyfills.
*
* @param target The parent node to observe. It's useful for observing Marpit
* slides inside shadow DOM. Default is `document`.
* @returns A function for stopping and cleaning up observation.
*/
export function observe(target: ParentNode = document): () => void {
if (target[observerSymbol]) return target[observerSymbol]

Expand All @@ -20,21 +35,70 @@ export function observe(target: ParentNode = document): () => void {
value: cleanup,
})

const observedPolyfills = polyfills()
let polyfillsArray: PolyfillsRawArray = []
let polyfillsPromiseDone = false

if (observedPolyfills.length > 0) {
const observer = () => {
for (const polyfill of observedPolyfills) polyfill({ target })
if (enableObserver) window.requestAnimationFrame(observer)
;(async () => {
try {
polyfillsArray = await polyfills()
} finally {
polyfillsPromiseDone = true
}
observer()
})()

const observer = () => {
for (const polyfill of polyfillsArray) polyfill({ target })

if (polyfillsPromiseDone && polyfillsArray.length === 0) return
if (enableObserver) window.requestAnimationFrame(observer)
}
observer()

return cleanup
}

export const polyfills = (): Array<(opts: PolyfillOption) => void> =>
navigator.vendor === 'Apple Computer, Inc.' ? [webkit] : []
/**
* Returns an array of polyfill functions that must call for the current browser
* environment.
*
* Including polyfills in the returned array are simply determined by the kind of
* browser. If you want detailed polyfills that were passed accurate feature
* detections, call asyncronous version by `polyfills().then()` or
* `await polyfills()`.
*
* ```js
* import { polyfills } from '@marp-team/marpit-svg-polyfill'
*
* polyfills().then((polyfills) => {
* for (const polyfill of polyfills) polyfill()
* })
* ```
*
* @returns A thenable array including polyfill functions
*/
export const polyfills = (): PolyfillsArray => {
const isSafari = navigator.vendor === 'Apple Computer, Inc.'

// Sync version of polyfills() has no feature detection. Detect only by the
// kind of browser.
const polyfillsSync: PolyfillsRawArray = isSafari ? [webkit] : []

const polyfillsPromiseLike: PromiseLike<PolyfillsRawArray> = {
then: ((resolve) => {
if (isSafari) {
isRequiredPolyfill().then((required) => {
resolve?.(required ? [webkit] : [])
})
} else {
resolve?.([])
}

return polyfillsPromiseLike
}) as PolyfillsArray['then'],
}

return Object.assign(polyfillsSync, polyfillsPromiseLike)
}

let previousZoomFactor: number
let zoomFactorFromParent: number | undefined
Expand All @@ -46,7 +110,14 @@ export const _resetCachedZoomFactor = () => {

_resetCachedZoomFactor()

export function webkit(opts?: number | (PolyfillOption & { zoom?: number })) {
export function webkit(
opts?:
| number
| (PolyfillOption & {
/** A zoom factor applied in the current view. You have to specify manually because there is not a reliable way to get the actual zoom factor in the browser. */
zoom?: number
})
) {
const target = (typeof opts === 'object' && opts.target) || document
const zoom = typeof opts === 'object' ? opts.zoom : opts

Expand All @@ -56,6 +127,11 @@ export function webkit(opts?: number | (PolyfillOption & { zoom?: number })) {
value: true,
})

// Repaint viewport forcibly when initial observing, to clear buggy SVG debris
document.body.style['zoom'] = 1.0001
void document.body.offsetHeight
document.body.style['zoom'] = 1

window.addEventListener('message', ({ data, origin }) => {
if (origin !== window.origin) return

Expand All @@ -79,9 +155,9 @@ export function webkit(opts?: number | (PolyfillOption & { zoom?: number })) {
(svg) => {
if (!svg.style.transform) svg.style.transform = 'translateZ(0)'

// NOTE: Safari reflects a zoom level to SVG's currentScale property, but
// the other browsers will always return 1. You have to specify the zoom
// factor manually if used in outdated Blink engine. (e.g. Electron)
// Safari 16.3 and eariler versions had applied the current scale factor
// of the view to `currentScale` property. In others, it becomes `1` as
// long as not set the custom scale to SVG element.
const zoomFactor = zoom || zoomFactorFromParent || svg.currentScale || 1

if (previousZoomFactor !== zoomFactor) {
Expand Down
27 changes: 27 additions & 0 deletions src/utils/feature-detection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
let _isRequiredPolyfill: boolean | undefined

export const isRequiredPolyfill = async () => {
if (_isRequiredPolyfill === undefined) {
const canvas = document.createElement('canvas')
canvas.width = 10
canvas.height = 10

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const ctx = canvas.getContext('2d')!

const svgImg = new Image(10, 10)
const svgOnLoadPromise = new Promise<void>((resolve) => {
svgImg.addEventListener('load', () => resolve())
})

svgImg.crossOrigin = 'anonymous'
svgImg.src =
'data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%2210%22%20viewBox%3D%220%200%201%201%22%3E%3CforeignObject%20width%3D%221%22%20height%3D%221%22%20requiredExtensions%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxhtml%22%3E%3Cdiv%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxhtml%22%20style%3D%22width%3A%201px%3B%20height%3A%201px%3B%20background%3A%20red%3B%20position%3A%20relative%22%3E%3C%2Fdiv%3E%3C%2FforeignObject%3E%3C%2Fsvg%3E'

await svgOnLoadPromise
ctx.drawImage(svgImg, 0, 0)

_isRequiredPolyfill = ctx.getImageData(5, 5, 1, 1).data[3] < 128
}
return _isRequiredPolyfill
}
45 changes: 33 additions & 12 deletions test/polyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,40 @@ describe('Marpit SVG polyfill', () => {
let spy: jest.SpyInstance

beforeEach(() => {
spy = jest.spyOn(window, 'requestAnimationFrame')
document.body.innerHTML = '<svg data-marpit-svg></svg>'
spy = jest.spyOn(window, 'requestAnimationFrame').mockImplementation()
})

it('has no operations when running in not supported browser', () => {
it('has no operations when running in not supported browser', async () => {
observe()
expect(spy).not.toHaveBeenCalled()
expect(spy).toHaveBeenCalledTimes(1)

// Wait for detecting whether polyfill is required
await new Promise((res) => setTimeout(res, 0))
spy.mock.calls[0][0]()

const svg = document.querySelector<SVGElement>('svg[data-marpit-svg]')
expect(svg?.style.transform).toBeFalsy()
})

it('applies polyfill once when running in WebKit browser', () => {
it('applies polyfill once when running in WebKit browser', async () => {
vendor.mockImplementation(() => 'Apple Computer, Inc.')

observe()
expect(spy).toHaveBeenCalledTimes(1)

// Call requestAnimationFrame only once
observe()
expect(spy).toHaveBeenCalledTimes(1)
const observer = spy.mock.calls[0][0]

// Wait for detecting whether polyfill is required
await new Promise((res) => setTimeout(res, 0))
observer()

// And wait canvas rendering for feature detection
await new Promise((res) => setTimeout(res, 0))
observer()

const svg = document.querySelector<SVGElement>('svg[data-marpit-svg]')
expect(svg?.style.transform).toBeTruthy()
})

describe('Clean-up function', () => {
Expand All @@ -53,19 +70,23 @@ describe('Marpit SVG polyfill', () => {
})

describe('Different target', () => {
it('availables observation for different target', () => {
it('availables observation for different target', async () => {
vendor.mockImplementation(() => 'Apple Computer, Inc.')

const element = document.createElement('div')
const querySpy = jest.spyOn(element, 'querySelectorAll')
const cleanup = observe(element)
expect(spy).toHaveBeenCalledTimes(1)

// Wait for detecting whether polyfill is required
await new Promise((res) => setTimeout(res, 0))
spy.mock.calls[0][0]()

expect(element[observerSymbol]).toStrictEqual(cleanup)
expect(querySpy).toHaveBeenCalled()

// Returns always same clean-up function even if observing some times
expect(observe(element)).toStrictEqual(cleanup)
expect(spy).toHaveBeenCalledTimes(1)
})
})
})
Expand All @@ -84,11 +105,11 @@ describe('Marpit SVG polyfill', () => {
})

it('applies transform style to SVG element for repainting', () => {
const svg = <SVGElement>document.querySelector('svg[data-marpit-svg]')
expect(svg.style.transform).not.toContain('translateZ(0)')
const svg = document.querySelector<SVGElement>('svg[data-marpit-svg]')
expect(svg?.style.transform).not.toContain('translateZ(0)')

webkit()
expect(svg.style.transform).toContain('translateZ(0)')
expect(svg?.style.transform).toContain('translateZ(0)')
})

it('applies calculated transform style to section elements for scaling', () => {
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src", "test"]
"include": ["src"]
}
Loading

0 comments on commit a7ff40a

Please sign in to comment.