diff --git a/src/app.tsx b/src/app.tsx index 828972b..dc46706 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -54,7 +54,7 @@ export class App extends React.Component { this.componentDidUpdate(); } - componentDidUpdate() { + async componentDidUpdate() { if (this.props.location.pathname !== '/view') { return; } @@ -77,45 +77,42 @@ export class App extends React.Component { if (!url && !hash) { this.props.history.replace({pathname: '/'}); } else if (this.isNewData(hash, url)) { - const loadedData = hash - ? loadGedcom(hash, gedcom, images) - : loadFromUrl(url!, handleCors); - loadedData.then( - (data) => { - // Set state with data. - this.setState( - Object.assign({}, this.state, { - data, - hash, - selection: getSelection(data.chartData, indi, generation), - error: undefined, - loading: false, - url, - showSidePanel, - }), - ); - }, - (error) => { - // Set error state. - this.setState( - Object.assign({}, this.state, { - error: error.message, - loading: false, - }), - ); - }, - ); - // Set loading state. - this.setState( - Object.assign({}, this.state, { - data: undefined, - selection: undefined, - hash, - error: undefined, - loading: true, - url, - }), - ); + try { + // Set loading state. + this.setState( + Object.assign({}, this.state, { + data: undefined, + selection: undefined, + hash, + error: undefined, + loading: true, + url, + }), + ); + const data = hash + ? await loadGedcom(hash, gedcom, images) + : await loadFromUrl(url!, handleCors); + // Set state with data. + this.setState( + Object.assign({}, this.state, { + data, + hash, + selection: getSelection(data.chartData, indi, generation), + error: undefined, + loading: false, + url, + showSidePanel, + }), + ); + } catch (error) { + // Set error state. + this.setState( + Object.assign({}, this.state, { + error: error.message, + loading: false, + }), + ); + } } else if (this.state.data && this.state.selection) { // Update selection if it has changed in the URL. const selection = getSelection( diff --git a/src/chart.tsx b/src/chart.tsx index 863097b..5c315cb 100644 --- a/src/chart.tsx +++ b/src/chart.tsx @@ -38,28 +38,29 @@ function loadAsDataUrl(blob: Blob): Promise { }); } +async function inlineImage(image: SVGImageElement) { + const href = image.href.baseVal; + if (!href) { + return; + } + try { + const response = await fetch(href); + const blob = await response.blob(); + const dataUrl = await loadAsDataUrl(blob); + image.href.baseVal = dataUrl; + } catch (e) { + console.warn('Failed to load image:', e); + } +} + /** * Fetches all images in the SVG and replaces them with inlined images as data * URLs. Images are replaced in place. The replacement is done, the returned * promise is resolved. */ -function inlineImages(svg: Element): Promise { +async function inlineImages(svg: Element): Promise { const images = Array.from(svg.getElementsByTagName('image')); - const promises = images.map((image) => { - const href = image.href && image.href.baseVal; - if (!href) { - return Promise.resolve(); - } - return fetch(href) - .then((response) => response.blob()) - .then(loadAsDataUrl) - .then((dataUrl) => { - image.href.baseVal = dataUrl; - }) - // Log and ignore errors. - .catch((e) => console.warn('Failed to load image:', e)); - }); - return Promise.all(promises); + await Promise.all(images.map(inlineImage)); } /** Loads a blob into an image object. */ @@ -67,9 +68,7 @@ function loadImage(blob: Blob): Promise { const image = new Image(); image.src = URL.createObjectURL(blob); return new Promise((resolve, reject) => { - image.addEventListener('load', () => { - resolve(image); - }); + image.addEventListener('load', () => resolve(image)); }); } @@ -215,12 +214,11 @@ export class Chart extends React.PureComponent { return new XMLSerializer().serializeToString(svg); } - private getSvgContentsWithInlinedImages() { + private async getSvgContentsWithInlinedImages() { const svg = document.getElementById('chart')!.cloneNode(true) as Element; svg.removeAttribute('transform'); - return inlineImages(svg).then(() => - new XMLSerializer().serializeToString(svg), - ); + await inlineImages(svg); + return new XMLSerializer().serializeToString(svg); } /** Shows the print dialog to print the currently displayed chart. */ @@ -243,35 +241,32 @@ export class Chart extends React.PureComponent { document.body.appendChild(printWindow); } - downloadSvg() { - this.getSvgContentsWithInlinedImages().then((contents) => { - const blob = new Blob([contents], {type: 'image/svg+xml'}); - saveAs(blob, 'topola.svg'); - }); - } - - drawOnCanvas(): Promise { - return this.getSvgContentsWithInlinedImages() - .then((contents) => new Blob([contents], {type: 'image/svg+xml'})) - .then(loadImage) - .then(drawOnCanvas); - } - - downloadPng() { - this.drawOnCanvas() - .then((canvas) => canvasToBlob(canvas, 'image/png')) - .then((blob) => saveAs(blob, 'topola.png')); - } - - downloadPdf() { - this.drawOnCanvas().then((canvas) => { - const doc = new jsPDF({ - orientation: canvas.width > canvas.height ? 'l' : 'p', - unit: 'pt', - format: [canvas.width, canvas.height], - }); - doc.addImage(canvas, 'PNG', 0, 0, canvas.width, canvas.height, 'NONE'); - doc.save('topola.pdf'); + async downloadSvg() { + const contents = await this.getSvgContentsWithInlinedImages(); + const blob = new Blob([contents], {type: 'image/svg+xml'}); + saveAs(blob, 'topola.svg'); + } + + private async drawOnCanvas(): Promise { + const contents = await this.getSvgContentsWithInlinedImages(); + const blob = new Blob([contents], {type: 'image/svg+xml'}); + return await drawOnCanvas(await loadImage(blob)); + } + + async downloadPng() { + const canvas = await this.drawOnCanvas(); + const blob = await canvasToBlob(canvas, 'image/png'); + saveAs(blob, 'topola.png'); + } + + async downloadPdf() { + const canvas = await this.drawOnCanvas(); + const doc = new jsPDF({ + orientation: canvas.width > canvas.height ? 'l' : 'p', + unit: 'pt', + format: [canvas.width, canvas.height], }); + doc.addImage(canvas, 'PNG', 0, 0, canvas.width, canvas.height, 'NONE'); + doc.save('topola.pdf'); } } diff --git a/src/load_data.ts b/src/load_data.ts index 08fa6cc..ddc0461 100644 --- a/src/load_data.ts +++ b/src/load_data.ts @@ -32,37 +32,32 @@ function prepareData( } /** Fetches data from the given URL. Uses cors-anywhere if handleCors is true. */ -export function loadFromUrl( +export async function loadFromUrl( url: string, handleCors: boolean, ): Promise { const cachedData = sessionStorage.getItem(url); if (cachedData) { - return Promise.resolve(JSON.parse(cachedData)); + return JSON.parse(cachedData); } const urlToFetch = handleCors ? 'https://cors-anywhere.herokuapp.com/' + url : url; - return window - .fetch(urlToFetch) - .then((response) => { - if (response.status !== 200) { - return Promise.reject(new Error(response.statusText)); - } - return response.text(); - }) - .then((gedcom) => { - return prepareData(gedcom, url); - }); + const response = await window.fetch(urlToFetch); + if (response.status !== 200) { + throw new Error(response.statusText); + } + const gedcom = await response.text(); + return prepareData(gedcom, url); } /** Loads data from the given GEDCOM file contents. */ -function loadGedcomSync( +export async function loadGedcom( hash: string, gedcom?: string, images?: Map, -) { +): Promise { const cachedData = sessionStorage.getItem(hash); if (cachedData) { return JSON.parse(cachedData); @@ -72,16 +67,3 @@ function loadGedcomSync( } return prepareData(gedcom, hash, images); } - -/** Loads data from the given GEDCOM file contents. */ -export function loadGedcom( - hash: string, - gedcom?: string, - images?: Map, -): Promise { - try { - return Promise.resolve(loadGedcomSync(hash, gedcom, images)); - } catch (e) { - return Promise.reject(new Error('Failed to read GEDCOM file')); - } -} diff --git a/src/top_bar.tsx b/src/top_bar.tsx index 66ad906..949a008 100644 --- a/src/top_bar.tsx +++ b/src/top_bar.tsx @@ -39,16 +39,6 @@ function loadFileAsText(file: File): Promise { }); } -function loadFileAsDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (evt: ProgressEvent) => { - resolve((evt.target as FileReader).result as string); - }; - reader.readAsDataURL(file); - }); -} - function isImageFileName(fileName: string) { const lower = fileName.toLowerCase(); return lower.endsWith('.jpg') || lower.endsWith('.png'); @@ -62,12 +52,14 @@ export class TopBar extends React.Component< inputRef?: Input; /** Handles the "Upload file" button. */ - handleUpload(event: React.SyntheticEvent) { + async handleUpload(event: React.SyntheticEvent) { const files = (event.target as HTMLInputElement).files; if (!files || !files.length) { return; } const filesArray = Array.from(files); + (event.target as HTMLInputElement).value = ''; // Reset the file input. + const gedcomFile = files.length === 1 ? files[0] @@ -86,20 +78,19 @@ export class TopBar extends React.Component< const imageMap = new Map( images.map((entry) => [entry.name, entry.url] as [string, string]), ); - loadFileAsText(gedcomFile).then((data) => { - const imageFileNames = images - .map((image) => image.name) - .sort() - .join('|'); - // Hash GEDCOM contents with uploaded image file names. - const hash = md5(md5(data) + imageFileNames); - this.props.history.push({ - pathname: '/view', - search: queryString.stringify({file: hash}), - state: {data, images: imageMap}, - }); + + const data = await loadFileAsText(gedcomFile); + const imageFileNames = images + .map((image) => image.name) + .sort() + .join('|'); + // Hash GEDCOM contents with uploaded image file names. + const hash = md5(md5(data) + imageFileNames); + this.props.history.push({ + pathname: '/view', + search: queryString.stringify({file: hash}), + state: {data, images: imageMap}, }); - (event.target as HTMLInputElement).value = ''; // Reset the file input. } /** Opens the "Load from URL" dialog. */