Skip to content

Commit

Permalink
feat: use FileSystemFileHandle to modify a file on the local filesy…
Browse files Browse the repository at this point in the history
…stem (#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 <[email protected]>
  • Loading branch information
josxha and HarelM authored Jan 9, 2025
1 parent c6174a5 commit d50ea76
Show file tree
Hide file tree
Showing 14 changed files with 170 additions and 97 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- Add scheme type options for vector/raster tile
- Add `tileSize` field for raster and raster-dem tile sources
- Update Protomaps Light gallery style to v4
- Add support to edit local files on the file system
- _...Add new stuff here..._

### 🐞 Bug fixes
Expand Down
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
"@types/react-icon-base": "^2.1.6",
"@types/string-hash": "^1.1.3",
"@types/uuid": "^9.0.8",
"@types/wicg-file-system-access": "^2023.10.5",
"@vitejs/plugin-react": "^4.2.1",
"cors": "^2.8.5",
"cypress": "^13.13.0",
Expand Down
12 changes: 11 additions & 1 deletion src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ type AppState = {
export: boolean
debug: boolean
}
fileHandle: FileSystemFileHandle | null
}

export default class App extends React.Component<any, AppState> {
Expand Down Expand Up @@ -284,6 +285,7 @@ export default class App extends React.Component<any, AppState> {
openlayersDebugOptions: {
debugToolbox: false,
},
fileHandle: null,
}

this.layerWatcher = new LayerWatcher({
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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}
Expand Down
14 changes: 11 additions & 3 deletions src/components/AppToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 />
Expand Down
76 changes: 61 additions & 15 deletions src/components/ModalExport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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;


Expand All @@ -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>
Expand Down Expand Up @@ -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) {
Expand All @@ -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>
Expand All @@ -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)}
>
<MdSave />
{t("Save")}
</InputButton>
<InputButton
onClick={this.saveStyleAs.bind(this)}
>
<MdFileDownload />
{t("Download Style")}
<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>
Expand Down
65 changes: 38 additions & 27 deletions src/components/ModalOpen.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -47,6 +46,7 @@ type ModalOpenInternalProps = {
isOpen: boolean
onOpenToggle(...args: unknown[]): unknown
onStyleOpen(...args: unknown[]): unknown
fileHandle: FileSystemFileHandle | null
} & WithTranslation;

type ModalOpenState = {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -196,7 +204,7 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
);
}

return (
return (
<div>
<Modal
data-wd-key="modal:open"
Expand All @@ -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">
Expand Down
2 changes: 1 addition & 1 deletion src/locales/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## Internationalization

The process of internationlization is pretty straight forward for Maputnik.
The process of internationalization is pretty straight forward for Maputnik.

## Add a new language

Expand Down
17 changes: 7 additions & 10 deletions src/locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"Map view": "Kartenansicht",
"Maputnik on GitHub": "Maputnik auf GitHub",
"Open": "Öffnen",
"Export": "Exportieren",
"Save": "Speichern",
"Data Sources": "Datenquellen",
"Style Settings": "Stileinstellungen",
"View": "Ansicht",
Expand Down Expand Up @@ -81,17 +81,14 @@
"Close modal": "Modale Fenster schließen",
"Debug": "Debug",
"Options": "Optionen",
"<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",
"Export Style": "Stil exportieren",
"Download Style": "Stil herunterladen",
"Download a JSON style to your computer.": "Lade einen JSON-Stil auf deinen Computer herunter.",
"Download HTML": "HTML herunterladen",
"Save Style": "Stil Speichern",
"Save the JSON style to your computer.": "Speichere den JSON Stil auf deinem Computer.",
"Save as": "Speichern unter",
"Create HTML": "HTML erstellen",
"Cancel": "Abbrechen",
"Open Style": "Stil öffnen",
"Upload Style": "Stil hochladen",
"Upload a JSON style from your computer.": "Lade einen JSON-Stil von deinem Computer hoch.",
"Style file": "Stildatei",
"Upload": "Hochladen",
"Open local Style": "Lokalen Stil öffnen",
"Open a local JSON style from your computer.": "Öffne einen lokalen JSON Stil von deinem Computer.",
"Load from URL": "Von URL laden",
"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.",
"Style URL": "Stil-URL",
Expand Down
Loading

0 comments on commit d50ea76

Please sign in to comment.