Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add feature detection for WebKit #50

Merged
merged 8 commits into from
Feb 10, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Comment on lines +130 to +133
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If non-polyfilled SVG had been rendered before applying polyfills, WebKit sometime leaves SVG debris. An updated feature detection is asynchronous so we have to deal with debris.

This snippet triggers repaint in the whole of HTML contents to clear SVG debris in everywhere.


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