diff --git a/draftlogs/8094_fix.md b/draftlogs/8094_fix.md new file mode 100644 index 00000000000..db611be4c4e --- /dev/null +++ b/draftlogs/8094_fix.md @@ -0,0 +1 @@ + - Redraw plots when a font that they use finishes loading [[#8094](https://github.com/plotly/plotly.js/pull/8094)] diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index f48d808425b..4a00e816cf7 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -198,6 +198,9 @@ function _doPlot(gd, data, layout, config) { Lib.clearResponsive(gd); } + // Text measured while one of these faces loads uses the fallback font + const unloadedFaces = document.fonts ? [...document.fonts].filter((face) => face.status !== 'loaded') : []; + /* * start async-friendly code - now we're actually drawing things */ @@ -408,11 +411,40 @@ function _doPlot(gd, data, layout, config) { if (!plotDone || !plotDone.then) plotDone = Promise.resolve(); return plotDone.then(function () { + redrawAfterFontLoads(gd, unloadedFaces); emitAfterPlot(gd); return gd; }); } +/** + * Redraw a plot after the web fonts that load during its draw finish loading. + * + * A plot that draws before its web font loads measures its text with the fallback font. + * The redraw happens after the draw that calls this function, once every face that started or + * finished loading during the draw settles. A face that fails to load still settles. + * A font that other text loads during the draw also causes one redraw. + * A later draw or `Plots.purge` cancels a pending redraw. + * + * @param gd - The graph div that finished a draw + * @param unloadedFaces - The faces in `document.fonts` that were not loaded when the draw started + */ +async function redrawAfterFontLoads(gd, unloadedFaces) { + // The draw started these loads, or they finished during the draw + const faces = unloadedFaces.filter((face) => ['loaded', 'loading'].includes(face.status)); + if (!faces.length) return; + + const token = {}; + gd._fontLoadToken = token; + await Promise.all(faces.map((face) => face.loaded.catch(() => {}))); + if (gd._fontLoadToken !== token) return; + delete gd._fontLoadToken; + + // Clear size cache to force new measurement with loaded fonts + Drawing.savedBBoxes = {}; + exports._doPlot(gd); +} + function emitAfterPlot(gd) { var fullLayout = gd._fullLayout; diff --git a/src/plots/plots.js b/src/plots/plots.js index 49726f043fb..ad53d4dd3e9 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1470,6 +1470,9 @@ plots.purge = function(gd) { // remove responsive handler Lib.clearResponsive(gd); + // Cancel the pending redraw for web fonts + delete gd._fontLoadToken; + // data and layout delete gd.data; delete gd.layout; diff --git a/src/types/core/graph-div.internal.d.ts b/src/types/core/graph-div.internal.d.ts index 4c621c8bad7..a4458741c65 100644 --- a/src/types/core/graph-div.internal.d.ts +++ b/src/types/core/graph-div.internal.d.ts @@ -28,10 +28,14 @@ export interface GraphContext extends Partial { * This is an HTMLDivElement with additional Plotly-specific properties. */ export interface GraphDiv extends HTMLDivElement { + /** Removes the listener that resizes the plot when `config.responsive` is on */ + _clearResponsive?: () => void; /** Resolved configuration plus internal context. */ _context?: GraphContext; /** True while an inline edit is in progress (e.g. axis title rename). */ _editing?: boolean; + /** Identifies the pending redraw for web fonts. A later draw or a purge replaces or deletes the token. */ + _fontLoadToken?: object; /** Trace data after defaults are applied. */ _fullData?: FullData[]; /** Layout after defaults are applied. */ diff --git a/test/jasmine/tests/plot_api_test.js b/test/jasmine/tests/plot_api_test.js index d71ea4a1e77..40c75212cda 100644 --- a/test/jasmine/tests/plot_api_test.js +++ b/test/jasmine/tests/plot_api_test.js @@ -3300,3 +3300,126 @@ describe('color attribute coercion:', () => { .then(done, done.fail); }); }); + +describe('web font loading', () => { + 'use strict'; + + const fontUrl = + '/base/node_modules/@plotly/mathjax-v3/es5/output/chtml/fonts/woff-v2/MathJax_Typewriter-Regular.woff'; + let gd; + let gd2; + let face; + let faceCount = 0; + + beforeEach(() => { + gd = createGraphDiv(); + }); + + afterEach(() => { + Plotly.purge(gd); + destroyGraphDiv(); + if (gd2) { + Plotly.purge(gd2); + gd2.remove(); + gd2 = undefined; + } + document.fonts.delete(face); + }); + + // Each test adds a new family, so that no earlier measurement can be in the bBox cache + const addFace = () => { + const family = `PlotlyTestFont${++faceCount}`; + face = new FontFace(family, `url(${fontUrl})`); + document.fonts.add(face); + return family; + }; + + const makeFigure = (family) => ({ + data: [{ y: [1, 3, 2], name: 'a long legend entry WWWWWWWW' }], + layout: { font: { family }, showlegend: true, width: 500, height: 400 } + }); + + const legendWidth = (div) => +div.querySelector('.legend .bg').getAttribute('width'); + + it('should redraw a plot with the metrics of its web font after the font loads', (done) => { + const fig = makeFigure(addFace()); + let widthBeforeLoad; + + Plotly.newPlot(gd, fig.data, fig.layout) + .then(() => { + expect(face.status).not.toBe('loaded'); + widthBeforeLoad = legendWidth(gd); + return new Promise((resolve) => gd.once('plotly_afterplot', resolve)); + }) + .then(() => { + expect(face.status).toBe('loaded'); + expect(legendWidth(gd)).not.toBe(widthBeforeLoad); + + // A plot drawn after the load must not reuse the sizes measured with the fallback font + gd2 = createGraphDiv('graph2'); + return Plotly.newPlot(gd2, fig.data, fig.layout); + }) + .then(() => { + expect(legendWidth(gd2)).toBe(legendWidth(gd)); + }) + .then(done, done.fail); + }); + + it('should not redraw a plot when a font loads after its draw', (done) => { + addFace(); + const fig = makeFigure('Arial'); + + Plotly.newPlot(gd, fig.data, fig.layout) + .then(() => { + spyOn(plotApi, '_doPlot').and.callThrough(); + return face.load(); + }) + // `document.fonts.ready` can resolve after the load promise + .then(() => new Promise((resolve) => setTimeout(resolve, 100))) + .then(() => { + expect(plotApi._doPlot).not.toHaveBeenCalled(); + }) + .then(done, done.fail); + }); + + it('should redraw once per font load after several redraws', (done) => { + const fig = makeFigure(addFace()); + + Plotly.newPlot(gd, fig.data, fig.layout) + .then(() => Plotly.redraw(gd)) + .then(() => Plotly.redraw(gd)) + .then(() => { + expect(face.status).not.toBe('loaded'); + spyOn(plotApi, '_doPlot').and.callThrough(); + return face.loaded; + }) + // `document.fonts.ready` can resolve after the load promise + .then(() => new Promise((resolve) => setTimeout(resolve, 100))) + .then(() => { + expect(plotApi._doPlot).toHaveBeenCalledTimes(1); + }) + .then(done, done.fail); + }); + + it('should not redraw a purged plot when its font loads', (done) => { + const fig = makeFigure(addFace()); + + Plotly.newPlot(gd, fig.data, fig.layout) + .then(() => { + expect(face.status).not.toBe('loaded'); + expect(gd._fontLoadToken).toBeDefined(); + + Plotly.purge(gd); + expect(gd._fontLoadToken).toBeUndefined(); + + spyOn(plotApi, '_doPlot').and.callThrough(); + return face.loaded; + }) + // `document.fonts.ready` can resolve after the load promise + .then(() => new Promise((resolve) => setTimeout(resolve, 100))) + .then(() => { + expect(plotApi._doPlot).not.toHaveBeenCalled(); + }) + .then(done, done.fail); + }); +});