mirror of
https://github.com/maputnik/editor.git
synced 2025-12-27 16:40:00 +00:00
feat: use FileSystemFileHandle to modify a file on the local filesystem (#965)
## Launch Checklist <!-- Thanks for the PR! Feel free to add or remove items from the checklist. --> - [x] Briefly describe the changes in this PR. - [x] Link to related issues. - [x] Include before/after visuals or gifs if this PR includes visual changes. - [ ] Write tests for all new functionality. - [x] Add an entry to `CHANGELOG.md` under the `## main` section. ## Changes - This pull request makes use of the FileSystemFileHandle API to modify a local file. No need to download it - just click save. - I don't know how to cover this functionality by tests so I need directions in case test coverage is required. - The pull request adds [@types/wicg-file-system-access](https://www.npmjs.com/package/@types/wicg-file-system-access) as a new dev dependency which I am not really pleased about but can't think of a way around it. ## Known Limitations - The used File API is only available in when accessed from https or on localhost. - There is no visual indicator that the file has been saved. Previously the browser showed it as a new download. ## Issue - https://github.com/maplibre/maputnik/issues/964 ## Screenshots ### Menu <img src="https://github.com/user-attachments/assets/dfcfc5c2-0019-4857-ba26-224b5598fc11" /> ### Open modal <img src="https://github.com/user-attachments/assets/4e1293e8-16b6-4b86-925b-3bebb49d8ca6" height="200px" /> ### Save modal <img src="https://github.com/user-attachments/assets/4f10e2c0-2dd3-4726-a613-30e0406619b0" height="200px" /> --------- Co-authored-by: Harel M <harel.mazor@gmail.com>
This commit is contained in:
@@ -129,6 +129,7 @@ type AppState = {
|
||||
export: boolean
|
||||
debug: boolean
|
||||
}
|
||||
fileHandle: FileSystemFileHandle | null
|
||||
}
|
||||
|
||||
export default class App extends React.Component<any, AppState> {
|
||||
@@ -284,6 +285,7 @@ export default class App extends React.Component<any, AppState> {
|
||||
openlayersDebugOptions: {
|
||||
debugToolbox: false,
|
||||
},
|
||||
fileHandle: null,
|
||||
}
|
||||
|
||||
this.layerWatcher = new LayerWatcher({
|
||||
@@ -611,7 +613,8 @@ export default class App extends React.Component<any, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
openStyle = (styleObj: StyleSpecification & {id: string}) => {
|
||||
openStyle = (styleObj: StyleSpecification & {id: string}, fileHandle: FileSystemFileHandle | null) => {
|
||||
this.setState({fileHandle: fileHandle});
|
||||
styleObj = this.setDefaultValues(styleObj)
|
||||
this.onStyleChanged(styleObj)
|
||||
}
|
||||
@@ -847,6 +850,10 @@ export default class App extends React.Component<any, AppState> {
|
||||
this.setModal(modalName, !this.state.isOpen[modalName]);
|
||||
}
|
||||
|
||||
onSetFileHandle(fileHandle: FileSystemFileHandle | null) {
|
||||
this.setState({fileHandle: fileHandle});
|
||||
}
|
||||
|
||||
onChangeOpenlayersDebug = (key: keyof AppState["openlayersDebugOptions"], value: boolean) => {
|
||||
this.setState({
|
||||
openlayersDebugOptions: {
|
||||
@@ -949,11 +956,14 @@ export default class App extends React.Component<any, AppState> {
|
||||
onStyleChanged={this.onStyleChanged}
|
||||
isOpen={this.state.isOpen.export}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'export')}
|
||||
fileHandle={this.state.fileHandle}
|
||||
onSetFileHandle={this.onSetFileHandle}
|
||||
/>
|
||||
<ModalOpen
|
||||
isOpen={this.state.isOpen.open}
|
||||
onStyleOpen={this.openStyle}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'open')}
|
||||
fileHandle={this.state.fileHandle}
|
||||
/>
|
||||
<ModalSources
|
||||
mapStyle={this.state.mapStyle}
|
||||
|
||||
@@ -2,7 +2,15 @@ import React from 'react'
|
||||
import classnames from 'classnames'
|
||||
import {detect} from 'detect-browser';
|
||||
|
||||
import {MdFileDownload, MdOpenInBrowser, MdSettings, MdLayers, MdHelpOutline, MdFindInPage, MdLanguage} from 'react-icons/md'
|
||||
import {
|
||||
MdOpenInBrowser,
|
||||
MdSettings,
|
||||
MdLayers,
|
||||
MdHelpOutline,
|
||||
MdFindInPage,
|
||||
MdLanguage,
|
||||
MdSave
|
||||
} from 'react-icons/md'
|
||||
import pkgJson from '../../package.json'
|
||||
//@ts-ignore
|
||||
import maputnikLogo from 'maputnik-design/logos/logo-color.svg?inline'
|
||||
@@ -216,8 +224,8 @@ class AppToolbarInternal extends React.Component<AppToolbarInternalProps> {
|
||||
<IconText>{t("Open")}</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction wdKey="nav:export" onClick={this.props.onToggleModal.bind(this, 'export')}>
|
||||
<MdFileDownload />
|
||||
<IconText>{t("Export")}</IconText>
|
||||
<MdSave />
|
||||
<IconText>{t("Save")}</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction wdKey="nav:sources" onClick={this.props.onToggleModal.bind(this, 'sources')}>
|
||||
<MdLayers />
|
||||
|
||||
@@ -4,7 +4,7 @@ import {saveAs} from 'file-saver'
|
||||
import {version} from 'maplibre-gl/package.json'
|
||||
import {format} from '@maplibre/maplibre-gl-style-spec'
|
||||
import type {StyleSpecification} from 'maplibre-gl'
|
||||
import {MdFileDownload} from 'react-icons/md'
|
||||
import {MdMap, MdSave} from 'react-icons/md'
|
||||
import { WithTranslation, withTranslation } from 'react-i18next';
|
||||
|
||||
import FieldString from './FieldString'
|
||||
@@ -22,6 +22,8 @@ type ModalExportInternalProps = {
|
||||
onStyleChanged(...args: unknown[]): unknown
|
||||
isOpen: boolean
|
||||
onOpenToggle(...args: unknown[]): unknown
|
||||
onSetFileHandle(fileHandle: FileSystemFileHandle | null): unknown
|
||||
fileHandle: FileSystemFileHandle | null
|
||||
} & WithTranslation;
|
||||
|
||||
|
||||
@@ -47,7 +49,7 @@ class ModalExportInternal extends React.Component<ModalExportInternalProps> {
|
||||
}
|
||||
}
|
||||
|
||||
downloadHtml() {
|
||||
createHtml() {
|
||||
const tokenStyle = this.tokenizedStyle();
|
||||
const htmlTitle = this.props.mapStyle.name || this.props.t("Map");
|
||||
const html = `<!DOCTYPE html>
|
||||
@@ -81,11 +83,49 @@ class ModalExportInternal extends React.Component<ModalExportInternalProps> {
|
||||
saveAs(blob, exportName + ".html");
|
||||
}
|
||||
|
||||
downloadStyle() {
|
||||
async saveStyle() {
|
||||
const tokenStyle = this.tokenizedStyle();
|
||||
const blob = new Blob([tokenStyle], {type: "application/json;charset=utf-8"});
|
||||
const exportName = this.exportName();
|
||||
saveAs(blob, exportName + ".json");
|
||||
|
||||
let fileHandle = this.props.fileHandle;
|
||||
if (fileHandle == null) {
|
||||
fileHandle = await this.createFileHandle();
|
||||
this.props.onSetFileHandle(fileHandle)
|
||||
if (fileHandle == null) return;
|
||||
}
|
||||
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(tokenStyle);
|
||||
await writable.close();
|
||||
this.props.onOpenToggle();
|
||||
}
|
||||
|
||||
async saveStyleAs() {
|
||||
const tokenStyle = this.tokenizedStyle();
|
||||
|
||||
const fileHandle = await this.createFileHandle();
|
||||
this.props.onSetFileHandle(fileHandle)
|
||||
if (fileHandle == null) return;
|
||||
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(tokenStyle);
|
||||
await writable.close();
|
||||
this.props.onOpenToggle();
|
||||
}
|
||||
|
||||
async createFileHandle() : Promise<FileSystemFileHandle | null> {
|
||||
const pickerOpts: SaveFilePickerOptions = {
|
||||
types: [
|
||||
{
|
||||
description: "json",
|
||||
accept: { "application/json": [".json"] },
|
||||
},
|
||||
],
|
||||
suggestedName: this.exportName(),
|
||||
};
|
||||
|
||||
const fileHandle = await window.showSaveFilePicker(pickerOpts) as FileSystemFileHandle;
|
||||
this.props.onSetFileHandle(fileHandle)
|
||||
return fileHandle;
|
||||
}
|
||||
|
||||
changeMetadataProperty(property: string, value: any) {
|
||||
@@ -107,14 +147,14 @@ class ModalExportInternal extends React.Component<ModalExportInternalProps> {
|
||||
data-wd-key="modal:export"
|
||||
isOpen={this.props.isOpen}
|
||||
onOpenToggle={this.props.onOpenToggle}
|
||||
title={t('Export Style')}
|
||||
title={t('Save Style')}
|
||||
className="maputnik-export-modal"
|
||||
>
|
||||
|
||||
<section className="maputnik-modal-section">
|
||||
<h1>{t("Download Style")}</h1>
|
||||
<h1>{t("Save Style")}</h1>
|
||||
<p>
|
||||
{t("Download a JSON style to your computer.")}
|
||||
{t("Save the JSON style to your computer.")}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
@@ -140,17 +180,23 @@ class ModalExportInternal extends React.Component<ModalExportInternalProps> {
|
||||
|
||||
<div className="maputnik-modal-export-buttons">
|
||||
<InputButton
|
||||
onClick={this.downloadStyle.bind(this)}
|
||||
onClick={this.saveStyle.bind(this)}
|
||||
>
|
||||
<MdFileDownload />
|
||||
{t("Download Style")}
|
||||
<MdSave />
|
||||
{t("Save")}
|
||||
</InputButton>
|
||||
<InputButton
|
||||
onClick={this.saveStyleAs.bind(this)}
|
||||
>
|
||||
<MdSave />
|
||||
{t("Save as")}
|
||||
</InputButton>
|
||||
|
||||
<InputButton
|
||||
onClick={this.downloadHtml.bind(this)}
|
||||
onClick={this.createHtml.bind(this)}
|
||||
>
|
||||
<MdFileDownload />
|
||||
{t("Download HTML")}
|
||||
<MdMap />
|
||||
{t("Create HTML")}
|
||||
</InputButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { FormEvent } from 'react'
|
||||
import {MdFileUpload} from 'react-icons/md'
|
||||
import {MdAddCircleOutline} from 'react-icons/md'
|
||||
import FileReaderInput, { Result } from 'react-file-reader-input'
|
||||
import { Trans, WithTranslation, withTranslation } from 'react-i18next';
|
||||
|
||||
import ModalLoading from './ModalLoading'
|
||||
@@ -47,6 +46,7 @@ type ModalOpenInternalProps = {
|
||||
isOpen: boolean
|
||||
onOpenToggle(...args: unknown[]): unknown
|
||||
onStyleOpen(...args: unknown[]): unknown
|
||||
fileHandle: FileSystemFileHandle | null
|
||||
} & WithTranslation;
|
||||
|
||||
type ModalOpenState = {
|
||||
@@ -135,29 +135,37 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
||||
this.onStyleSelect(this.state.styleUrl);
|
||||
}
|
||||
|
||||
onUpload = (_: any, files: Result[]) => {
|
||||
const [, file] = files[0];
|
||||
const reader = new FileReader();
|
||||
|
||||
onOpenFile = async () => {
|
||||
this.clearError();
|
||||
|
||||
reader.readAsText(file, "UTF-8");
|
||||
reader.onload = e => {
|
||||
let mapStyle;
|
||||
try {
|
||||
mapStyle = JSON.parse(e.target?.result as string)
|
||||
}
|
||||
catch(err) {
|
||||
this.setState({
|
||||
error: (err as Error).toString()
|
||||
});
|
||||
return;
|
||||
}
|
||||
mapStyle = style.ensureStyleValidity(mapStyle)
|
||||
this.props.onStyleOpen(mapStyle);
|
||||
this.onOpenToggle();
|
||||
const pickerOpts: OpenFilePickerOptions = {
|
||||
types: [
|
||||
{
|
||||
description: "json",
|
||||
accept: { "application/json": [".json"] },
|
||||
},
|
||||
],
|
||||
multiple: false,
|
||||
};
|
||||
|
||||
const [fileHandle] = await window.showOpenFilePicker(pickerOpts) as Array<FileSystemFileHandle>;
|
||||
const file = await fileHandle.getFile();
|
||||
const content = await file.text();
|
||||
|
||||
let mapStyle;
|
||||
try {
|
||||
mapStyle = JSON.parse(content)
|
||||
} catch (err) {
|
||||
this.setState({
|
||||
error: (err as Error).toString()
|
||||
});
|
||||
return;
|
||||
}
|
||||
reader.onerror = e => console.log(e.target);
|
||||
mapStyle = style.ensureStyleValidity(mapStyle)
|
||||
|
||||
this.props.onStyleOpen(mapStyle, fileHandle);
|
||||
this.onOpenToggle();
|
||||
return file;
|
||||
}
|
||||
|
||||
onOpenToggle() {
|
||||
@@ -196,7 +204,7 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
return (
|
||||
<div>
|
||||
<Modal
|
||||
data-wd-key="modal:open"
|
||||
@@ -206,11 +214,14 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
||||
>
|
||||
{errorElement}
|
||||
<section className="maputnik-modal-section">
|
||||
<h1>{t("Upload Style")}</h1>
|
||||
<p>{t("Upload a JSON style from your computer.")}</p>
|
||||
<FileReaderInput onChange={this.onUpload} tabIndex={-1} aria-label={t("Style file")}>
|
||||
<InputButton className="maputnik-upload-button"><MdFileUpload /> {t("Upload")}</InputButton>
|
||||
</FileReaderInput>
|
||||
<h1>{t("Open local Style")}</h1>
|
||||
<p>{t("Open a local JSON style from your computer.")}</p>
|
||||
<div>
|
||||
<InputButton
|
||||
className="maputnik-big-button"
|
||||
onClick={this.onOpenFile}><MdFileUpload/> {t("Open Style")}
|
||||
</InputButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="maputnik-modal-section">
|
||||
|
||||
Reference in New Issue
Block a user