Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use FileSystemFileHandle to modify a file on the local filesystem #965

Merged
merged 11 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 />
HarelM marked this conversation as resolved.
Show resolved Hide resolved
<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
Loading