Files
editor/src/components/modals/ModalOpen.tsx
Kanish Jeba Mathew M 93596b3540 feat: add drag and drop support to Upload Style area (#1852)
## Feat: Add drag and drop support to Upload Style area

Closes #911

---

### Problem
The Upload Style area only supported click-to-upload. Users could not
drag and drop a JSON file directly onto it.

### Fix
Added `onDragOver` and `onDrop` event handlers to the Upload Style area.
The dropped file is passed to the existing file reading logic already
used by the Upload button. A visual highlight is shown when dragging
over the area.
2026-05-06 17:11:17 +03:00

369 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { type DragEvent, type FormEvent } from "react";
import { MdFileUpload } from "react-icons/md";
import { MdAddCircleOutline } from "react-icons/md";
import { Trans, type WithTranslation, withTranslation } from "react-i18next";
import ModalLoading from "./ModalLoading";
import Modal from "./Modal";
import InputButton from "../InputButton";
import InputUrl from "../InputUrl";
import style from "../../libs/style";
import publicStyles from "../../config/styles.json";
type PublicStyleProps = {
url: string
thumbnailUrl: string
title: string
onSelect(...args: unknown[]): unknown
};
class PublicStyle extends React.Component<PublicStyleProps> {
render() {
return <div className="maputnik-public-style">
<InputButton
className="maputnik-public-style-button"
aria-label={this.props.title}
onClick={() => this.props.onSelect(this.props.url)}
>
<div className="maputnik-public-style-header">
<div>{this.props.title}</div>
<span className="maputnik-space" />
<MdAddCircleOutline />
</div>
<div
className="maputnik-public-style-thumbnail"
style={{
backgroundImage: `url(${this.props.thumbnailUrl})`
}}
></div>
</InputButton>
</div>;
}
}
type ModalOpenInternalProps = {
isOpen: boolean
onOpenToggle(): void
onStyleOpen(...args: unknown[]): unknown
fileHandle: FileSystemFileHandle | null
} & WithTranslation;
type ModalOpenState = {
styleUrl: string
isDragOver: boolean
error?: string | null
activeRequest?: any
activeRequestUrl?: string | null
};
class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpenState> {
private fileInputRef = React.createRef<HTMLInputElement>();
constructor(props: ModalOpenInternalProps) {
super(props);
this.state = {
styleUrl: "",
isDragOver: false,
};
}
clearError() {
this.setState({
error: null
});
}
onCancelActiveRequest(e: Event) {
// Else the click propagates to the underlying modal
if (e) e.stopPropagation();
if (this.state.activeRequest) {
this.state.activeRequest.abort();
this.setState({
activeRequest: null,
activeRequestUrl: null
});
}
}
onStyleSelect = (styleUrl: string) => {
this.clearError();
let canceled: boolean = false;
fetch(styleUrl, {
mode: "cors",
credentials: "same-origin"
})
.then(function (response) {
return response.json();
})
.then((body) => {
if (canceled) {
return;
}
this.setState({
activeRequest: null,
activeRequestUrl: null
});
const mapStyle = style.ensureStyleValidity(body);
console.log("Loaded style ", mapStyle.id);
this.props.onStyleOpen(mapStyle);
this.onOpenToggle();
})
.catch((err) => {
this.setState({
error: `Failed to load: '${styleUrl}'`,
activeRequest: null,
activeRequestUrl: null
});
console.error(err);
console.warn("Could not open the style URL", styleUrl);
});
this.setState({
activeRequest: {
abort: function () {
canceled = true;
}
},
activeRequestUrl: styleUrl
});
};
onSubmitUrl = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
this.onStyleSelect(this.state.styleUrl);
};
onOpenFile = async () => {
this.clearError();
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;
}
mapStyle = style.ensureStyleValidity(mapStyle);
this.props.onStyleOpen(mapStyle, fileHandle);
this.onOpenToggle();
return file;
};
// it is not guaranteed that the File System Access API is available on all
// browsers. If the function is not available, a fallback behavior is used.
onFileChanged = (files: FileList | null) => {
if (!files) return;
if (files.length === 0) return;
const file = files[0];
const reader = new FileReader();
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();
};
reader.onerror = e => console.log(e.target);
};
onOpenToggle() {
this.setState({
styleUrl: "",
isDragOver: false,
});
this.clearError();
this.props.onOpenToggle();
}
onBrowseClick = async () => {
if (typeof window.showOpenFilePicker === "function") {
await this.onOpenFile();
return;
}
this.fileInputRef.current?.click();
};
onFileDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (!this.state.isDragOver) {
this.setState({ isDragOver: true });
}
};
onFileDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
this.setState({ isDragOver: false });
};
onFileDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
this.setState({ isDragOver: false });
this.onFileChanged(e.dataTransfer.files);
};
onChangeUrl = (url: string) => {
this.setState({
styleUrl: url,
});
};
render() {
const t = this.props.t;
const styleOptions = publicStyles.map(style => {
return <PublicStyle
key={style.id}
url={style.url}
title={style.title}
thumbnailUrl={style.thumbnail}
onSelect={this.onStyleSelect}
/>;
});
let errorElement;
if (this.state.error) {
errorElement = (
<div className="maputnik-modal-error">
{this.state.error}
<a href="#" onClick={() => this.clearError()} className="maputnik-modal-error-close">×</a>
</div>
);
}
return (
<div>
<Modal
data-wd-key="modal:open"
isOpen={this.props.isOpen}
onOpenToggle={() => this.onOpenToggle()}
title={t("Open Style")}
>
{errorElement}
<section className="maputnik-modal-section">
<h1>{t("Open local Style")}</h1>
<p>{t("Open a local JSON style from your computer.")}</p>
<div
data-wd-key="modal:open.dropzone"
className={`maputnik-upload-dropzone${this.state.isDragOver ? " maputnik-upload-dropzone--active" : ""}`}
role="button"
tabIndex={0}
onDragOver={this.onFileDragOver}
onDragLeave={this.onFileDragLeave}
onDrop={this.onFileDrop}
onClick={() => void this.onBrowseClick()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
void this.onBrowseClick();
}
}}
>
<div className="maputnik-upload-dropzone-content">
<MdFileUpload className="maputnik-upload-dropzone-icon" aria-hidden="true" />
<p className="maputnik-upload-dropzone-text">
{t("Drag and drop a style JSON file here or click to browse")}
</p>
</div>
<input
ref={this.fileInputRef}
data-wd-key="modal:open.file.input"
type="file"
style={{ display: "none" }}
onChange={(e) => this.onFileChanged(e.target.files)}
/>
</div>
</section>
<section className="maputnik-modal-section">
<form onSubmit={this.onSubmitUrl}>
<h1>{t("Load from URL")}</h1>
<p>
<Trans t={t}>
Load from a URL. Note that the URL must have <a href="https://enable-cors.org" target="_blank" rel="noopener noreferrer">CORS enabled</a>.
</Trans>
</p>
<InputUrl
aria-label={t("Style URL")}
data-wd-key="modal:open.url.input"
type="text"
className="maputnik-input"
default={t("Enter URL...")}
value={this.state.styleUrl}
onInput={this.onChangeUrl}
onChange={this.onChangeUrl}
/>
<div>
<InputButton
data-wd-key="modal:open.url.button"
type="submit"
className="maputnik-big-button"
disabled={this.state.styleUrl.length < 1}
>Load from URL</InputButton>
</div>
</form>
</section>
<section className="maputnik-modal-section maputnik-modal-section--shrink">
<h1>{t("Gallery Styles")}</h1>
<p>
{t("Open one of the publicly available styles to start from.")}
</p>
<div className="maputnik-style-gallery-container">
{styleOptions}
</div>
</section>
</Modal>
<ModalLoading
isOpen={!!this.state.activeRequest}
title={t("Loading style")}
onCancel={(e: Event) => this.onCancelActiveRequest(e)}
message={t("Loading") + ": " + this.state.activeRequestUrl}
/>
</div>
);
}
}
const ModalOpen = withTranslation()(ModalOpenInternal);
export default ModalOpen;