Skip to content

Commit d50ea76

Browse files
josxhaHarelM
andauthored
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 - #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>
1 parent c6174a5 commit d50ea76

14 files changed

+170
-97
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- Add scheme type options for vector/raster tile
77
- Add `tileSize` field for raster and raster-dem tile sources
88
- Update Protomaps Light gallery style to v4
9+
- Add support to edit local files on the file system
910
- _...Add new stuff here..._
1011

1112
### 🐞 Bug fixes

package-lock.json

+8
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@
125125
"@types/react-icon-base": "^2.1.6",
126126
"@types/string-hash": "^1.1.3",
127127
"@types/uuid": "^9.0.8",
128+
"@types/wicg-file-system-access": "^2023.10.5",
128129
"@vitejs/plugin-react": "^4.2.1",
129130
"cors": "^2.8.5",
130131
"cypress": "^13.13.0",

src/components/App.tsx

+11-1
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ type AppState = {
129129
export: boolean
130130
debug: boolean
131131
}
132+
fileHandle: FileSystemFileHandle | null
132133
}
133134

134135
export default class App extends React.Component<any, AppState> {
@@ -284,6 +285,7 @@ export default class App extends React.Component<any, AppState> {
284285
openlayersDebugOptions: {
285286
debugToolbox: false,
286287
},
288+
fileHandle: null,
287289
}
288290

289291
this.layerWatcher = new LayerWatcher({
@@ -611,7 +613,8 @@ export default class App extends React.Component<any, AppState> {
611613
}
612614
}
613615

614-
openStyle = (styleObj: StyleSpecification & {id: string}) => {
616+
openStyle = (styleObj: StyleSpecification & {id: string}, fileHandle: FileSystemFileHandle | null) => {
617+
this.setState({fileHandle: fileHandle});
615618
styleObj = this.setDefaultValues(styleObj)
616619
this.onStyleChanged(styleObj)
617620
}
@@ -847,6 +850,10 @@ export default class App extends React.Component<any, AppState> {
847850
this.setModal(modalName, !this.state.isOpen[modalName]);
848851
}
849852

853+
onSetFileHandle(fileHandle: FileSystemFileHandle | null) {
854+
this.setState({fileHandle: fileHandle});
855+
}
856+
850857
onChangeOpenlayersDebug = (key: keyof AppState["openlayersDebugOptions"], value: boolean) => {
851858
this.setState({
852859
openlayersDebugOptions: {
@@ -949,11 +956,14 @@ export default class App extends React.Component<any, AppState> {
949956
onStyleChanged={this.onStyleChanged}
950957
isOpen={this.state.isOpen.export}
951958
onOpenToggle={this.toggleModal.bind(this, 'export')}
959+
fileHandle={this.state.fileHandle}
960+
onSetFileHandle={this.onSetFileHandle}
952961
/>
953962
<ModalOpen
954963
isOpen={this.state.isOpen.open}
955964
onStyleOpen={this.openStyle}
956965
onOpenToggle={this.toggleModal.bind(this, 'open')}
966+
fileHandle={this.state.fileHandle}
957967
/>
958968
<ModalSources
959969
mapStyle={this.state.mapStyle}

src/components/AppToolbar.tsx

+11-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@ import React from 'react'
22
import classnames from 'classnames'
33
import {detect} from 'detect-browser';
44

5-
import {MdFileDownload, MdOpenInBrowser, MdSettings, MdLayers, MdHelpOutline, MdFindInPage, MdLanguage} from 'react-icons/md'
5+
import {
6+
MdOpenInBrowser,
7+
MdSettings,
8+
MdLayers,
9+
MdHelpOutline,
10+
MdFindInPage,
11+
MdLanguage,
12+
MdSave
13+
} from 'react-icons/md'
614
import pkgJson from '../../package.json'
715
//@ts-ignore
816
import maputnikLogo from 'maputnik-design/logos/logo-color.svg?inline'
@@ -216,8 +224,8 @@ class AppToolbarInternal extends React.Component<AppToolbarInternalProps> {
216224
<IconText>{t("Open")}</IconText>
217225
</ToolbarAction>
218226
<ToolbarAction wdKey="nav:export" onClick={this.props.onToggleModal.bind(this, 'export')}>
219-
<MdFileDownload />
220-
<IconText>{t("Export")}</IconText>
227+
<MdSave />
228+
<IconText>{t("Save")}</IconText>
221229
</ToolbarAction>
222230
<ToolbarAction wdKey="nav:sources" onClick={this.props.onToggleModal.bind(this, 'sources')}>
223231
<MdLayers />

src/components/ModalExport.tsx

+61-15
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {saveAs} from 'file-saver'
44
import {version} from 'maplibre-gl/package.json'
55
import {format} from '@maplibre/maplibre-gl-style-spec'
66
import type {StyleSpecification} from 'maplibre-gl'
7-
import {MdFileDownload} from 'react-icons/md'
7+
import {MdMap, MdSave} from 'react-icons/md'
88
import { WithTranslation, withTranslation } from 'react-i18next';
99

1010
import FieldString from './FieldString'
@@ -22,6 +22,8 @@ type ModalExportInternalProps = {
2222
onStyleChanged(...args: unknown[]): unknown
2323
isOpen: boolean
2424
onOpenToggle(...args: unknown[]): unknown
25+
onSetFileHandle(fileHandle: FileSystemFileHandle | null): unknown
26+
fileHandle: FileSystemFileHandle | null
2527
} & WithTranslation;
2628

2729

@@ -47,7 +49,7 @@ class ModalExportInternal extends React.Component<ModalExportInternalProps> {
4749
}
4850
}
4951

50-
downloadHtml() {
52+
createHtml() {
5153
const tokenStyle = this.tokenizedStyle();
5254
const htmlTitle = this.props.mapStyle.name || this.props.t("Map");
5355
const html = `<!DOCTYPE html>
@@ -81,11 +83,49 @@ class ModalExportInternal extends React.Component<ModalExportInternalProps> {
8183
saveAs(blob, exportName + ".html");
8284
}
8385

84-
downloadStyle() {
86+
async saveStyle() {
8587
const tokenStyle = this.tokenizedStyle();
86-
const blob = new Blob([tokenStyle], {type: "application/json;charset=utf-8"});
87-
const exportName = this.exportName();
88-
saveAs(blob, exportName + ".json");
88+
89+
let fileHandle = this.props.fileHandle;
90+
if (fileHandle == null) {
91+
fileHandle = await this.createFileHandle();
92+
this.props.onSetFileHandle(fileHandle)
93+
if (fileHandle == null) return;
94+
}
95+
96+
const writable = await fileHandle.createWritable();
97+
await writable.write(tokenStyle);
98+
await writable.close();
99+
this.props.onOpenToggle();
100+
}
101+
102+
async saveStyleAs() {
103+
const tokenStyle = this.tokenizedStyle();
104+
105+
const fileHandle = await this.createFileHandle();
106+
this.props.onSetFileHandle(fileHandle)
107+
if (fileHandle == null) return;
108+
109+
const writable = await fileHandle.createWritable();
110+
await writable.write(tokenStyle);
111+
await writable.close();
112+
this.props.onOpenToggle();
113+
}
114+
115+
async createFileHandle() : Promise<FileSystemFileHandle | null> {
116+
const pickerOpts: SaveFilePickerOptions = {
117+
types: [
118+
{
119+
description: "json",
120+
accept: { "application/json": [".json"] },
121+
},
122+
],
123+
suggestedName: this.exportName(),
124+
};
125+
126+
const fileHandle = await window.showSaveFilePicker(pickerOpts) as FileSystemFileHandle;
127+
this.props.onSetFileHandle(fileHandle)
128+
return fileHandle;
89129
}
90130

91131
changeMetadataProperty(property: string, value: any) {
@@ -107,14 +147,14 @@ class ModalExportInternal extends React.Component<ModalExportInternalProps> {
107147
data-wd-key="modal:export"
108148
isOpen={this.props.isOpen}
109149
onOpenToggle={this.props.onOpenToggle}
110-
title={t('Export Style')}
150+
title={t('Save Style')}
111151
className="maputnik-export-modal"
112152
>
113153

114154
<section className="maputnik-modal-section">
115-
<h1>{t("Download Style")}</h1>
155+
<h1>{t("Save Style")}</h1>
116156
<p>
117-
{t("Download a JSON style to your computer.")}
157+
{t("Save the JSON style to your computer.")}
118158
</p>
119159

120160
<div>
@@ -140,17 +180,23 @@ class ModalExportInternal extends React.Component<ModalExportInternalProps> {
140180

141181
<div className="maputnik-modal-export-buttons">
142182
<InputButton
143-
onClick={this.downloadStyle.bind(this)}
183+
onClick={this.saveStyle.bind(this)}
184+
>
185+
<MdSave />
186+
{t("Save")}
187+
</InputButton>
188+
<InputButton
189+
onClick={this.saveStyleAs.bind(this)}
144190
>
145-
<MdFileDownload />
146-
{t("Download Style")}
191+
<MdSave />
192+
{t("Save as")}
147193
</InputButton>
148194

149195
<InputButton
150-
onClick={this.downloadHtml.bind(this)}
196+
onClick={this.createHtml.bind(this)}
151197
>
152-
<MdFileDownload />
153-
{t("Download HTML")}
198+
<MdMap />
199+
{t("Create HTML")}
154200
</InputButton>
155201
</div>
156202
</section>

src/components/ModalOpen.tsx

+38-27
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import React, { FormEvent } from 'react'
22
import {MdFileUpload} from 'react-icons/md'
33
import {MdAddCircleOutline} from 'react-icons/md'
4-
import FileReaderInput, { Result } from 'react-file-reader-input'
54
import { Trans, WithTranslation, withTranslation } from 'react-i18next';
65

76
import ModalLoading from './ModalLoading'
@@ -47,6 +46,7 @@ type ModalOpenInternalProps = {
4746
isOpen: boolean
4847
onOpenToggle(...args: unknown[]): unknown
4948
onStyleOpen(...args: unknown[]): unknown
49+
fileHandle: FileSystemFileHandle | null
5050
} & WithTranslation;
5151

5252
type ModalOpenState = {
@@ -135,29 +135,37 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
135135
this.onStyleSelect(this.state.styleUrl);
136136
}
137137

138-
onUpload = (_: any, files: Result[]) => {
139-
const [, file] = files[0];
140-
const reader = new FileReader();
141-
138+
onOpenFile = async () => {
142139
this.clearError();
143140

144-
reader.readAsText(file, "UTF-8");
145-
reader.onload = e => {
146-
let mapStyle;
147-
try {
148-
mapStyle = JSON.parse(e.target?.result as string)
149-
}
150-
catch(err) {
151-
this.setState({
152-
error: (err as Error).toString()
153-
});
154-
return;
155-
}
156-
mapStyle = style.ensureStyleValidity(mapStyle)
157-
this.props.onStyleOpen(mapStyle);
158-
this.onOpenToggle();
141+
const pickerOpts: OpenFilePickerOptions = {
142+
types: [
143+
{
144+
description: "json",
145+
accept: { "application/json": [".json"] },
146+
},
147+
],
148+
multiple: false,
149+
};
150+
151+
const [fileHandle] = await window.showOpenFilePicker(pickerOpts) as Array<FileSystemFileHandle>;
152+
const file = await fileHandle.getFile();
153+
const content = await file.text();
154+
155+
let mapStyle;
156+
try {
157+
mapStyle = JSON.parse(content)
158+
} catch (err) {
159+
this.setState({
160+
error: (err as Error).toString()
161+
});
162+
return;
159163
}
160-
reader.onerror = e => console.log(e.target);
164+
mapStyle = style.ensureStyleValidity(mapStyle)
165+
166+
this.props.onStyleOpen(mapStyle, fileHandle);
167+
this.onOpenToggle();
168+
return file;
161169
}
162170

163171
onOpenToggle() {
@@ -196,7 +204,7 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
196204
);
197205
}
198206

199-
return (
207+
return (
200208
<div>
201209
<Modal
202210
data-wd-key="modal:open"
@@ -206,11 +214,14 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
206214
>
207215
{errorElement}
208216
<section className="maputnik-modal-section">
209-
<h1>{t("Upload Style")}</h1>
210-
<p>{t("Upload a JSON style from your computer.")}</p>
211-
<FileReaderInput onChange={this.onUpload} tabIndex={-1} aria-label={t("Style file")}>
212-
<InputButton className="maputnik-upload-button"><MdFileUpload /> {t("Upload")}</InputButton>
213-
</FileReaderInput>
217+
<h1>{t("Open local Style")}</h1>
218+
<p>{t("Open a local JSON style from your computer.")}</p>
219+
<div>
220+
<InputButton
221+
className="maputnik-big-button"
222+
onClick={this.onOpenFile}><MdFileUpload/> {t("Open Style")}
223+
</InputButton>
224+
</div>
214225
</section>
215226

216227
<section className="maputnik-modal-section">

src/locales/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
## Internationalization
22

3-
The process of internationlization is pretty straight forward for Maputnik.
3+
The process of internationalization is pretty straight forward for Maputnik.
44

55
## Add a new language
66

src/locales/de/translation.json

+7-10
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"Map view": "Kartenansicht",
3030
"Maputnik on GitHub": "Maputnik auf GitHub",
3131
"Open": "Öffnen",
32-
"Export": "Exportieren",
32+
"Save": "Speichern",
3333
"Data Sources": "Datenquellen",
3434
"Style Settings": "Stileinstellungen",
3535
"View": "Ansicht",
@@ -81,17 +81,14 @@
8181
"Close modal": "Modale Fenster schließen",
8282
"Debug": "Debug",
8383
"Options": "Optionen",
84-
"<0>Open in OSM</0> &mdash; Opens the current view on openstreetmap.org": "<0>In OSM öffnen</0> &mdash; Öffnet die aktuelle Ansicht auf openstreetmap.org",
85-
"Export Style": "Stil exportieren",
86-
"Download Style": "Stil herunterladen",
87-
"Download a JSON style to your computer.": "Lade einen JSON-Stil auf deinen Computer herunter.",
88-
"Download HTML": "HTML herunterladen",
84+
"Save Style": "Stil Speichern",
85+
"Save the JSON style to your computer.": "Speichere den JSON Stil auf deinem Computer.",
86+
"Save as": "Speichern unter",
87+
"Create HTML": "HTML erstellen",
8988
"Cancel": "Abbrechen",
9089
"Open Style": "Stil öffnen",
91-
"Upload Style": "Stil hochladen",
92-
"Upload a JSON style from your computer.": "Lade einen JSON-Stil von deinem Computer hoch.",
93-
"Style file": "Stildatei",
94-
"Upload": "Hochladen",
90+
"Open local Style": "Lokalen Stil öffnen",
91+
"Open a local JSON style from your computer.": "Öffne einen lokalen JSON Stil von deinem Computer.",
9592
"Load from URL": "Von URL laden",
9693
"Load from a URL. Note that the URL must have <1>CORS enabled</1>.": "Von einer URL laden. Beachte, dass die URL <1>CORS aktiviert</1> haben muss.",
9794
"Style URL": "Stil-URL",

0 commit comments

Comments
 (0)